1use 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#[derive(Debug)]
18pub struct FeatureScanner {
19 trees_dir: PathBuf,
20 coda_dir: PathBuf,
21}
22
23impl FeatureScanner {
24 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 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 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 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 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 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}