1mod atomic;
41mod dirs;
42mod documents;
43mod ecosystem;
44mod folder_watch;
45#[cfg(test)]
46mod healing_tests;
47mod instance_lock;
48mod lock;
49mod machine;
50mod migrate;
51mod preferences;
52mod schema;
53mod update_notice;
54mod value;
55
56use std::fs;
57use std::io;
58use std::path::{Path, PathBuf};
59
60use toml::de::{DeTable, DeValue};
61
62pub use atomic::{WriteStep, atomic_write, atomic_write_reporting};
63pub use dirs::{cache_dir, config_dir, data_dir, state_dir};
64pub use documents::documents_dir;
65pub use ecosystem::Ecosystem;
66pub type Family = Ecosystem;
70pub use folder_watch::{FolderChange, FolderChangeKind, FolderChanges, FolderWatch};
71pub use instance_lock::InstanceLock;
72pub use lock::{AppLock, holder_pid};
73pub use machine::machine_name;
74pub use migrate::Migration;
75pub use preferences::{Preferences, Resolved, Scope, Shared, Source};
76pub use schema::{Schema, SettingKind};
77pub use value::{Setting, SettingValue};
78
79use crate::diagnostics::{Diagnostic, Location};
80use crate::doc::Doc;
81use crate::icons::IconMode;
82use crate::runtime::Command;
83
84const FILE_NAME: &str = "settings.toml";
86
87#[derive(Debug, Clone, Default, PartialEq)]
90pub struct Settings {
91 path: Option<PathBuf>,
92 values: Vec<(String, SettingValue)>,
93 diagnostics: Vec<Diagnostic>,
94 keep_backup: bool,
95 origins: Vec<(String, Location)>,
97 skipped: Vec<String>,
99 read_problems: usize,
101 repairs: Vec<Diagnostic>,
103 schema: Option<Schema>,
104 self_heal: bool,
105 ecosystem: Option<Ecosystem>,
108}
109
110impl Settings {
111 pub const THEME: &'static str = "theme";
113 pub const LANGUAGE: &'static str = "language";
115 pub const ICONS: &'static str = "icons";
117 pub const REDUCED_MOTION: &'static str = "reduced-motion";
119 pub const PILLAR: &'static str = "pillar";
121 pub const SLIDE: &'static str = "slide";
123 pub const UPDATE_NOTICE: &'static str = "update-notice";
126
127 #[must_use]
130 pub fn in_memory() -> Self {
131 Self::default()
132 }
133
134 #[must_use]
140 pub fn load(app: &str) -> Self {
141 Self::open_or_keep_in_memory(config_dir(app).map(|dir| dir.join(FILE_NAME)))
142 }
143
144 #[must_use]
150 pub fn load_member(ecosystem: &Ecosystem, app: &str) -> Self {
151 Self::open_or_keep_in_memory(ecosystem.app_file(app)).member_of(ecosystem)
152 }
153
154 #[must_use]
179 pub fn member_of(mut self, ecosystem: &Ecosystem) -> Self {
180 self.ecosystem = Some(*ecosystem);
181 self.review();
182 self
183 }
184
185 fn follows_ecosystem(&self, key: &str, value: &SettingValue) -> bool {
187 let Some(ecosystem) = self.ecosystem else { return false };
188 Shared::ALL.iter().any(|shared| shared.key() == key)
189 && matches!(value, SettingValue::Text(text) if text == ecosystem.id())
190 }
191
192 fn open_or_keep_in_memory(path: Option<PathBuf>) -> Self {
194 match path {
195 Some(path) => Self::open(path),
196 None => {
197 let mut settings = Self::in_memory();
198 settings.read_problem(Diagnostic::warning(None, "no config directory found; settings are not saved"));
199 settings
200 }
201 }
202 }
203
204 #[must_use]
208 pub fn with_diagnostics(mut self, diagnostics: impl IntoIterator<Item = Diagnostic>) -> Self {
209 let before: Vec<Diagnostic> = diagnostics.into_iter().collect();
210 self.read_problems += before.len();
211 self.diagnostics.splice(0..0, before);
212 self
213 }
214
215 #[must_use]
217 pub fn open(path: impl Into<PathBuf>) -> Self {
218 let path = path.into();
219 let mut settings = Self { path: Some(path.clone()), ..Self::default() };
220 match fs::read_to_string(&path) {
221 Ok(text) => settings.parse(&display_name(&path), &text),
222 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
223 Err(error) => {
224 settings.keep_backup = true;
225 settings.read_problem(Diagnostic::error(None, format!("{}: {error}", path.display())));
226 }
227 }
228 settings
229 }
230
231 fn read_problem(&mut self, diagnostic: Diagnostic) {
233 self.diagnostics.push(diagnostic);
234 self.read_problems = self.diagnostics.len();
235 }
236
237 #[must_use]
239 pub fn parse_str(file: &str, text: &str) -> Self {
240 let mut settings = Self::default();
241 settings.parse(file, text);
242 settings
243 }
244
245 fn parse(&mut self, file: &str, text: &str) {
246 let (root, errors) = Doc::new(file, text).parse_recoverable();
247 self.keep_backup |= !errors.is_empty();
248 self.diagnostics.extend(errors);
249 let mut reader = Reader { file, text, settings: self };
250 reader.table(&root, "");
251 self.read_problems = self.diagnostics.len();
252 self.review();
253 }
254
255 #[must_use]
268 pub fn schema(mut self, schema: Schema) -> Self {
269 self.schema = Some(schema);
270 self.review();
271 self
272 }
273
274 #[must_use]
286 pub fn self_heal(mut self, on: bool) -> Self {
287 self.self_heal = on;
288 self.review();
289 self
290 }
291
292 fn review(&mut self) {
295 self.diagnostics.truncate(self.read_problems);
296 self.diagnostics.extend(self.repairs.iter().cloned());
297 let explicit = self.schema.is_some();
298 let heal = self.self_heal && explicit;
299 let schema = self.schema.clone().unwrap_or_else(Schema::builtin);
300 let mut changed = false;
301 let keys: Vec<String> = self.keys().map(str::to_owned).collect();
302 for key in keys {
303 let location = self.origin(&key);
304 let Some(rule) = schema.get(&key) else {
305 if schema.is_open(&key) {
306 continue;
307 }
308 if heal {
309 self.remove(&key);
310 self.repaired(Diagnostic::warning(location, format!("`{key}` is not a known setting; removed")));
311 changed = true;
312 } else if explicit {
313 self.diagnostics
314 .push(Diagnostic::warning(location, format!("`{key}` is not a known setting; it is ignored")));
315 }
316 continue;
317 };
318 let Some(value) =
319 self.value(&key).filter(|value| !rule.accepts(value) && !self.follows_ecosystem(&key, value))
320 else {
321 continue;
322 };
323 let found = value.literal();
324 let expected = rule.describe();
325 if heal {
326 let message = match rule.default_value().cloned() {
327 Some(default) => {
328 let message =
329 format!("`{key}` must be {expected}, found {found}; replaced with {}", default.literal());
330 self.store(&key, default);
331 message
332 }
333 None => {
334 self.remove(&key);
335 format!("`{key}` must be {expected}, found {found}; removed")
336 }
337 };
338 self.repaired(Diagnostic::warning(location, message));
339 changed = true;
340 } else {
341 self.diagnostics.push(Diagnostic::warning(
342 location,
343 format!("`{key}` must be {expected}, found {found}; it is ignored"),
344 ));
345 }
346 }
347 if heal {
348 for key in std::mem::take(&mut self.skipped) {
350 if let Some(rule) = schema.get(&key)
351 && self.value(&key).is_none()
352 {
353 let message = match rule.default_value().cloned() {
354 Some(default) => {
355 let message = format!(
356 "`{key}` holds a value settings cannot store; replaced with {}",
357 default.literal()
358 );
359 self.store(&key, default);
360 message
361 }
362 None => format!("`{key}` holds a value settings cannot store; removed"),
363 };
364 self.repaired(Diagnostic::warning(self.origin(&key), message));
365 changed = true;
366 }
367 }
368 changed |= self.separate_tables(&schema);
369 }
370 if changed && self.path.is_some() {
371 self.keep_backup = true;
373 if let Err(error) = self.save() {
374 let place = self.path.as_deref().map(|path| path.display().to_string()).unwrap_or_default();
375 self.diagnostics
376 .push(Diagnostic::error(None, format!("{place}: repaired settings not saved: {error}")));
377 }
378 }
379 }
380
381 fn separate_tables(&mut self, schema: &Schema) -> bool {
385 let nested = |a: &str, b: &str| {
386 let (short, long) = if a.len() < b.len() { (a, b) } else { (b, a) };
387 long.strip_prefix(short).is_some_and(|rest| rest.starts_with('.'))
388 };
389 let mut removed = false;
390 loop {
391 let clash = self.values.iter().enumerate().find_map(|(later, (key, _))| {
392 self.values[..later].iter().position(|(kept, _)| nested(kept, key)).map(|first| (first, later))
393 });
394 let Some((first, later)) = clash else { break };
395 let declared = |index: usize| schema.get(&self.values[index].0).is_some();
396 let (gone, stays) = if declared(later) && !declared(first) { (first, later) } else { (later, first) };
397 let kept = self.values[stays].0.clone();
398 let (key, _) = self.values.remove(gone);
399 let message = format!("`{key}` cannot sit next to `{kept}` in one file; removed");
400 self.repaired(Diagnostic::warning(self.origin(&key), message));
401 removed = true;
402 }
403 removed
404 }
405
406 fn repaired(&mut self, diagnostic: Diagnostic) {
407 self.repairs.push(diagnostic.clone());
408 self.diagnostics.push(diagnostic);
409 }
410
411 fn store(&mut self, key: &str, value: SettingValue) -> bool {
414 match self.values.iter_mut().find(|(k, _)| k == key) {
415 Some((_, current)) if *current == value => false,
416 Some((_, current)) => {
417 *current = value;
418 true
419 }
420 None => {
421 self.values.push((key.to_owned(), value));
422 true
423 }
424 }
425 }
426
427 fn origin(&self, key: &str) -> Option<Location> {
429 self.origins.iter().find(|(k, _)| k == key).map(|(_, location)| location.clone())
430 }
431
432 #[must_use]
434 pub fn path(&self) -> Option<&Path> {
435 self.path.as_deref()
436 }
437
438 #[must_use]
440 pub fn diagnostics(&self) -> &[Diagnostic] {
441 &self.diagnostics
442 }
443
444 #[must_use]
446 pub fn value(&self, key: &str) -> Option<&SettingValue> {
447 self.values.iter().find(|(k, _)| k == key).map(|(_, value)| value)
448 }
449
450 #[must_use]
452 pub fn get<T: Setting>(&self, key: &str) -> Option<T> {
453 self.value(key).and_then(T::from_setting)
454 }
455
456 #[must_use]
458 pub fn get_or<T: Setting>(&self, key: &str, default: T) -> T {
459 self.get(key).unwrap_or(default)
460 }
461
462 pub fn set<T: Setting>(&mut self, key: &str, value: T) -> bool {
464 self.store(key, value.to_setting())
465 }
466
467 pub fn remove(&mut self, key: &str) -> bool {
469 let before = self.values.len();
470 self.values.retain(|(k, _)| k != key);
471 before != self.values.len()
472 }
473
474 pub fn keys(&self) -> impl Iterator<Item = &str> {
476 self.values.iter().map(|(key, _)| key.as_str())
477 }
478
479 #[must_use]
482 pub fn theme(&self) -> Option<String> {
483 self.own(Self::THEME)
484 }
485
486 #[must_use]
489 pub fn language(&self) -> Option<String> {
490 self.own(Self::LANGUAGE)
491 }
492
493 fn own(&self, key: &str) -> Option<String> {
495 self.value(key).filter(|value| !self.follows_ecosystem(key, value)).and_then(String::from_setting)
496 }
497
498 #[must_use]
500 pub fn icon_mode(&self) -> Option<IconMode> {
501 self.get::<String>(Self::ICONS).and_then(|name| IconMode::from_name(&name))
502 }
503
504 #[must_use]
506 pub fn reduced_motion(&self) -> Option<bool> {
507 self.get(Self::REDUCED_MOTION)
508 }
509
510 #[must_use]
512 pub fn pillar_style(&self) -> Option<crate::icons::PillarStyle> {
513 self.get::<String>(Self::PILLAR).and_then(|name| crate::icons::PillarStyle::from_name(&name))
514 }
515
516 #[must_use]
518 pub fn slide(&self) -> Option<bool> {
519 self.get(Self::SLIDE)
520 }
521
522 #[must_use]
525 pub fn apply<Msg: Send + 'static>(&self) -> Command<Msg> {
526 let mut commands = Vec::new();
527 if let Some(theme) = self.theme() {
528 commands.push(Command::set_theme(theme));
529 }
530 if let Some(language) = self.language() {
531 commands.push(Command::set_locale(language));
532 }
533 if let Some(mode) = self.icon_mode() {
534 commands.push(Command::set_icon_mode(mode));
535 }
536 if let Some(reduced) = self.reduced_motion() {
537 commands.push(Command::set_reduced_motion(reduced));
538 }
539 if let Some(style) = self.pillar_style() {
540 commands.push(Command::set_pillar(style));
541 }
542 if let Some(slide) = self.slide() {
543 commands.push(Command::set_slide(slide));
544 }
545 Command::batch(commands)
546 }
547
548 #[must_use]
550 pub fn to_toml(&self) -> String {
551 let mut out = String::new();
552 let mut sections: Vec<(&str, Vec<(&str, &SettingValue)>)> = Vec::new();
553 for (key, value) in &self.values {
554 let (section, name) = key.rsplit_once('.').unwrap_or(("", key));
555 match sections.iter_mut().find(|(s, _)| *s == section) {
556 Some((_, entries)) => entries.push((name, value)),
557 None => sections.push((section, vec![(name, value)])),
558 }
559 }
560 sections.sort_by_key(|(section, _)| !section.is_empty());
561 for (section, entries) in sections {
562 if !section.is_empty() {
563 if !out.is_empty() {
564 out.push('\n');
565 }
566 out.push('[');
567 for (index, part) in section.split('.').enumerate() {
568 if index > 0 {
569 out.push('.');
570 }
571 value::key(part, &mut out);
572 }
573 out.push_str("]\n");
574 }
575 for (name, value) in entries {
576 value::key(name, &mut out);
577 out.push_str(" = ");
578 value.write(&mut out);
579 out.push('\n');
580 }
581 }
582 out
583 }
584
585 pub fn save(&mut self) -> io::Result<()> {
593 let Some(path) = self.path.clone() else {
594 return Ok(());
595 };
596 if let Some(dir) = path.parent() {
597 fs::create_dir_all(dir)?;
598 }
599 if self.keep_backup && path.exists() {
600 fs::copy(&path, backup_path(&path))?;
601 }
602 atomic_write(&path, self.to_toml().as_bytes())?;
603 self.keep_backup = false;
604 Ok(())
605 }
606
607 #[must_use]
610 pub fn save_command<Msg: Send + 'static>(
611 &self,
612 done: impl FnOnce(Result<(), String>) -> Msg + Send + 'static,
613 ) -> Command<Msg> {
614 let mut copy = self.clone();
615 Command::perform(move || done(copy.save().map_err(|error| error.to_string())))
616 }
617}
618
619struct Reader<'a> {
621 file: &'a str,
622 text: &'a str,
623 settings: &'a mut Settings,
624}
625
626impl Reader<'_> {
627 fn table(&mut self, table: &DeTable<'_>, prefix: &str) {
628 for (name, value) in table {
629 let key =
630 if prefix.is_empty() { name.get_ref().to_string() } else { format!("{prefix}.{}", name.get_ref()) };
631 if let DeValue::Table(inner) = value.get_ref() {
632 self.table(inner, &key);
633 continue;
634 }
635 let origin = Location::from_offset(self.file, self.text, name.span().start);
638 self.settings.origins.retain(|(k, _)| *k != key);
639 self.settings.origins.push((key.clone(), origin));
640 match self.value(value.get_ref()) {
641 Some(parsed) => {
642 self.settings.values.retain(|(k, _)| *k != key);
643 self.settings.values.push((key, parsed));
644 }
645 None => {
646 self.settings.skipped.push(key.clone());
647 let location = Location::from_offset(self.file, self.text, value.span().start);
648 self.settings.diagnostics.push(Diagnostic::warning(
649 Some(location),
650 format!(
651 "`{key}` holds a {} that settings do not store; it is skipped",
652 value.get_ref().type_str()
653 ),
654 ));
655 self.settings.keep_backup = true;
656 }
657 }
658 }
659 }
660
661 fn value(&self, value: &DeValue<'_>) -> Option<SettingValue> {
662 Some(match value {
663 DeValue::Boolean(flag) => SettingValue::Bool(*flag),
664 DeValue::String(text) => SettingValue::Text(text.to_string()),
665 DeValue::Integer(number) => {
666 SettingValue::Integer(i64::from_str_radix(number.as_str(), number.radix()).ok()?)
667 }
668 DeValue::Float(number) => SettingValue::Float(number.as_str().replace('_', "").parse().ok()?),
669 DeValue::Array(items) => {
670 SettingValue::List(items.iter().map(|item| self.value(item.get_ref())).collect::<Option<Vec<_>>>()?)
671 }
672 DeValue::Datetime(_) | DeValue::Table(_) => return None,
673 })
674 }
675}
676
677fn backup_path(path: &Path) -> PathBuf {
680 let mut name = path.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_default();
681 name.push(".bak");
682 path.with_file_name(name)
683}
684
685fn display_name(path: &Path) -> String {
686 path.file_name().and_then(|name| name.to_str()).unwrap_or(FILE_NAME).to_owned()
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 fn temp_dir(name: &str) -> PathBuf {
694 let dir = std::env::temp_dir().join(format!("quvyta-storage-{name}-{}", std::process::id()));
695 let _ = fs::remove_dir_all(&dir);
696 dir
697 }
698
699 #[test]
700 fn typed_get_set_and_round_trip() {
701 let mut settings = Settings::in_memory();
702 assert!(settings.set(Settings::THEME, "nordic".to_owned()));
703 assert!(!settings.set(Settings::THEME, "nordic".to_owned()));
704 settings.set("editor.tab-width", 4u16);
705 settings.set("editor.ratio", 0.25f64);
706 settings.set("recent.projects", vec!["api".to_owned(), "web \"beta\"".to_owned()]);
707 settings.set(Settings::REDUCED_MOTION, true);
708 let text = settings.to_toml();
709 assert_eq!(
710 text,
711 "theme = \"nordic\"\nreduced-motion = true\n\n[editor]\ntab-width = 4\nratio = 0.25\n\n[recent]\nprojects = [\"api\", \"web \\\"beta\\\"\"]\n"
712 );
713 let back = Settings::parse_str("settings.toml", &text);
714 assert!(back.diagnostics().is_empty(), "{:?}", back.diagnostics());
715 assert_eq!(back.get::<u16>("editor.tab-width"), Some(4));
716 assert_eq!(back.get::<Vec<String>>("recent.projects").map(|p| p.len()), Some(2));
717 assert_eq!(back.get::<bool>("editor.tab-width"), None);
718 assert_eq!(back.get_or("missing", 7u8), 7);
719 assert_eq!(back.theme().as_deref(), Some("nordic"));
720 }
721
722 #[test]
723 fn broken_files_give_located_diagnostics_and_keep_good_values() {
724 let text =
725 "theme = \"amber\"\nlanguage = \nicons = \"sparkly\"\nreduced-motion = \"yes\"\nstarted = 2026-09-16\n";
726 let settings = Settings::parse_str("settings.toml", text);
727 assert_eq!(settings.theme().as_deref(), Some("amber"));
728 let lines: Vec<(usize, String)> = settings
729 .diagnostics()
730 .iter()
731 .map(|d| (d.location.as_ref().map_or(0, |l| l.line), d.message.clone()))
732 .collect();
733 assert!(lines.iter().any(|(line, _)| *line == 2), "{lines:?}");
734 assert!(lines.iter().any(|(line, m)| *line == 3 && m.contains("auto, nerd")), "{lines:?}");
735 assert!(lines.iter().any(|(line, m)| *line == 4 && m.contains("boolean")), "{lines:?}");
736 assert!(lines.iter().any(|(line, m)| *line == 5 && m.contains("datetime")), "{lines:?}");
737 assert_eq!(settings.icon_mode(), None);
738 assert_eq!(settings.reduced_motion(), None);
739 }
740
741 #[test]
742 fn saves_atomically_and_backs_up_broken_files() {
743 let dir = temp_dir("save");
744 let path = dir.join("nested").join(FILE_NAME);
745 let mut settings = Settings::open(&path);
746 assert!(settings.diagnostics().is_empty());
747 settings.set(Settings::LANGUAGE, "tr".to_owned());
748 settings.save().expect("saved");
749 assert_eq!(fs::read_to_string(&path).expect("written"), "language = \"tr\"\n");
750 let leftovers: Vec<_> = fs::read_dir(path.parent().expect("dir")).expect("list").collect();
751 assert_eq!(leftovers.len(), 1, "no temporary file stays behind");
752
753 fs::write(&path, "language = \"tr\"\nicons = [\n").expect("break the file");
754 let mut broken = Settings::open(&path);
755 assert!(!broken.diagnostics().is_empty());
756 assert_eq!(broken.language().as_deref(), Some("tr"));
757 broken.set(Settings::ICONS, "ascii".to_owned());
758 broken.save().expect("saved");
759 assert!(fs::read_to_string(path.with_extension("toml.bak")).expect("backup").contains("icons = ["));
760 assert_eq!(Settings::open(&path).icon_mode(), Some(IconMode::Ascii));
761 fs::remove_dir_all(&dir).expect("clean");
762 }
763
764 #[test]
765 fn the_backup_is_the_file_name_with_bak_added() {
766 let dir = temp_dir("backup-name");
767 fs::create_dir_all(&dir).expect("dir");
768 let path = dir.join("code.conf");
769 fs::write(&path, "language = \"tr\"\nicons = [\n").expect("a broken file");
770 let mut broken = Settings::open(&path);
771 broken.set(Settings::ICONS, "ascii".to_owned());
772 broken.save().expect("saved");
773 assert!(fs::read_to_string(dir.join("code.conf.bak")).expect("backup").contains("icons = ["));
774 assert!(!dir.join("code.toml.bak").exists(), "not named after another extension");
775 fs::remove_dir_all(&dir).expect("clean");
776 }
777
778 #[test]
779 fn a_ecosystem_member_loads_its_own_conf_file() {
780 let settings = Settings::load_member(&Ecosystem::QUVYTA, "code");
781 match Ecosystem::QUVYTA.app_file("code") {
782 Some(file) => assert_eq!(settings.path(), Some(file.as_path())),
783 None => assert_eq!(settings.diagnostics()[0].message, "no config directory found; settings are not saved"),
784 }
785 }
786
787 #[test]
788 fn diagnostics_from_around_loading_come_first_and_stay() {
789 let adopted = Diagnostic::warning(None, "old/settings.toml: new.conf already exists");
790 let settings = Settings::parse_str("code.conf", "color = \"red\"\n")
791 .with_diagnostics([adopted.clone()])
792 .schema(Schema::builtin())
793 .self_heal(false);
794 assert_eq!(settings.diagnostics().len(), 2, "{:?}", settings.diagnostics());
795 assert_eq!(settings.diagnostics()[0], adopted);
796 let checked = settings.schema(Schema::default());
797 assert_eq!(checked.diagnostics()[0], adopted, "a second check keeps it");
798 }
799
800 #[test]
801 fn apply_turns_saved_values_into_commands() {
802 let settings = Settings::parse_str("s.toml", "theme = \"iris\"\nicons = \"ascii\"\n");
803 let command: Command<()> = settings.apply();
804 assert_eq!(command.actions.len(), 2);
805 }
806}