use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use claude_agent_sdk_rs::Message;
use coda_pm::PromptManager;
use serde::Serialize;
use tracing::{debug, info, warn};
use tokio::sync::mpsc::UnboundedSender;
use crate::CoreError;
use crate::config::CodaConfig;
use crate::gh::{DefaultGhOps, GhOps};
use crate::git::{DefaultGitOps, GitOps};
use crate::planner::PlanSession;
use crate::profile::AgentProfile;
use crate::runner::RunEvent;
use crate::scanner::FeatureScanner;
use crate::task::TaskResult;
const SKIP_DIRS: &[&str] = &[
".git",
".coda",
".trees",
"target",
"node_modules",
".next",
"dist",
"build",
"__pycache__",
".venv",
"venv",
".tox",
".mypy_cache",
".pytest_cache",
".cargo",
"vendor",
".idea",
".vscode",
];
const SAMPLE_FILES: &[&str] = &[
"Cargo.toml",
"package.json",
"pyproject.toml",
"requirements.txt",
"go.mod",
"Makefile",
"Dockerfile",
"docker-compose.yml",
"README.md",
"CLAUDE.md",
".gitignore",
"tsconfig.json",
"CMakeLists.txt",
"build.gradle",
"pom.xml",
];
const SAMPLE_MAX_LINES: usize = 40;
const TREE_MAX_DEPTH: usize = 4;
#[derive(Debug, Serialize)]
struct FileSample {
path: String,
content: String,
}
pub struct Engine {
project_root: PathBuf,
pm: PromptManager,
config: CodaConfig,
scanner: FeatureScanner,
git: Arc<dyn GitOps>,
gh: Arc<dyn GhOps>,
}
impl Engine {
pub async fn new(project_root: PathBuf) -> Result<Self, CoreError> {
let config_path = project_root.join(".coda/config.yml");
let config = if config_path.exists() {
let content = fs::read_to_string(&config_path).map_err(|e| {
CoreError::ConfigError(format!(
"Cannot read config file at {}: {e}",
config_path.display()
))
})?;
serde_yaml::from_str::<CodaConfig>(&content).map_err(|e| {
CoreError::ConfigError(format!(
"Invalid YAML in config file at {}: {e}",
config_path.display()
))
})?
} else {
info!("No .coda/config.yml found, using default configuration");
CodaConfig::default()
};
let mut pm = PromptManager::with_builtin_templates()?;
info!(
template_count = pm.template_count(),
"Loaded built-in templates"
);
for extra_dir in &config.prompts.extra_dirs {
let dir = project_root.join(extra_dir);
if dir.exists() {
pm.load_from_dir(&dir)?;
info!(dir = %dir.display(), "Loaded custom templates");
}
}
let scanner = FeatureScanner::new(&project_root);
let git: Arc<dyn GitOps> = Arc::new(DefaultGitOps::new(project_root.clone()));
let gh: Arc<dyn GhOps> = Arc::new(DefaultGhOps::new(project_root.clone()));
Ok(Self {
project_root,
pm,
config,
scanner,
git,
gh,
})
}
pub fn project_root(&self) -> &Path {
&self.project_root
}
pub fn prompt_manager(&self) -> &PromptManager {
&self.pm
}
pub fn config(&self) -> &CodaConfig {
&self.config
}
pub fn git(&self) -> &dyn GitOps {
self.git.as_ref()
}
pub fn gh(&self) -> &dyn GhOps {
self.gh.as_ref()
}
pub async fn init(&self) -> Result<(), CoreError> {
if self.project_root.join(".coda").exists() {
return Err(CoreError::ConfigError(
"Project already initialized. .coda/ directory exists.".into(),
));
}
let system_prompt = self.pm.render("init/system", minijinja::context!())?;
let repo_tree = gather_repo_tree(&self.project_root)?;
let file_samples = gather_file_samples(&self.project_root)?;
let analyze_prompt = self.pm.render(
"init/analyze_repo",
minijinja::context!(
repo_tree => repo_tree,
file_samples => file_samples,
),
)?;
debug!("Analyzing repository structure...");
let planner_options = AgentProfile::Planner.to_options(
&system_prompt,
self.project_root.clone(),
5, self.config.agent.max_budget_usd,
&self.config.agent.model,
);
let messages = claude_agent_sdk_rs::query(analyze_prompt, Some(planner_options))
.await
.map_err(|e| CoreError::AgentError(e.to_string()))?;
let analysis_result = extract_text_from_messages(&messages);
debug!(
analysis_len = analysis_result.len(),
"Repository analysis complete"
);
let setup_prompt = self.pm.render(
"init/setup_project",
minijinja::context!(
project_root => self.project_root.display().to_string(),
analysis_result => analysis_result,
),
)?;
debug!("Setting up project structure...");
let coder_options = AgentProfile::Coder.to_options(
&system_prompt,
self.project_root.clone(),
10, self.config.agent.max_budget_usd,
&self.config.agent.model,
);
let _messages = claude_agent_sdk_rs::query(setup_prompt, Some(coder_options))
.await
.map_err(|e| CoreError::AgentError(e.to_string()))?;
info!("Project initialized successfully");
Ok(())
}
pub fn plan(&self, feature_slug: &str) -> Result<PlanSession, CoreError> {
validate_feature_slug(feature_slug)?;
let worktree_path = self.project_root.join(".trees").join(feature_slug);
if worktree_path.exists() {
return Err(CoreError::PlanError(format!(
"Feature '{feature_slug}' already exists at {}. \
Use `coda status {feature_slug}` to check its state, \
or choose a different slug.",
worktree_path.display(),
)));
}
info!(feature_slug, "Starting planning session");
PlanSession::new(
feature_slug.to_string(),
self.project_root.clone(),
&self.pm,
&self.config,
Arc::clone(&self.git),
)
}
pub fn list_features(&self) -> Result<Vec<crate::state::FeatureState>, CoreError> {
self.scanner.list()
}
pub fn feature_status(
&self,
feature_slug: &str,
) -> Result<crate::state::FeatureState, CoreError> {
self.scanner.get(feature_slug)
}
pub async fn run(
&self,
feature_slug: &str,
progress_tx: Option<UnboundedSender<RunEvent>>,
) -> Result<Vec<TaskResult>, CoreError> {
info!(feature_slug, "Starting feature run");
let mut runner = crate::runner::Runner::new(
feature_slug,
self.project_root.clone(),
&self.pm,
&self.config,
Arc::clone(&self.git),
Arc::clone(&self.gh),
)?;
if let Some(tx) = progress_tx {
runner.set_progress_sender(tx);
}
runner.execute().await
}
pub fn scan_cleanable_worktrees(&self) -> Result<Vec<CleanedWorktree>, CoreError> {
let features = self.list_features()?;
let mut candidates = Vec::new();
for feature in &features {
match self.check_feature_pr_status(feature) {
Ok(Some(result)) => candidates.push(result),
Ok(None) => {}
Err(e) => {
warn!(
slug = %feature.feature.slug,
error = %e,
"Failed to check PR status, skipping"
);
}
}
}
Ok(candidates)
}
pub fn remove_worktrees(
&self,
candidates: &[CleanedWorktree],
) -> Result<Vec<CleanedWorktree>, CoreError> {
let mut removed = Vec::new();
for c in candidates {
let worktree_abs = self.project_root.join(".trees").join(&c.slug);
if !worktree_abs.exists() {
info!(path = %worktree_abs.display(), "Worktree path does not exist, running prune");
self.git.worktree_prune()?;
} else {
self.git.worktree_remove(&worktree_abs, true)?;
}
if let Err(e) = self.git.branch_delete(&c.branch) {
warn!(branch = %c.branch, error = %e, "Failed to delete local branch (may already be deleted)");
}
let _ = remove_feature_logs(&self.project_root, &c.slug);
removed.push(c.clone());
}
Ok(removed)
}
pub fn clean_logs(&self) -> Result<Vec<String>, CoreError> {
let coda_dir = self.project_root.join(".coda");
if !coda_dir.is_dir() {
return Ok(Vec::new());
}
let entries = fs::read_dir(&coda_dir).map_err(|e| {
CoreError::ConfigError(format!(
"Cannot read .coda/ directory at {}: {e}",
coda_dir.display()
))
})?;
let mut cleaned = Vec::new();
for entry in entries.filter_map(Result::ok) {
if !entry.file_type().is_ok_and(|ft| ft.is_dir()) {
continue;
}
let slug = entry.file_name();
let slug_str = slug.to_string_lossy();
let logs_dir = entry.path().join("logs");
if logs_dir.is_dir() && remove_feature_logs(&self.project_root, &slug_str) {
cleaned.push(slug_str.into_owned());
}
}
cleaned.sort();
info!(count = cleaned.len(), "Cleaned all feature logs");
Ok(cleaned)
}
fn check_feature_pr_status(
&self,
feature: &crate::state::FeatureState,
) -> Result<Option<CleanedWorktree>, CoreError> {
let slug = &feature.feature.slug;
let branch = &feature.git.branch;
let worktree_dir = self.project_root.join(".trees").join(slug);
if !worktree_dir.is_dir() {
debug!(
slug,
path = %worktree_dir.display(),
"Worktree directory does not exist, skipping ghost feature"
);
return Ok(None);
}
let pr_status = if let Some(ref pr) = feature.pr {
self.gh.pr_view_state(pr.number)?
} else {
self.gh.pr_list_by_branch(branch)?
};
let Some(pr_status) = pr_status else {
debug!(slug, branch, "No PR found, skipping");
return Ok(None);
};
let state_upper = pr_status.state.to_uppercase();
if state_upper != "MERGED" && state_upper != "CLOSED" {
debug!(
slug,
branch,
state = %pr_status.state,
"PR still open, skipping"
);
return Ok(None);
}
Ok(Some(CleanedWorktree {
slug: slug.clone(),
branch: branch.clone(),
pr_number: Some(pr_status.number),
pr_state: state_upper,
}))
}
}
#[derive(Debug, Clone)]
pub struct CleanedWorktree {
pub slug: String,
pub branch: String,
pub pr_number: Option<u32>,
pub pr_state: String,
}
impl std::fmt::Debug for Engine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Engine")
.field("project_root", &self.project_root)
.field("config", &self.config)
.finish_non_exhaustive()
}
}
const SLUG_MAX_LEN: usize = 64;
pub fn validate_feature_slug(slug: &str) -> Result<(), CoreError> {
if slug.is_empty() {
return Err(CoreError::PlanError(
"Feature slug cannot be empty.".to_string(),
));
}
if slug.len() > SLUG_MAX_LEN {
return Err(CoreError::PlanError(format!(
"Feature slug is too long ({} chars, max {SLUG_MAX_LEN}).",
slug.len(),
)));
}
if !slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(CoreError::PlanError(format!(
"Feature slug '{slug}' contains invalid characters. \
Only lowercase letters, digits, and hyphens are allowed.",
)));
}
if slug.starts_with('-') || slug.ends_with('-') {
return Err(CoreError::PlanError(format!(
"Feature slug '{slug}' must not start or end with a hyphen.",
)));
}
if slug.contains("--") {
return Err(CoreError::PlanError(format!(
"Feature slug '{slug}' must not contain consecutive hyphens.",
)));
}
Ok(())
}
pub fn remove_feature_logs(project_root: &Path, slug: &str) -> bool {
let logs_dir = project_root.join(".coda").join(slug).join("logs");
match fs::remove_dir_all(&logs_dir) {
Ok(()) => {
info!(slug, path = %logs_dir.display(), "Removed feature log directory");
true
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
debug!(slug, path = %logs_dir.display(), "No log directory to clean");
true
}
Err(e) => {
warn!(
slug,
path = %logs_dir.display(),
error = %e,
"Failed to remove feature log directory"
);
false
}
}
}
fn gather_repo_tree(root: &Path) -> Result<String, CoreError> {
let mut output = String::new();
build_tree(root, "", &mut output, 0)?;
Ok(output)
}
fn build_tree(
current: &Path,
prefix: &str,
output: &mut String,
depth: usize,
) -> Result<(), CoreError> {
if depth > TREE_MAX_DEPTH {
return Ok(());
}
let mut entries: Vec<_> = fs::read_dir(current)?
.filter_map(|e| e.ok())
.filter(|entry| {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.')
&& !matches!(
name_str.as_ref(),
".gitignore" | ".coda.md" | ".env.example"
)
{
return false;
}
if entry.file_type().is_ok_and(|ft| ft.is_dir())
&& SKIP_DIRS.contains(&name_str.as_ref())
{
return false;
}
true
})
.collect();
entries.sort_by_key(|e| e.file_name());
let total = entries.len();
for (i, entry) in entries.iter().enumerate() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
let is_last = i == total - 1;
let connector = if is_last { "└── " } else { "├── " };
let child_prefix = if is_last { " " } else { "│ " };
if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
output.push_str(&format!("{prefix}{connector}{name_str}/\n"));
build_tree(
&entry.path(),
&format!("{prefix}{child_prefix}"),
output,
depth + 1,
)?;
} else {
output.push_str(&format!("{prefix}{connector}{name_str}\n"));
}
}
Ok(())
}
fn gather_file_samples(root: &Path) -> Result<Vec<FileSample>, CoreError> {
let mut samples = Vec::new();
for &filename in SAMPLE_FILES {
let path = root.join(filename);
if path.is_file() {
let content = fs::read_to_string(&path)?;
let truncated: String = content
.lines()
.take(SAMPLE_MAX_LINES)
.collect::<Vec<_>>()
.join("\n");
samples.push(FileSample {
path: filename.to_string(),
content: truncated,
});
}
}
Ok(samples)
}
fn extract_text_from_messages(messages: &[Message]) -> String {
let mut text_parts: Vec<String> = Vec::new();
for message in messages {
match message {
Message::Assistant(assistant) => {
for block in &assistant.message.content {
if let claude_agent_sdk_rs::ContentBlock::Text(text_block) = block {
text_parts.push(text_block.text.clone());
}
}
}
Message::Result(result) => {
if let Some(ref result_text) = result.result {
text_parts.push(result_text.clone());
}
}
_ => {}
}
}
text_parts.join("\n")
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
use crate::state::{
FeatureInfo, FeatureState, FeatureStatus, GitInfo, PhaseKind, PhaseRecord, PhaseStatus,
TokenCost, TotalStats,
};
fn make_state(slug: &str) -> FeatureState {
let now = chrono::Utc::now();
FeatureState {
feature: FeatureInfo {
slug: slug.to_string(),
created_at: now,
updated_at: now,
},
status: FeatureStatus::Planned,
current_phase: 0,
git: GitInfo {
worktree_path: std::path::PathBuf::from(format!(".trees/{slug}")),
branch: format!("feature/{slug}"),
base_branch: "main".to_string(),
},
phases: vec![
PhaseRecord {
name: "dev".to_string(),
kind: PhaseKind::Dev,
status: PhaseStatus::Pending,
started_at: None,
completed_at: None,
turns: 0,
cost_usd: 0.0,
cost: TokenCost::default(),
duration_secs: 0,
details: serde_json::json!({}),
},
PhaseRecord {
name: "review".to_string(),
kind: PhaseKind::Quality,
status: PhaseStatus::Pending,
started_at: None,
completed_at: None,
turns: 0,
cost_usd: 0.0,
cost: TokenCost::default(),
duration_secs: 0,
details: serde_json::json!({}),
},
PhaseRecord {
name: "verify".to_string(),
kind: PhaseKind::Quality,
status: PhaseStatus::Pending,
started_at: None,
completed_at: None,
turns: 0,
cost_usd: 0.0,
cost: TokenCost::default(),
duration_secs: 0,
details: serde_json::json!({}),
},
],
pr: None,
total: TotalStats::default(),
}
}
fn write_state(root: &std::path::Path, slug: &str, state: &FeatureState) {
let dir = root.join(".trees").join(slug).join(".coda").join(slug);
fs::create_dir_all(&dir).expect("create state dir");
let yaml = serde_yaml::to_string(state).expect("serialize state");
fs::write(dir.join("state.yml"), yaml).expect("write state.yml");
}
async fn make_engine(root: &std::path::Path) -> Engine {
Engine::new(root.to_path_buf())
.await
.expect("create Engine")
}
#[tokio::test]
async fn test_should_list_features_empty() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::create_dir_all(tmp.path().join(".trees")).expect("mkdir");
let engine = make_engine(tmp.path()).await;
let features = engine.list_features().expect("list");
assert!(features.is_empty());
}
#[tokio::test]
async fn test_should_list_features_single() {
let tmp = tempfile::tempdir().expect("tempdir");
let state = make_state("add-auth");
write_state(tmp.path(), "add-auth", &state);
let engine = make_engine(tmp.path()).await;
let features = engine.list_features().expect("list");
assert_eq!(features.len(), 1);
assert_eq!(features[0].feature.slug, "add-auth");
}
#[tokio::test]
async fn test_should_list_features_sorted_by_slug() {
let tmp = tempfile::tempdir().expect("tempdir");
write_state(tmp.path(), "zzz-last", &make_state("zzz-last"));
write_state(tmp.path(), "aaa-first", &make_state("aaa-first"));
write_state(tmp.path(), "mmm-middle", &make_state("mmm-middle"));
let engine = make_engine(tmp.path()).await;
let features = engine.list_features().expect("list");
assert_eq!(features.len(), 3);
assert_eq!(features[0].feature.slug, "aaa-first");
assert_eq!(features[1].feature.slug, "mmm-middle");
assert_eq!(features[2].feature.slug, "zzz-last");
}
#[tokio::test]
async fn test_should_list_features_skip_invalid_state() {
let tmp = tempfile::tempdir().expect("tempdir");
write_state(tmp.path(), "good", &make_state("good"));
let bad_dir = tmp.path().join(".trees/bad/.coda/bad");
fs::create_dir_all(&bad_dir).expect("mkdir");
fs::write(bad_dir.join("state.yml"), "not: valid: yaml: [").expect("write");
let engine = make_engine(tmp.path()).await;
let features = engine.list_features().expect("list");
assert_eq!(features.len(), 1);
assert_eq!(features[0].feature.slug, "good");
}
#[tokio::test]
async fn test_should_list_features_error_when_no_trees_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let engine = make_engine(tmp.path()).await;
let result = engine.list_features();
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains(".trees/"));
}
#[tokio::test]
async fn test_should_get_feature_status_direct_lookup() {
let tmp = tempfile::tempdir().expect("tempdir");
let state = make_state("add-auth");
write_state(tmp.path(), "add-auth", &state);
let engine = make_engine(tmp.path()).await;
let found = engine.feature_status("add-auth").expect("status");
assert_eq!(found.feature.slug, "add-auth");
assert_eq!(found.git.branch, "feature/add-auth");
}
#[tokio::test]
async fn test_should_get_feature_status_not_found() {
let tmp = tempfile::tempdir().expect("tempdir");
write_state(tmp.path(), "existing", &make_state("existing"));
let engine = make_engine(tmp.path()).await;
let result = engine.feature_status("nonexistent");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("nonexistent"));
assert!(err.contains("existing"));
}
#[tokio::test]
async fn test_should_get_feature_status_error_when_no_trees_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let engine = make_engine(tmp.path()).await;
let result = engine.feature_status("anything");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains(".trees/"));
}
#[test]
fn test_should_gather_repo_tree_from_temp_dir() {
let tmp = tempfile::tempdir().expect("failed to create temp dir");
let root = tmp.path();
fs::create_dir_all(root.join("src")).expect("mkdir");
fs::write(root.join("src/main.rs"), "fn main() {}").expect("write");
fs::write(root.join("Cargo.toml"), "[package]").expect("write");
fs::create_dir_all(root.join("target/debug")).expect("mkdir");
let tree = gather_repo_tree(root).expect("gather_repo_tree");
assert!(tree.contains("src/"));
assert!(tree.contains("Cargo.toml"));
assert!(!tree.contains("target"));
}
#[test]
fn test_should_gather_file_samples() {
let tmp = tempfile::tempdir().expect("failed to create temp dir");
let root = tmp.path();
fs::write(root.join("Cargo.toml"), "[package]\nname = \"test\"\n").expect("write");
fs::write(root.join("README.md"), "# Test\nHello world").expect("write");
let samples = gather_file_samples(root).expect("gather_file_samples");
assert_eq!(samples.len(), 2);
let names: Vec<&str> = samples.iter().map(|s| s.path.as_str()).collect();
assert!(names.contains(&"Cargo.toml"));
assert!(names.contains(&"README.md"));
}
#[test]
fn test_should_extract_text_from_assistant_messages() {
let messages = vec![
Message::Assistant(claude_agent_sdk_rs::AssistantMessage {
message: claude_agent_sdk_rs::AssistantMessageInner {
content: vec![claude_agent_sdk_rs::ContentBlock::Text(
claude_agent_sdk_rs::TextBlock {
text: "Hello from assistant".to_string(),
},
)],
model: None,
id: None,
stop_reason: None,
usage: None,
error: None,
},
parent_tool_use_id: None,
session_id: None,
uuid: None,
}),
Message::Result(claude_agent_sdk_rs::ResultMessage {
subtype: "success".to_string(),
duration_ms: 100,
duration_api_ms: 80,
is_error: false,
num_turns: 1,
session_id: "test".to_string(),
total_cost_usd: Some(0.01),
usage: None,
result: Some("Result text".to_string()),
structured_output: None,
}),
];
let text = extract_text_from_messages(&messages);
assert!(text.contains("Hello from assistant"));
assert!(text.contains("Result text"));
}
#[test]
fn test_should_return_empty_for_no_text_messages() {
let messages: Vec<Message> = vec![];
let text = extract_text_from_messages(&messages);
assert!(text.is_empty());
}
#[test]
fn test_should_accept_valid_slugs() {
assert!(validate_feature_slug("add-auth").is_ok());
assert!(validate_feature_slug("feature123").is_ok());
assert!(validate_feature_slug("a").is_ok());
assert!(validate_feature_slug("a-b-c").is_ok());
}
#[test]
fn test_should_reject_empty_slug() {
let err = validate_feature_slug("").unwrap_err().to_string();
assert!(err.contains("empty"));
}
#[test]
fn test_should_reject_slug_with_invalid_chars() {
assert!(validate_feature_slug("Add-Auth").is_err());
assert!(validate_feature_slug("add auth").is_err());
assert!(validate_feature_slug("add/auth").is_err());
assert!(validate_feature_slug("add_auth").is_err());
assert!(validate_feature_slug("add.auth").is_err());
}
#[test]
fn test_should_reject_slug_with_leading_trailing_hyphen() {
assert!(validate_feature_slug("-add").is_err());
assert!(validate_feature_slug("add-").is_err());
}
#[test]
fn test_should_reject_slug_with_consecutive_hyphens() {
assert!(validate_feature_slug("add--auth").is_err());
}
#[test]
fn test_should_reject_slug_too_long() {
let long_slug = "a".repeat(65);
assert!(validate_feature_slug(&long_slug).is_err());
}
#[test]
fn test_should_remove_existing_log_directory() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let logs_dir = root.join(".coda/my-feature/logs");
fs::create_dir_all(&logs_dir).expect("mkdir");
fs::write(logs_dir.join("run-20260101T000000.log"), "log data").expect("write");
assert!(remove_feature_logs(root, "my-feature"));
assert!(!logs_dir.exists());
assert!(root.join(".coda/my-feature").exists());
}
#[test]
fn test_should_ignore_missing_log_directory() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
assert!(remove_feature_logs(root, "nonexistent"));
}
#[tokio::test]
async fn test_should_clean_logs_for_multiple_features() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let logs_a = root.join(".coda/feature-a/logs");
let logs_b = root.join(".coda/feature-b/logs");
fs::create_dir_all(&logs_a).expect("mkdir");
fs::create_dir_all(&logs_b).expect("mkdir");
fs::write(logs_a.join("run.log"), "data").expect("write");
fs::write(logs_b.join("run.log"), "data").expect("write");
fs::create_dir_all(root.join(".coda/feature-c")).expect("mkdir");
let engine = make_engine(root).await;
let cleaned = engine.clean_logs().expect("clean_logs");
assert_eq!(cleaned, vec!["feature-a", "feature-b"]);
assert!(!logs_a.exists());
assert!(!logs_b.exists());
assert!(root.join(".coda/feature-a").exists());
assert!(root.join(".coda/feature-b").exists());
}
#[tokio::test]
async fn test_should_return_empty_when_no_coda_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let engine = make_engine(tmp.path()).await;
let cleaned = engine.clean_logs().expect("clean_logs");
assert!(cleaned.is_empty());
}
#[tokio::test]
async fn test_should_return_empty_when_no_features_have_logs() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
fs::create_dir_all(root.join(".coda/some-feature")).expect("mkdir");
let engine = make_engine(root).await;
let cleaned = engine.clean_logs().expect("clean_logs");
assert!(cleaned.is_empty());
}
}