1use std::fs;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use claude_agent_sdk_rs::Message;
13use coda_pm::PromptManager;
14use serde::Serialize;
15use tracing::{debug, info, warn};
16
17use tokio::sync::mpsc::UnboundedSender;
18
19use crate::CoreError;
20use crate::config::CodaConfig;
21use crate::gh::{DefaultGhOps, GhOps};
22use crate::git::{DefaultGitOps, GitOps};
23use crate::planner::PlanSession;
24use crate::profile::AgentProfile;
25use crate::runner::RunEvent;
26use crate::scanner::FeatureScanner;
27use crate::task::TaskResult;
28
29const SKIP_DIRS: &[&str] = &[
31 ".git",
32 ".coda",
33 ".trees",
34 "target",
35 "node_modules",
36 ".next",
37 "dist",
38 "build",
39 "__pycache__",
40 ".venv",
41 "venv",
42 ".tox",
43 ".mypy_cache",
44 ".pytest_cache",
45 ".cargo",
46 "vendor",
47 ".idea",
48 ".vscode",
49];
50
51const SAMPLE_FILES: &[&str] = &[
53 "Cargo.toml",
54 "package.json",
55 "pyproject.toml",
56 "requirements.txt",
57 "go.mod",
58 "Makefile",
59 "Dockerfile",
60 "docker-compose.yml",
61 "README.md",
62 "CLAUDE.md",
63 ".gitignore",
64 "tsconfig.json",
65 "CMakeLists.txt",
66 "build.gradle",
67 "pom.xml",
68];
69
70const SAMPLE_MAX_LINES: usize = 40;
72
73const TREE_MAX_DEPTH: usize = 4;
75
76#[derive(Debug, Serialize)]
78struct FileSample {
79 path: String,
81 content: String,
83}
84
85pub struct Engine {
90 project_root: PathBuf,
92
93 pm: PromptManager,
95
96 config: CodaConfig,
98
99 scanner: FeatureScanner,
101
102 git: Arc<dyn GitOps>,
104
105 gh: Arc<dyn GhOps>,
107}
108
109impl Engine {
110 pub async fn new(project_root: PathBuf) -> Result<Self, CoreError> {
122 let config_path = project_root.join(".coda/config.yml");
124 let config = if config_path.exists() {
125 let content = fs::read_to_string(&config_path).map_err(|e| {
126 CoreError::ConfigError(format!(
127 "Cannot read config file at {}: {e}",
128 config_path.display()
129 ))
130 })?;
131 serde_yaml::from_str::<CodaConfig>(&content).map_err(|e| {
132 CoreError::ConfigError(format!(
133 "Invalid YAML in config file at {}: {e}",
134 config_path.display()
135 ))
136 })?
137 } else {
138 info!("No .coda/config.yml found, using default configuration");
139 CodaConfig::default()
140 };
141
142 let mut pm = PromptManager::with_builtin_templates()?;
144 info!(
145 template_count = pm.template_count(),
146 "Loaded built-in templates"
147 );
148
149 for extra_dir in &config.prompts.extra_dirs {
151 let dir = project_root.join(extra_dir);
152 if dir.exists() {
153 pm.load_from_dir(&dir)?;
154 info!(dir = %dir.display(), "Loaded custom templates");
155 }
156 }
157
158 let scanner = FeatureScanner::new(&project_root);
159 let git: Arc<dyn GitOps> = Arc::new(DefaultGitOps::new(project_root.clone()));
160 let gh: Arc<dyn GhOps> = Arc::new(DefaultGhOps::new(project_root.clone()));
161
162 Ok(Self {
163 project_root,
164 pm,
165 config,
166 scanner,
167 git,
168 gh,
169 })
170 }
171
172 pub fn project_root(&self) -> &Path {
174 &self.project_root
175 }
176
177 pub fn prompt_manager(&self) -> &PromptManager {
179 &self.pm
180 }
181
182 pub fn config(&self) -> &CodaConfig {
184 &self.config
185 }
186
187 pub fn git(&self) -> &dyn GitOps {
189 self.git.as_ref()
190 }
191
192 pub fn gh(&self) -> &dyn GhOps {
194 self.gh.as_ref()
195 }
196
197 pub async fn init(&self) -> Result<(), CoreError> {
210 if self.project_root.join(".coda").exists() {
212 return Err(CoreError::ConfigError(
213 "Project already initialized. .coda/ directory exists.".into(),
214 ));
215 }
216
217 let system_prompt = self.pm.render("init/system", minijinja::context!())?;
219
220 let repo_tree = gather_repo_tree(&self.project_root)?;
222 let file_samples = gather_file_samples(&self.project_root)?;
223
224 let analyze_prompt = self.pm.render(
225 "init/analyze_repo",
226 minijinja::context!(
227 repo_tree => repo_tree,
228 file_samples => file_samples,
229 ),
230 )?;
231
232 debug!("Analyzing repository structure...");
233
234 let planner_options = AgentProfile::Planner.to_options(
235 &system_prompt,
236 self.project_root.clone(),
237 5, self.config.agent.max_budget_usd,
239 &self.config.agent.model,
240 );
241
242 let messages = claude_agent_sdk_rs::query(analyze_prompt, Some(planner_options))
243 .await
244 .map_err(|e| CoreError::AgentError(e.to_string()))?;
245
246 let analysis_result = extract_text_from_messages(&messages);
248 debug!(
249 analysis_len = analysis_result.len(),
250 "Repository analysis complete"
251 );
252
253 let setup_prompt = self.pm.render(
255 "init/setup_project",
256 minijinja::context!(
257 project_root => self.project_root.display().to_string(),
258 analysis_result => analysis_result,
259 ),
260 )?;
261
262 debug!("Setting up project structure...");
263
264 let coder_options = AgentProfile::Coder.to_options(
265 &system_prompt,
266 self.project_root.clone(),
267 10, self.config.agent.max_budget_usd,
269 &self.config.agent.model,
270 );
271
272 let _messages = claude_agent_sdk_rs::query(setup_prompt, Some(coder_options))
273 .await
274 .map_err(|e| CoreError::AgentError(e.to_string()))?;
275
276 info!("Project initialized successfully");
277 Ok(())
278 }
279
280 pub fn plan(&self, feature_slug: &str) -> Result<PlanSession, CoreError> {
293 validate_feature_slug(feature_slug)?;
294
295 let worktree_path = self.project_root.join(".trees").join(feature_slug);
296 if worktree_path.exists() {
297 return Err(CoreError::PlanError(format!(
298 "Feature '{feature_slug}' already exists at {}. \
299 Use `coda status {feature_slug}` to check its state, \
300 or choose a different slug.",
301 worktree_path.display(),
302 )));
303 }
304
305 info!(feature_slug, "Starting planning session");
306 PlanSession::new(
307 feature_slug.to_string(),
308 self.project_root.clone(),
309 &self.pm,
310 &self.config,
311 Arc::clone(&self.git),
312 )
313 }
314
315 pub fn list_features(&self) -> Result<Vec<crate::state::FeatureState>, CoreError> {
325 self.scanner.list()
326 }
327
328 pub fn feature_status(
337 &self,
338 feature_slug: &str,
339 ) -> Result<crate::state::FeatureState, CoreError> {
340 self.scanner.get(feature_slug)
341 }
342
343 pub async fn run(
357 &self,
358 feature_slug: &str,
359 progress_tx: Option<UnboundedSender<RunEvent>>,
360 ) -> Result<Vec<TaskResult>, CoreError> {
361 info!(feature_slug, "Starting feature run");
362 let mut runner = crate::runner::Runner::new(
363 feature_slug,
364 self.project_root.clone(),
365 &self.pm,
366 &self.config,
367 Arc::clone(&self.git),
368 Arc::clone(&self.gh),
369 )?;
370 if let Some(tx) = progress_tx {
371 runner.set_progress_sender(tx);
372 }
373 runner.execute().await
374 }
375
376 pub fn scan_cleanable_worktrees(&self) -> Result<Vec<CleanedWorktree>, CoreError> {
393 let features = self.list_features()?;
394 let mut candidates = Vec::new();
395
396 for feature in &features {
397 match self.check_feature_pr_status(feature) {
398 Ok(Some(result)) => candidates.push(result),
399 Ok(None) => {}
400 Err(e) => {
401 warn!(
402 slug = %feature.feature.slug,
403 error = %e,
404 "Failed to check PR status, skipping"
405 );
406 }
407 }
408 }
409
410 Ok(candidates)
411 }
412
413 pub fn remove_worktrees(
425 &self,
426 candidates: &[CleanedWorktree],
427 ) -> Result<Vec<CleanedWorktree>, CoreError> {
428 let mut removed = Vec::new();
429
430 for c in candidates {
431 let worktree_abs = self.project_root.join(".trees").join(&c.slug);
432 if !worktree_abs.exists() {
433 info!(path = %worktree_abs.display(), "Worktree path does not exist, running prune");
434 self.git.worktree_prune()?;
435 } else {
436 self.git.worktree_remove(&worktree_abs, true)?;
437 }
438
439 if let Err(e) = self.git.branch_delete(&c.branch) {
440 warn!(branch = %c.branch, error = %e, "Failed to delete local branch (may already be deleted)");
441 }
442
443 let _ = remove_feature_logs(&self.project_root, &c.slug);
445
446 removed.push(c.clone());
447 }
448
449 Ok(removed)
450 }
451
452 pub fn clean_logs(&self) -> Result<Vec<String>, CoreError> {
462 let coda_dir = self.project_root.join(".coda");
463 if !coda_dir.is_dir() {
464 return Ok(Vec::new());
465 }
466
467 let entries = fs::read_dir(&coda_dir).map_err(|e| {
468 CoreError::ConfigError(format!(
469 "Cannot read .coda/ directory at {}: {e}",
470 coda_dir.display()
471 ))
472 })?;
473
474 let mut cleaned = Vec::new();
475 for entry in entries.filter_map(Result::ok) {
476 if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
477 continue;
478 }
479 let slug = entry.file_name();
480 let slug_str = slug.to_string_lossy();
481 let logs_dir = entry.path().join("logs");
482 if logs_dir.is_dir() && remove_feature_logs(&self.project_root, &slug_str) {
483 cleaned.push(slug_str.into_owned());
484 }
485 }
486
487 cleaned.sort();
488 info!(count = cleaned.len(), "Cleaned all feature logs");
489 Ok(cleaned)
490 }
491
492 fn check_feature_pr_status(
498 &self,
499 feature: &crate::state::FeatureState,
500 ) -> Result<Option<CleanedWorktree>, CoreError> {
501 let slug = &feature.feature.slug;
502 let branch = &feature.git.branch;
503
504 let worktree_dir = self.project_root.join(".trees").join(slug);
505 if !worktree_dir.is_dir() {
506 debug!(
507 slug,
508 path = %worktree_dir.display(),
509 "Worktree directory does not exist, skipping ghost feature"
510 );
511 return Ok(None);
512 }
513
514 let pr_status = if let Some(ref pr) = feature.pr {
515 self.gh.pr_view_state(pr.number)?
516 } else {
517 self.gh.pr_list_by_branch(branch)?
518 };
519
520 let Some(pr_status) = pr_status else {
521 debug!(slug, branch, "No PR found, skipping");
522 return Ok(None);
523 };
524
525 let state_upper = pr_status.state.to_uppercase();
526 if state_upper != "MERGED" && state_upper != "CLOSED" {
527 debug!(
528 slug,
529 branch,
530 state = %pr_status.state,
531 "PR still open, skipping"
532 );
533 return Ok(None);
534 }
535
536 Ok(Some(CleanedWorktree {
537 slug: slug.clone(),
538 branch: branch.clone(),
539 pr_number: Some(pr_status.number),
540 pr_state: state_upper,
541 }))
542 }
543}
544
545#[derive(Debug, Clone)]
547pub struct CleanedWorktree {
548 pub slug: String,
550
551 pub branch: String,
553
554 pub pr_number: Option<u32>,
556
557 pub pr_state: String,
559}
560
561impl std::fmt::Debug for Engine {
562 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
563 f.debug_struct("Engine")
564 .field("project_root", &self.project_root)
565 .field("config", &self.config)
566 .finish_non_exhaustive()
567 }
568}
569
570const SLUG_MAX_LEN: usize = 64;
576
577pub fn validate_feature_slug(slug: &str) -> Result<(), CoreError> {
588 if slug.is_empty() {
589 return Err(CoreError::PlanError(
590 "Feature slug cannot be empty.".to_string(),
591 ));
592 }
593 if slug.len() > SLUG_MAX_LEN {
594 return Err(CoreError::PlanError(format!(
595 "Feature slug is too long ({} chars, max {SLUG_MAX_LEN}).",
596 slug.len(),
597 )));
598 }
599 if !slug
600 .chars()
601 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
602 {
603 return Err(CoreError::PlanError(format!(
604 "Feature slug '{slug}' contains invalid characters. \
605 Only lowercase letters, digits, and hyphens are allowed.",
606 )));
607 }
608 if slug.starts_with('-') || slug.ends_with('-') {
609 return Err(CoreError::PlanError(format!(
610 "Feature slug '{slug}' must not start or end with a hyphen.",
611 )));
612 }
613 if slug.contains("--") {
614 return Err(CoreError::PlanError(format!(
615 "Feature slug '{slug}' must not contain consecutive hyphens.",
616 )));
617 }
618 Ok(())
619}
620
621pub fn remove_feature_logs(project_root: &Path, slug: &str) -> bool {
634 let logs_dir = project_root.join(".coda").join(slug).join("logs");
635 match fs::remove_dir_all(&logs_dir) {
636 Ok(()) => {
637 info!(slug, path = %logs_dir.display(), "Removed feature log directory");
638 true
639 }
640 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
641 debug!(slug, path = %logs_dir.display(), "No log directory to clean");
642 true
643 }
644 Err(e) => {
645 warn!(
646 slug,
647 path = %logs_dir.display(),
648 error = %e,
649 "Failed to remove feature log directory"
650 );
651 false
652 }
653 }
654}
655
656fn gather_repo_tree(root: &Path) -> Result<String, CoreError> {
661 let mut output = String::new();
662 build_tree(root, "", &mut output, 0)?;
663 Ok(output)
664}
665
666fn build_tree(
668 current: &Path,
669 prefix: &str,
670 output: &mut String,
671 depth: usize,
672) -> Result<(), CoreError> {
673 if depth > TREE_MAX_DEPTH {
674 return Ok(());
675 }
676
677 let mut entries: Vec<_> = fs::read_dir(current)?
678 .filter_map(|e| e.ok())
679 .filter(|entry| {
680 let name = entry.file_name();
681 let name_str = name.to_string_lossy();
682 if name_str.starts_with('.')
684 && !matches!(
685 name_str.as_ref(),
686 ".gitignore" | ".coda.md" | ".env.example"
687 )
688 {
689 return false;
690 }
691 if entry.file_type().is_ok_and(|ft| ft.is_dir())
693 && SKIP_DIRS.contains(&name_str.as_ref())
694 {
695 return false;
696 }
697 true
698 })
699 .collect();
700
701 entries.sort_by_key(|e| e.file_name());
702
703 let total = entries.len();
704 for (i, entry) in entries.iter().enumerate() {
705 let name = entry.file_name();
706 let name_str = name.to_string_lossy();
707 let is_last = i == total - 1;
708 let connector = if is_last { "└── " } else { "├── " };
709 let child_prefix = if is_last { " " } else { "│ " };
710
711 if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
712 output.push_str(&format!("{prefix}{connector}{name_str}/\n"));
713 build_tree(
714 &entry.path(),
715 &format!("{prefix}{child_prefix}"),
716 output,
717 depth + 1,
718 )?;
719 } else {
720 output.push_str(&format!("{prefix}{connector}{name_str}\n"));
721 }
722 }
723
724 Ok(())
725}
726
727fn gather_file_samples(root: &Path) -> Result<Vec<FileSample>, CoreError> {
732 let mut samples = Vec::new();
733
734 for &filename in SAMPLE_FILES {
735 let path = root.join(filename);
736 if path.is_file() {
737 let content = fs::read_to_string(&path)?;
738 let truncated: String = content
739 .lines()
740 .take(SAMPLE_MAX_LINES)
741 .collect::<Vec<_>>()
742 .join("\n");
743
744 samples.push(FileSample {
745 path: filename.to_string(),
746 content: truncated,
747 });
748 }
749 }
750
751 Ok(samples)
752}
753
754fn extract_text_from_messages(messages: &[Message]) -> String {
759 let mut text_parts: Vec<String> = Vec::new();
760
761 for message in messages {
762 match message {
763 Message::Assistant(assistant) => {
764 for block in &assistant.message.content {
765 if let claude_agent_sdk_rs::ContentBlock::Text(text_block) = block {
766 text_parts.push(text_block.text.clone());
767 }
768 }
769 }
770 Message::Result(result) => {
771 if let Some(ref result_text) = result.result {
772 text_parts.push(result_text.clone());
773 }
774 }
775 _ => {}
776 }
777 }
778
779 text_parts.join("\n")
780}
781
782#[cfg(test)]
783mod tests {
784 use std::fs;
785
786 use super::*;
787 use crate::state::{
788 FeatureInfo, FeatureState, FeatureStatus, GitInfo, PhaseKind, PhaseRecord, PhaseStatus,
789 TokenCost, TotalStats,
790 };
791
792 fn make_state(slug: &str) -> FeatureState {
793 let now = chrono::Utc::now();
794 FeatureState {
795 feature: FeatureInfo {
796 slug: slug.to_string(),
797 created_at: now,
798 updated_at: now,
799 },
800 status: FeatureStatus::Planned,
801 current_phase: 0,
802 git: GitInfo {
803 worktree_path: std::path::PathBuf::from(format!(".trees/{slug}")),
804 branch: format!("feature/{slug}"),
805 base_branch: "main".to_string(),
806 },
807 phases: vec![
808 PhaseRecord {
809 name: "dev".to_string(),
810 kind: PhaseKind::Dev,
811 status: PhaseStatus::Pending,
812 started_at: None,
813 completed_at: None,
814 turns: 0,
815 cost_usd: 0.0,
816 cost: TokenCost::default(),
817 duration_secs: 0,
818 details: serde_json::json!({}),
819 },
820 PhaseRecord {
821 name: "review".to_string(),
822 kind: PhaseKind::Quality,
823 status: PhaseStatus::Pending,
824 started_at: None,
825 completed_at: None,
826 turns: 0,
827 cost_usd: 0.0,
828 cost: TokenCost::default(),
829 duration_secs: 0,
830 details: serde_json::json!({}),
831 },
832 PhaseRecord {
833 name: "verify".to_string(),
834 kind: PhaseKind::Quality,
835 status: PhaseStatus::Pending,
836 started_at: None,
837 completed_at: None,
838 turns: 0,
839 cost_usd: 0.0,
840 cost: TokenCost::default(),
841 duration_secs: 0,
842 details: serde_json::json!({}),
843 },
844 ],
845 pr: None,
846 total: TotalStats::default(),
847 }
848 }
849
850 fn write_state(root: &std::path::Path, slug: &str, state: &FeatureState) {
851 let dir = root.join(".trees").join(slug).join(".coda").join(slug);
852 fs::create_dir_all(&dir).expect("create state dir");
853 let yaml = serde_yaml::to_string(state).expect("serialize state");
854 fs::write(dir.join("state.yml"), yaml).expect("write state.yml");
855 }
856
857 async fn make_engine(root: &std::path::Path) -> Engine {
858 Engine::new(root.to_path_buf())
859 .await
860 .expect("create Engine")
861 }
862
863 #[tokio::test]
864 async fn test_should_list_features_empty() {
865 let tmp = tempfile::tempdir().expect("tempdir");
866 fs::create_dir_all(tmp.path().join(".trees")).expect("mkdir");
867 let engine = make_engine(tmp.path()).await;
868
869 let features = engine.list_features().expect("list");
870 assert!(features.is_empty());
871 }
872
873 #[tokio::test]
874 async fn test_should_list_features_single() {
875 let tmp = tempfile::tempdir().expect("tempdir");
876 let state = make_state("add-auth");
877 write_state(tmp.path(), "add-auth", &state);
878 let engine = make_engine(tmp.path()).await;
879
880 let features = engine.list_features().expect("list");
881 assert_eq!(features.len(), 1);
882 assert_eq!(features[0].feature.slug, "add-auth");
883 }
884
885 #[tokio::test]
886 async fn test_should_list_features_sorted_by_slug() {
887 let tmp = tempfile::tempdir().expect("tempdir");
888 write_state(tmp.path(), "zzz-last", &make_state("zzz-last"));
889 write_state(tmp.path(), "aaa-first", &make_state("aaa-first"));
890 write_state(tmp.path(), "mmm-middle", &make_state("mmm-middle"));
891 let engine = make_engine(tmp.path()).await;
892
893 let features = engine.list_features().expect("list");
894 assert_eq!(features.len(), 3);
895 assert_eq!(features[0].feature.slug, "aaa-first");
896 assert_eq!(features[1].feature.slug, "mmm-middle");
897 assert_eq!(features[2].feature.slug, "zzz-last");
898 }
899
900 #[tokio::test]
901 async fn test_should_list_features_skip_invalid_state() {
902 let tmp = tempfile::tempdir().expect("tempdir");
903 write_state(tmp.path(), "good", &make_state("good"));
904 let bad_dir = tmp.path().join(".trees/bad/.coda/bad");
906 fs::create_dir_all(&bad_dir).expect("mkdir");
907 fs::write(bad_dir.join("state.yml"), "not: valid: yaml: [").expect("write");
908 let engine = make_engine(tmp.path()).await;
909
910 let features = engine.list_features().expect("list");
911 assert_eq!(features.len(), 1);
912 assert_eq!(features[0].feature.slug, "good");
913 }
914
915 #[tokio::test]
916 async fn test_should_list_features_error_when_no_trees_dir() {
917 let tmp = tempfile::tempdir().expect("tempdir");
918 let engine = make_engine(tmp.path()).await;
919
920 let result = engine.list_features();
921 assert!(result.is_err());
922 let err = result.unwrap_err().to_string();
923 assert!(err.contains(".trees/"));
924 }
925
926 #[tokio::test]
927 async fn test_should_get_feature_status_direct_lookup() {
928 let tmp = tempfile::tempdir().expect("tempdir");
929 let state = make_state("add-auth");
930 write_state(tmp.path(), "add-auth", &state);
931 let engine = make_engine(tmp.path()).await;
932
933 let found = engine.feature_status("add-auth").expect("status");
934 assert_eq!(found.feature.slug, "add-auth");
935 assert_eq!(found.git.branch, "feature/add-auth");
936 }
937
938 #[tokio::test]
939 async fn test_should_get_feature_status_not_found() {
940 let tmp = tempfile::tempdir().expect("tempdir");
941 write_state(tmp.path(), "existing", &make_state("existing"));
942 let engine = make_engine(tmp.path()).await;
943
944 let result = engine.feature_status("nonexistent");
945 assert!(result.is_err());
946 let err = result.unwrap_err().to_string();
947 assert!(err.contains("nonexistent"));
948 assert!(err.contains("existing"));
949 }
950
951 #[tokio::test]
952 async fn test_should_get_feature_status_error_when_no_trees_dir() {
953 let tmp = tempfile::tempdir().expect("tempdir");
954 let engine = make_engine(tmp.path()).await;
955
956 let result = engine.feature_status("anything");
957 assert!(result.is_err());
958 let err = result.unwrap_err().to_string();
959 assert!(err.contains(".trees/"));
960 }
961
962 #[test]
963 fn test_should_gather_repo_tree_from_temp_dir() {
964 let tmp = tempfile::tempdir().expect("failed to create temp dir");
965 let root = tmp.path();
966
967 fs::create_dir_all(root.join("src")).expect("mkdir");
969 fs::write(root.join("src/main.rs"), "fn main() {}").expect("write");
970 fs::write(root.join("Cargo.toml"), "[package]").expect("write");
971 fs::create_dir_all(root.join("target/debug")).expect("mkdir");
972
973 let tree = gather_repo_tree(root).expect("gather_repo_tree");
974
975 assert!(tree.contains("src/"));
976 assert!(tree.contains("Cargo.toml"));
977 assert!(!tree.contains("target"));
979 }
980
981 #[test]
982 fn test_should_gather_file_samples() {
983 let tmp = tempfile::tempdir().expect("failed to create temp dir");
984 let root = tmp.path();
985
986 fs::write(root.join("Cargo.toml"), "[package]\nname = \"test\"\n").expect("write");
987 fs::write(root.join("README.md"), "# Test\nHello world").expect("write");
988
989 let samples = gather_file_samples(root).expect("gather_file_samples");
990
991 assert_eq!(samples.len(), 2);
992 let names: Vec<&str> = samples.iter().map(|s| s.path.as_str()).collect();
993 assert!(names.contains(&"Cargo.toml"));
994 assert!(names.contains(&"README.md"));
995 }
996
997 #[test]
998 fn test_should_extract_text_from_assistant_messages() {
999 let messages = vec![
1000 Message::Assistant(claude_agent_sdk_rs::AssistantMessage {
1001 message: claude_agent_sdk_rs::AssistantMessageInner {
1002 content: vec![claude_agent_sdk_rs::ContentBlock::Text(
1003 claude_agent_sdk_rs::TextBlock {
1004 text: "Hello from assistant".to_string(),
1005 },
1006 )],
1007 model: None,
1008 id: None,
1009 stop_reason: None,
1010 usage: None,
1011 error: None,
1012 },
1013 parent_tool_use_id: None,
1014 session_id: None,
1015 uuid: None,
1016 }),
1017 Message::Result(claude_agent_sdk_rs::ResultMessage {
1018 subtype: "success".to_string(),
1019 duration_ms: 100,
1020 duration_api_ms: 80,
1021 is_error: false,
1022 num_turns: 1,
1023 session_id: "test".to_string(),
1024 total_cost_usd: Some(0.01),
1025 usage: None,
1026 result: Some("Result text".to_string()),
1027 structured_output: None,
1028 }),
1029 ];
1030
1031 let text = extract_text_from_messages(&messages);
1032 assert!(text.contains("Hello from assistant"));
1033 assert!(text.contains("Result text"));
1034 }
1035
1036 #[test]
1037 fn test_should_return_empty_for_no_text_messages() {
1038 let messages: Vec<Message> = vec![];
1039 let text = extract_text_from_messages(&messages);
1040 assert!(text.is_empty());
1041 }
1042
1043 #[test]
1044 fn test_should_accept_valid_slugs() {
1045 assert!(validate_feature_slug("add-auth").is_ok());
1046 assert!(validate_feature_slug("feature123").is_ok());
1047 assert!(validate_feature_slug("a").is_ok());
1048 assert!(validate_feature_slug("a-b-c").is_ok());
1049 }
1050
1051 #[test]
1052 fn test_should_reject_empty_slug() {
1053 let err = validate_feature_slug("").unwrap_err().to_string();
1054 assert!(err.contains("empty"));
1055 }
1056
1057 #[test]
1058 fn test_should_reject_slug_with_invalid_chars() {
1059 assert!(validate_feature_slug("Add-Auth").is_err());
1060 assert!(validate_feature_slug("add auth").is_err());
1061 assert!(validate_feature_slug("add/auth").is_err());
1062 assert!(validate_feature_slug("add_auth").is_err());
1063 assert!(validate_feature_slug("add.auth").is_err());
1064 }
1065
1066 #[test]
1067 fn test_should_reject_slug_with_leading_trailing_hyphen() {
1068 assert!(validate_feature_slug("-add").is_err());
1069 assert!(validate_feature_slug("add-").is_err());
1070 }
1071
1072 #[test]
1073 fn test_should_reject_slug_with_consecutive_hyphens() {
1074 assert!(validate_feature_slug("add--auth").is_err());
1075 }
1076
1077 #[test]
1078 fn test_should_reject_slug_too_long() {
1079 let long_slug = "a".repeat(65);
1080 assert!(validate_feature_slug(&long_slug).is_err());
1081 }
1082
1083 #[test]
1084 fn test_should_remove_existing_log_directory() {
1085 let tmp = tempfile::tempdir().expect("tempdir");
1086 let root = tmp.path();
1087 let logs_dir = root.join(".coda/my-feature/logs");
1088 fs::create_dir_all(&logs_dir).expect("mkdir");
1089 fs::write(logs_dir.join("run-20260101T000000.log"), "log data").expect("write");
1090
1091 assert!(remove_feature_logs(root, "my-feature"));
1092
1093 assert!(!logs_dir.exists());
1094 assert!(root.join(".coda/my-feature").exists());
1096 }
1097
1098 #[test]
1099 fn test_should_ignore_missing_log_directory() {
1100 let tmp = tempfile::tempdir().expect("tempdir");
1101 let root = tmp.path();
1102 assert!(remove_feature_logs(root, "nonexistent"));
1104 }
1105
1106 #[tokio::test]
1107 async fn test_should_clean_logs_for_multiple_features() {
1108 let tmp = tempfile::tempdir().expect("tempdir");
1109 let root = tmp.path();
1110
1111 let logs_a = root.join(".coda/feature-a/logs");
1113 let logs_b = root.join(".coda/feature-b/logs");
1114 fs::create_dir_all(&logs_a).expect("mkdir");
1115 fs::create_dir_all(&logs_b).expect("mkdir");
1116 fs::write(logs_a.join("run.log"), "data").expect("write");
1117 fs::write(logs_b.join("run.log"), "data").expect("write");
1118
1119 fs::create_dir_all(root.join(".coda/feature-c")).expect("mkdir");
1121
1122 let engine = make_engine(root).await;
1123 let cleaned = engine.clean_logs().expect("clean_logs");
1124
1125 assert_eq!(cleaned, vec!["feature-a", "feature-b"]);
1126 assert!(!logs_a.exists());
1127 assert!(!logs_b.exists());
1128 assert!(root.join(".coda/feature-a").exists());
1130 assert!(root.join(".coda/feature-b").exists());
1131 }
1132
1133 #[tokio::test]
1134 async fn test_should_return_empty_when_no_coda_dir() {
1135 let tmp = tempfile::tempdir().expect("tempdir");
1136 let engine = make_engine(tmp.path()).await;
1137
1138 let cleaned = engine.clean_logs().expect("clean_logs");
1139 assert!(cleaned.is_empty());
1140 }
1141
1142 #[tokio::test]
1143 async fn test_should_return_empty_when_no_features_have_logs() {
1144 let tmp = tempfile::tempdir().expect("tempdir");
1145 let root = tmp.path();
1146 fs::create_dir_all(root.join(".coda/some-feature")).expect("mkdir");
1147
1148 let engine = make_engine(root).await;
1149 let cleaned = engine.clean_logs().expect("clean_logs");
1150 assert!(cleaned.is_empty());
1151 }
1152}