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