1 //! Local user preferences.
2 //!
3 //! Persisted as TOML at `$SIGIT_CONFIG_DIR/settings.toml` or
4 //! `~/.config/sigit/settings.toml`. Mirrors the storage pattern of
5 //! [`crate::credentials`] but holds preferences rather than secrets, so it is
6 //! not permission-restricted.
7 //!
8 //! The only setting today is `local_inference`: whether on-device inference is
9 //! the active mode. It is the source of truth for the local/cloud toggle and
10 //! drives how `/models` presents the picker. It is stored locally so the toggle
11 //! works even on ACP clients that do not support slash commands (e.g. Xcode),
12 //! where it is also surfaced as a session config option.
13
14 use std::path::PathBuf;
15
16 use serde::{Deserialize, Serialize};
17
18 /// Env override for `local_inference`. When set to a truthy/falsy value it wins
19 /// over the stored file for reads (matching the existing `SIGIT_*` override
20 /// style); it never writes the file.
21 const LOCAL_INFERENCE_ENV: &str = "SIGIT_LOCAL_INFERENCE";
22
23 fn default_local_inference() -> bool {
24 true
25 }
26
27 /// Persisted preferences. New fields must carry `#[serde(default)]` so older
28 /// files keep deserializing.
29 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30 pub struct Settings {
31 /// Whether on-device inference is the active mode. `true` (local-first) on a
32 /// fresh install.
33 #[serde(default = "default_local_inference")]
34 pub local_inference: bool,
35 }
36
37 impl Default for Settings {
38 fn default() -> Self {
39 Self {
40 local_inference: default_local_inference(),
41 }
42 }
43 }
44
45 /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
46 fn config_dir() -> Option<PathBuf> {
47 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
48 return Some(PathBuf::from(dir));
49 }
50 let home = std::env::var("HOME").ok()?;
51 Some(PathBuf::from(home).join(".config/sigit"))
52 }
53
54 fn settings_path() -> Option<PathBuf> {
55 config_dir().map(|dir| dir.join("settings.toml"))
56 }
57
58 /// Parse an env value as a boolean. Accepts `1/0`, `true/false`, `on/off`,
59 /// `yes/no` (case-insensitive). Returns `None` for anything unrecognized so a
60 /// stray value falls back to the stored setting instead of silently flipping.
61 fn parse_bool_env(value: &str) -> Option<bool> {
62 match value.trim().to_ascii_lowercase().as_str() {
63 "1" | "true" | "on" | "yes" => Some(true),
64 "0" | "false" | "off" | "no" => Some(false),
65 _ => None,
66 }
67 }
68
69 /// Load stored settings, or defaults if the file is absent or unreadable.
70 pub fn load() -> Settings {
71 let Some(path) = settings_path() else {
72 return Settings::default();
73 };
74 match std::fs::read_to_string(&path) {
75 Ok(contents) => toml::from_str::<Settings>(&contents).unwrap_or_else(|error| {
76 log::warn!("settings: ignoring settings.toml: {error}");
77 Settings::default()
78 }),
79 Err(_) => Settings::default(),
80 }
81 }
82
83 /// Persist settings, creating the config dir if needed.
84 pub fn store(settings: &Settings) -> Result<(), String> {
85 let dir = config_dir().ok_or_else(|| "cannot resolve config directory".to_string())?;
86 std::fs::create_dir_all(&dir).map_err(|error| format!("create {dir:?}: {error}"))?;
87 let path = dir.join("settings.toml");
88 let body = toml::to_string(settings).map_err(|error| format!("serialize settings: {error}"))?;
89 std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}"))?;
90 Ok(())
91 }
92
93 /// Whether on-device inference is the active mode. The `SIGIT_LOCAL_INFERENCE`
94 /// env var, when set to a recognized boolean, overrides the stored value.
95 pub fn local_inference_enabled() -> bool {
96 if let Ok(raw) = std::env::var(LOCAL_INFERENCE_ENV)
97 && let Some(value) = parse_bool_env(&raw)
98 {
99 return value;
100 }
101 load().local_inference
102 }
103
104 /// Persist a new `local_inference` value, preserving any other settings.
105 pub fn set_local_inference(enabled: bool) -> Result<(), String> {
106 let mut settings = load();
107 settings.local_inference = enabled;
108 store(&settings)
109 }
110
111 #[cfg(test)]
112 mod tests {
113 use super::*;
114
115 // One test (not several) because each mutates the process-global
116 // `SIGIT_CONFIG_DIR` / `SIGIT_LOCAL_INFERENCE` env vars; splitting would let
117 // them race under `cargo test`'s parallel runner.
118 #[test]
119 fn defaults_round_trip_and_env_override() {
120 let _guard = crate::ENV_TEST_LOCK
121 .lock()
122 .unwrap_or_else(|poisoned| poisoned.into_inner());
123 let dir = std::env::temp_dir().join(format!("sigit_settings_{}", std::process::id()));
124 let _ = std::fs::remove_dir_all(&dir);
125 // SAFETY: single-threaded test; restores below.
126 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
127 unsafe { std::env::remove_var(LOCAL_INFERENCE_ENV) };
128
129 // Fresh install: no file → local-first.
130 assert!(
131 load().local_inference,
132 "fresh install should be local-first"
133 );
134 assert!(local_inference_enabled());
135
136 set_local_inference(false).unwrap();
137 assert!(!load().local_inference);
138 assert!(!local_inference_enabled());
139
140 // Env override wins over the stored `false`.
141 unsafe { std::env::set_var(LOCAL_INFERENCE_ENV, "true") };
142 assert!(local_inference_enabled());
143 unsafe { std::env::set_var(LOCAL_INFERENCE_ENV, "garbage") };
144 assert!(
145 !local_inference_enabled(),
146 "unrecognized env value falls back to stored setting"
147 );
148
149 unsafe { std::env::remove_var(LOCAL_INFERENCE_ENV) };
150 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
151 let _ = std::fs::remove_dir_all(&dir);
152 }
153 }