Skip to main content

torii_lib/util/
config.rs

1use std::path::{Path, PathBuf};
2use std::fs;
3use serde::{Deserialize, Serialize};
4use crate::error::{Result, ToriiError};
5
6/// Global Torii configuration
7#[derive(Debug, Serialize, Deserialize, Clone)]
8pub struct ToriiConfig {
9    /// User settings
10    pub user: UserConfig,
11
12    /// Snapshot settings
13    pub snapshot: SnapshotConfig,
14
15    /// Mirror settings
16    pub mirror: MirrorConfig,
17
18    /// Git settings
19    pub git: GitConfig,
20
21    /// UI settings
22    pub ui: UiConfig,
23
24    /// Platform auth tokens
25    #[serde(default)]
26    pub auth: AuthConfig,
27
28    /// Update notifier settings
29    #[serde(default)]
30    pub update: UpdateConfig,
31
32    /// Worktree settings
33    #[serde(default)]
34    pub worktree: WorktreeConfig,
35}
36
37#[derive(Debug, Serialize, Deserialize, Clone)]
38pub struct WorktreeConfig {
39    /// Where `torii worktree add` puts new worktrees when no path is given.
40    /// Default is `..` (sibling directories of the main repo). Examples:
41    ///   ".."                  → ../<repo>-<branch>/
42    ///   "~/worktrees"         → ~/worktrees/<repo>-<branch>/
43    ///   "/tmp/wt"             → /tmp/wt/<repo>-<branch>/
44    /// `~` expansion is honoured. The `<repo>-<branch>` suffix is appended
45    /// automatically (branch slashes replaced with `-`).
46    pub base_dir: String,
47
48    /// Paths from the main repo to also drop into every freshly-created
49    /// worktree. Typical entries: `.env`, `target/`, `node_modules/`, build
50    /// caches that aren't tracked by git but you don't want to regenerate
51    /// from scratch in every linked working copy.
52    ///
53    /// Each entry is resolved relative to the main repo's working
54    /// directory. Heuristic per entry:
55    ///   - directory present → symlink into the new worktree
56    ///   - file present      → copy into the new worktree
57    ///   - missing           → silently skipped
58    ///
59    /// Default is empty (no inheritance, vanilla `git worktree` behaviour).
60    #[serde(default)]
61    pub inherit_paths: Vec<String>,
62}
63
64impl Default for WorktreeConfig {
65    fn default() -> Self {
66        Self {
67            base_dir: "..".to_string(),
68            inherit_paths: Vec::new(),
69        }
70    }
71}
72
73#[derive(Debug, Serialize, Deserialize, Clone)]
74pub struct UpdateConfig {
75    /// Check crates.io for newer versions on CLI exit
76    pub check: bool,
77
78    /// Hours between checks (cached locally)
79    pub interval_hours: u64,
80}
81
82impl Default for UpdateConfig {
83    fn default() -> Self {
84        Self { check: true, interval_hours: 24 }
85    }
86}
87
88#[derive(Debug, Serialize, Deserialize, Clone, Default)]
89pub struct AuthConfig {
90    /// GitHub personal access token
91    pub github_token: Option<String>,
92
93    /// GitLab personal access token
94    pub gitlab_token: Option<String>,
95
96    /// Gitea token
97    pub gitea_token: Option<String>,
98
99    /// Forgejo token
100    pub forgejo_token: Option<String>,
101
102    /// Codeberg token
103    pub codeberg_token: Option<String>,
104}
105
106#[derive(Debug, Serialize, Deserialize, Clone)]
107pub struct UserConfig {
108    /// Default author name for commits
109    pub name: Option<String>,
110    
111    /// Default author email for commits
112    pub email: Option<String>,
113    
114    /// Preferred editor
115    pub editor: Option<String>,
116}
117
118#[derive(Debug, Serialize, Deserialize, Clone)]
119pub struct SnapshotConfig {
120    /// Enable auto-snapshots
121    pub auto_enabled: bool,
122    
123    /// Auto-snapshot interval in minutes
124    pub auto_interval_minutes: u32,
125    
126    /// Retention period in days
127    pub retention_days: u32,
128    
129    /// Maximum number of snapshots to keep
130    pub max_snapshots: Option<u32>,
131}
132
133#[derive(Debug, Serialize, Deserialize, Clone)]
134pub struct MirrorConfig {
135    /// Enable auto-fetch from mirrors
136    pub autofetch_enabled: bool,
137    
138    /// Auto-fetch interval in minutes
139    pub autofetch_interval_minutes: u32,
140    
141    /// Default protocol (ssh or https)
142    pub default_protocol: String,
143}
144
145#[derive(Debug, Serialize, Deserialize, Clone)]
146pub struct GitConfig {
147    /// Default branch name for new repos
148    pub default_branch: String,
149
150    /// Auto-sign commits with GPG
151    pub sign_commits: bool,
152
153    /// GPG key ID
154    pub gpg_key: Option<String>,
155
156    /// 0.7.35 — Binary used to invoke GPG. Defaults to `gpg`; set this
157    /// when your distro ships GPG as `gpg2` or you have a vendor-built
158    /// install at a different path. Mirrors git's `gpg.program`.
159    #[serde(default)]
160    pub gpg_program: Option<String>,
161
162    /// Always use rebase instead of merge for pulls
163    pub pull_rebase: bool,
164}
165
166#[derive(Debug, Serialize, Deserialize, Clone)]
167pub struct UiConfig {
168    /// Use colored output
169    pub colors: bool,
170    
171    /// Show emoji in output
172    pub emoji: bool,
173    
174    /// Verbose output
175    pub verbose: bool,
176    
177    /// Preferred date format
178    pub date_format: String,
179}
180
181impl Default for ToriiConfig {
182    fn default() -> Self {
183        Self {
184            user: UserConfig {
185                name: None,
186                email: None,
187                editor: std::env::var("EDITOR").ok(),
188            },
189            snapshot: SnapshotConfig {
190                auto_enabled: false,
191                auto_interval_minutes: 30,
192                retention_days: 30,
193                max_snapshots: Some(100),
194            },
195            mirror: MirrorConfig {
196                autofetch_enabled: false,
197                autofetch_interval_minutes: 30,
198                default_protocol: "ssh".to_string(),
199            },
200            git: GitConfig {
201                default_branch: "main".to_string(),
202                sign_commits: false,
203                gpg_key: None,
204                gpg_program: None,
205                pull_rebase: false,
206            },
207            ui: UiConfig {
208                colors: true,
209                emoji: true,
210                verbose: false,
211                date_format: "%Y-%m-%d %H:%M".to_string(),
212            },
213            auth: AuthConfig::default(),
214            update: UpdateConfig::default(),
215            worktree: WorktreeConfig::default(),
216        }
217    }
218}
219
220impl ToriiConfig {
221    /// Get the global config file path
222    fn global_config_path() -> Result<PathBuf> {
223        let config_dir = dirs::config_dir()
224            .ok_or_else(|| ToriiError::InvalidConfig("Could not determine config directory for this platform".to_string()))?
225            .join("torii");
226        fs::create_dir_all(&config_dir)?;
227        Ok(config_dir.join("config.toml"))
228    }
229    
230    /// Get the local repo config file path
231    fn local_config_path<P: AsRef<Path>>(repo_path: P) -> Result<PathBuf> {
232        let torii_dir = repo_path.as_ref().join(".torii");
233        fs::create_dir_all(&torii_dir)?;
234        Ok(torii_dir.join("config.toml"))
235    }
236    
237    /// Load global configuration
238    pub fn load_global() -> Result<Self> {
239        let config_path = Self::global_config_path()?;
240        
241        if !config_path.exists() {
242            return Ok(Self::default());
243        }
244        
245        let config_str = fs::read_to_string(&config_path)?;
246        let config: ToriiConfig = toml::from_str(&config_str)
247            .map_err(|e| ToriiError::InvalidConfig(format!("Failed to parse config: {}", e)))?;
248        
249        Ok(config)
250    }
251    
252    /// Load local repository configuration (merged with global)
253    pub fn load_local<P: AsRef<Path>>(repo_path: P) -> Result<Self> {
254        let mut config = Self::load_global()?;
255        
256        let local_path = Self::local_config_path(&repo_path)?;
257        if local_path.exists() {
258            let local_str = fs::read_to_string(&local_path)?;
259            let local_config: ToriiConfig = toml::from_str(&local_str)
260                .map_err(|e| ToriiError::InvalidConfig(format!("Failed to parse local config: {}", e)))?;
261            
262            // Merge local config over global (local takes precedence)
263            config = Self::merge(config, local_config);
264        }
265        
266        Ok(config)
267    }
268    
269    /// Save global configuration
270    pub fn save_global(&self) -> Result<()> {
271        let config_path = Self::global_config_path()?;
272        let config_str = toml::to_string_pretty(self)
273            .map_err(|e| ToriiError::InvalidConfig(format!("Failed to serialize config: {}", e)))?;
274        fs::write(&config_path, config_str)?;
275        Ok(())
276    }
277    
278    /// Save local repository configuration
279    pub fn save_local<P: AsRef<Path>>(&self, repo_path: P) -> Result<()> {
280        let config_path = Self::local_config_path(repo_path)?;
281        let config_str = toml::to_string_pretty(self)
282            .map_err(|e| ToriiError::InvalidConfig(format!("Failed to serialize config: {}", e)))?;
283        fs::write(&config_path, config_str)?;
284        Ok(())
285    }
286    
287    /// Merge two configs (second takes precedence for non-None values)
288    fn merge(mut base: Self, overlay: Self) -> Self {
289        // User config
290        if overlay.user.name.is_some() {
291            base.user.name = overlay.user.name;
292        }
293        if overlay.user.email.is_some() {
294            base.user.email = overlay.user.email;
295        }
296        if overlay.user.editor.is_some() {
297            base.user.editor = overlay.user.editor;
298        }
299        
300        // Snapshot config
301        base.snapshot = overlay.snapshot;
302        
303        // Mirror config
304        base.mirror = overlay.mirror;
305        
306        // Git config — 0.7.35 fix: merge field-by-field instead of a
307        // wholesale replace. With the old `base.git = overlay.git` line,
308        // a local `.torii/config.toml` that only declared (say)
309        // `default_branch = "master"` would reset `sign_commits` to
310        // `false` and `gpg_key` to `None` because the deserialized
311        // local config carries the *struct defaults* for the unset
312        // fields. Result: GPG signing silently turning off whenever
313        // the user had any local git override. Now we only let the
314        // overlay override the fields the user actually meant to set:
315        // strings/options only if non-empty/Some, bool only when the
316        // local file literally writes the key.
317        //
318        // Bool fields are still tricky because TOML doesn't tell us
319        // "unset vs false". We pragmatically OR them — `true` in
320        // either layer wins. That matches the historical "set in
321        // global, keep in local" expectation for `sign_commits` and
322        // `pull_rebase`. If a user genuinely wants to *disable*
323        // signing locally they can `torii config set --local
324        // git.sign_commits false`, which goes through the explicit
325        // `set` path that doesn't traverse merge logic.
326        if !overlay.git.default_branch.is_empty() && overlay.git.default_branch != "main" {
327            base.git.default_branch = overlay.git.default_branch;
328        }
329        base.git.sign_commits = base.git.sign_commits || overlay.git.sign_commits;
330        if overlay.git.gpg_key.is_some()     { base.git.gpg_key     = overlay.git.gpg_key; }
331        if overlay.git.gpg_program.is_some() { base.git.gpg_program = overlay.git.gpg_program; }
332        base.git.pull_rebase = base.git.pull_rebase || overlay.git.pull_rebase;
333
334        // UI config
335        base.ui = overlay.ui;
336
337        // Auth config
338        if overlay.auth.github_token.is_some() { base.auth.github_token = overlay.auth.github_token; }
339        if overlay.auth.gitlab_token.is_some() { base.auth.gitlab_token = overlay.auth.gitlab_token; }
340        if overlay.auth.gitea_token.is_some() { base.auth.gitea_token = overlay.auth.gitea_token; }
341        if overlay.auth.forgejo_token.is_some() { base.auth.forgejo_token = overlay.auth.forgejo_token; }
342        if overlay.auth.codeberg_token.is_some() { base.auth.codeberg_token = overlay.auth.codeberg_token; }
343
344        // Worktree config — full overwrite, like snapshot/mirror.
345        base.worktree = overlay.worktree;
346
347        base
348    }
349    
350    /// Get a configuration value by key path (e.g., "user.name", "snapshot.auto_enabled")
351    pub fn get(&self, key: &str) -> Option<String> {
352        let parts: Vec<&str> = key.split('.').collect();
353        if parts.len() != 2 {
354            return None;
355        }
356        
357        match (parts[0], parts[1]) {
358            ("user", "name") => self.user.name.clone(),
359            ("user", "email") => self.user.email.clone(),
360            ("user", "editor") => self.user.editor.clone(),
361            ("snapshot", "auto_enabled") => Some(self.snapshot.auto_enabled.to_string()),
362            ("snapshot", "auto_interval_minutes") => Some(self.snapshot.auto_interval_minutes.to_string()),
363            ("snapshot", "retention_days") => Some(self.snapshot.retention_days.to_string()),
364            ("snapshot", "max_snapshots") => self.snapshot.max_snapshots.map(|v| v.to_string()),
365            ("mirror", "autofetch_enabled") => Some(self.mirror.autofetch_enabled.to_string()),
366            ("mirror", "autofetch_interval_minutes") => Some(self.mirror.autofetch_interval_minutes.to_string()),
367            ("mirror", "default_protocol") => Some(self.mirror.default_protocol.clone()),
368            ("git", "default_branch") => Some(self.git.default_branch.clone()),
369            ("git", "sign_commits") => Some(self.git.sign_commits.to_string()),
370            ("git", "gpg_key") => self.git.gpg_key.clone(),
371            ("git", "gpg_program") => self.git.gpg_program.clone(),
372            // git-friendly alias for the gpg.program key git itself uses.
373            ("gpg", "program") => self.git.gpg_program.clone(),
374            // 0.7.14: git-friendly alias. Mirrors how git stores it.
375            ("user", "signingkey") => self.git.gpg_key.clone(),
376            ("commit", "gpgsign") => Some(self.git.sign_commits.to_string()),
377            ("git", "pull_rebase") => Some(self.git.pull_rebase.to_string()),
378            ("ui", "colors") => Some(self.ui.colors.to_string()),
379            ("ui", "emoji") => Some(self.ui.emoji.to_string()),
380            ("ui", "verbose") => Some(self.ui.verbose.to_string()),
381            ("ui", "date_format") => Some(self.ui.date_format.clone()),
382            ("auth", "github_token") => self.auth.github_token.clone().map(|_| "[set]".to_string()),
383            ("auth", "gitlab_token") => self.auth.gitlab_token.clone().map(|_| "[set]".to_string()),
384            ("auth", "gitea_token") => self.auth.gitea_token.clone().map(|_| "[set]".to_string()),
385            ("auth", "forgejo_token") => self.auth.forgejo_token.clone().map(|_| "[set]".to_string()),
386            ("auth", "codeberg_token") => self.auth.codeberg_token.clone().map(|_| "[set]".to_string()),
387            ("worktree", "base_dir") => Some(self.worktree.base_dir.clone()),
388            ("worktree", "inherit_paths") => {
389                if self.worktree.inherit_paths.is_empty() {
390                    None
391                } else {
392                    Some(self.worktree.inherit_paths.join(","))
393                }
394            }
395            _ => None,
396        }
397    }
398    
399    /// Set a configuration value by key path
400    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
401        let parts: Vec<&str> = key.split('.').collect();
402        if parts.len() != 2 {
403            return Err(ToriiError::InvalidConfig(format!("Invalid config key: {}", key)));
404        }
405        
406        match (parts[0], parts[1]) {
407            ("user", "name") => self.user.name = Some(value.to_string()),
408            ("user", "email") => self.user.email = Some(value.to_string()),
409            ("user", "editor") => self.user.editor = Some(value.to_string()),
410            ("snapshot", "auto_enabled") => {
411                self.snapshot.auto_enabled = value.parse()
412                    .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
413            }
414            ("snapshot", "auto_interval_minutes") => {
415                self.snapshot.auto_interval_minutes = value.parse()
416                    .map_err(|_| ToriiError::InvalidConfig("Value must be a number".to_string()))?;
417            }
418            ("snapshot", "retention_days") => {
419                self.snapshot.retention_days = value.parse()
420                    .map_err(|_| ToriiError::InvalidConfig("Value must be a number".to_string()))?;
421            }
422            ("snapshot", "max_snapshots") => {
423                self.snapshot.max_snapshots = Some(value.parse()
424                    .map_err(|_| ToriiError::InvalidConfig("Value must be a number".to_string()))?);
425            }
426            ("mirror", "autofetch_enabled") => {
427                self.mirror.autofetch_enabled = value.parse()
428                    .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
429            }
430            ("mirror", "autofetch_interval_minutes") => {
431                self.mirror.autofetch_interval_minutes = value.parse()
432                    .map_err(|_| ToriiError::InvalidConfig("Value must be a number".to_string()))?;
433            }
434            ("mirror", "default_protocol") => {
435                if value != "ssh" && value != "https" {
436                    return Err(ToriiError::InvalidConfig("Protocol must be 'ssh' or 'https'".to_string()));
437                }
438                self.mirror.default_protocol = value.to_string();
439            }
440            ("git", "default_branch") => self.git.default_branch = value.to_string(),
441            ("git", "sign_commits") => {
442                self.git.sign_commits = value.parse()
443                    .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
444            }
445            ("git", "gpg_key") => self.git.gpg_key = Some(value.to_string()),
446            ("git", "gpg_program") => self.git.gpg_program = Some(value.to_string()),
447            // 0.7.14: git-friendly aliases that map to git.gpg_key /
448            // git.sign_commits respectively. 0.7.35 adds gpg.program.
449            // Either name works.
450            ("user", "signingkey") => self.git.gpg_key = Some(value.to_string()),
451            ("gpg", "program") => self.git.gpg_program = Some(value.to_string()),
452            ("commit", "gpgsign") => {
453                self.git.sign_commits = value.parse()
454                    .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
455            }
456            ("git", "pull_rebase") => {
457                self.git.pull_rebase = value.parse()
458                    .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
459            }
460            ("ui", "colors") => {
461                self.ui.colors = value.parse()
462                    .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
463            }
464            ("ui", "emoji") => {
465                self.ui.emoji = value.parse()
466                    .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
467            }
468            ("ui", "verbose") => {
469                self.ui.verbose = value.parse()
470                    .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
471            }
472            ("ui", "date_format") => self.ui.date_format = value.to_string(),
473            ("auth", "github_token") => self.auth.github_token = Some(value.to_string()),
474            ("auth", "gitlab_token") => self.auth.gitlab_token = Some(value.to_string()),
475            ("auth", "gitea_token") => self.auth.gitea_token = Some(value.to_string()),
476            ("auth", "forgejo_token") => self.auth.forgejo_token = Some(value.to_string()),
477            ("auth", "codeberg_token") => self.auth.codeberg_token = Some(value.to_string()),
478            ("worktree", "base_dir") => {
479                if value.trim().is_empty() {
480                    return Err(ToriiError::InvalidConfig(
481                        "worktree.base_dir must not be empty (use '..' for sibling directories)".to_string(),
482                    ));
483                }
484                self.worktree.base_dir = value.to_string();
485            }
486            ("worktree", "inherit_paths") => {
487                // Accept comma-separated list; empty string clears.
488                self.worktree.inherit_paths = if value.trim().is_empty() {
489                    Vec::new()
490                } else {
491                    value
492                        .split(',')
493                        .map(|s| s.trim().to_string())
494                        .filter(|s| !s.is_empty())
495                        .collect()
496                };
497            }
498            _ => return Err(ToriiError::InvalidConfig(format!("Unknown config key: {}", key))),
499        }
500        
501        Ok(())
502    }
503    
504    /// List all configuration values
505    pub fn list(&self) -> Vec<(String, String)> {
506        let mut items = Vec::new();
507        
508        // User
509        if let Some(name) = &self.user.name {
510            items.push(("user.name".to_string(), name.clone()));
511        }
512        if let Some(email) = &self.user.email {
513            items.push(("user.email".to_string(), email.clone()));
514        }
515        if let Some(editor) = &self.user.editor {
516            items.push(("user.editor".to_string(), editor.clone()));
517        }
518        
519        // Snapshot
520        items.push(("snapshot.auto_enabled".to_string(), self.snapshot.auto_enabled.to_string()));
521        items.push(("snapshot.auto_interval_minutes".to_string(), self.snapshot.auto_interval_minutes.to_string()));
522        items.push(("snapshot.retention_days".to_string(), self.snapshot.retention_days.to_string()));
523        if let Some(max) = self.snapshot.max_snapshots {
524            items.push(("snapshot.max_snapshots".to_string(), max.to_string()));
525        }
526        
527        // Mirror
528        items.push(("mirror.autofetch_enabled".to_string(), self.mirror.autofetch_enabled.to_string()));
529        items.push(("mirror.autofetch_interval_minutes".to_string(), self.mirror.autofetch_interval_minutes.to_string()));
530        items.push(("mirror.default_protocol".to_string(), self.mirror.default_protocol.clone()));
531        
532        // Git
533        items.push(("git.default_branch".to_string(), self.git.default_branch.clone()));
534        items.push(("git.sign_commits".to_string(), self.git.sign_commits.to_string()));
535        if let Some(key) = &self.git.gpg_key {
536            items.push(("git.gpg_key".to_string(), key.clone()));
537        }
538        if let Some(p) = &self.git.gpg_program {
539            items.push(("git.gpg_program".to_string(), p.clone()));
540        }
541        items.push(("git.pull_rebase".to_string(), self.git.pull_rebase.to_string()));
542        
543        // UI
544        items.push(("ui.colors".to_string(), self.ui.colors.to_string()));
545        items.push(("ui.emoji".to_string(), self.ui.emoji.to_string()));
546        items.push(("ui.verbose".to_string(), self.ui.verbose.to_string()));
547        items.push(("ui.date_format".to_string(), self.ui.date_format.clone()));
548
549        // Auth (always show, mask value if set)
550        items.push(("auth.github_token".to_string(), if self.auth.github_token.is_some() { "[set]".to_string() } else { "[not set]".to_string() }));
551        items.push(("auth.gitlab_token".to_string(), if self.auth.gitlab_token.is_some() { "[set]".to_string() } else { "[not set]".to_string() }));
552        items.push(("auth.gitea_token".to_string(), if self.auth.gitea_token.is_some() { "[set]".to_string() } else { "[not set]".to_string() }));
553        items.push(("auth.forgejo_token".to_string(), if self.auth.forgejo_token.is_some() { "[set]".to_string() } else { "[not set]".to_string() }));
554        items.push(("auth.codeberg_token".to_string(), if self.auth.codeberg_token.is_some() { "[set]".to_string() } else { "[not set]".to_string() }));
555
556        // Worktree
557        items.push(("worktree.base_dir".to_string(), self.worktree.base_dir.clone()));
558        if !self.worktree.inherit_paths.is_empty() {
559            items.push((
560                "worktree.inherit_paths".to_string(),
561                self.worktree.inherit_paths.join(","),
562            ));
563        }
564
565        items
566    }
567}