Skip to main content

tycode_core/settings/
manager.rs

1use crate::settings::config::Settings;
2use anyhow::{Context, Result};
3use serde::{de::DeserializeOwned, Serialize};
4use std::fs;
5use std::ops::DerefMut;
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, Mutex};
8
9/// Various settings used throughout Tycode. Each process has its own local
10/// settings that the user may update without impacting any other session (for
11/// example increase the maximum model cost for 1 session). Settings can also be
12/// saved and when saved, future processes will use the same settings.
13#[derive(Clone)]
14pub struct SettingsManager {
15    settings_dir: PathBuf,
16    settings_path: PathBuf,
17    current_profile: Option<String>,
18    // Arc<Mutex<..>> is AI slop friendly - everything wants its own settings
19    // and this ensures that everyone has the same instance.
20    inner: Arc<Mutex<Settings>>,
21}
22
23impl SettingsManager {
24    /// Create a settings manager from a specific settings directory and optional profile
25    pub fn from_settings_dir(settings_dir: PathBuf, profile_name: Option<&str>) -> Result<Self> {
26        // Ensure directory exists
27        fs::create_dir_all(&settings_dir)
28            .with_context(|| format!("Failed to create settings directory: {:?}", settings_dir))?;
29
30        let settings_path = if let Some(name) = profile_name {
31            settings_dir.join(format!("settings_{}.toml", name))
32        } else {
33            settings_dir.join("settings.toml")
34        };
35
36        let current_profile = profile_name.map(|s| s.to_string());
37
38        let loaded = Self::load_from_file_with_backup(&settings_path)?;
39
40        Ok(Self {
41            settings_dir,
42            settings_path,
43            current_profile,
44            inner: Arc::new(Mutex::new(loaded)),
45        })
46    }
47
48    /// Create a settings manager from a specific path
49    pub fn from_path(path: PathBuf) -> Result<Self> {
50        let settings_dir = path
51            .parent()
52            .ok_or_else(|| anyhow::anyhow!("Settings path has no parent directory"))?
53            .to_path_buf();
54
55        let current_profile = Self::infer_profile_from_path(&path);
56
57        let loaded = Self::load_from_file_with_backup(&path)?;
58
59        Ok(Self {
60            settings_dir,
61            settings_path: path,
62            current_profile,
63            inner: Arc::new(Mutex::new(loaded)),
64        })
65    }
66
67    fn infer_profile_from_path(path: &Path) -> Option<String> {
68        let file_name = match path.file_name().and_then(|s| s.to_str()) {
69            Some(name) => name,
70            None => return None,
71        };
72
73        if file_name == "settings.toml" {
74            return None;
75        }
76
77        if !file_name.ends_with(".toml") {
78            return None;
79        }
80
81        let len = file_name.len();
82        let without_ext = &file_name[..len - 5];
83
84        if !without_ext.starts_with("settings_") {
85            return None;
86        }
87
88        let potential_name = &without_ext[9..];
89
90        if potential_name.is_empty() {
91            None
92        } else {
93            Some(potential_name.to_string())
94        }
95    }
96
97    /// Load settings from a TOML file with backup on parse failure
98    fn load_from_file_with_backup(path: &Path) -> Result<Settings> {
99        if !path.exists() {
100            let default_settings = Settings::default();
101            if let Some(parent) = path.parent() {
102                fs::create_dir_all(parent)
103                    .with_context(|| format!("Failed to create directory: {parent:?}"))?;
104            }
105            let contents = toml::to_string_pretty(&default_settings)
106                .context("Failed to serialize default settings")?;
107            fs::write(path, contents)
108                .with_context(|| format!("Failed to write default settings to {path:?}"))?;
109            return Ok(default_settings);
110        }
111
112        let contents = fs::read_to_string(path)
113            .with_context(|| format!("Failed to read settings from {path:?}"))?;
114
115        match toml::from_str::<Settings>(&contents) {
116            Ok(settings) => Ok(settings),
117            Err(_) => {
118                // Move corrupted file to backup
119                let backup_path = path.with_extension("toml.backup");
120                fs::rename(path, &backup_path).with_context(|| {
121                    format!("Failed to backup corrupted settings to {backup_path:?}")
122                })?;
123
124                // Create new default settings file
125                let default_settings = Settings::default();
126                if let Some(parent) = path.parent() {
127                    fs::create_dir_all(parent)
128                        .with_context(|| format!("Failed to create directory: {parent:?}"))?;
129                }
130                let contents = toml::to_string_pretty(&default_settings)
131                    .context("Failed to serialize default settings")?;
132                fs::write(path, contents)
133                    .with_context(|| format!("Failed to write default settings to {path:?}"))?;
134
135                Ok(default_settings)
136            }
137        }
138    }
139
140    /// Get the in-memory settings
141    pub fn settings(&self) -> Settings {
142        self.inner.lock().unwrap().clone()
143    }
144
145    /// Update in-memory settings with a closure. Note: settings are not saved to disk
146    pub fn update_setting<F>(&self, updater: F)
147    where
148        F: FnOnce(&mut Settings),
149    {
150        let mut guard = self.inner.lock().unwrap();
151        updater(guard.deref_mut());
152    }
153
154    /// Save provided settings
155    pub fn save_settings(&self, settings: Settings) -> Result<()> {
156        // Ensure directory exists
157        if let Some(parent) = self.settings_path.parent() {
158            fs::create_dir_all(parent)
159                .with_context(|| format!("Failed to create directory: {parent:?}"))?;
160        }
161
162        let contents = toml::to_string_pretty(&settings).context("Failed to serialize settings")?;
163
164        fs::write(&self.settings_path, contents)
165            .with_context(|| format!("Failed to write settings to {:?}", self.settings_path))?;
166        *self.inner.lock().unwrap() = settings;
167
168        Ok(())
169    }
170
171    /// Explicitly persist in-memory settings to disk
172    pub fn save(&self) -> Result<()> {
173        self.save_settings(self.settings())
174    }
175
176    /// Get the current profile name if set
177    pub fn current_profile(&self) -> Option<&str> {
178        self.current_profile.as_deref()
179    }
180
181    /// Switch to a different profile, loading its settings (creates if not exists)
182    pub fn switch_profile(&mut self, name: &str) -> Result<()> {
183        let new_path = if name == "default" {
184            self.settings_dir.join("settings.toml")
185        } else {
186            self.settings_dir.join(format!("settings_{}.toml", name))
187        };
188        fs::create_dir_all(&self.settings_dir)
189            .with_context(|| format!("Failed to create directory: {:?}", self.settings_dir))?;
190        let new_settings = Self::load_from_file_with_backup(&new_path)?;
191        self.settings_path = new_path;
192        self.current_profile = if name == "default" {
193            None
194        } else {
195            Some(name.to_string())
196        };
197        *self.inner.lock().unwrap() = new_settings;
198        Ok(())
199    }
200
201    /// Save current settings as a new profile file
202    pub fn save_as_profile(&self, name: &str) -> Result<()> {
203        fs::create_dir_all(&self.settings_dir)
204            .with_context(|| format!("Failed to create directory: {:?}", self.settings_dir))?;
205        let target_path = self.settings_dir.join(format!("settings_{}.toml", name));
206        let contents =
207            toml::to_string_pretty(&self.settings()).context("Failed to serialize settings")?;
208        fs::write(&target_path, contents)
209            .with_context(|| format!("Failed to write settings to {target_path:?}"))?;
210        Ok(())
211    }
212
213    /// List all available profile names
214    pub fn list_profiles(&self) -> Result<Vec<String>> {
215        let mut profiles = Vec::new();
216
217        let default_path = self.settings_dir.join("settings.toml");
218        if default_path.exists() {
219            profiles.push("default".to_string());
220        }
221
222        let entries = fs::read_dir(&self.settings_dir).with_context(|| {
223            format!("Failed to read settings directory: {:?}", self.settings_dir)
224        })?;
225
226        for entry in entries {
227            let entry = entry?;
228            let path = entry.path();
229
230            if !path.is_file() {
231                continue;
232            }
233
234            if let Some(profile_name) = Self::infer_profile_from_path(&path) {
235                profiles.push(profile_name);
236            }
237        }
238
239        profiles.sort();
240        Ok(profiles)
241    }
242
243    /// Get the settings file path
244    pub fn path(&self) -> &Path {
245        &self.settings_path
246    }
247
248    /// Get a module's configuration, falling back to default if missing or unparseable.
249    pub fn get_module_config<T: Default + DeserializeOwned>(&self, namespace: &str) -> T {
250        self.inner
251            .lock()
252            .unwrap()
253            .modules
254            .get(namespace)
255            .and_then(|v| {
256                serde_json::from_value(v.clone())
257                    .map_err(|e| tracing::warn!("Failed to parse module config '{namespace}': {e}"))
258                    .ok()
259            })
260            .unwrap_or_default()
261    }
262
263    pub fn set_module_config<T: Serialize>(&self, namespace: &str, value: T) {
264        if let Ok(json_value) = serde_json::to_value(value) {
265            self.inner
266                .lock()
267                .unwrap()
268                .modules
269                .insert(namespace.to_string(), json_value);
270        }
271    }
272}