git_workty/
config.rs

1use crate::git::GitRepo;
2use anyhow::{Context, Result};
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::path::PathBuf;
6
7const CONFIG_FILENAME: &str = "workty.toml";
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(default)]
11pub struct Config {
12    pub version: u32,
13    pub base: String,
14    pub root: String,
15    pub layout: String,
16    pub open_cmd: Option<String>,
17}
18
19impl Default for Config {
20    fn default() -> Self {
21        Self {
22            version: 1,
23            base: "main".to_string(),
24            root: "~/.workty/{repo}-{id}".to_string(),
25            layout: "flat".to_string(),
26            open_cmd: None,
27        }
28    }
29}
30
31impl Config {
32    pub fn load(repo: &GitRepo) -> Result<Self> {
33        let mut candidates = vec![
34            // 1. Repo root
35            repo.root.join(CONFIG_FILENAME),
36            // 2. Git dir
37            config_path(repo),
38        ];
39
40        // 3. User config dir (~/.config/workty/workty.toml)
41        if let Some(config_dir) = dirs::config_dir() {
42            candidates.push(config_dir.join("workty").join(CONFIG_FILENAME));
43        }
44
45        if let Some(home) = dirs::home_dir() {
46            // 4. ~/.workty.toml
47            candidates.push(home.join(format!(".{}", CONFIG_FILENAME)));
48            // 5. ~/workty.toml
49            candidates.push(home.join(CONFIG_FILENAME));
50        }
51
52        for path in candidates {
53            if path.exists() {
54                let contents = std::fs::read_to_string(&path)
55                    .with_context(|| format!("Failed to read config from {}", path.display()))?;
56                return toml::from_str(&contents)
57                    .with_context(|| format!("Failed to parse config from {}", path.display()));
58            }
59        }
60
61        Ok(Self::default())
62    }
63
64    #[allow(dead_code)]
65    pub fn save(&self, repo: &GitRepo) -> Result<()> {
66        let path = config_path(repo);
67        let contents = toml::to_string_pretty(self).context("Failed to serialize config")?;
68        std::fs::write(&path, contents)
69            .with_context(|| format!("Failed to write config to {}", path.display()))
70    }
71
72    pub fn workspace_root(&self, repo: &GitRepo) -> PathBuf {
73        let repo_name = repo
74            .root
75            .file_name()
76            .and_then(|s| s.to_str())
77            .unwrap_or("repo");
78
79        let id = compute_repo_id(repo);
80
81        let expanded = self.root.replace("{repo}", repo_name).replace("{id}", &id);
82
83        expand_tilde(&expanded)
84    }
85
86    pub fn worktree_path(&self, repo: &GitRepo, branch_slug: &str) -> PathBuf {
87        let root = self.workspace_root(repo);
88        root.join(branch_slug)
89    }
90}
91
92pub fn config_path(repo: &GitRepo) -> PathBuf {
93    repo.common_dir.join(CONFIG_FILENAME)
94}
95
96pub fn config_exists(repo: &GitRepo) -> bool {
97    config_path(repo).exists()
98}
99
100fn compute_repo_id(repo: &GitRepo) -> String {
101    let input = repo
102        .origin_url()
103        .unwrap_or_else(|| repo.common_dir.to_string_lossy().to_string());
104
105    let normalized = normalize_url(&input);
106    let mut hasher = Sha256::new();
107    hasher.update(normalized.as_bytes());
108    let result = hasher.finalize();
109    hex::encode(&result[..4])
110}
111
112fn normalize_url(url: &str) -> String {
113    url.trim()
114        .trim_end_matches('/')
115        .trim_end_matches(".git")
116        .to_lowercase()
117}
118
119fn expand_tilde(path: &str) -> PathBuf {
120    if path == "~" {
121        return dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
122    }
123
124    if let Some(rest) = path.strip_prefix("~/") {
125        if let Some(home) = dirs::home_dir() {
126            return home.join(rest);
127        }
128    }
129    PathBuf::from(path)
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn test_config_default() {
138        let config = Config::default();
139        assert_eq!(config.version, 1);
140        assert_eq!(config.base, "main");
141        assert_eq!(config.layout, "flat");
142    }
143
144    #[test]
145    fn test_normalize_url() {
146        assert_eq!(
147            normalize_url("https://github.com/user/repo.git"),
148            "https://github.com/user/repo"
149        );
150        assert_eq!(
151            normalize_url("git@github.com:user/repo.git/"),
152            "git@github.com:user/repo"
153        );
154    }
155
156    #[test]
157    fn test_expand_tilde() {
158        // We can't easily verify the exact home dir path in a cross-platform way without dirs::home_dir
159        // but we can check that it doesn't panic and returns something different than "~" if home exists
160        if let Some(home) = dirs::home_dir() {
161            assert_eq!(expand_tilde("~"), home);
162            assert_eq!(expand_tilde("~/foo"), home.join("foo"));
163        }
164
165        assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
166        assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path"));
167    }
168
169    #[test]
170    fn test_config_roundtrip() {
171        let config = Config {
172            version: 1,
173            base: "develop".to_string(),
174            root: "~/.worktrees/{repo}".to_string(),
175            layout: "flat".to_string(),
176            open_cmd: Some("code".to_string()),
177        };
178
179        let serialized = toml::to_string_pretty(&config).unwrap();
180        let deserialized: Config = toml::from_str(&serialized).unwrap();
181
182        assert_eq!(config.base, deserialized.base);
183        assert_eq!(config.open_cmd, deserialized.open_cmd);
184    }
185}