Skip to main content

coda_core/
engine.rs

1//! Core execution engine implementation.
2//!
3//! The `Engine` orchestrates all CODA operations: initialization, planning,
4//! execution, and cleanup. It delegates git/gh operations through the
5//! [`GitOps`](crate::git::GitOps) and [`GhOps`](crate::gh::GhOps) traits,
6//! and feature discovery through [`FeatureScanner`](crate::scanner::FeatureScanner).
7
8use 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
29/// Directories to skip when building the repository tree.
30const 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
51/// Key files to sample for repository analysis.
52const 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
70/// Maximum number of lines to read from each sample file.
71const SAMPLE_MAX_LINES: usize = 40;
72
73/// Maximum tree depth when gathering the repository tree.
74const TREE_MAX_DEPTH: usize = 4;
75
76/// A sampled file for repository analysis, serializable for template rendering.
77#[derive(Debug, Serialize)]
78struct FileSample {
79    /// Relative path of the sampled file.
80    path: String,
81    /// First N lines of file content.
82    content: String,
83}
84
85/// The core execution engine for CODA.
86///
87/// Manages project configuration, prompt templates, and orchestrates
88/// interactions with the Claude Agent SDK.
89pub struct Engine {
90    /// Root directory of the project.
91    project_root: PathBuf,
92
93    /// Prompt template manager loaded with built-in and custom templates.
94    pm: PromptManager,
95
96    /// Project configuration loaded from `.coda/config.yml`.
97    config: CodaConfig,
98
99    /// Feature worktree scanner.
100    scanner: FeatureScanner,
101
102    /// Git operations implementation (Arc for sharing with sub-sessions).
103    git: Arc<dyn GitOps>,
104
105    /// GitHub CLI operations implementation.
106    gh: Arc<dyn GhOps>,
107}
108
109impl Engine {
110    /// Creates a new engine for the given project root.
111    ///
112    /// Loads configuration from `.coda/config.yml` (falling back to defaults
113    /// if the file doesn't exist), initializes the prompt manager with
114    /// built-in templates, and loads any custom templates from configured
115    /// extra directories.
116    ///
117    /// # Errors
118    ///
119    /// Returns `CoreError` if configuration parsing fails or template
120    /// loading encounters an error.
121    pub async fn new(project_root: PathBuf) -> Result<Self, CoreError> {
122        // Load config from .coda/config.yml, or use defaults if not present
123        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        // Create PromptManager pre-loaded with built-in templates
143        let mut pm = PromptManager::with_builtin_templates()?;
144        info!(
145            template_count = pm.template_count(),
146            "Loaded built-in templates"
147        );
148
149        // Load custom templates from configured extra directories
150        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    /// Returns a reference to the project root directory.
173    pub fn project_root(&self) -> &Path {
174        &self.project_root
175    }
176
177    /// Returns a reference to the prompt manager.
178    pub fn prompt_manager(&self) -> &PromptManager {
179        &self.pm
180    }
181
182    /// Returns a reference to the project configuration.
183    pub fn config(&self) -> &CodaConfig {
184        &self.config
185    }
186
187    /// Returns a reference to the git operations implementation.
188    pub fn git(&self) -> &dyn GitOps {
189        self.git.as_ref()
190    }
191
192    /// Returns a reference to the GitHub CLI operations implementation.
193    pub fn gh(&self) -> &dyn GhOps {
194        self.gh.as_ref()
195    }
196
197    /// Initializes the current repository as a CODA project.
198    ///
199    /// The init flow performs two agent calls:
200    /// 1. `query(Planner)` with `init/analyze_repo` to analyze the repository
201    ///    structure, tech stack, and architecture.
202    /// 2. `query(Coder)` with `init/setup_project` to create `.coda/`, `.trees/`,
203    ///    `config.yml`, `.coda.md`, and update `.gitignore`.
204    ///
205    /// # Errors
206    ///
207    /// Returns `CoreError::ConfigError` if the project is already initialized
208    /// (`.coda/` exists), or `CoreError::AgentError` if agent calls fail.
209    pub async fn init(&self) -> Result<(), CoreError> {
210        // 1. Check if .coda/ already exists
211        if self.project_root.join(".coda").exists() {
212            return Err(CoreError::ConfigError(
213                "Project already initialized. .coda/ directory exists.".into(),
214            ));
215        }
216
217        // 2. Render system prompt for init
218        let system_prompt = self.pm.render("init/system", minijinja::context!())?;
219
220        // 3. Analyze repository structure
221        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, // max_turns for analysis
238            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        // Extract analysis result text from messages
247        let analysis_result = extract_text_from_messages(&messages);
248        debug!(
249            analysis_len = analysis_result.len(),
250            "Repository analysis complete"
251        );
252
253        // 4. Setup project structure
254        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, // max_turns for setup
268            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    /// Starts an interactive planning session for a feature.
281    ///
282    /// Validates the slug format and checks for duplicate features before
283    /// creating a `PlanSession` wrapping a `ClaudeClient` with the Planner
284    /// profile for multi-turn conversation. The session must be explicitly
285    /// connected and finalized by the caller (typically the CLI layer).
286    ///
287    /// # Errors
288    ///
289    /// Returns `CoreError::PlanError` if the slug is invalid or a feature
290    /// with the same slug already exists. Returns other `CoreError` variants
291    /// if the session cannot be created.
292    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    /// Lists all features: active worktrees from `.trees/` and merged
316    /// features from `.coda/`.
317    ///
318    /// Delegates to [`FeatureScanner::list`].
319    ///
320    /// # Errors
321    ///
322    /// Returns `CoreError::ConfigError` if neither `.trees/` nor `.coda/`
323    /// contains any features and `.trees/` does not exist.
324    pub fn list_features(&self) -> Result<Vec<crate::state::FeatureState>, CoreError> {
325        self.scanner.list()
326    }
327
328    /// Returns detailed state for a specific feature identified by its slug.
329    ///
330    /// Delegates to [`FeatureScanner::get`].
331    ///
332    /// # Errors
333    ///
334    /// Returns `CoreError::ConfigError` if `.trees/` does not exist, or
335    /// `CoreError::StateError` if no matching feature is found.
336    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    /// Executes a feature development run through all phases.
344    ///
345    /// Reads `state.yml` and resumes from the last completed phase. Uses
346    /// a single continuous `ClaudeClient` session with the Coder profile
347    /// to execute setup → implement → test → review → verify → PR.
348    ///
349    /// When `progress_tx` is provided, emits [`RunEvent`]s for real-time
350    /// progress display.
351    ///
352    /// # Errors
353    ///
354    /// Returns `CoreError` if the runner cannot be created or any phase
355    /// fails after all retries.
356    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    /// Cleans up worktrees whose corresponding PR has been merged or closed.
377    ///
378    /// For each feature in `.trees/`:
379    /// 1. If `state.yml` contains a PR number, queries its status via `gh pr view`.
380    /// 2. Otherwise, queries `gh pr list --head <branch>` to discover the PR.
381    /// 3. If the PR is `MERGED` or `CLOSED`, removes the worktree and deletes
382    ///    the local branch.
383    ///
384    /// Scans features and returns candidates whose PR is merged or closed.
385    ///
386    /// Does **not** delete anything. Use [`remove_worktrees`](Self::remove_worktrees)
387    /// to perform the actual removal after user confirmation.
388    ///
389    /// # Errors
390    ///
391    /// Returns `CoreError` if `.trees/` does not exist.
392    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    /// Removes worktrees and branches for the given candidates.
414    ///
415    /// For each candidate, removes the git worktree, deletes the local branch,
416    /// and cleans up the corresponding log directory at `.coda/<slug>/logs/`.
417    ///
418    /// Designed to be called after [`scan_cleanable_worktrees`](Self::scan_cleanable_worktrees)
419    /// and user confirmation.
420    ///
421    /// # Errors
422    ///
423    /// Returns `CoreError` if a git operation fails during removal.
424    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            // Clean up log directory in the main repo (best-effort)
444            let _ = remove_feature_logs(&self.project_root, &c.slug);
445
446            removed.push(c.clone());
447        }
448
449        Ok(removed)
450    }
451
452    /// Removes log directories for all features under `.coda/*/logs/`.
453    ///
454    /// Scans the `.coda/` directory for feature subdirectories that contain
455    /// a `logs/` child, deletes each one, and returns the list of feature
456    /// slugs whose logs were successfully cleaned.
457    ///
458    /// # Errors
459    ///
460    /// Returns `CoreError::ConfigError` if `.coda/` cannot be read.
461    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    /// Checks a single feature's PR status. Returns a [`CleanedWorktree`]
493    /// candidate if the PR is merged or closed, `None` otherwise.
494    ///
495    /// As a defensive check, skips features whose worktree directory no
496    /// longer exists under `.trees/` (e.g. ghost entries from merged branches).
497    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/// Result of cleaning a single worktree.
546#[derive(Debug, Clone)]
547pub struct CleanedWorktree {
548    /// Feature slug.
549    pub slug: String,
550
551    /// Git branch name.
552    pub branch: String,
553
554    /// PR number if found.
555    pub pr_number: Option<u32>,
556
557    /// PR state (e.g., "MERGED", "CLOSED").
558    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
570// ---------------------------------------------------------------------------
571// Helper functions
572// ---------------------------------------------------------------------------
573
574/// Maximum length for a feature slug (keeps branch names and paths manageable).
575const SLUG_MAX_LEN: usize = 64;
576
577/// Validates that a feature slug is URL-safe and suitable for use in
578/// branch names and filesystem paths.
579///
580/// Accepted format: lowercase ASCII alphanumeric characters and hyphens,
581/// starting and ending with an alphanumeric character (e.g., `"add-user-auth"`).
582///
583/// # Errors
584///
585/// Returns `CoreError::PlanError` with a human-readable explanation when
586/// validation fails.
587pub 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
621/// Removes the log directory for a feature at `.coda/<slug>/logs/`.
622///
623/// Returns `true` if the directory was successfully removed or did not
624/// exist. Returns `false` if deletion failed (a warning is logged).
625///
626/// # Examples
627///
628/// ```no_run
629/// # use std::path::Path;
630/// // After cleaning a worktree, remove its logs:
631/// let removed = coda_core::remove_feature_logs(Path::new("/repo"), "my-feature");
632/// ```
633pub 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
656/// Builds a simple directory tree listing of the repository.
657///
658/// Skips common non-source directories (`.git`, `target`, `node_modules`, etc.)
659/// and limits depth to [`TREE_MAX_DEPTH`] levels.
660fn 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
666/// Recursively builds the tree string for [`gather_repo_tree`].
667fn 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            // Skip hidden files (except key dotfiles)
683            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            // Skip excluded directories
692            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
727/// Reads the first [`SAMPLE_MAX_LINES`] lines from key project files.
728///
729/// Returns a list of [`FileSample`] structs suitable for template rendering.
730/// Only files that actually exist in the repository are included.
731fn 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
754/// Extracts all text content from a sequence of Claude SDK messages.
755///
756/// Iterates through assistant messages, collecting text blocks into a
757/// single concatenated string. Non-text content blocks are skipped.
758fn 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        // Write invalid YAML
905        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        // Create a simple structure
968        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        // target/ should be skipped
978        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        // The parent .coda/my-feature/ should still exist
1095        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        // No .coda directory at all — should not panic and should return true
1103        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        // Create log directories for two features
1112        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        // Feature without logs should be skipped
1120        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        // Parent dirs remain
1129        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}