1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use crate::config::Config;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum ProjectType {
9 Go,
10 Node,
11 Rust,
12 Python,
13 Generic,
14}
15
16impl ProjectType {
17 pub fn as_str(self) -> &'static str {
18 match self {
19 ProjectType::Go => "go",
20 ProjectType::Node => "node",
21 ProjectType::Rust => "rust",
22 ProjectType::Python => "python",
23 ProjectType::Generic => "generic",
24 }
25 }
26}
27
28#[derive(Debug, Clone)]
29pub struct Project {
30 pub name: String,
31 pub path: PathBuf,
32 pub git_branch: String,
33 pub git_dirty: bool,
34 pub git_ahead: u32,
35 pub git_behind: u32,
36 pub project_type: ProjectType,
37}
38
39pub fn scan_projects(cfg: &Config) -> Vec<Project> {
40 let mut projects = Vec::new();
41 let mut seen: HashSet<PathBuf> = HashSet::new();
42
43 for root in &cfg.roots {
44 let root_path = PathBuf::from(shellexpand_env(root));
45 let Ok(entries) = std::fs::read_dir(&root_path) else {
46 continue;
47 };
48
49 for entry in entries.flatten() {
50 let file_type = match entry.file_type() {
51 Ok(ft) => ft,
52 Err(_) => continue,
53 };
54 if !file_type.is_dir() {
55 continue;
56 }
57 let name = entry.file_name().to_string_lossy().into_owned();
58 if name.starts_with('.') {
59 continue;
60 }
61
62 let full_path = root_path.join(&name);
63 if !seen.insert(full_path.clone()) {
64 continue;
65 }
66
67 if !full_path.join(".git").exists() {
68 continue;
69 }
70
71 let project_type = detect_project_type(&full_path);
72 let (git_branch, git_dirty, git_ahead, git_behind) = get_git_info(&full_path);
73
74 projects.push(Project {
75 name,
76 path: full_path,
77 git_branch,
78 git_dirty,
79 git_ahead,
80 git_behind,
81 project_type,
82 });
83 }
84 }
85
86 projects
87}
88
89pub fn detect_project_type(path: &Path) -> ProjectType {
90 let checks: &[(&str, ProjectType)] = &[
93 ("go.mod", ProjectType::Go),
94 ("Cargo.toml", ProjectType::Rust),
95 ("package.json", ProjectType::Node),
96 ("pyproject.toml", ProjectType::Python),
97 ("requirements.txt", ProjectType::Python),
98 ];
99 for (file, kind) in checks {
100 if path.join(file).exists() {
101 return *kind;
102 }
103 }
104 ProjectType::Generic
105}
106
107pub fn get_git_info(path: &Path) -> (String, bool, u32, u32) {
108 let branch = run_git(path, &["rev-parse", "--abbrev-ref", "HEAD"])
109 .map(|s| s.trim().to_string())
110 .unwrap_or_else(|| "unknown".to_string());
111
112 let dirty = match run_git(path, &["status", "--porcelain"]) {
113 Some(out) => !out.trim().is_empty(),
114 None => false,
115 };
116
117 let (ahead, behind) = match run_git(
118 path,
119 &["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
120 ) {
121 Some(out) => parse_ahead_behind(&out),
122 None => (0, 0),
123 };
124
125 (branch, dirty, ahead, behind)
126}
127
128fn parse_ahead_behind(s: &str) -> (u32, u32) {
129 let parts: Vec<&str> = s.split_whitespace().collect();
130 if parts.len() != 2 {
131 return (0, 0);
132 }
133 let ahead = parts[0].parse().unwrap_or(0);
134 let behind = parts[1].parse().unwrap_or(0);
135 (ahead, behind)
136}
137
138fn run_git(path: &Path, args: &[&str]) -> Option<String> {
139 let mut cmd = Command::new("git");
140 cmd.arg("-C").arg(path);
141 for a in args {
142 cmd.arg(a);
143 }
144 let output = cmd.output().ok()?;
145 if !output.status.success() {
146 return None;
147 }
148 String::from_utf8(output.stdout).ok()
149}
150
151fn shellexpand_env(input: &str) -> String {
153 expand_env_with(input, |name| std::env::var(name).ok())
154}
155
156fn expand_env_with<F>(input: &str, lookup: F) -> String
160where
161 F: Fn(&str) -> Option<String>,
162{
163 let mut out = String::with_capacity(input.len());
164 let mut rest = input;
165
166 while !rest.is_empty() {
167 let Some(dollar) = rest.find('$') else {
170 out.push_str(rest);
171 break;
172 };
173 out.push_str(&rest[..dollar]);
174 let after_dollar = &rest[dollar + 1..];
175
176 if let Some(inside) = after_dollar.strip_prefix('{')
178 && let Some(close) = inside.find('}')
179 {
180 let name = &inside[..close];
181 if let Some(val) = lookup(name) {
182 out.push_str(&val);
183 }
184 rest = &inside[close + 1..];
185 continue;
186 }
187
188 let name_end = after_dollar
190 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
191 .unwrap_or(after_dollar.len());
192
193 if name_end == 0 {
194 out.push('$');
196 rest = after_dollar;
197 continue;
198 }
199
200 let name = &after_dollar[..name_end];
201 if let Some(val) = lookup(name) {
202 out.push_str(&val);
203 }
204 rest = &after_dollar[name_end..];
205 }
206
207 out
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use tempfile::TempDir;
214
215 #[test]
216 fn detect_type_recognises_markers() {
217 let dir = TempDir::new().unwrap();
218 let p = dir.path();
219 assert_eq!(detect_project_type(p), ProjectType::Generic);
220
221 std::fs::write(p.join("requirements.txt"), "").unwrap();
222 assert_eq!(detect_project_type(p), ProjectType::Python);
223
224 std::fs::write(p.join("package.json"), "{}").unwrap();
225 assert_eq!(detect_project_type(p), ProjectType::Node);
226
227 std::fs::write(p.join("Cargo.toml"), "").unwrap();
228 assert_eq!(detect_project_type(p), ProjectType::Rust);
229
230 std::fs::write(p.join("go.mod"), "").unwrap();
232 assert_eq!(detect_project_type(p), ProjectType::Go);
233 }
234
235 #[test]
236 fn project_type_string_matches_go_version() {
237 assert_eq!(ProjectType::Go.as_str(), "go");
238 assert_eq!(ProjectType::Node.as_str(), "node");
239 assert_eq!(ProjectType::Rust.as_str(), "rust");
240 assert_eq!(ProjectType::Python.as_str(), "python");
241 assert_eq!(ProjectType::Generic.as_str(), "generic");
242 }
243
244 #[test]
245 fn parse_ahead_behind_handles_well_formed() {
246 assert_eq!(parse_ahead_behind("3\t5\n"), (3, 5));
247 assert_eq!(parse_ahead_behind("0 0"), (0, 0));
248 }
249
250 #[test]
251 fn parse_ahead_behind_handles_malformed() {
252 assert_eq!(parse_ahead_behind(""), (0, 0));
253 assert_eq!(parse_ahead_behind("3"), (0, 0));
254 assert_eq!(parse_ahead_behind("abc def"), (0, 0));
255 }
256
257 #[test]
258 fn expand_env_with_stub_lookup() {
259 let lookup = |name: &str| match name {
260 "FOO" => Some("bar".to_string()),
261 "EMPTY" => Some(String::new()),
262 _ => None,
263 };
264 assert_eq!(expand_env_with("$FOO/x", lookup), "bar/x");
265 assert_eq!(expand_env_with("${FOO}-end", lookup), "bar-end");
266 assert_eq!(expand_env_with("plain", lookup), "plain");
267 assert_eq!(expand_env_with("$MISSING/x", lookup), "/x");
268 assert_eq!(expand_env_with("${EMPTY}!", lookup), "!");
269 assert_eq!(expand_env_with("$", lookup), "$");
270 assert_eq!(expand_env_with("$ foo", lookup), "$ foo");
271 assert_eq!(expand_env_with("price: $50", lookup), "price: ");
272 }
273
274 #[test]
275 fn expand_env_with_preserves_unicode() {
276 let lookup = |name: &str| match name {
280 "ORG" => Some("toss".to_string()),
281 _ => None,
282 };
283 assert_eq!(
284 expand_env_with("/home/me/개발/$ORG", lookup),
285 "/home/me/개발/toss"
286 );
287 assert_eq!(
288 expand_env_with("프로젝트/${ORG}/x", lookup),
289 "프로젝트/toss/x"
290 );
291 assert_eq!(expand_env_with("개발-${MISSING}-끝", lookup), "개발--끝");
292 assert_eq!(expand_env_with("순수한경로", lookup), "순수한경로");
293 }
294
295 #[test]
296 fn scan_projects_skips_non_git_and_hidden() {
297 let dir = TempDir::new().unwrap();
298 let root = dir.path();
299
300 let repo = root.join("real-repo");
301 std::fs::create_dir(&repo).unwrap();
302 std::fs::create_dir(repo.join(".git")).unwrap();
303 std::fs::write(repo.join("Cargo.toml"), "").unwrap();
304
305 let plain = root.join("not-a-repo");
306 std::fs::create_dir(&plain).unwrap();
307
308 let hidden = root.join(".hidden-repo");
309 std::fs::create_dir(&hidden).unwrap();
310 std::fs::create_dir(hidden.join(".git")).unwrap();
311
312 let cfg = Config {
313 roots: vec![root.to_string_lossy().into_owned()],
314 ..Config::default()
315 };
316 let projects = scan_projects(&cfg);
317 assert_eq!(projects.len(), 1);
318 assert_eq!(projects[0].name, "real-repo");
319 assert_eq!(projects[0].project_type, ProjectType::Rust);
320 }
321}