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