wsx_core/config/
global.rs1use 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
13pub 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 #[serde(default = "default_exclude_worktree_paths")]
31 pub exclude_worktree_paths: Vec<String>,
32 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub tab: Option<String>,
56 #[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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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}