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 repo.root.join(CONFIG_FILENAME),
36 config_path(repo),
38 ];
39
40 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 candidates.push(home.join(format!(".{}", CONFIG_FILENAME)));
48 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 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}