Skip to main content

coda_core/
scanner.rs

1//! Feature scanning and state lookup.
2//!
3//! [`FeatureScanner`] encapsulates the logic for discovering features in the
4//! `.trees/` directory (active worktrees) and `.coda/` directory (merged
5//! features whose PRs have been merged and worktrees cleaned up).
6
7use std::collections::HashSet;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use tracing::debug;
12
13use crate::CoreError;
14use crate::state::{FeatureState, FeatureStatus};
15
16/// Scans `.trees/` for active worktrees and `.coda/` for merged features.
17#[derive(Debug)]
18pub struct FeatureScanner {
19    trees_dir: PathBuf,
20    coda_dir: PathBuf,
21}
22
23impl FeatureScanner {
24    /// Creates a scanner for the given project root.
25    ///
26    /// The scanner looks for active features in `<project_root>/.trees/`
27    /// and merged features in `<project_root>/.coda/`.
28    pub fn new(project_root: &Path) -> Self {
29        Self {
30            trees_dir: project_root.join(".trees"),
31            coda_dir: project_root.join(".coda"),
32        }
33    }
34
35    /// Lists all features: active (from `.trees/`) and merged (from `.coda/`).
36    ///
37    /// Active features come from worktree state files at
38    /// `.trees/<slug>/.coda/<slug>/state.yml`. Merged features come from
39    /// `.coda/<slug>/state.yml` on the main branch. If a slug appears in
40    /// both locations, the active worktree version takes precedence.
41    ///
42    /// Invalid or unparseable state files are silently skipped.
43    /// Results are sorted by slug within each group.
44    ///
45    /// # Errors
46    ///
47    /// Returns `CoreError::ConfigError` if neither `.trees/` nor `.coda/`
48    /// exists.
49    pub fn list(&self) -> Result<Vec<FeatureState>, CoreError> {
50        let active = self.list_active();
51        let merged = self.list_merged(&active);
52
53        if active.is_empty() && merged.is_empty() && !self.trees_dir.is_dir() {
54            return Err(CoreError::ConfigError(
55                "No .trees/ directory found. Run `coda init` first.".into(),
56            ));
57        }
58
59        let mut all = active;
60        all.extend(merged);
61        Ok(all)
62    }
63
64    /// Lists only active features from `.trees/`.
65    ///
66    /// Each worktree directory `<slug>` owns exactly one feature at
67    /// `.coda/<slug>/state.yml`. Other `.coda/` subdirectories inherited
68    /// from the base branch (e.g. merged features) are ignored to prevent
69    /// ghost features from appearing.
70    ///
71    /// Returns an empty `Vec` if `.trees/` does not exist.
72    fn list_active(&self) -> Vec<FeatureState> {
73        let mut features = Vec::new();
74
75        let Ok(entries) = fs::read_dir(&self.trees_dir) else {
76            return features;
77        };
78
79        for worktree_entry in entries.flatten() {
80            if !worktree_entry.file_type().is_ok_and(|ft| ft.is_dir()) {
81                continue;
82            }
83
84            let slug = worktree_entry.file_name();
85            let state_path = worktree_entry
86                .path()
87                .join(".coda")
88                .join(&slug)
89                .join("state.yml");
90
91            if !state_path.is_file() {
92                continue;
93            }
94
95            match Self::read_state(&state_path) {
96                Ok(state) => features.push(state),
97                Err(e) => {
98                    debug!(
99                        path = %state_path.display(),
100                        error = %e,
101                        "Skipping invalid state.yml in worktree"
102                    );
103                }
104            }
105        }
106
107        features.sort_by(|a, b| a.feature.slug.cmp(&b.feature.slug));
108        features
109    }
110
111    /// Lists merged features from `.coda/`, excluding any slugs already
112    /// present in `active_features`.
113    ///
114    /// Scans `<project_root>/.coda/<slug>/state.yml` for subdirectories
115    /// that contain a valid state file. The status is overwritten to
116    /// [`FeatureStatus::Merged`] since the original status may be stale.
117    ///
118    /// Returns an empty `Vec` if `.coda/` does not exist.
119    fn list_merged(&self, active_features: &[FeatureState]) -> Vec<FeatureState> {
120        let mut features = Vec::new();
121
122        let Ok(entries) = fs::read_dir(&self.coda_dir) else {
123            return features;
124        };
125
126        let active_slugs: HashSet<&str> = active_features
127            .iter()
128            .map(|f| f.feature.slug.as_str())
129            .collect();
130
131        for entry in entries.flatten() {
132            if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
133                continue;
134            }
135
136            let dir_name = entry.file_name();
137            let slug = dir_name.to_string_lossy();
138
139            if active_slugs.contains(slug.as_ref()) {
140                continue;
141            }
142
143            let state_path = entry.path().join("state.yml");
144            if !state_path.is_file() {
145                continue;
146            }
147
148            match Self::read_state(&state_path) {
149                Ok(mut state) => {
150                    state.status = FeatureStatus::Merged;
151                    features.push(state);
152                }
153                Err(e) => {
154                    debug!(
155                        path = %state_path.display(),
156                        error = %e,
157                        "Skipping invalid merged state.yml"
158                    );
159                }
160            }
161        }
162
163        features.sort_by(|a, b| a.feature.slug.cmp(&b.feature.slug));
164        features
165    }
166
167    /// Returns the state for a specific feature identified by its slug.
168    ///
169    /// Looks up `.trees/<slug>/.coda/<slug>/state.yml` first (active),
170    /// then falls back to `.coda/<slug>/state.yml` (merged).
171    ///
172    /// # Errors
173    ///
174    /// Returns `CoreError::ConfigError` if `.trees/` does not exist, or
175    /// `CoreError::StateError` if no matching feature is found.
176    pub fn get(&self, feature_slug: &str) -> Result<FeatureState, CoreError> {
177        if !self.trees_dir.is_dir() {
178            return Err(CoreError::ConfigError(
179                "No .trees/ directory found. Run `coda init` first.".into(),
180            ));
181        }
182
183        let active_path = self
184            .trees_dir
185            .join(feature_slug)
186            .join(".coda")
187            .join(feature_slug)
188            .join("state.yml");
189
190        if active_path.is_file() {
191            return Self::read_state(&active_path);
192        }
193
194        let merged_path = self.coda_dir.join(feature_slug).join("state.yml");
195        if merged_path.is_file() {
196            let mut state = Self::read_state(&merged_path)?;
197            state.status = FeatureStatus::Merged;
198            return Ok(state);
199        }
200
201        let available: Vec<String> = fs::read_dir(&self.trees_dir)?
202            .flatten()
203            .filter(|e| e.file_type().is_ok_and(|ft| ft.is_dir()))
204            .map(|e| e.file_name().to_string_lossy().to_string())
205            .collect();
206
207        let hint = if available.is_empty() {
208            "No features have been planned yet.".to_string()
209        } else {
210            format!("Available features: {}", available.join(", "))
211        };
212
213        Err(CoreError::StateError(format!(
214            "No feature found for slug '{feature_slug}'. {hint}"
215        )))
216    }
217
218    /// Reads and deserializes a `state.yml` file.
219    fn read_state(path: &Path) -> Result<FeatureState, CoreError> {
220        let content = fs::read_to_string(path)
221            .map_err(|e| CoreError::StateError(format!("Cannot read {}: {e}", path.display())))?;
222        serde_yaml::from_str(&content).map_err(|e| {
223            CoreError::StateError(format!("Invalid state.yml at {}: {e}", path.display()))
224        })
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use std::fs;
231
232    use crate::state::{
233        FeatureInfo, FeatureState, FeatureStatus, GitInfo, PhaseKind, PhaseRecord, PhaseStatus,
234        TokenCost, TotalStats,
235    };
236
237    use super::*;
238
239    fn make_state(slug: &str) -> FeatureState {
240        let now = chrono::Utc::now();
241        FeatureState {
242            feature: FeatureInfo {
243                slug: slug.to_string(),
244                created_at: now,
245                updated_at: now,
246            },
247            status: FeatureStatus::Planned,
248            current_phase: 0,
249            git: GitInfo {
250                worktree_path: PathBuf::from(format!(".trees/{slug}")),
251                branch: format!("feature/{slug}"),
252                base_branch: "main".to_string(),
253            },
254            phases: vec![
255                PhaseRecord {
256                    name: "dev".to_string(),
257                    kind: PhaseKind::Dev,
258                    status: PhaseStatus::Pending,
259                    started_at: None,
260                    completed_at: None,
261                    turns: 0,
262                    cost_usd: 0.0,
263                    cost: TokenCost::default(),
264                    duration_secs: 0,
265                    details: serde_json::json!({}),
266                },
267                PhaseRecord {
268                    name: "review".to_string(),
269                    kind: PhaseKind::Quality,
270                    status: PhaseStatus::Pending,
271                    started_at: None,
272                    completed_at: None,
273                    turns: 0,
274                    cost_usd: 0.0,
275                    cost: TokenCost::default(),
276                    duration_secs: 0,
277                    details: serde_json::json!({}),
278                },
279                PhaseRecord {
280                    name: "verify".to_string(),
281                    kind: PhaseKind::Quality,
282                    status: PhaseStatus::Pending,
283                    started_at: None,
284                    completed_at: None,
285                    turns: 0,
286                    cost_usd: 0.0,
287                    cost: TokenCost::default(),
288                    duration_secs: 0,
289                    details: serde_json::json!({}),
290                },
291            ],
292            pr: None,
293            total: TotalStats::default(),
294        }
295    }
296
297    fn write_active_state(root: &Path, slug: &str, state: &FeatureState) {
298        let dir = root.join(".trees").join(slug).join(".coda").join(slug);
299        fs::create_dir_all(&dir).expect("create state dir");
300        let yaml = serde_yaml::to_string(state).expect("serialize state");
301        fs::write(dir.join("state.yml"), yaml).expect("write state.yml");
302    }
303
304    fn write_merged_state(root: &Path, slug: &str, state: &FeatureState) {
305        let dir = root.join(".coda").join(slug);
306        fs::create_dir_all(&dir).expect("create merged state dir");
307        let yaml = serde_yaml::to_string(state).expect("serialize state");
308        fs::write(dir.join("state.yml"), yaml).expect("write state.yml");
309    }
310
311    #[test]
312    fn test_should_list_empty_trees() {
313        let tmp = tempfile::tempdir().expect("tempdir");
314        fs::create_dir_all(tmp.path().join(".trees")).expect("mkdir");
315        let scanner = FeatureScanner::new(tmp.path());
316        assert!(scanner.list().expect("list").is_empty());
317    }
318
319    #[test]
320    fn test_should_list_sorted_features() {
321        let tmp = tempfile::tempdir().expect("tempdir");
322        write_active_state(tmp.path(), "zzz", &make_state("zzz"));
323        write_active_state(tmp.path(), "aaa", &make_state("aaa"));
324        let scanner = FeatureScanner::new(tmp.path());
325
326        let features = scanner.list().expect("list");
327        assert_eq!(features.len(), 2);
328        assert_eq!(features[0].feature.slug, "aaa");
329        assert_eq!(features[1].feature.slug, "zzz");
330    }
331
332    #[test]
333    fn test_should_get_feature_by_slug() {
334        let tmp = tempfile::tempdir().expect("tempdir");
335        write_active_state(tmp.path(), "my-feat", &make_state("my-feat"));
336        let scanner = FeatureScanner::new(tmp.path());
337
338        let state = scanner.get("my-feat").expect("get");
339        assert_eq!(state.feature.slug, "my-feat");
340    }
341
342    #[test]
343    fn test_should_error_when_feature_not_found() {
344        let tmp = tempfile::tempdir().expect("tempdir");
345        write_active_state(tmp.path(), "existing", &make_state("existing"));
346        let scanner = FeatureScanner::new(tmp.path());
347
348        let err = scanner.get("missing").unwrap_err().to_string();
349        assert!(err.contains("missing"));
350        assert!(err.contains("existing"));
351    }
352
353    #[test]
354    fn test_should_error_when_no_trees_and_no_coda() {
355        let tmp = tempfile::tempdir().expect("tempdir");
356        let scanner = FeatureScanner::new(tmp.path());
357        assert!(scanner.list().is_err());
358        assert!(scanner.get("any").is_err());
359    }
360
361    #[test]
362    fn test_should_skip_invalid_state_files() {
363        let tmp = tempfile::tempdir().expect("tempdir");
364        write_active_state(tmp.path(), "good", &make_state("good"));
365        let bad_dir = tmp.path().join(".trees/bad/.coda/bad");
366        fs::create_dir_all(&bad_dir).expect("mkdir");
367        fs::write(bad_dir.join("state.yml"), "not: valid: yaml: [").expect("write");
368        let scanner = FeatureScanner::new(tmp.path());
369
370        let features = scanner.list().expect("list");
371        assert_eq!(features.len(), 1);
372        assert_eq!(features[0].feature.slug, "good");
373    }
374
375    #[test]
376    fn test_should_ignore_ghost_features_inherited_from_base_branch() {
377        let tmp = tempfile::tempdir().expect("tempdir");
378
379        write_active_state(tmp.path(), "new-feat", &make_state("new-feat"));
380
381        let ghost_dir = tmp.path().join(".trees/new-feat/.coda/old-merged");
382        fs::create_dir_all(&ghost_dir).expect("create ghost dir");
383        let ghost_yaml = serde_yaml::to_string(&make_state("old-merged")).expect("serialize ghost");
384        fs::write(ghost_dir.join("state.yml"), ghost_yaml).expect("write ghost state");
385
386        let scanner = FeatureScanner::new(tmp.path());
387        let features = scanner.list().expect("list");
388
389        assert_eq!(features.len(), 1, "ghost feature must not appear");
390        assert_eq!(features[0].feature.slug, "new-feat");
391    }
392
393    #[test]
394    fn test_should_not_find_ghost_feature_via_get() {
395        let tmp = tempfile::tempdir().expect("tempdir");
396
397        write_active_state(tmp.path(), "active", &make_state("active"));
398
399        let ghost_dir = tmp.path().join(".trees/active/.coda/ghost");
400        fs::create_dir_all(&ghost_dir).expect("create ghost dir");
401        let ghost_yaml = serde_yaml::to_string(&make_state("ghost")).expect("serialize ghost");
402        fs::write(ghost_dir.join("state.yml"), ghost_yaml).expect("write ghost state");
403
404        let scanner = FeatureScanner::new(tmp.path());
405
406        let err = scanner.get("ghost").unwrap_err().to_string();
407        assert!(err.contains("ghost"), "error should mention the slug");
408        assert!(
409            err.contains("active"),
410            "hint should list available worktrees"
411        );
412    }
413
414    #[test]
415    fn test_should_list_merged_features_from_coda_dir() {
416        let tmp = tempfile::tempdir().expect("tempdir");
417        fs::create_dir_all(tmp.path().join(".trees")).expect("mkdir .trees");
418
419        let mut state = make_state("old-feat");
420        state.status = FeatureStatus::Completed;
421        write_merged_state(tmp.path(), "old-feat", &state);
422
423        let scanner = FeatureScanner::new(tmp.path());
424        let features = scanner.list().expect("list");
425
426        assert_eq!(features.len(), 1);
427        assert_eq!(features[0].feature.slug, "old-feat");
428        assert_eq!(features[0].status, FeatureStatus::Merged);
429    }
430
431    #[test]
432    fn test_should_combine_active_and_merged_features() {
433        let tmp = tempfile::tempdir().expect("tempdir");
434
435        write_active_state(tmp.path(), "active-feat", &make_state("active-feat"));
436        write_merged_state(tmp.path(), "merged-feat", &make_state("merged-feat"));
437
438        let scanner = FeatureScanner::new(tmp.path());
439        let features = scanner.list().expect("list");
440
441        assert_eq!(features.len(), 2);
442        assert_eq!(features[0].feature.slug, "active-feat");
443        assert_eq!(features[0].status, FeatureStatus::Planned);
444        assert_eq!(features[1].feature.slug, "merged-feat");
445        assert_eq!(features[1].status, FeatureStatus::Merged);
446    }
447
448    #[test]
449    fn test_should_deduplicate_active_over_merged() {
450        let tmp = tempfile::tempdir().expect("tempdir");
451
452        let mut active = make_state("my-feat");
453        active.status = FeatureStatus::InProgress;
454        write_active_state(tmp.path(), "my-feat", &active);
455
456        let mut merged = make_state("my-feat");
457        merged.status = FeatureStatus::Completed;
458        write_merged_state(tmp.path(), "my-feat", &merged);
459
460        let scanner = FeatureScanner::new(tmp.path());
461        let features = scanner.list().expect("list");
462
463        assert_eq!(features.len(), 1, "duplicate slug must be deduplicated");
464        assert_eq!(features[0].status, FeatureStatus::InProgress);
465    }
466
467    #[test]
468    fn test_should_list_only_merged_when_no_trees_dir() {
469        let tmp = tempfile::tempdir().expect("tempdir");
470
471        write_merged_state(tmp.path(), "old-feat", &make_state("old-feat"));
472
473        let scanner = FeatureScanner::new(tmp.path());
474        let features = scanner.list().expect("list");
475
476        assert_eq!(features.len(), 1);
477        assert_eq!(features[0].status, FeatureStatus::Merged);
478    }
479
480    #[test]
481    fn test_should_skip_non_dir_entries_in_coda() {
482        let tmp = tempfile::tempdir().expect("tempdir");
483        fs::create_dir_all(tmp.path().join(".trees")).expect("mkdir .trees");
484        fs::create_dir_all(tmp.path().join(".coda")).expect("mkdir .coda");
485        fs::write(tmp.path().join(".coda/config.yml"), "base_branch: main").expect("write config");
486
487        let scanner = FeatureScanner::new(tmp.path());
488        let features = scanner.list().expect("list");
489        assert!(features.is_empty());
490    }
491
492    #[test]
493    fn test_should_skip_invalid_merged_state_files() {
494        let tmp = tempfile::tempdir().expect("tempdir");
495        fs::create_dir_all(tmp.path().join(".trees")).expect("mkdir .trees");
496
497        let bad_dir = tmp.path().join(".coda/bad-feat");
498        fs::create_dir_all(&bad_dir).expect("mkdir");
499        fs::write(bad_dir.join("state.yml"), "not: valid: yaml: [").expect("write");
500
501        write_merged_state(tmp.path(), "good-feat", &make_state("good-feat"));
502
503        let scanner = FeatureScanner::new(tmp.path());
504        let features = scanner.list().expect("list");
505
506        assert_eq!(features.len(), 1);
507        assert_eq!(features[0].feature.slug, "good-feat");
508    }
509
510    #[test]
511    fn test_should_get_merged_feature_by_slug() {
512        let tmp = tempfile::tempdir().expect("tempdir");
513        fs::create_dir_all(tmp.path().join(".trees")).expect("mkdir .trees");
514        write_merged_state(tmp.path(), "old-feat", &make_state("old-feat"));
515
516        let scanner = FeatureScanner::new(tmp.path());
517        let state = scanner.get("old-feat").expect("get");
518
519        assert_eq!(state.feature.slug, "old-feat");
520        assert_eq!(state.status, FeatureStatus::Merged);
521    }
522
523    #[test]
524    fn test_should_prefer_active_over_merged_in_get() {
525        let tmp = tempfile::tempdir().expect("tempdir");
526
527        let mut active = make_state("my-feat");
528        active.status = FeatureStatus::InProgress;
529        write_active_state(tmp.path(), "my-feat", &active);
530
531        let mut merged = make_state("my-feat");
532        merged.status = FeatureStatus::Completed;
533        write_merged_state(tmp.path(), "my-feat", &merged);
534
535        let scanner = FeatureScanner::new(tmp.path());
536        let state = scanner.get("my-feat").expect("get");
537
538        assert_eq!(state.status, FeatureStatus::InProgress);
539    }
540}