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