1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
33pub enum Platform {
34 Windows,
36 Darwin,
38 Nix,
40}
41
42impl Platform {
43 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 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 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
89pub 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#[derive(Clone, Debug, Default, Eq, PartialEq)]
107pub struct LocalCorpus {
108 pub name: Option<String>,
110 pub paths: Vec<String>,
112 pub cache_file_path: Option<String>,
114}
115
116#[derive(Clone, Debug, Eq, PartialEq)]
118pub enum Value {
119 Unset,
121 Text(String),
123 Number(i64),
125 LocalCorpus(LocalCorpus),
127 LocalCorpora(Vec<LocalCorpus>),
129}
130
131impl Value {
132 pub fn text(text: impl Into<String>) -> Self {
134 Self::Text(text.into())
135 }
136}
137
138pub 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
190fn 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
232pub fn is_path_key(key: &str) -> bool {
239 PATH_KEYS.contains(&key)
240}
241
242pub 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
271pub 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
305pub 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
327pub 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
353pub 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
393struct 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 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
446fn 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
475fn 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
500fn escaped_text(text: &str) -> String {
502 text.replace('&', "&")
503 .replace('<', "<")
504 .replace('>', ">")
505}
506
507fn escaped_attribute(text: &str) -> String {
509 escaped_text(text)
510 .replace('"', """)
511 .replace('\r', " ")
512 .replace('\n', " ")
513 .replace('\t', "	")
514}
515
516pub 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
563fn 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
579pub 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
618pub fn is_accessible(path: &Path) -> bool {
622 std::fs::metadata(path).is_ok_and(|metadata| !metadata.permissions().readonly())
623}
624
625pub 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
656pub 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 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
688fn 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
699fn 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&A <Beta>"));
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}