Skip to main content

gobby_code/commands/status/
projects.rs

1use std::collections::{BTreeMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use crate::config;
5use crate::models::IndexedProject;
6use crate::output::{self, Format};
7use crate::utils::short_id;
8
9use super::shared::{collect_projects, display_name, format_coverage, format_timestamp};
10
11pub fn projects(format: Format) -> anyhow::Result<()> {
12    let all_projects = collect_projects()?;
13
14    match format {
15        Format::Json => output::print_json(&all_projects),
16        Format::Text => {
17            if all_projects.is_empty() {
18                eprintln!("No indexed projects. Run `gcode init` in a project directory.");
19            } else {
20                let mut text = String::new();
21                for p in &all_projects {
22                    text.push_str(&format!("{} — {}\n", display_name(p), p.root_path));
23                    text.push_str(&format!(
24                        "  {} files, {} symbols | Last indexed: {}\n",
25                        format_coverage(p.total_files, p.total_eligible_files),
26                        p.total_symbols,
27                        format_timestamp(&p.last_indexed_at)
28                    ));
29                }
30                output::print_text(text.trim_end())?;
31            }
32            Ok(())
33        }
34    }
35}
36
37fn is_stale(p: &IndexedProject) -> Option<&'static str> {
38    if p.id.starts_with("00000000") {
39        return Some("sentinel project (not a code project)");
40    }
41    if p.root_path.is_empty() {
42        return Some("empty root path");
43    }
44    if !Path::new(&p.root_path).is_absolute() {
45        return Some("relative root path");
46    }
47    if !Path::new(&p.root_path).exists() {
48        return Some("path does not exist");
49    }
50    None
51}
52
53pub(super) struct StaleProject<'a> {
54    pub(super) project: &'a IndexedProject,
55    pub(super) reason: String,
56}
57
58pub(super) fn stale_projects(projects: &[IndexedProject]) -> Vec<StaleProject<'_>> {
59    let mut stale = Vec::new();
60    let mut stale_ids = HashSet::new();
61
62    for project in projects {
63        if let Some(reason) = is_stale(project) {
64            stale_ids.insert(project.id.clone());
65            stale.push(StaleProject {
66                project,
67                reason: reason.to_string(),
68            });
69        }
70    }
71
72    let mut by_root: BTreeMap<PathBuf, Vec<&IndexedProject>> = BTreeMap::new();
73    for project in projects {
74        if stale_ids.contains(&project.id) {
75            continue;
76        }
77        let Ok(canonical_root) = Path::new(&project.root_path).canonicalize() else {
78            continue;
79        };
80        by_root.entry(canonical_root).or_default().push(project);
81    }
82
83    for (root, entries) in by_root {
84        if entries.len() < 2 {
85            continue;
86        }
87        let Ok(identity) = config::resolve_project_identity(&root, config::MissingIdentity::Error)
88        else {
89            continue;
90        };
91        if !entries
92            .iter()
93            .any(|project| project.id == identity.project_id)
94        {
95            continue;
96        }
97        for project in entries {
98            if project.id == identity.project_id || !stale_ids.insert(project.id.clone()) {
99                continue;
100            }
101            stale.push(StaleProject {
102                project,
103                reason: format!(
104                    "duplicate root superseded by current project id {}",
105                    short_id(&identity.project_id)
106                ),
107            });
108        }
109    }
110
111    stale
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn indexed_project(id: &str, root_path: &Path) -> IndexedProject {
119        IndexedProject {
120            id: id.to_string(),
121            root_path: root_path.to_string_lossy().to_string(),
122            total_files: 1,
123            total_symbols: 1,
124            last_indexed_at: "1".to_string(),
125            index_duration_ms: 1,
126            total_eligible_files: Some(1),
127        }
128    }
129
130    fn write_project_json(root: &Path, id: &str) {
131        let gobby_dir = root.join(".gobby");
132        std::fs::create_dir_all(&gobby_dir).expect("create .gobby");
133        std::fs::write(
134            gobby_dir.join("project.json"),
135            serde_json::json!({
136                "id": id,
137                "name": "project",
138                "parent_project_path": root.to_string_lossy(),
139                "parent_project_id": id
140            })
141            .to_string(),
142        )
143        .expect("write project.json");
144    }
145
146    #[test]
147    fn duplicate_root_prune_detection_keeps_resolved_project_id() {
148        let tmp = tempfile::tempdir().expect("tempdir");
149        let root = tmp.path().canonicalize().expect("canonical root");
150        let current_id = "d45545c5-current-project-id";
151        let stale_id = "39c31b8f-stale-project-id";
152        write_project_json(&root, current_id);
153
154        let projects = vec![
155            indexed_project(current_id, &root),
156            indexed_project(stale_id, &root),
157        ];
158
159        let stale = stale_projects(&projects);
160
161        assert_eq!(stale.len(), 1);
162        assert_eq!(stale[0].project.id, stale_id);
163        assert!(stale[0].reason.contains("duplicate root"));
164        assert!(stale.iter().all(|entry| entry.project.id != current_id));
165    }
166}