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), `[permissions]` (the tool permission policy consumed by
12 //! `crate::permissions`: a default mode for mutating tools plus per-tool
13 //! overrides), and `[hooks]` (extensibility hooks for session and tool
14 //! lifecycle events).
15
16 use std::collections::BTreeMap;
17 use std::path::PathBuf;
18
19 use serde::{Deserialize, Serialize};
20
21 use crate::hooks;
22
23 /// Env override for `local_inference`. When set to a truthy/falsy value it wins
24 /// over the stored file for reads (matching the existing `SIGIT_*` override
25 /// style); it never writes the file.
26 const LOCAL_INFERENCE_ENV: &str = "SIGIT_LOCAL_INFERENCE";
27
28 /// Env override for the default permission mode (`allow`/`ask`/`deny`). Wins
29 /// over the stored default (but not over per-tool overrides); never writes the
30 /// file. The escape hatch for ACP clients that cannot answer permission
31 /// requests and for headless runs.
32 const PERMISSIONS_ENV: &str = "SIGIT_PERMISSIONS";
33
34 fn default_local_inference() -> bool {
35 true
36 }
37
38 /// What to do when the model calls a mutating tool.
39 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
40 #[serde(rename_all = "lowercase")]
41 pub enum PermissionMode {
42 /// Run without asking.
43 Allow,
44 /// Ask the user first (ACP permission request / TUI approval prompt).
45 #[default]
46 Ask,
47 /// Never run; the model gets an explanatory tool result.
48 Deny,
49 }
50
51 impl PermissionMode {
52 fn parse(value: &str) -> Option<Self> {
53 match value.trim().to_ascii_lowercase().as_str() {
54 "allow" => Some(Self::Allow),
55 "ask" => Some(Self::Ask),
56 "deny" => Some(Self::Deny),
57 _ => None,
58 }
59 }
60 }
61
62 impl std::fmt::Display for PermissionMode {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.write_str(match self {
65 Self::Allow => "allow",
66 Self::Ask => "ask",
67 Self::Deny => "deny",
68 })
69 }
70 }
71
72 /// The `[permissions.rules]` table: ordered allow/deny lists of rule strings.
73 /// A rule is `tool_name` or `tool_name(argument_pattern)`; the pattern is
74 /// matched against the command string for `run_command` and the path for the
75 /// file-mutating tools (see `crate::permissions` for the matching semantics).
76 ///
77 /// ```toml
78 /// [permissions.rules]
79 /// allow = ["run_command(git *)", "edit_file(src/*)"]
80 /// deny = ["run_command(git push*)"]
81 /// ```
82 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
83 pub struct PermissionRules {
84 /// Rules that let a matching call run without asking.
85 #[serde(default)]
86 pub allow: Vec<String>,
87 /// Rules that block a matching call. Checked before `allow`, so a deny
88 /// always beats an allow that matches the same call.
89 #[serde(default)]
90 pub deny: Vec<String>,
91 }
92
93 /// The `[permissions]` table: a default mode for mutating tools plus per-tool
94 /// overrides and argument-level rule lists, e.g.
95 ///
96 /// ```toml
97 /// [permissions]
98 /// default = "ask"
99 ///
100 /// [permissions.tools]
101 /// edit_file = "allow"
102 /// delete_file = "deny"
103 ///
104 /// [permissions.rules]
105 /// allow = ["run_command(cargo *)"]
106 /// deny = ["run_command(git push*)"]
107 /// ```
108 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
109 pub struct PermissionSettings {
110 /// Mode for mutating tools without a per-tool override. `ask` on a fresh
111 /// install.
112 #[serde(default)]
113 pub default: PermissionMode,
114 /// Per-tool overrides by tool name (MCP tools use their full
115 /// `mcp__<server>__<tool>` name).
116 #[serde(default)]
117 pub tools: BTreeMap<String, PermissionMode>,
118 /// Argument-level allow/deny rules, consulted after session grants and
119 /// before the per-tool overrides.
120 #[serde(default)]
121 pub rules: PermissionRules,
122 }
123
124 /// Persisted preferences. New fields must carry `#[serde(default)]` so older
125 /// files keep deserializing.
126 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127 pub struct Settings {
128 /// Whether on-device inference is the active mode. `true` (local-first) on a
129 /// fresh install.
130 #[serde(default = "default_local_inference")]
131 pub local_inference: bool,
132 /// Permission policy for mutating agent tools.
133 #[serde(default)]
134 pub permissions: PermissionSettings,
135 /// Extensibility hooks for session and tool lifecycle events.
136 #[serde(default)]
137 pub hooks: hooks::HookSettings,
138 }
139
140 impl Default for Settings {
141 fn default() -> Self {
142 Self {
143 local_inference: default_local_inference(),
144 permissions: PermissionSettings::default(),
145 hooks: hooks::HookSettings::default(),
146 }
147 }
148 }
149
150 /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
151 fn config_dir() -> Option<PathBuf> {
152 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
153 return Some(PathBuf::from(dir));
154 }
155 let home = std::env::var("HOME").ok()?;
156 Some(PathBuf::from(home).join(".config/sigit"))
157 }
158
159 fn settings_path() -> Option<PathBuf> {
160 config_dir().map(|dir| dir.join("settings.toml"))
161 }
162
163 /// Parse an env value as a boolean. Accepts `1/0`, `true/false`, `on/off`,
164 /// `yes/no` (case-insensitive). Returns `None` for anything unrecognized so a
165 /// stray value falls back to the stored setting instead of silently flipping.
166 fn parse_bool_env(value: &str) -> Option<bool> {
167 match value.trim().to_ascii_lowercase().as_str() {
168 "1" | "true" | "on" | "yes" => Some(true),
169 "0" | "false" | "off" | "no" => Some(false),
170 _ => None,
171 }
172 }
173
174 /// Load stored settings, or defaults if the file is absent or unreadable.
175 pub fn load() -> Settings {
176 let Some(path) = settings_path() else {
177 return Settings::default();
178 };
179 match std::fs::read_to_string(&path) {
180 Ok(contents) => toml::from_str::<Settings>(&contents).unwrap_or_else(|error| {
181 log::warn!("settings: ignoring settings.toml: {error}");
182 Settings::default()
183 }),
184 Err(_) => Settings::default(),
185 }
186 }
187
188 /// Persist settings, creating the config dir if needed.
189 pub fn store(settings: &Settings) -> Result<(), String> {
190 let dir = config_dir().ok_or_else(|| "cannot resolve config directory".to_string())?;
191 std::fs::create_dir_all(&dir).map_err(|error| format!("create {dir:?}: {error}"))?;
192 let path = dir.join("settings.toml");
193 let body = toml::to_string(settings).map_err(|error| format!("serialize settings: {error}"))?;
194 std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}"))?;
195 Ok(())
196 }
197
198 /// Whether on-device inference is the active mode. The `SIGIT_LOCAL_INFERENCE`
199 /// env var, when set to a recognized boolean, overrides the stored value.
200 pub fn local_inference_enabled() -> bool {
201 if let Ok(raw) = std::env::var(LOCAL_INFERENCE_ENV)
202 && let Some(value) = parse_bool_env(&raw)
203 {
204 return value;
205 }
206 load().local_inference
207 }
208
209 /// Persist a new `local_inference` value, preserving any other settings.
210 pub fn set_local_inference(enabled: bool) -> Result<(), String> {
211 let mut settings = load();
212 settings.local_inference = enabled;
213 store(&settings)
214 }
215
216 /// The default permission mode for mutating tools: the `SIGIT_PERMISSIONS` env
217 /// var when set to a recognized mode, else the stored `[permissions] default`.
218 pub fn permission_default() -> PermissionMode {
219 if let Ok(raw) = std::env::var(PERMISSIONS_ENV)
220 && let Some(mode) = PermissionMode::parse(&raw)
221 {
222 return mode;
223 }
224 load().permissions.default
225 }
226
227 /// The stored `[permissions.rules]` allow/deny lists.
228 pub fn permission_rules() -> PermissionRules {
229 load().permissions.rules
230 }
231
232 /// The tool's explicit `[permissions.tools]` override, or `None` when it has
233 /// no entry. This never falls back to the default or the env override, so
234 /// callers can tell "the user set a mode for this exact tool" apart from "the
235 /// tool inherits the default".
236 pub fn permission_mode_explicit(tool_name: &str) -> Option<PermissionMode> {
237 load().permissions.tools.get(tool_name).copied()
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_explicit("run_command"), None);
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_explicit("run_command"), None);
294 assert_eq!(
295 permission_mode_explicit("delete_file"),
296 Some(PermissionMode::Deny)
297 );
298
299 unsafe { std::env::set_var(PERMISSIONS_ENV, "deny") };
300 assert_eq!(permission_default(), PermissionMode::Deny);
301 assert_eq!(
302 permission_mode_explicit("delete_file"),
303 Some(PermissionMode::Deny),
304 "per-tool override is unaffected by the env var"
305 );
306 unsafe { std::env::set_var(PERMISSIONS_ENV, "garbage") };
307 assert_eq!(
308 permission_default(),
309 PermissionMode::Allow,
310 "unrecognized env value falls back to stored setting"
311 );
312
313 // Permission rules: absent on a fresh file, and they survive a
314 // store/load round trip without disturbing the other settings.
315 assert_eq!(permission_rules(), PermissionRules::default());
316 let mut settings = load();
317 settings.permissions.rules.allow = vec![
318 "run_command(git *)".to_string(),
319 "edit_file(src/*)".to_string(),
320 ];
321 settings.permissions.rules.deny = vec!["run_command(git push*)".to_string()];
322 store(&settings).unwrap();
323 let reloaded = load();
324 assert_eq!(reloaded.permissions.rules, settings.permissions.rules);
325 assert_eq!(permission_rules(), settings.permissions.rules);
326 assert_eq!(
327 reloaded.permissions.tools.get("delete_file"),
328 Some(&PermissionMode::Deny),
329 "storing rules preserves the per-tool overrides"
330 );
331
332 unsafe { std::env::remove_var(PERMISSIONS_ENV) };
333 unsafe { std::env::remove_var(LOCAL_INFERENCE_ENV) };
334 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
335 let _ = std::fs::remove_dir_all(&dir);
336 }
337 }