Skip to main content

wsx_core/config/
global.rs

1// ~/.config/wsx/config.toml
2// ref: toml crate — https://docs.rs/toml/
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6use std::io::Write;
7use std::path::{Path, PathBuf};
8
9fn default_exclude_worktree_paths() -> Vec<String> {
10    vec![".claude/worktrees".to_string()]
11}
12
13/// Canonical form used for project-path identity. A trailing `/` is the only
14/// divergence we've seen between a user-typed path and its stored form, and an
15/// un-normalized duplicate silently breaks dedup / delete / cache lookups.
16/// Single source of truth — `load`, `add_project`, and `ops::register_project`
17/// must all route through this so the stored path and the in-memory path match.
18pub fn normalize_project_path(path: &Path) -> PathBuf {
19    PathBuf::from(path.to_string_lossy().trim_end_matches('/').to_string())
20}
21
22#[derive(Debug, Clone, Deserialize, Serialize)]
23pub struct GlobalConfig {
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub tabs: Vec<String>,
26    #[serde(default)]
27    pub projects: Vec<ProjectEntry>,
28    /// Worktree paths containing any of these substrings are hidden.
29    /// Defaults to [".claude/worktrees"] to exclude Claude agent worktrees.
30    #[serde(default = "default_exclude_worktree_paths")]
31    pub exclude_worktree_paths: Vec<String>,
32    /// Extra no-prefix tmux key bound to detach-client when --mobile is set.
33    /// Example: "C-q". Unset by default (use prefix + d as normal).
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub mobile_detach_key: Option<String>,
36}
37
38impl Default for GlobalConfig {
39    fn default() -> Self {
40        Self {
41            tabs: vec![],
42            projects: vec![],
43            exclude_worktree_paths: default_exclude_worktree_paths(),
44            mobile_detach_key: None,
45        }
46    }
47}
48
49#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct ProjectEntry {
51    pub name: String,
52    pub path: PathBuf,
53    /// Which tab this project belongs to. None = default tab.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub tab: Option<String>,
56    /// branch -> alias mapping (stored at app level, independent of git)
57    #[serde(default)]
58    pub aliases: std::collections::HashMap<String, String>,
59}
60
61impl GlobalConfig {
62    pub fn config_path() -> Option<PathBuf> {
63        dirs::config_dir().map(|d| d.join("wsx").join("config.toml"))
64    }
65
66    /// Returns `(config, warning)`. On TOML parse error, falls back to defaults
67    /// and sets `warning` so the caller can surface it in the TUI status bar.
68    pub fn load() -> Result<(Self, Option<String>)> {
69        let path = Self::config_path().context("no config dir")?;
70        if !path.exists() {
71            return Ok((Self::default(), None));
72        }
73        let text = std::fs::read_to_string(&path)
74            .with_context(|| format!("reading {}", path.display()))?;
75        match toml::from_str::<Self>(&text) {
76            Err(e) => {
77                let warn = format!("config parse error (using defaults): {e}");
78                Ok((Self::default(), Some(warn)))
79            }
80            Ok(mut config) => {
81                for entry in &mut config.projects {
82                    entry.path = normalize_project_path(&entry.path);
83                }
84                Ok((config, None))
85            }
86        }
87    }
88
89    pub fn save(&self) -> Result<()> {
90        let path = Self::config_path().context("no config dir")?;
91        if let Some(parent) = path.parent() {
92            std::fs::create_dir_all(parent)?;
93        }
94        let text = toml::to_string_pretty(self)?;
95        let tmp = path.with_extension("toml.tmp");
96        let mut file =
97            std::fs::File::create(&tmp).with_context(|| format!("writing {}", tmp.display()))?;
98        file.write_all(text.as_bytes())
99            .with_context(|| format!("writing {}", tmp.display()))?;
100        file.sync_all()
101            .with_context(|| format!("syncing {}", tmp.display()))?;
102        drop(file);
103        std::fs::rename(&tmp, &path).with_context(|| format!("renaming {}", path.display()))?;
104        Ok(())
105    }
106
107    pub fn is_worktree_excluded(&self, path: &PathBuf) -> bool {
108        let path_str = path.to_string_lossy();
109        self.exclude_worktree_paths
110            .iter()
111            .any(|pat| path_str.contains(pat.as_str()))
112    }
113
114    /// Returns tab order as [None, Some("work"), …]. None = default tab (always first).
115    pub fn ordered_tabs(&self) -> Vec<Option<&str>> {
116        let mut out: Vec<Option<&str>> = vec![None];
117        for t in &self.tabs {
118            out.push(Some(t.as_str()));
119        }
120        out
121    }
122
123    pub fn tab_exists(&self, name: &str) -> bool {
124        self.tabs.iter().any(|t| t == name)
125    }
126
127    /// Returns the tab name for the project at `path`, or `None` if unassigned.
128    pub fn project_tab<'a>(&'a self, path: &Path) -> Option<&'a str> {
129        self.projects
130            .iter()
131            .find(|e| e.path == path)
132            .and_then(|e| e.tab.as_deref())
133    }
134
135    /// Set or clear the tab assignment for the project at `path`.
136    pub fn move_project_tab(&mut self, path: &PathBuf, tab: Option<String>) {
137        if let Some(entry) = self.projects.iter_mut().find(|p| &p.path == path) {
138            entry.tab = tab;
139        }
140    }
141
142    pub fn add_project(&mut self, name: String, path: PathBuf) {
143        let path = normalize_project_path(&path);
144        self.projects.retain(|p| p.path != path);
145        self.projects.push(ProjectEntry {
146            name,
147            path,
148            tab: None,
149            aliases: Default::default(),
150        });
151    }
152
153    pub fn remove_project(&mut self, path: &PathBuf) {
154        self.projects.retain(|p| &p.path != path);
155    }
156
157    pub fn set_alias(&mut self, project_path: &PathBuf, branch: &str, alias: &str) {
158        if let Some(entry) = self.projects.iter_mut().find(|p| &p.path == project_path) {
159            if alias.is_empty() {
160                entry.aliases.remove(branch);
161            } else {
162                entry.aliases.insert(branch.to_string(), alias.to_string());
163            }
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    // --- normalize_project_path ---
173
174    #[test]
175    fn given_path_with_single_trailing_slash_when_normalized_then_slash_stripped() {
176        assert_eq!(normalize_project_path(Path::new("/foo/")), PathBuf::from("/foo"));
177    }
178
179    #[test]
180    fn given_path_with_multiple_trailing_slashes_when_normalized_then_all_stripped() {
181        assert_eq!(normalize_project_path(Path::new("/foo///")), PathBuf::from("/foo"));
182    }
183
184    #[test]
185    fn given_relative_path_with_trailing_slash_when_normalized_then_slash_stripped() {
186        assert_eq!(normalize_project_path(Path::new("foo/bar/")), PathBuf::from("foo/bar"));
187    }
188
189    // Semantically-wrong-input guard: only the trailing run is stripped.
190    // A naive "collapse all slashes" impl would wrongly yield "/foo/bar".
191    #[test]
192    fn given_path_with_interior_and_trailing_slashes_when_normalized_then_only_trailing_stripped() {
193        assert_eq!(normalize_project_path(Path::new("/foo//bar/")), PathBuf::from("/foo//bar"));
194    }
195
196    #[test]
197    fn given_empty_path_when_normalized_then_does_not_panic() {
198        let _ = normalize_project_path(Path::new(""));
199    }
200
201    // All-slashes collapses to empty (not just "no panic").
202    #[test]
203    fn given_all_slashes_path_when_normalized_then_empty() {
204        assert_eq!(normalize_project_path(Path::new("///")), PathBuf::from(""));
205    }
206
207    #[test]
208    fn given_root_slash_path_when_normalized_then_empty() {
209        assert_eq!(normalize_project_path(Path::new("/")), PathBuf::from(""));
210    }
211
212    // --- GlobalConfig::default (precondition for add_project suite) ---
213
214    #[test]
215    fn given_default_config_when_constructed_then_projects_is_empty() {
216        let config = GlobalConfig::default();
217        assert!(config.projects.is_empty());
218    }
219
220    // --- GlobalConfig::add_project ---
221
222    #[test]
223    fn given_path_with_trailing_slash_when_add_project_then_stored_path_normalized() {
224        let mut config = GlobalConfig::default();
225        config.add_project("a".to_string(), PathBuf::from("/foo/"));
226        assert_eq!(config.projects[0].path, PathBuf::from("/foo"));
227    }
228
229    #[test]
230    fn given_duplicate_normalized_path_when_add_project_twice_then_len_is_one() {
231        let mut config = GlobalConfig::default();
232        config.add_project("a".to_string(), PathBuf::from("/foo"));
233        config.add_project("b".to_string(), PathBuf::from("/foo/"));
234        assert_eq!(config.projects.len(), 1);
235    }
236
237    #[test]
238    fn given_duplicate_normalized_path_when_add_project_twice_then_last_name_wins() {
239        let mut config = GlobalConfig::default();
240        config.add_project("a".to_string(), PathBuf::from("/foo"));
241        config.add_project("b".to_string(), PathBuf::from("/foo/"));
242        assert_eq!(config.projects[0].name, "b");
243    }
244
245    // Identity is keyed on the normalized path: the survivor's stored path is
246    // the normalized form regardless of which call carried the trailing slash.
247    #[test]
248    fn given_duplicate_normalized_path_when_add_project_twice_then_survivor_path_normalized() {
249        let mut config = GlobalConfig::default();
250        config.add_project("a".to_string(), PathBuf::from("/foo/"));
251        config.add_project("b".to_string(), PathBuf::from("/foo"));
252        assert_eq!(config.projects[0].path, PathBuf::from("/foo"));
253    }
254
255    #[test]
256    fn given_two_distinct_paths_when_add_project_each_then_both_persist() {
257        let mut config = GlobalConfig::default();
258        config.add_project("a".to_string(), PathBuf::from("/foo"));
259        config.add_project("b".to_string(), PathBuf::from("/bar"));
260        assert_eq!(config.projects.len(), 2);
261    }
262}