Skip to main content

music21/
environment.rs

1//! music21's user environment: the preferences kept in `.music21rc`, where
2//! that file lives, and where temporary files go.
3//!
4//! music21 keeps a fixed set of preferences: where MuseScore, Lilypond and
5//! the viewers are, which format `show()` and `write()` use, whether the
6//! corpus may download, how much debugging to print. Each has a default
7//! that depends on the platform; the user's own values are read from an XML
8//! file and written back to it in music21's layout, so a file written here
9//! reads in music21 and the other way round.
10//!
11//! ```
12//! use music21::environment::{self, Platform};
13//!
14//! let defaults = environment::defaults(Platform::Nix, (0, 0, 0));
15//! let musicxml = defaults.iter().find(|(key, _)| *key == "musicxmlPath");
16//! assert_eq!(
17//!     musicxml.map(|(_, value)| value),
18//!     Some(&environment::Value::text("/usr/bin/mscore3"))
19//! );
20//! assert_eq!(environment::format_to_key("png"), Some("graphicsPath"));
21//! ```
22
23use std::path::{Path, PathBuf};
24
25use crate::common::formats::{VALID_AUTO_DOWNLOAD, VALID_SHOW_FORMATS, VALID_WRITE_FORMATS};
26use crate::common::pathtools::{clean_path, expand_user};
27use crate::common::{DEBUG_ALL, DEBUG_DEVEL, DEBUG_OFF, DEBUG_USER};
28use crate::error::{Error, Result};
29
30/// The three kinds of system music21 tells apart, by the names it gives
31/// them: `'win'`, `'darwin'` and `'nix'`.
32#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
33pub enum Platform {
34    /// Windows: music21's `'win'`.
35    Windows,
36    /// macOS: music21's `'darwin'`.
37    Darwin,
38    /// Anything else, Linux among them: music21's `'nix'`.
39    Nix,
40}
41
42impl Platform {
43    /// The platform this is running on, as music21's `getPlatform` says.
44    ///
45    /// ```
46    /// use music21::environment::Platform;
47    ///
48    /// assert_eq!(Platform::from_name(Platform::current().as_str()), Some(Platform::current()));
49    /// ```
50    pub fn current() -> Self {
51        match std::env::consts::OS {
52            "windows" => Self::Windows,
53            "macos" => Self::Darwin,
54            _ => Self::Nix,
55        }
56    }
57
58    /// music21's name for it.
59    ///
60    /// ```
61    /// assert_eq!(music21::environment::Platform::Darwin.as_str(), "darwin");
62    /// ```
63    pub fn as_str(self) -> &'static str {
64        match self {
65            Self::Windows => "win",
66            Self::Darwin => "darwin",
67            Self::Nix => "nix",
68        }
69    }
70
71    /// The platform music21 names so, if any.
72    ///
73    /// ```
74    /// use music21::environment::Platform;
75    ///
76    /// assert_eq!(Platform::from_name("win"), Some(Platform::Windows));
77    /// assert_eq!(Platform::from_name("amiga"), None);
78    /// ```
79    pub fn from_name(name: &str) -> Option<Self> {
80        match name {
81            "win" => Some(Self::Windows),
82            "darwin" => Some(Self::Darwin),
83            "nix" => Some(Self::Nix),
84            _ => None,
85        }
86    }
87}
88
89/// The preferences that name a file or an application, in music21's order.
90pub const PATH_KEYS: [&str; 10] = [
91    "braillePath",
92    "graphicsPath",
93    "lilypondPath",
94    "localCorpusPath",
95    "manualCoreCorpusPath",
96    "midiPath",
97    "musescoreDirectPNGPath",
98    "musicxmlPath",
99    "pdfPath",
100    "vectorPath",
101];
102
103/// A local corpus's settings: the directories it reads, the file its
104/// metadata is cached in, and its name, which the unnamed corpus has none
105/// of. music21's `LocalCorpusSettings`.
106#[derive(Clone, Debug, Default, Eq, PartialEq)]
107pub struct LocalCorpus {
108    /// Its name; `None` for the unnamed local corpus.
109    pub name: Option<String>,
110    /// The directories it reads.
111    pub paths: Vec<String>,
112    /// Where its metadata is cached; `None` for the temporary directory.
113    pub cache_file_path: Option<String>,
114}
115
116/// The value of one preference.
117#[derive(Clone, Debug, Eq, PartialEq)]
118pub enum Value {
119    /// Not set: music21's `None`.
120    Unset,
121    /// Text, as every preference read back from a file is.
122    Text(String),
123    /// A number: `debug` and `warnings` before they are written out.
124    Number(i64),
125    /// `localCorpusSettings`: the unnamed local corpus.
126    LocalCorpus(LocalCorpus),
127    /// `localCorporaSettings`: the named local corpora, by name.
128    LocalCorpora(Vec<LocalCorpus>),
129}
130
131impl Value {
132    /// A text value.
133    pub fn text(text: impl Into<String>) -> Self {
134        Self::Text(text.into())
135    }
136}
137
138/// Every preference with its default on `platform`, in music21's order.
139///
140/// `mac_version` is the running macOS's version, which says where Preview
141/// is; music21 reads it as `(0, 0, 0)` off a Mac. The paths come as music21
142/// stores them, cleaned as [`clean_path`] cleans them.
143///
144/// ```
145/// use music21::environment::{self, Platform, Value};
146///
147/// let defaults = environment::defaults(Platform::Darwin, (14, 0, 0));
148/// let preview = defaults.iter().find(|(key, _)| *key == "pdfPath");
149/// assert_eq!(
150///     preview.map(|(_, value)| value),
151///     Some(&Value::text("/System/Applications/Preview.app"))
152/// );
153/// assert_eq!(defaults.len(), 21);
154/// ```
155pub fn defaults(platform: Platform, mac_version: (u32, u32, u32)) -> Vec<(&'static str, Value)> {
156    let mut found = vec![
157        ("directoryScratch", Value::Unset),
158        ("lilypondPath", Value::Unset),
159        ("lilypondVersion", Value::Unset),
160        ("lilypondFormat", Value::text("pdf")),
161        ("lilypondBackend", Value::text("ps")),
162        ("musicxmlPath", Value::Unset),
163        ("midiPath", Value::Unset),
164        ("graphicsPath", Value::Unset),
165        ("vectorPath", Value::Unset),
166        ("pdfPath", Value::Unset),
167        ("braillePath", Value::Unset),
168        ("musescoreDirectPNGPath", Value::Unset),
169        ("showFormat", Value::text("musicxml")),
170        ("writeFormat", Value::text("musicxml")),
171        ("ipythonShowFormat", Value::text("ipython.musicxml.png")),
172        ("autoDownload", Value::text("ask")),
173        ("debug", Value::Number(DEBUG_OFF)),
174        ("warnings", Value::Number(1)),
175        (
176            "localCorpusSettings",
177            Value::LocalCorpus(LocalCorpus::default()),
178        ),
179        ("localCorporaSettings", Value::LocalCorpora(Vec::new())),
180        ("manualCoreCorpusPath", Value::Unset),
181    ];
182    for (key, path) in platform_paths(platform, mac_version) {
183        if let Some(entry) = found.iter_mut().find(|(name, _)| *name == key) {
184            entry.1 = Value::Text(clean_path(&path));
185        }
186    }
187    found
188}
189
190/// The applications music21 expects to find on each platform.
191fn platform_paths(platform: Platform, mac_version: (u32, u32, u32)) -> Vec<(&'static str, String)> {
192    match platform {
193        Platform::Windows => {
194            let musescore = clean_path(r"%PROGRAMFILES%\MuseScore 4\bin\MuseScore4.exe");
195            vec![
196                ("lilypondPath", "lilypond".to_owned()),
197                ("musicxmlPath", musescore.clone()),
198                ("musescoreDirectPNGPath", musescore),
199            ]
200        }
201        Platform::Nix => vec![
202            ("lilypondPath", "/usr/bin/lilypond".to_owned()),
203            ("musicxmlPath", "/usr/bin/mscore3".to_owned()),
204            ("musescoreDirectPNGPath", "/usr/bin/mscore3".to_owned()),
205            ("graphicsPath", "/usr/bin/xdg-open".to_owned()),
206            ("pdfPath", "/usr/bin/xdg-open".to_owned()),
207        ],
208        Platform::Darwin => {
209            let (major, minor, _) = mac_version;
210            let preview = if major >= 11 || (major == 10 && minor >= 15) {
211                "/System/Applications/Preview.app"
212            } else {
213                "/Applications/Preview.app"
214            };
215            let musescore = "/Applications/MuseScore 4.app/Contents/MacOS/mscore";
216            vec![
217                (
218                    "lilypondPath",
219                    "/Applications/Lilypond.app/Contents/Resources/bin/lilypond".to_owned(),
220                ),
221                ("musicxmlPath", musescore.to_owned()),
222                ("graphicsPath", preview.to_owned()),
223                ("vectorPath", preview.to_owned()),
224                ("pdfPath", preview.to_owned()),
225                ("midiPath", "/Applications/GarageBand.app".to_owned()),
226                ("musescoreDirectPNGPath", musescore.to_owned()),
227            ]
228        }
229    }
230}
231
232/// Whether a preference names a file or an application.
233///
234/// ```
235/// assert!(music21::environment::is_path_key("musicxmlPath"));
236/// assert!(!music21::environment::is_path_key("debug"));
237/// ```
238pub fn is_path_key(key: &str) -> bool {
239    PATH_KEYS.contains(&key)
240}
241
242/// `value` as `showFormat`, `writeFormat` or `autoDownload` takes it: in
243/// lower case, and an error where it is not one music21 allows. Any other
244/// preference takes any value, and `value` comes back as it was given.
245///
246/// ```
247/// use music21::environment::accepted;
248///
249/// assert_eq!(accepted("showFormat", "MusicXML").unwrap(), "musicxml");
250/// assert_eq!(
251///     accepted("autoDownload", "asdf").unwrap_err().to_string(),
252///     "Environment error: asdf is not an acceptable value for preference: autoDownload"
253/// );
254/// ```
255pub fn accepted(key: &str, value: &str) -> Result<String> {
256    let allowed: &[&str] = match key {
257        "showFormat" => &VALID_SHOW_FORMATS,
258        "writeFormat" => &VALID_WRITE_FORMATS,
259        "autoDownload" => &VALID_AUTO_DOWNLOAD,
260        _ => return Ok(value.to_owned()),
261    };
262    let lowered = value.to_lowercase();
263    if allowed.contains(&lowered.as_str()) {
264        return Ok(lowered);
265    }
266    Err(Error::Environment(format!(
267        "{lowered} is not an acceptable value for preference: {key}"
268    )))
269}
270
271/// The debugging level `debug` is set to, read as music21 reads it: `true`
272/// and `false`, a word naming a level, or a number.
273///
274/// ```
275/// use music21::environment::debug_level;
276///
277/// assert_eq!(debug_level("True").unwrap(), 1);
278/// assert_eq!(debug_level("devel").unwrap(), 63);
279/// assert_eq!(debug_level("7").unwrap(), 7);
280/// assert!(debug_level("loud").is_err());
281/// ```
282pub fn debug_level(value: &str) -> Result<i64> {
283    let lowered = value.to_lowercase();
284    if lowered == "true" {
285        return Ok(DEBUG_USER);
286    }
287    if lowered == "false" || lowered.contains("off") {
288        return Ok(DEBUG_OFF);
289    }
290    if lowered.contains("user") {
291        return Ok(DEBUG_USER);
292    }
293    if lowered.contains("devel") {
294        return Ok(DEBUG_DEVEL);
295    }
296    if lowered.contains("all") {
297        return Ok(DEBUG_ALL);
298    }
299    let digits: String = value.trim().chars().filter(|&c| c != '_').collect();
300    digits
301        .parse()
302        .map_err(|_| Error::Value(format!("invalid literal for int() with base 10: {value:?}")))
303}
304
305/// The preference naming the application that opens a format, if any.
306///
307/// ```
308/// use music21::environment::format_to_key;
309///
310/// assert_eq!(format_to_key("lilypond"), Some("lilypondPath"));
311/// assert_eq!(format_to_key("jpeg"), Some("graphicsPath"));
312/// assert_eq!(format_to_key("ipython"), None);
313/// ```
314pub fn format_to_key(format: &str) -> Option<&'static str> {
315    match format {
316        "lilypond" => Some("lilypondPath"),
317        "png" | "jpeg" => Some("graphicsPath"),
318        "svg" => Some("vectorPath"),
319        "pdf" => Some("pdfPath"),
320        "musicxml" => Some("musicxmlPath"),
321        "midi" => Some("midiPath"),
322        "braille" => Some("braillePath"),
323        _ => None,
324    }
325}
326
327/// Which MusicXML reader a path names: music21's `xmlReaderType`.
328///
329/// ```
330/// use music21::environment::xml_reader_type;
331///
332/// assert_eq!(xml_reader_type("/Applications/Sibelius 7.app"), Some("Sibelius"));
333/// assert_eq!(xml_reader_type("/usr/bin/mscore3"), Some("unknown"));
334/// assert_eq!(xml_reader_type(""), None);
335/// ```
336pub fn xml_reader_type(path: &str) -> Option<&'static str> {
337    if path.is_empty() {
338        return None;
339    }
340    let lowered = path.to_lowercase();
341    if lowered.contains("sibelius") {
342        return Some("Sibelius");
343    }
344    if lowered.contains("finale") {
345        return Some("Finale");
346    }
347    if lowered.contains("musescore") {
348        return Some("Musescore");
349    }
350    Some("unknown")
351}
352
353/// The settings as music21 writes them out, sorted by name: a
354/// `<settings>` element holding a `<preference>` for each value and an
355/// element apiece for the local corpora.
356///
357/// Compact, as ElementTree writes a tree it has not indented; with
358/// `indented`, as music21 writes `.music21rc`: two spaces a level and a
359/// closing newline, and no XML declaration, which ElementTree leaves out
360/// of a UTF-8 file.
361///
362/// ```
363/// use music21::environment::{settings_xml, Value};
364///
365/// let settings = [("debug".to_owned(), Value::Number(0)), ("braillePath".to_owned(), Value::Unset)];
366/// assert_eq!(
367///     settings_xml(&settings, false),
368///     "<settings encoding=\"utf-8\"><preference name=\"braillePath\" />\
369///      <preference name=\"debug\" value=\"0\" /></settings>"
370/// );
371/// ```
372pub fn settings_xml(settings: &[(String, Value)], indented: bool) -> String {
373    let mut sorted: Vec<&(String, Value)> = settings.iter().collect();
374    sorted.sort_by(|first, second| first.0.cmp(&second.0));
375    let mut children = Vec::new();
376    for (key, value) in sorted {
377        children.push(setting_element(key, value));
378    }
379    let root = Element {
380        tag: "settings",
381        attributes: vec![("encoding", "utf-8".to_owned())],
382        text: None,
383        children,
384    };
385    let mut written = String::new();
386    root.write(&mut written, indented.then_some(0));
387    if indented {
388        written.push('\n');
389    }
390    written
391}
392
393/// One element of a settings file.
394struct Element {
395    tag: &'static str,
396    attributes: Vec<(&'static str, String)>,
397    text: Option<String>,
398    children: Vec<Element>,
399}
400
401impl Element {
402    fn bare(tag: &'static str) -> Self {
403        Self {
404            tag,
405            attributes: Vec::new(),
406            text: None,
407            children: Vec::new(),
408        }
409    }
410
411    /// Written as ElementTree writes it, indented at `level` as music21's
412    /// `etIndent` lays it out, or compact where `level` is `None`.
413    fn write(&self, out: &mut String, level: Option<usize>) {
414        out.push('<');
415        out.push_str(self.tag);
416        for (name, value) in &self.attributes {
417            out.push_str(&format!(" {name}=\"{}\"", escaped_attribute(value)));
418        }
419        if self.children.is_empty() && self.text.as_deref().is_none_or(str::is_empty) {
420            out.push_str(" />");
421            return;
422        }
423        out.push('>');
424        if let Some(text) = &self.text {
425            out.push_str(&escaped_text(text));
426        }
427        for child in &self.children {
428            if let Some(level) = level {
429                out.push('\n');
430                out.push_str(&"  ".repeat(level + 1));
431            }
432            child.write(out, level.map(|level| level + 1));
433        }
434        if let Some(level) = level
435            && !self.children.is_empty()
436        {
437            out.push('\n');
438            out.push_str(&"  ".repeat(level));
439        }
440        out.push_str("</");
441        out.push_str(self.tag);
442        out.push('>');
443    }
444}
445
446/// The element one setting is written as.
447fn setting_element(key: &str, value: &Value) -> Element {
448    match value {
449        Value::LocalCorpus(corpus) => corpus_element(corpus, false),
450        Value::LocalCorpora(corpora) => {
451            let mut sorted: Vec<&LocalCorpus> = corpora.iter().collect();
452            sorted.sort_by(|first, second| first.name.cmp(&second.name));
453            let mut element = Element::bare("localCorporaSettings");
454            element.children = sorted
455                .into_iter()
456                .map(|corpus| corpus_element(corpus, true))
457                .collect();
458            element
459        }
460        Value::Unset => Element {
461            attributes: vec![("name", key.to_owned())],
462            ..Element::bare("preference")
463        },
464        Value::Text(text) => Element {
465            attributes: vec![("name", key.to_owned()), ("value", text.clone())],
466            ..Element::bare("preference")
467        },
468        Value::Number(number) => Element {
469            attributes: vec![("name", key.to_owned()), ("value", number.to_string())],
470            ..Element::bare("preference")
471        },
472    }
473}
474
475/// A local corpus's element: its paths, sorted, then its cache file.
476fn corpus_element(corpus: &LocalCorpus, named: bool) -> Element {
477    let mut element = Element::bare("localCorpusSettings");
478    if named {
479        element
480            .attributes
481            .push(("name", corpus.name.clone().unwrap_or_default()));
482    }
483    let mut paths: Vec<&String> = corpus.paths.iter().collect();
484    paths.sort();
485    for path in paths {
486        element.children.push(Element {
487            text: Some(path.clone()),
488            ..Element::bare("localCorpusPath")
489        });
490    }
491    if let Some(cache) = &corpus.cache_file_path {
492        element.children.push(Element {
493            text: Some(cache.clone()),
494            ..Element::bare("cacheFilePath")
495        });
496    }
497    element
498}
499
500/// Text escaped as ElementTree escapes an element's text.
501fn escaped_text(text: &str) -> String {
502    text.replace('&', "&amp;")
503        .replace('<', "&lt;")
504        .replace('>', "&gt;")
505}
506
507/// Text escaped as ElementTree escapes an attribute's value.
508fn escaped_attribute(text: &str) -> String {
509    escaped_text(text)
510        .replace('"', "&quot;")
511        .replace('\r', "&#13;")
512        .replace('\n', "&#10;")
513        .replace('\t', "&#09;")
514}
515
516/// The settings a settings file holds, in the order it holds them, as
517/// music21's `_fromSettings` reads them: each `<preference>` by its name,
518/// with its value or none, and the local corpora.
519///
520/// ```
521/// use music21::environment::{read_settings, LocalCorpus, Value};
522///
523/// let read = read_settings(
524///     "<settings><preference name=\"midiPath\" value=\"w\"/>\
525///      <localCorpusSettings><localCorpusPath> x </localCorpusPath></localCorpusSettings>\
526///      </settings>",
527/// )
528/// .unwrap();
529/// assert_eq!(read[0], ("midiPath".to_owned(), Value::text("w")));
530/// assert_eq!(
531///     read[1].1,
532///     Value::LocalCorpus(LocalCorpus { paths: vec!["x".to_owned()], ..Default::default() })
533/// );
534/// ```
535pub fn read_settings(document: &str) -> Result<Vec<(String, Value)>> {
536    let root =
537        crate::xml::Xml::parse(document).map_err(|error| Error::Environment(error.to_string()))?;
538    let mut found = Vec::new();
539    for slot in &root.children {
540        match slot.tag.as_str() {
541            "localCorpusSettings" => {
542                found.push((slot.tag.clone(), Value::LocalCorpus(read_corpus(slot))));
543            }
544            "localCorporaSettings" => {
545                let corpora = slot.children.iter().map(read_corpus).collect();
546                found.push((slot.tag.clone(), Value::LocalCorpora(corpora)));
547            }
548            _ => {
549                let Some(name) = slot.get("name") else {
550                    continue;
551                };
552                let value = match slot.get("value") {
553                    Some(value) => Value::text(value),
554                    None => Value::Unset,
555                };
556                found.push((name.to_owned(), value));
557            }
558        }
559    }
560    Ok(found)
561}
562
563/// One `<localCorpusSettings>` read back.
564fn read_corpus(slot: &crate::xml::Xml) -> LocalCorpus {
565    let mut corpus = LocalCorpus {
566        name: slot.get("name").map(str::to_owned),
567        ..LocalCorpus::default()
568    };
569    for child in &slot.children {
570        match child.tag.as_str() {
571            "localCorpusPath" => corpus.paths.push(child.stripped().to_owned()),
572            "cacheFilePath" => corpus.cache_file_path = Some(child.stripped().to_owned()),
573            _ => {}
574        }
575    }
576    corpus
577}
578
579/// Where the settings file is on `platform`, as music21's
580/// `getSettingsPath` finds it.
581///
582/// `variable` reads the environment, `writable` says whether a directory
583/// can be written to, and `temporary` is the directory to fall back on
584/// where the home directory cannot be: music21's
585/// `getDefaultRootTempDir`.
586///
587/// ```
588/// use std::path::{Path, PathBuf};
589/// use music21::environment::{settings_path, Platform};
590///
591/// let home = |name: &str| (name == "HOME").then(|| "/home/me".to_owned());
592/// let found = settings_path(Platform::Nix, home, |_: &Path| true, || PathBuf::from("/tmp/music21"));
593/// assert_eq!(found, PathBuf::from("/home/me/.music21rc"));
594/// let found = settings_path(Platform::Nix, |_: &str| None, |_: &Path| true, || PathBuf::from("/tmp/music21"));
595/// assert_eq!(found, PathBuf::from("/tmp/music21/.music21rc"));
596/// ```
597pub fn settings_path(
598    platform: Platform,
599    variable: impl Fn(&str) -> Option<String>,
600    writable: impl Fn(&Path) -> bool,
601    temporary: impl FnOnce() -> PathBuf,
602) -> PathBuf {
603    if platform == Platform::Windows {
604        let directory = match (variable("APPDATA"), variable("USERPROFILE")) {
605            (Some(appdata), _) => PathBuf::from(appdata),
606            (None, Some(profile)) => PathBuf::from(profile).join("Application Data"),
607            (None, None) => PathBuf::from(expand_user("~")),
608        };
609        return directory.join("music21-settings.xml");
610    }
611    let directory = match variable("HOME") {
612        Some(home) if writable(Path::new(&home)) => PathBuf::from(home),
613        _ => temporary(),
614    };
615    directory.join(".music21rc")
616}
617
618/// Whether a path exists and can be read and written, roughly: it is there
619/// and not marked read-only. Callers that can ask the system properly
620/// (Python's `os.access`) pass their own answer to the functions here.
621pub fn is_accessible(path: &Path) -> bool {
622    std::fs::metadata(path).is_ok_and(|metadata| !metadata.permissions().readonly())
623}
624
625/// Whether a directory is safe to keep temporary files in: accessible and,
626/// where `user` is given, a real directory (not a link) that user owns and
627/// no one else can write to. music21's `_isSafeTempDir`.
628pub fn is_safe_temp_dir(
629    path: &Path,
630    user: Option<u32>,
631    accessible: impl Fn(&Path) -> bool,
632) -> bool {
633    if !accessible(path) {
634        return false;
635    }
636    let Some(user) = user else {
637        return true;
638    };
639    owned_privately(path, user)
640}
641
642#[cfg(unix)]
643fn owned_privately(path: &Path, user: u32) -> bool {
644    use std::os::unix::fs::MetadataExt;
645
646    std::fs::symlink_metadata(path).is_ok_and(|metadata| {
647        metadata.file_type().is_dir() && metadata.uid() == user && metadata.mode() & 0o022 == 0
648    })
649}
650
651#[cfg(not(unix))]
652fn owned_privately(path: &Path, _user: u32) -> bool {
653    std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir())
654}
655
656/// A directory for music21's temporary files under `system_temp`, as
657/// music21's `getDefaultRootTempDir` picks it: `music21` there, then
658/// `music21-userid-<user>` where a user is given, each made if missing and
659/// used only if [`is_safe_temp_dir`]; failing both, a new directory named
660/// `music21-` and something random.
661///
662/// ```
663/// use music21::environment::{default_root_temp_dir, is_accessible};
664///
665/// let system = std::env::temp_dir();
666/// let found = default_root_temp_dir(&system, None, is_accessible).unwrap();
667/// assert!(found.starts_with(&system));
668/// ```
669pub fn default_root_temp_dir(
670    system_temp: &Path,
671    user: Option<u32>,
672    accessible: impl Fn(&Path) -> bool,
673) -> std::io::Result<PathBuf> {
674    let mut candidates = vec![system_temp.join("music21")];
675    if let Some(user) = user {
676        candidates.push(system_temp.join(format!("music21-userid-{user}")));
677    }
678    for candidate in candidates {
679        // One that is there already, or cannot be made, is judged as it is.
680        let _ = private_directory(&candidate);
681        if is_safe_temp_dir(&candidate, user, &accessible) {
682            return Ok(candidate);
683        }
684    }
685    fresh_temp_dir(system_temp)
686}
687
688/// Makes a directory only its owner can open, as `mkdir(mode=0o700)` does.
689fn private_directory(path: &Path) -> std::io::Result<()> {
690    let mut builder = std::fs::DirBuilder::new();
691    #[cfg(unix)]
692    {
693        use std::os::unix::fs::DirBuilderExt;
694        builder.mode(0o700);
695    }
696    builder.create(path)
697}
698
699/// A new directory named `music21-` and eight random characters, as
700/// `tempfile.mkdtemp(prefix='music21-')` makes one.
701fn fresh_temp_dir(system_temp: &Path) -> std::io::Result<PathBuf> {
702    use std::hash::{BuildHasher, Hasher};
703
704    const LETTERS: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789_";
705    let random = std::collections::hash_map::RandomState::new();
706    for attempt in 0..100u64 {
707        let mut hasher = random.build_hasher();
708        hasher.write_u64(attempt);
709        let mut seed = hasher.finish();
710        let mut name = String::from("music21-");
711        for _ in 0..8 {
712            name.push(char::from(LETTERS[(seed % LETTERS.len() as u64) as usize]));
713            seed /= LETTERS.len() as u64;
714        }
715        let candidate = system_temp.join(name);
716        match private_directory(&candidate) {
717            Ok(()) => return Ok(candidate),
718            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
719            Err(error) => return Err(error),
720        }
721    }
722    Err(std::io::Error::new(
723        std::io::ErrorKind::AlreadyExists,
724        "no unused temporary directory name was found",
725    ))
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    #[test]
733    fn darwin_settings_are_written_as_music21_writes_them() {
734        let settings: Vec<(String, Value)> = defaults(Platform::Darwin, (14, 0, 0))
735            .into_iter()
736            .map(|(key, value)| (key.to_owned(), value))
737            .collect();
738        let written = settings_xml(&settings, true);
739        let lines: Vec<&str> = written.lines().collect();
740        assert_eq!(lines[0], "<settings encoding=\"utf-8\">");
741        assert_eq!(
742            lines[1],
743            "  <preference name=\"autoDownload\" value=\"ask\" />"
744        );
745        assert_eq!(lines[11], "  <localCorporaSettings />");
746        assert_eq!(lines[12], "  <localCorpusSettings />");
747        assert_eq!(lines.last(), Some(&"</settings>"));
748        assert!(written.ends_with("</settings>\n"));
749    }
750
751    #[test]
752    fn local_corpora_are_written_nested_and_read_back() {
753        let corpus = LocalCorpus {
754            name: Some("foo".to_owned()),
755            paths: vec!["baz".to_owned(), "bar".to_owned()],
756            cache_file_path: Some("/tmp/local.json".to_owned()),
757        };
758        let settings = vec![(
759            "localCorporaSettings".to_owned(),
760            Value::LocalCorpora(vec![corpus.clone()]),
761        )];
762        let written = settings_xml(&settings, true);
763        assert!(written.contains(
764            "  <localCorporaSettings>\n    <localCorpusSettings name=\"foo\">\n      \
765             <localCorpusPath>bar</localCorpusPath>\n"
766        ));
767        let read = read_settings(&written).unwrap();
768        let Value::LocalCorpora(corpora) = &read[0].1 else {
769            panic!("not read back as corpora: {read:?}");
770        };
771        assert_eq!(corpora[0].name.as_deref(), Some("foo"));
772        assert_eq!(corpora[0].paths, ["bar", "baz"]);
773        assert_eq!(corpora[0].cache_file_path, corpus.cache_file_path);
774    }
775
776    #[test]
777    fn special_characters_survive_the_round_trip() {
778        let path = "/Applications/Q&A <Beta>/MuseScore 4.app";
779        let settings = vec![("musicxmlPath".to_owned(), Value::text(path))];
780        let written = settings_xml(&settings, false);
781        assert!(written.contains("Q&amp;A &lt;Beta&gt;"));
782        assert_eq!(read_settings(&written).unwrap()[0].1, Value::text(path));
783    }
784
785    #[test]
786    fn a_settings_file_that_is_not_xml_is_an_error() {
787        assert!(matches!(
788            read_settings("<settings>"),
789            Err(Error::Environment(_))
790        ));
791    }
792
793    #[cfg(unix)]
794    #[test]
795    fn a_directory_others_can_write_is_passed_over() {
796        use std::os::unix::fs::{MetadataExt, PermissionsExt};
797
798        let parent = std::env::temp_dir().join(format!("music21-rs-env-{}", std::process::id()));
799        let _ = std::fs::remove_dir_all(&parent);
800        std::fs::create_dir(&parent).unwrap();
801        let shared = parent.join("music21");
802        std::fs::create_dir(&shared).unwrap();
803        std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o777)).unwrap();
804        let user = std::fs::metadata(&parent).unwrap().uid();
805        let chosen = default_root_temp_dir(&parent, Some(user), is_accessible).unwrap();
806        assert_eq!(chosen, parent.join(format!("music21-userid-{user}")));
807        let mode = std::fs::metadata(&chosen).unwrap().mode();
808        assert_eq!(mode & 0o077, 0);
809        std::fs::remove_dir_all(&parent).unwrap();
810    }
811}