gobby_code/commands/status/
projects.rs1use 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}