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 //! Settings today: `local_inference` (whether on-device inference is the
9 //! active mode — the source of truth for the local/cloud toggle, also surfaced
10 //! as a session config option for ACP clients without slash commands, e.g.
11 //! Xcode) and `[permissions]` (the tool permission policy consumed by
12 //! `crate::permissions`: a default mode for mutating tools plus per-tool
13 //! overrides).
14
15 use std::collections::BTreeMap;
16 use std::path::PathBuf;
17
18 use serde::{Deserialize, Serialize};
19
20 /// Env override for `local_inference`. When set to a truthy/falsy value it wins
21 /// over the stored file for reads (matching the existing `SIGIT_*` override
22 /// style); it never writes the file.
23 const LOCAL_INFERENCE_ENV: &str = "SIGIT_LOCAL_INFERENCE";
24
25 /// Env override for the default permission mode (`allow`/`ask`/`deny`). Wins
26 /// over the stored default (but not over per-tool overrides); never writes the
27 /// file. The escape hatch for ACP clients that cannot answer permission
28 /// requests and for headless runs.
29 const PERMISSIONS_ENV: &str = "SIGIT_PERMISSIONS";
30
31 fn default_local_inference() -> bool {
32 true
33 }
34
35 /// What to do when the model calls a mutating tool.
36 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
37 #[serde(rename_all = "lowercase")]
38 pub enum PermissionMode {
39 /// Run without asking.
40 Allow,
41 /// Ask the user first (ACP permission request / TUI approval prompt).
42 #[default]
43 Ask,
44 /// Never run; the model gets an explanatory tool result.
45 Deny,
46 }
47
48 impl PermissionMode {
49 fn parse(value: &str) -> Option<Self> {
50 match value.trim().to_ascii_lowercase().as_str() {
51 "allow" => Some(Self::Allow),
52 "ask" => Some(Self::Ask),
53 "deny" => Some(Self::Deny),
54 _ => None,
55 }
56 }
57 }
58
59 impl std::fmt::Display for PermissionMode {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.write_str(match self {
62 Self::Allow => "allow",
63 Self::Ask => "ask",
64 Self::Deny => "deny",
65 })
66 }
67 }
68
69 /// The `[permissions]` table: a default mode for mutating tools plus per-tool
70 /// overrides, e.g.
71 ///
72 /// ```toml
73 /// [permissions]
74 /// default = "ask"
75 ///
76 /// [permissions.tools]
77 /// edit_file = "allow"
78 /// delete_file = "deny"
79 /// ```
80 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
81 pub struct PermissionSettings {
82 /// Mode for mutating tools without a per-tool override. `ask` on a fresh
83 /// install.
84 #[serde(default)]
85 pub default: PermissionMode,
86 /// Per-tool overrides by tool name (MCP tools use their full
87 /// `mcp__<server>__<tool>` name).
88 #[serde(default)]
89 pub tools: BTreeMap<String, PermissionMode>,
90 }
91
92 /// Persisted preferences. New fields must carry `#[serde(default)]` so older
93 /// files keep deserializing.
94 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95 pub struct Settings {
96 /// Whether on-device inference is the active mode. `true` (local-first) on a
97 /// fresh install.
98 #[serde(default = "default_local_inference")]
99 pub local_inference: bool,
100 /// Permission policy for mutating agent tools.
101 #[serde(default)]
102 pub permissions: PermissionSettings,
103 }
104
105 impl Default for Settings {
106 fn default() -> Self {
107 Self {
108 local_inference: default_local_inference(),
109 permissions: PermissionSettings::default(),
110 }
111 }
112 }
113
114 /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
115 fn config_dir() -> Option<PathBuf> {
116 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
117 return Some(PathBuf::from(dir));
118 }
119 let home = std::env::var("HOME").ok()?;
120 Some(PathBuf::from(home).join(".config/sigit"))
121 }
122
123 fn settings_path() -> Option<PathBuf> {
124 config_dir().map(|dir| dir.join("settings.toml"))
125 }
126
127 /// Parse an env value as a boolean. Accepts `1/0`, `true/false`, `on/off`,
128 /// `yes/no` (case-insensitive). Returns `None` for anything unrecognized so a
129 /// stray value falls back to the stored setting instead of silently flipping.
130 fn parse_bool_env(value: &str) -> Option<bool> {
131 match value.trim().to_ascii_lowercase().as_str() {
132 "1" | "true" | "on" | "yes" => Some(true),
133 "0" | "false" | "off" | "no" => Some(false),
134 _ => None,
135 }
136 }
137
138 /// Load stored settings, or defaults if the file is absent or unreadable.
139 pub fn load() -> Settings {
140 let Some(path) = settings_path() else {
141 return Settings::default();
142 };
143 match std::fs::read_to_string(&path) {
144 Ok(contents) => toml::from_str::<Settings>(&contents).unwrap_or_else(|error| {
145 log::warn!("settings: ignoring settings.toml: {error}");
146 Settings::default()
147 }),
148 Err(_) => Settings::default(),
149 }
150 }
151
152 /// Persist settings, creating the config dir if needed.
153 pub fn store(settings: &Settings) -> Result<(), String> {
154 let dir = config_dir().ok_or_else(|| "cannot resolve config directory".to_string())?;
155 std::fs::create_dir_all(&dir).map_err(|error| format!("create {dir:?}: {error}"))?;
156 let path = dir.join("settings.toml");
157 let body = toml::to_string(settings).map_err(|error| format!("serialize settings: {error}"))?;
158 std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}"))?;
159 Ok(())
160 }
161
162 /// Whether on-device inference is the active mode. The `SIGIT_LOCAL_INFERENCE`
163 /// env var, when set to a recognized boolean, overrides the stored value.
164 pub fn local_inference_enabled() -> bool {
165 if let Ok(raw) = std::env::var(LOCAL_INFERENCE_ENV)
166 && let Some(value) = parse_bool_env(&raw)
167 {
168 return value;
169 }
170 load().local_inference
171 }
172
173 /// Persist a new `local_inference` value, preserving any other settings.
174 pub fn set_local_inference(enabled: bool) -> Result<(), String> {
175 let mut settings = load();
176 settings.local_inference = enabled;
177 store(&settings)
178 }
179
180 /// The default permission mode for mutating tools: the `SIGIT_PERMISSIONS` env
181 /// var when set to a recognized mode, else the stored `[permissions] default`.
182 pub fn permission_default() -> PermissionMode {
183 if let Ok(raw) = std::env::var(PERMISSIONS_ENV)
184 && let Some(mode) = PermissionMode::parse(&raw)
185 {
186 return mode;
187 }
188 load().permissions.default
189 }
190
191 /// The effective permission mode for one tool: its `[permissions.tools]`
192 /// override when present, else the default (see [`permission_default`]).
193 pub fn permission_mode_for(tool_name: &str) -> PermissionMode {
194 let settings = load();
195 if let Some(mode) = settings.permissions.tools.get(tool_name) {
196 return *mode;
197 }
198 if let Ok(raw) = std::env::var(PERMISSIONS_ENV)
199 && let Some(mode) = PermissionMode::parse(&raw)
200 {
201 return mode;
202 }
203 settings.permissions.default
204 }
205
206 #[cfg(test)]
207 mod tests {
208 use super::*;
209
210 // One test (not several) because each mutates the process-global
211 // `SIGIT_CONFIG_DIR` / `SIGIT_LOCAL_INFERENCE` env vars; splitting would let
212 // them race under `cargo test`'s parallel runner.
213 #[test]
214 fn defaults_round_trip_and_env_override() {
215 let _guard = crate::ENV_TEST_LOCK
216 .lock()
217 .unwrap_or_else(|poisoned| poisoned.into_inner());
218 let dir = std::env::temp_dir().join(format!("sigit_settings_{}", std::process::id()));
219 let _ = std::fs::remove_dir_all(&dir);
220 // SAFETY: single-threaded test; restores below.
221 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
222 unsafe { std::env::remove_var(LOCAL_INFERENCE_ENV) };
223
224 // Fresh install: no file → local-first.
225 assert!(
226 load().local_inference,
227 "fresh install should be local-first"
228 );
229 assert!(local_inference_enabled());
230
231 set_local_inference(false).unwrap();
232 assert!(!load().local_inference);
233 assert!(!local_inference_enabled());
234
235 // Env override wins over the stored `false`.
236 unsafe { std::env::set_var(LOCAL_INFERENCE_ENV, "true") };
237 assert!(local_inference_enabled());
238 unsafe { std::env::set_var(LOCAL_INFERENCE_ENV, "garbage") };
239 assert!(
240 !local_inference_enabled(),
241 "unrecognized env value falls back to stored setting"
242 );
243
244 // Permissions: fresh install asks; per-tool overrides win over the
245 // default; the env var overrides the stored default but not per-tool
246 // overrides.
247 unsafe { std::env::remove_var(PERMISSIONS_ENV) };
248 assert_eq!(permission_default(), PermissionMode::Ask);
249 assert_eq!(permission_mode_for("run_command"), PermissionMode::Ask);
250
251 let mut settings = load();
252 settings.permissions.default = PermissionMode::Allow;
253 settings
254 .permissions
255 .tools
256 .insert("delete_file".to_string(), PermissionMode::Deny);
257 store(&settings).unwrap();
258 assert_eq!(permission_default(), PermissionMode::Allow);
259 assert_eq!(permission_mode_for("run_command"), PermissionMode::Allow);
260 assert_eq!(permission_mode_for("delete_file"), PermissionMode::Deny);
261
262 unsafe { std::env::set_var(PERMISSIONS_ENV, "deny") };
263 assert_eq!(permission_default(), PermissionMode::Deny);
264 assert_eq!(permission_mode_for("run_command"), PermissionMode::Deny);
265 assert_eq!(
266 permission_mode_for("delete_file"),
267 PermissionMode::Deny,
268 "per-tool override still wins"
269 );
270 unsafe { std::env::set_var(PERMISSIONS_ENV, "garbage") };
271 assert_eq!(
272 permission_default(),
273 PermissionMode::Allow,
274 "unrecognized env value falls back to stored setting"
275 );
276
277 unsafe { std::env::remove_var(PERMISSIONS_ENV) };
278 unsafe { std::env::remove_var(LOCAL_INFERENCE_ENV) };
279 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
280 let _ = std::fs::remove_dir_all(&dir);
281 }
282 }