use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use tokio::sync::mpsc;
use tracing::info;
use uuid::Uuid;
use dk_engine::repo::Engine;
use crate::executor::Executor;
use crate::scheduler::{self, StepResult};
use crate::workflow::parser::parse_yaml_workflow_file;
use crate::workflow::types::{Stage, Step, StepType, Workflow};
use crate::workflow::validator::validate_workflow;
pub struct Runner {
engine: Arc<Engine>,
executor: Box<dyn Executor>,
}
impl Runner {
pub fn new(engine: Arc<Engine>, executor: Box<dyn Executor>) -> Self {
Self { engine, executor }
}
pub async fn verify(
&self,
changeset_id: Uuid,
repo_name: &str,
tx: mpsc::Sender<StepResult>,
) -> Result<bool> {
let (repo_id, repo_dir) = {
let (repo_id, git_repo) = self.engine.get_repo(repo_name).await?;
(repo_id, git_repo.path().to_path_buf())
};
let changeset_data = self.engine.changeset_store().get_files(changeset_id).await?;
let temp_dir = tempfile::tempdir().context("failed to create temp dir for verify")?;
let work_dir = temp_dir.path().to_path_buf();
copy_dir_recursive(&repo_dir, &work_dir).await
.context("failed to copy repo into temp dir")?;
let mut changeset_paths: Vec<String> = Vec::with_capacity(changeset_data.len());
for file in &changeset_data {
changeset_paths.push(file.file_path.clone());
if let Some(content) = &file.content {
if file.file_path.contains("..") {
anyhow::bail!(
"changeset file path contains traversal component: '{}'",
file.file_path
);
}
if file.file_path.starts_with('/') || file.file_path.starts_with('\\') {
anyhow::bail!(
"changeset file path is absolute: '{}'",
file.file_path
);
}
let dest = work_dir.join(&file.file_path);
if !dest.starts_with(&work_dir) {
anyhow::bail!(
"changeset file path escapes sandbox: '{}' resolves outside work_dir",
file.file_path
);
}
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&dest, content).await?;
}
}
info!(
"copied repo and overlaid {} changeset files into {} for verification",
changeset_paths.len(),
work_dir.display()
);
let workflow = self.load_workflow(&repo_dir, repo_id).await?;
if workflow.stages.is_empty() {
tracing::warn!(
changeset_id = %changeset_id,
repo = %repo_name,
"auto-approving changeset: no verification pipeline and no recognized project type"
);
return Ok(true);
}
validate_workflow(&workflow).context("workflow validation failed")?;
let mut env = HashMap::new();
env.insert("DKOD_CHANGESET_ID".to_string(), changeset_id.to_string());
env.insert("DKOD_REPO_ID".to_string(), repo_id.to_string());
let passed = tokio::time::timeout(
workflow.timeout,
scheduler::run_workflow(
&workflow,
self.executor.as_ref(),
&work_dir,
&changeset_paths,
&env,
&tx,
Some(&self.engine),
Some(repo_id),
Some(changeset_id),
),
)
.await
.unwrap_or_else(|_| {
tracing::warn!("workflow '{}' timed out after {:?}", workflow.name, workflow.timeout);
false
});
Ok(passed)
}
async fn load_workflow(&self, repo_dir: &Path, repo_id: Uuid) -> Result<Workflow> {
let yaml_path = repo_dir.join(".dkod/pipeline.yaml");
if yaml_path.exists() {
info!("loading workflow from {}", yaml_path.display());
let workflow = parse_yaml_workflow_file(&yaml_path).await?;
if workflow.stages.is_empty() {
anyhow::bail!(
"pipeline.yaml exists but defines no stages — refusing to auto-approve; \
add at least one stage or remove the file to use auto-detection"
);
}
return Ok(workflow);
}
let legacy_toml = repo_dir.join(".dekode/pipeline.toml");
if legacy_toml.exists() {
tracing::warn!(
path = %legacy_toml.display(),
"found legacy .dekode/pipeline.toml \u{2014} this format is no longer loaded; please migrate to .dkod/pipeline.yaml"
);
}
let db_steps = self.engine
.pipeline_store()
.get_pipeline(repo_id)
.await
.unwrap_or_default();
if !db_steps.is_empty() {
info!(
"loading workflow from DB pipeline ({} steps)",
db_steps.len()
);
return Ok(db_pipeline_to_workflow(db_steps));
}
info!("auto-detecting verification workflow from project files");
Ok(detect_workflow(repo_dir))
}
}
fn db_pipeline_to_workflow(steps: Vec<dk_engine::pipeline::PipelineStep>) -> Workflow {
let resolved_steps: Vec<Step> = steps
.into_iter()
.map(|s| {
let command = s
.config
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("echo 'no command configured'")
.to_string();
let timeout_secs = s
.config
.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(120);
let step_type = match s.step_type.as_str() {
"agent-review" => StepType::AgentReview {
prompt: "Review this changeset".to_string(),
},
"human-approve" => StepType::HumanApprove,
_ => StepType::Command { run: command },
};
Step {
name: s.step_type.clone(),
step_type,
timeout: Duration::from_secs(timeout_secs),
required: s.required,
changeset_aware: false,
work_dir: None,
}
})
.collect();
Workflow {
name: "db-pipeline".to_string(),
timeout: Duration::from_secs(600),
stages: vec![Stage {
name: "pipeline".to_string(),
parallel: false,
steps: resolved_steps,
}],
allowed_commands: vec![],
}
}
pub fn detect_workflow(repo_dir: &Path) -> Workflow {
use std::path::PathBuf;
let mut steps: Vec<Step> = Vec::new();
let skip = ["node_modules", "target"];
let mut scan_dirs: Vec<(String, std::path::PathBuf)> = vec![("".to_string(), repo_dir.to_path_buf())];
if let Ok(entries) = std::fs::read_dir(repo_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name();
let name_str = name.to_string_lossy().to_string();
if name_str.starts_with('.') || skip.contains(&name_str.as_str()) {
continue;
}
scan_dirs.push((name_str, path));
}
}
if scan_dirs.len() > 1 {
scan_dirs[1..].sort_by(|a, b| a.0.cmp(&b.0));
}
let root_dir = &scan_dirs[0].1;
let mut root_languages: std::collections::HashSet<&str> = std::collections::HashSet::new();
if root_dir.join("Cargo.toml").exists() {
root_languages.insert("rust");
}
if root_dir.join("package.json").exists() {
let is_bun = root_dir.join("bun.lock").exists() || root_dir.join("bun.lockb").exists();
root_languages.insert(if is_bun { "bun" } else { "node" });
}
if root_dir.join("pyproject.toml").exists() || root_dir.join("requirements.txt").exists() {
root_languages.insert("python");
}
if root_dir.join("go.mod").exists() {
root_languages.insert("go");
}
for (subdir_name, dir) in &scan_dirs {
let is_root = subdir_name.is_empty();
let step_work_dir: Option<PathBuf> = if is_root {
None
} else {
Some(PathBuf::from(subdir_name))
};
if dir.join("Cargo.toml").exists() && (is_root || !root_languages.contains("rust")) {
let name_prefix = if is_root {
"rust".to_string()
} else {
format!("rust({subdir_name})")
};
steps.push(Step {
name: format!("{name_prefix}:check"),
step_type: StepType::Command { run: "cargo check".to_string() },
timeout: Duration::from_secs(60),
required: true,
changeset_aware: true,
work_dir: step_work_dir.clone(),
});
steps.push(Step {
name: format!("{name_prefix}:test"),
step_type: StepType::Command { run: "cargo test".to_string() },
timeout: Duration::from_secs(60),
required: true,
changeset_aware: true,
work_dir: step_work_dir.clone(),
});
}
if dir.join("package.json").exists() {
let is_bun = dir.join("bun.lock").exists()
|| dir.join("bun.lockb").exists();
let lang_key = if is_bun { "bun" } else { "node" };
if is_root || !root_languages.contains(lang_key) {
let (label, install_cmd, test_cmd) = if is_bun {
("bun", "bun install --frozen-lockfile", "bun test")
} else {
("node", "npm ci", "npm test")
};
let name_prefix = if is_root {
label.to_string()
} else {
format!("{label}({subdir_name})")
};
steps.push(Step {
name: format!("{name_prefix}:install"),
step_type: StepType::Command { run: install_cmd.to_string() },
timeout: Duration::from_secs(120),
required: true,
changeset_aware: false,
work_dir: step_work_dir.clone(),
});
steps.push(Step {
name: format!("{name_prefix}:test"),
step_type: StepType::Command { run: test_cmd.to_string() },
timeout: Duration::from_secs(60),
required: true,
changeset_aware: true,
work_dir: step_work_dir.clone(),
});
}
}
if (dir.join("pyproject.toml").exists()
|| dir.join("requirements.txt").exists())
&& (is_root || !root_languages.contains("python"))
{
let name_prefix = if is_root {
"python".to_string()
} else {
format!("python({subdir_name})")
};
if dir.join("pyproject.toml").exists() {
steps.push(Step {
name: format!("{name_prefix}:install"),
step_type: StepType::Command { run: "pip install -e .".to_string() },
timeout: Duration::from_secs(120),
required: true,
changeset_aware: false,
work_dir: step_work_dir.clone(),
});
}
if dir.join("requirements.txt").exists() {
steps.push(Step {
name: format!("{name_prefix}:install-deps"),
step_type: StepType::Command {
run: "pip install -r requirements.txt".to_string(),
},
timeout: Duration::from_secs(120),
required: true,
changeset_aware: false,
work_dir: step_work_dir.clone(),
});
}
steps.push(Step {
name: format!("{name_prefix}:test"),
step_type: StepType::Command { run: "pytest".to_string() },
timeout: Duration::from_secs(60),
required: true,
changeset_aware: true,
work_dir: step_work_dir.clone(),
});
}
if dir.join("go.mod").exists() && (is_root || !root_languages.contains("go")) {
let name_prefix = if is_root {
"go".to_string()
} else {
format!("go({subdir_name})")
};
steps.push(Step {
name: format!("{name_prefix}:build"),
step_type: StepType::Command { run: "go build ./...".to_string() },
timeout: Duration::from_secs(60),
required: true,
changeset_aware: true,
work_dir: step_work_dir.clone(),
});
steps.push(Step {
name: format!("{name_prefix}:vet"),
step_type: StepType::Command { run: "go vet ./...".to_string() },
timeout: Duration::from_secs(60),
required: true,
changeset_aware: true,
work_dir: step_work_dir.clone(),
});
steps.push(Step {
name: format!("{name_prefix}:test"),
step_type: StepType::Command { run: "go test ./...".to_string() },
timeout: Duration::from_secs(60),
required: true,
changeset_aware: true,
work_dir: step_work_dir.clone(),
});
}
}
if steps.is_empty() {
return Workflow {
name: "auto-none".to_string(),
timeout: Duration::from_secs(30),
allowed_commands: vec![],
stages: vec![],
};
}
let unique_langs = steps.iter().map(|s| s.name.split(':').next().unwrap_or("")).collect::<std::collections::HashSet<_>>();
let unique_work_dirs = steps.iter().map(|s| s.work_dir.as_deref()).collect::<std::collections::HashSet<_>>();
let name = if unique_langs.len() > 1 || unique_work_dirs.len() > 1 {
"auto-polyglot".to_string()
} else {
format!("auto-{}", steps[0].name.split(':').next().unwrap_or("unknown"))
};
let total_timeout_secs = steps.iter().map(|s| s.timeout.as_secs()).sum::<u64>().max(60);
Workflow {
name,
timeout: Duration::from_secs(total_timeout_secs),
allowed_commands: vec![],
stages: vec![Stage {
name: "checks".to_string(),
parallel: false,
steps,
}],
}
}
async fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
tokio::fs::create_dir_all(dst).await?;
let mut entries = tokio::fs::read_dir(src).await?;
while let Some(entry) = entries.next_entry().await? {
let file_name = entry.file_name();
if file_name == ".git" {
continue;
}
let src_path = entry.path();
let dst_path = dst.join(&file_name);
let file_type = entry.file_type().await?;
if file_type.is_dir() {
Box::pin(copy_dir_recursive(&src_path, &dst_path)).await?;
} else if file_type.is_symlink() {
let target = tokio::fs::read_link(&src_path).await?;
let target_str = target.to_string_lossy();
if target_str.starts_with('/') || target_str.contains("..") {
tracing::warn!(
src = %src_path.display(),
target = %target.display(),
"skipping symlink that points outside sandbox"
);
continue;
}
#[cfg(unix)]
tokio::fs::symlink(target, &dst_path).await?;
} else {
tokio::fs::copy(&src_path, &dst_path).await?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_detect_workflow_rust() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("Cargo.toml"), b"[package]\nname = \"test\"")
.await.unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-rust");
assert_eq!(wf.stages.len(), 1);
assert_eq!(wf.stages[0].steps.len(), 2);
}
#[tokio::test]
async fn test_detect_workflow_bun() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("package.json"), b"{}").await.unwrap();
tokio::fs::write(dir.path().join("bun.lock"), b"").await.unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-bun");
assert_eq!(wf.stages[0].steps.len(), 2);
let cmds: Vec<_> = wf.stages[0].steps.iter().filter_map(|s| {
if let StepType::Command { run } = &s.step_type { Some(run.as_str()) } else { None }
}).collect();
assert!(cmds.contains(&"bun install --frozen-lockfile"));
assert!(cmds.contains(&"bun test"));
}
#[tokio::test]
async fn test_detect_workflow_bun_lockb() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("package.json"), b"{}").await.unwrap();
tokio::fs::write(dir.path().join("bun.lockb"), b"\x00").await.unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-bun");
assert_eq!(wf.stages[0].steps.len(), 2);
let cmds: Vec<_> = wf.stages[0].steps.iter().filter_map(|s| {
if let StepType::Command { run } = &s.step_type { Some(run.as_str()) } else { None }
}).collect();
assert!(cmds.contains(&"bun install --frozen-lockfile"));
assert!(cmds.contains(&"bun test"));
}
#[tokio::test]
async fn test_detect_workflow_npm() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("package.json"), b"{}").await.unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-node");
assert_eq!(wf.stages[0].steps.len(), 2);
let cmds: Vec<_> = wf.stages[0].steps.iter().filter_map(|s| {
if let StepType::Command { run } = &s.step_type { Some(run.as_str()) } else { None }
}).collect();
assert!(cmds.contains(&"npm ci"));
assert!(cmds.contains(&"npm test"));
}
#[tokio::test]
async fn test_detect_workflow_python_pyproject() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("pyproject.toml"), b"[project]").await.unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-python");
assert_eq!(wf.stages[0].steps.len(), 2);
let cmds: Vec<_> = wf.stages[0].steps.iter().filter_map(|s| {
if let StepType::Command { run } = &s.step_type { Some(run.as_str()) } else { None }
}).collect();
assert!(cmds.contains(&"pip install -e ."));
assert!(cmds.contains(&"pytest"));
}
#[tokio::test]
async fn test_detect_workflow_python_dual_file() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("pyproject.toml"), b"[project]").await.unwrap();
tokio::fs::write(dir.path().join("requirements.txt"), b"pytest\nrequests").await.unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-python");
assert_eq!(wf.stages[0].steps.len(), 3);
let cmds: Vec<_> = wf.stages[0].steps.iter().filter_map(|s| {
if let StepType::Command { run } = &s.step_type { Some(run.as_str()) } else { None }
}).collect();
assert!(cmds.contains(&"pip install -e ."));
assert!(cmds.contains(&"pip install -r requirements.txt"));
assert!(cmds.contains(&"pytest"));
}
#[tokio::test]
async fn test_detect_workflow_python_requirements() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("requirements.txt"), b"pytest\nrequests").await.unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-python");
assert_eq!(wf.stages[0].steps.len(), 2);
let cmds: Vec<_> = wf.stages[0].steps.iter().filter_map(|s| {
if let StepType::Command { run } = &s.step_type { Some(run.as_str()) } else { None }
}).collect();
assert!(cmds.contains(&"pip install -r requirements.txt"));
assert!(cmds.contains(&"pytest"));
}
#[tokio::test]
async fn test_detect_workflow_python_both_files() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("pyproject.toml"), b"[project]").await.unwrap();
tokio::fs::write(dir.path().join("requirements.txt"), b"pytest\nrequests").await.unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-python");
assert_eq!(wf.stages[0].steps.len(), 3);
let cmds: Vec<_> = wf.stages[0].steps.iter().filter_map(|s| {
if let StepType::Command { run } = &s.step_type { Some(run.as_str()) } else { None }
}).collect();
assert!(cmds.contains(&"pip install -e ."));
assert!(cmds.contains(&"pip install -r requirements.txt"));
assert!(cmds.contains(&"pytest"));
}
#[tokio::test]
async fn test_detect_workflow_go() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("go.mod"), b"module example.com/test").await.unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-go");
assert_eq!(wf.stages[0].steps.len(), 3);
}
#[tokio::test]
async fn test_detect_workflow_unknown() {
let dir = tempfile::tempdir().unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-none");
assert!(wf.stages.is_empty());
}
#[tokio::test]
async fn test_copy_dir_recursive_copies_files() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
tokio::fs::write(src.path().join("Cargo.toml"), b"[package]\nname = \"test\"")
.await
.unwrap();
tokio::fs::create_dir_all(src.path().join("src")).await.unwrap();
tokio::fs::write(src.path().join("src/main.rs"), b"fn main() {}")
.await
.unwrap();
tokio::fs::create_dir_all(src.path().join(".git/objects")).await.unwrap();
tokio::fs::write(src.path().join(".git/HEAD"), b"ref: refs/heads/main")
.await
.unwrap();
copy_dir_recursive(src.path(), dst.path()).await.unwrap();
assert!(dst.path().join("Cargo.toml").exists(), "Cargo.toml must be at dst root");
assert!(dst.path().join("src/main.rs").exists(), "src/main.rs must exist");
assert!(!dst.path().join(".git").exists(), ".git must be skipped");
}
#[tokio::test]
async fn test_copy_dir_recursive_handles_symlinks() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
tokio::fs::write(src.path().join("real.txt"), b"hello").await.unwrap();
#[cfg(unix)]
tokio::fs::symlink("real.txt", src.path().join("link.txt")).await.unwrap();
copy_dir_recursive(src.path(), dst.path()).await.unwrap();
assert!(dst.path().join("real.txt").exists());
#[cfg(unix)]
{
let meta = tokio::fs::symlink_metadata(dst.path().join("link.txt")).await.unwrap();
assert!(meta.file_type().is_symlink(), "symlink should be preserved");
let target = tokio::fs::read_link(dst.path().join("link.txt")).await.unwrap();
assert_eq!(target.to_str().unwrap(), "real.txt");
}
}
#[tokio::test]
async fn test_copy_dir_recursive_handles_dir_symlinks() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
tokio::fs::create_dir_all(src.path().join("real_dir")).await.unwrap();
tokio::fs::write(src.path().join("real_dir/file.txt"), b"content").await.unwrap();
#[cfg(unix)]
tokio::fs::symlink("real_dir", src.path().join("linked_dir")).await.unwrap();
copy_dir_recursive(src.path(), dst.path()).await.unwrap();
assert!(dst.path().join("real_dir/file.txt").exists());
#[cfg(unix)]
{
let meta = tokio::fs::symlink_metadata(dst.path().join("linked_dir")).await.unwrap();
assert!(meta.file_type().is_symlink(), "dir symlink should be preserved");
let target = tokio::fs::read_link(dst.path().join("linked_dir")).await.unwrap();
assert_eq!(target.to_str().unwrap(), "real_dir");
}
}
#[test]
fn test_db_pipeline_conversion() {
let steps = vec![
dk_engine::pipeline::PipelineStep {
repo_id: Uuid::new_v4(),
step_order: 1,
step_type: "typecheck".to_string(),
config: serde_json::json!({"command": "cargo check", "timeout_secs": 120}),
required: true,
},
dk_engine::pipeline::PipelineStep {
repo_id: Uuid::new_v4(),
step_order: 2,
step_type: "test".to_string(),
config: serde_json::json!({"command": "cargo test", "timeout_secs": 300}),
required: true,
},
];
let wf = db_pipeline_to_workflow(steps);
assert_eq!(wf.stages.len(), 1);
assert_eq!(wf.stages[0].steps.len(), 2);
}
#[tokio::test]
async fn test_detect_workflow_polyglot_rust_and_node() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("Cargo.toml"), b"[package]\nname = \"test\"").await.unwrap();
tokio::fs::write(dir.path().join("package.json"), b"{}").await.unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-polyglot");
assert_eq!(wf.stages.len(), 1);
let step_names: Vec<&str> = wf.stages[0].steps.iter().map(|s| s.name.as_str()).collect();
assert!(step_names.iter().any(|n| n.starts_with("rust:")), "missing rust steps");
assert!(step_names.iter().any(|n| n.starts_with("node:")), "missing node steps");
}
#[tokio::test]
async fn test_detect_workflow_polyglot_three_languages() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("Cargo.toml"), b"[package]\nname = \"test\"").await.unwrap();
tokio::fs::write(dir.path().join("package.json"), b"{}").await.unwrap();
tokio::fs::write(dir.path().join("pyproject.toml"), b"[project]\nname = \"test\"").await.unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-polyglot");
let step_names: Vec<&str> = wf.stages[0].steps.iter().map(|s| s.name.as_str()).collect();
assert!(step_names.iter().any(|n| n.starts_with("rust:")), "missing rust");
assert!(step_names.iter().any(|n| n.starts_with("node:")), "missing node");
assert!(step_names.iter().any(|n| n.starts_with("python:")), "missing python");
}
#[tokio::test]
async fn test_detect_workflow_polyglot_bun_and_go() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("package.json"), b"{}").await.unwrap();
tokio::fs::write(dir.path().join("bun.lock"), b"").await.unwrap();
tokio::fs::write(dir.path().join("go.mod"), b"module example.com/test").await.unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-polyglot");
let step_names: Vec<&str> = wf.stages[0].steps.iter().map(|s| s.name.as_str()).collect();
assert!(step_names.iter().any(|n| n.starts_with("bun:")), "missing bun steps");
assert!(step_names.iter().any(|n| n.starts_with("go:")), "missing go steps");
}
#[tokio::test]
async fn test_detect_workflow_subdirectory() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("rust")).unwrap();
std::fs::write(dir.path().join("rust/Cargo.toml"), "[package]\nname = \"test\"").unwrap();
std::fs::create_dir_all(dir.path().join("python")).unwrap();
std::fs::write(dir.path().join("python/requirements.txt"), "flask\n").unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-polyglot");
let steps: Vec<&Step> = wf.stages.iter()
.flat_map(|s| s.steps.iter())
.collect();
let rust_steps: Vec<&&Step> = steps.iter()
.filter(|s| s.name.starts_with("rust("))
.collect();
assert!(!rust_steps.is_empty(), "should detect rust steps");
for step in &rust_steps {
assert_eq!(step.work_dir.as_ref().map(|p| p.to_str().unwrap()), Some("rust"),
"rust steps should have work_dir = 'rust'");
if let StepType::Command { run } = &step.step_type {
assert!(!run.contains("cd "), "commands should not contain cd prefix");
}
}
let python_steps: Vec<&&Step> = steps.iter()
.filter(|s| s.name.starts_with("python("))
.collect();
assert!(!python_steps.is_empty(), "should detect python steps");
for step in &python_steps {
assert_eq!(step.work_dir.as_ref().map(|p| p.to_str().unwrap()), Some("python"),
"python steps should have work_dir = 'python'");
if let StepType::Command { run } = &step.step_type {
assert!(!run.contains("cd "), "commands should not contain cd prefix");
}
}
let first_lang = steps[0].name.split(':').next().unwrap();
assert_eq!(first_lang, "python(python)", "python should come before rust (alphabetical subdirectory order)");
}
#[tokio::test]
async fn test_detect_workflow_same_language_sibling_subdirs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("backend")).unwrap();
std::fs::write(dir.path().join("backend/package.json"), "{}").unwrap();
std::fs::create_dir_all(dir.path().join("frontend")).unwrap();
std::fs::write(dir.path().join("frontend/package.json"), "{}").unwrap();
let wf = detect_workflow(dir.path());
assert_eq!(wf.name, "auto-polyglot");
let work_dirs: Vec<Option<&Path>> = wf.stages.iter()
.flat_map(|s| s.steps.iter())
.map(|step| step.work_dir.as_deref())
.collect();
assert!(work_dirs.iter().any(|wd| wd == &Some(Path::new("backend"))),
"backend should have steps");
assert!(work_dirs.iter().any(|wd| wd == &Some(Path::new("frontend"))),
"frontend should have steps");
let step_names: Vec<&str> = wf.stages.iter()
.flat_map(|s| s.steps.iter())
.map(|step| step.name.as_str())
.collect();
assert!(step_names.contains(&"node(backend):install"), "missing node(backend):install");
assert!(step_names.contains(&"node(backend):test"), "missing node(backend):test");
assert!(step_names.contains(&"node(frontend):install"), "missing node(frontend):install");
assert!(step_names.contains(&"node(frontend):test"), "missing node(frontend):test");
let unique_names: std::collections::HashSet<&str> = step_names.iter().copied().collect();
assert_eq!(step_names.len(), unique_names.len(),
"step names must be unique, got: {:?}", step_names);
}
}