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