Skip to main content

tmx/
project.rs

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    // Order matches the Go version's intent but is explicit (HashMap order was
91    // accidental there). Check the most language-specific markers first.
92    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
151/// Expand `$VAR` style references in a path. Mirrors Go's `os.ExpandEnv`.
152fn shellexpand_env(input: &str) -> String {
153    expand_env_with(input, |name| std::env::var(name).ok())
154}
155
156/// Core of `shellexpand_env` parameterised on the lookup function. Splitting
157/// the I/O lets tests inject a stub instead of mutating process env, which
158/// would race other parallel tests.
159fn 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        // `'$'` is single-byte ASCII so byte-index `find` is UTF-8 safe and
168        // slicing on it stays on a char boundary.
169        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        // ${VAR}
177        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        // $VAR — name is [A-Za-z0-9_]+
189        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            // Bare `$` with no name: emit it as a literal.
195            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        // go.mod wins because it is checked first
231        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        // Regression: a naive byte-level expander would corrupt multibyte
277        // UTF-8 sequences. Roots like `/home/me/개발/$ORG` must round-trip
278        // their non-ASCII segments untouched and still expand `$ORG`.
279        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}