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.rules]` table: ordered allow/deny lists of rule strings.
70 /// A rule is `tool_name` or `tool_name(argument_pattern)`; the pattern is
71 /// matched against the command string for `run_command` and the path for the
72 /// file-mutating tools (see `crate::permissions` for the matching semantics).
73 ///
74 /// ```toml
75 /// [permissions.rules]
76 /// allow = ["run_command(git *)", "edit_file(src/*)"]
77 /// deny = ["run_command(git push*)"]
78 /// ```
79 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
80 pub struct PermissionRules {
81 /// Rules that let a matching call run without asking.
82 #[serde(default)]
83 pub allow: Vec<String>,
84 /// Rules that block a matching call. Checked before `allow`, so a deny
85 /// always beats an allow that matches the same call.
86 #[serde(default)]
87 pub deny: Vec<String>,
88 }
89
90 /// The `[permissions]` table: a default mode for mutating tools plus per-tool
91 /// overrides and argument-level rule lists, e.g.
92 ///
93 /// ```toml
94 /// [permissions]
95 /// default = "ask"
96 ///
97 /// [permissions.tools]
98 /// edit_file = "allow"
99 /// delete_file = "deny"
100 ///
101 /// [permissions.rules]
102 /// allow = ["run_command(cargo *)"]
103 /// deny = ["run_command(git push*)"]
104 /// ```
105 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
106 pub struct PermissionSettings {
107 /// Mode for mutating tools without a per-tool override. `ask` on a fresh
108 /// install.
109 #[serde(default)]
110 pub default: PermissionMode,
111 /// Per-tool overrides by tool name (MCP tools use their full
112 /// `mcp__<server>__<tool>` name).
113 #[serde(default)]
114 pub tools: BTreeMap<String, PermissionMode>,
115 /// Argument-level allow/deny rules, consulted after session grants and
116 /// before the per-tool overrides.
117 #[serde(default)]
118 pub rules: PermissionRules,
119 }
120
121 /// Persisted preferences. New fields must carry `#[serde(default)]` so older
122 /// files keep deserializing.
123 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124 pub struct Settings {
125 /// Whether on-device inference is the active mode. `true` (local-first) on a
126 /// fresh install.
127 #[serde(default = "default_local_inference")]
128 pub local_inference: bool,
129 /// Permission policy for mutating agent tools.
130 #[serde(default)]
131 pub permissions: PermissionSettings,
132 }
133
134 impl Default for Settings {
135 fn default() -> Self {
136 Self {
137 local_inference: default_local_inference(),
138 permissions: PermissionSettings::default(),
139 }
140 }
141 }
142
143 /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
144 fn config_dir() -> Option<PathBuf> {
145 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
146 return Some(PathBuf::from(dir));
147 }
148 let home = std::env::var("HOME").ok()?;
149 Some(PathBuf::from(home).join(".config/sigit"))
150 }
151
152 fn settings_path() -> Option<PathBuf> {
153 config_dir().map(|dir| dir.join("settings.toml"))
154 }
155
156 /// Parse an env value as a boolean. Accepts `1/0`, `true/false`, `on/off`,
157 /// `yes/no` (case-insensitive). Returns `None` for anything unrecognized so a
158 /// stray value falls back to the stored setting instead of silently flipping.
159 fn parse_bool_env(value: &str) -> Option<bool> {
160 match value.trim().to_ascii_lowercase().as_str() {
161 "1" | "true" | "on" | "yes" => Some(true),
162 "0" | "false" | "off" | "no" => Some(false),
163 _ => None,
164 }
165 }
166
167 /// Load stored settings, or defaults if the file is absent or unreadable.
168 pub fn load() -> Settings {
169 let Some(path) = settings_path() else {
170 return Settings::default();
171 };
172 match std::fs::read_to_string(&path) {
173 Ok(contents) => toml::from_str::<Settings>(&contents).unwrap_or_else(|error| {
174 log::warn!("settings: ignoring settings.toml: {error}");
175 Settings::default()
176 }),
177 Err(_) => Settings::default(),
178 }
179 }
180
181 /// Persist settings, creating the config dir if needed.
182 pub fn store(settings: &Settings) -> Result<(), String> {
183 let dir = config_dir().ok_or_else(|| "cannot resolve config directory".to_string())?;
184 std::fs::create_dir_all(&dir).map_err(|error| format!("create {dir:?}: {error}"))?;
185 let path = dir.join("settings.toml");
186 let body = toml::to_string(settings).map_err(|error| format!("serialize settings: {error}"))?;
187 std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}"))?;
188 Ok(())
189 }
190
191 /// Whether on-device inference is the active mode. The `SIGIT_LOCAL_INFERENCE`
192 /// env var, when set to a recognized boolean, overrides the stored value.
193 pub fn local_inference_enabled() -> bool {
194 if let Ok(raw) = std::env::var(LOCAL_INFERENCE_ENV)
195 && let Some(value) = parse_bool_env(&raw)
196 {
197 return value;
198 }
199 load().local_inference
200 }
201
202 /// Persist a new `local_inference` value, preserving any other settings.
203 pub fn set_local_inference(enabled: bool) -> Result<(), String> {
204 let mut settings = load();
205 settings.local_inference = enabled;
206 store(&settings)
207 }
208
209 /// The default permission mode for mutating tools: the `SIGIT_PERMISSIONS` env
210 /// var when set to a recognized mode, else the stored `[permissions] default`.
211 pub fn permission_default() -> PermissionMode {
212 if let Ok(raw) = std::env::var(PERMISSIONS_ENV)
213 && let Some(mode) = PermissionMode::parse(&raw)
214 {
215 return mode;
216 }
217 load().permissions.default
218 }
219
220 /// The stored `[permissions.rules]` allow/deny lists.
221 pub fn permission_rules() -> PermissionRules {
222 load().permissions.rules
223 }
224
225 /// The effective permission mode for one tool: its `[permissions.tools]`
226 /// override when present, else the default (see [`permission_default`]).
227 pub fn permission_mode_for(tool_name: &str) -> PermissionMode {
228 let settings = load();
229 if let Some(mode) = settings.permissions.tools.get(tool_name) {
230 return *mode;
231 }
232 if let Ok(raw) = std::env::var(PERMISSIONS_ENV)
233 && let Some(mode) = PermissionMode::parse(&raw)
234 {
235 return mode;
236 }
237 settings.permissions.default
238 }
239
240 #[cfg(test)]
241 mod tests {
242 use super::*;
243
244 // One test (not several) because each mutates the process-global
245 // `SIGIT_CONFIG_DIR` / `SIGIT_LOCAL_INFERENCE` env vars; splitting would let
246 // them race under `cargo test`'s parallel runner.
247 #[test]
248 fn defaults_round_trip_and_env_override() {
249 let _guard = crate::ENV_TEST_LOCK
250 .lock()
251 .unwrap_or_else(|poisoned| poisoned.into_inner());
252 let dir = std::env::temp_dir().join(format!("sigit_settings_{}", std::process::id()));
253 let _ = std::fs::remove_dir_all(&dir);
254 // SAFETY: single-threaded test; restores below.
255 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
256 unsafe { std::env::remove_var(LOCAL_INFERENCE_ENV) };
257
258 // Fresh install: no file → local-first.
259 assert!(
260 load().local_inference,
261 "fresh install should be local-first"
262 );
263 assert!(local_inference_enabled());
264
265 set_local_inference(false).unwrap();
266 assert!(!load().local_inference);
267 assert!(!local_inference_enabled());
268
269 // Env override wins over the stored `false`.
270 unsafe { std::env::set_var(LOCAL_INFERENCE_ENV, "true") };
271 assert!(local_inference_enabled());
272 unsafe { std::env::set_var(LOCAL_INFERENCE_ENV, "garbage") };
273 assert!(
274 !local_inference_enabled(),
275 "unrecognized env value falls back to stored setting"
276 );
277
278 // Permissions: fresh install asks; per-tool overrides win over the
279 // default; the env var overrides the stored default but not per-tool
280 // overrides.
281 unsafe { std::env::remove_var(PERMISSIONS_ENV) };
282 assert_eq!(permission_default(), PermissionMode::Ask);
283 assert_eq!(permission_mode_for("run_command"), PermissionMode::Ask);
284
285 let mut settings = load();
286 settings.permissions.default = PermissionMode::Allow;
287 settings
288 .permissions
289 .tools
290 .insert("delete_file".to_string(), PermissionMode::Deny);
291 store(&settings).unwrap();
292 assert_eq!(permission_default(), PermissionMode::Allow);
293 assert_eq!(permission_mode_for("run_command"), PermissionMode::Allow);
294 assert_eq!(permission_mode_for("delete_file"), PermissionMode::Deny);
295
296 unsafe { std::env::set_var(PERMISSIONS_ENV, "deny") };
297 assert_eq!(permission_default(), PermissionMode::Deny);
298 assert_eq!(permission_mode_for("run_command"), PermissionMode::Deny);
299 assert_eq!(
300 permission_mode_for("delete_file"),
301 PermissionMode::Deny,
302 "per-tool override still wins"
303 );
304 unsafe { std::env::set_var(PERMISSIONS_ENV, "garbage") };
305 assert_eq!(
306 permission_default(),
307 PermissionMode::Allow,
308 "unrecognized env value falls back to stored setting"
309 );
310
311 // Permission rules: absent on a fresh file, and they survive a
312 // store/load round trip without disturbing the other settings.
313 assert_eq!(permission_rules(), PermissionRules::default());
314 let mut settings = load();
315 settings.permissions.rules.allow = vec![
316 "run_command(git *)".to_string(),
317 "edit_file(src/*)".to_string(),
318 ];
319 settings.permissions.rules.deny = vec!["run_command(git push*)".to_string()];
320 store(&settings).unwrap();
321 let reloaded = load();
322 assert_eq!(reloaded.permissions.rules, settings.permissions.rules);
323 assert_eq!(permission_rules(), settings.permissions.rules);
324 assert_eq!(
325 reloaded.permissions.tools.get("delete_file"),
326 Some(&PermissionMode::Deny),
327 "storing rules preserves the per-tool overrides"
328 );
329
330 unsafe { std::env::remove_var(PERMISSIONS_ENV) };
331 unsafe { std::env::remove_var(LOCAL_INFERENCE_ENV) };
332 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
333 let _ = std::fs::remove_dir_all(&dir);
334 }
335 }