use kdo_core::WorkspaceConfig;
use kdo_graph::WorkspaceGraph;
use owo_colors::OwoColorize;
use std::path::Path;
use std::process::Stdio;
const PROJECT_COLORS: &[&str] = &["cyan", "green", "yellow", "magenta", "blue", "red"];
pub fn run_task(
graph: &WorkspaceGraph,
config: &WorkspaceConfig,
task_name: &str,
filter: Option<&str>,
parallel: bool,
) -> miette::Result<()> {
let projects = get_target_projects(graph, filter);
if projects.is_empty() {
eprintln!("{}", "No projects matched filter.".yellow());
return Ok(());
}
let workspace_cmd = config.tasks.get(task_name).cloned();
let work: Vec<(kdo_core::Project, String)> = projects
.into_iter()
.filter_map(|p| {
let cmd = resolve_task_command(p, task_name).or_else(|| workspace_cmd.clone())?;
Some((p.clone(), cmd))
})
.collect();
if work.is_empty() {
miette::bail!("task '{task_name}' not found in any project or kdo.toml");
}
let failures = if parallel {
run_parallel(&work)?
} else {
run_sequential(&work)?
};
if !failures.is_empty() {
miette::bail!("{} task(s) failed: {}", failures.len(), failures.join(", "));
}
Ok(())
}
pub fn exec_command(
graph: &WorkspaceGraph,
command: &str,
filter: Option<&str>,
parallel: bool,
) -> miette::Result<()> {
let projects = get_target_projects(graph, filter);
if projects.is_empty() {
eprintln!("{}", "No projects matched filter.".yellow());
return Ok(());
}
let work: Vec<(kdo_core::Project, String)> = projects
.into_iter()
.map(|p| (p.clone(), command.to_string()))
.collect();
let failures = if parallel {
run_parallel(&work)?
} else {
run_sequential(&work)?
};
if !failures.is_empty() {
miette::bail!(
"{} command(s) failed: {}",
failures.len(),
failures.join(", ")
);
}
Ok(())
}
fn get_target_projects<'a>(
graph: &'a WorkspaceGraph,
filter: Option<&str>,
) -> Vec<&'a kdo_core::Project> {
let ordered = graph.topological_order();
if let Some(filter_name) = filter {
ordered
.into_iter()
.filter(|p| p.name == filter_name || p.name.contains(filter_name))
.collect()
} else {
ordered
}
}
fn run_sequential(work: &[(kdo_core::Project, String)]) -> miette::Result<Vec<String>> {
let mut failures = Vec::new();
for (i, (project, cmd)) in work.iter().enumerate() {
let prefix = format_prefix(&project.name, i % PROJECT_COLORS.len());
eprintln!("{prefix} {}", cmd.dimmed());
let success = execute_in_dir(&project.path, cmd, &prefix)?;
if success {
eprintln!("{prefix} {}", "done".green());
} else {
eprintln!("{prefix} {}", "FAILED".red().bold());
failures.push(project.name.clone());
}
}
Ok(failures)
}
fn run_parallel(work: &[(kdo_core::Project, String)]) -> miette::Result<Vec<String>> {
use rayon::prelude::*;
use std::sync::Mutex;
let failures = Mutex::new(Vec::new());
work.par_iter().enumerate().for_each(|(i, (project, cmd))| {
let prefix = format_prefix(&project.name, i % PROJECT_COLORS.len());
eprintln!("{prefix} {}", cmd.dimmed());
match execute_in_dir(&project.path, cmd, &prefix) {
Ok(true) => eprintln!("{prefix} {}", "done".green()),
Ok(false) => {
eprintln!("{prefix} {}", "FAILED".red().bold());
failures.lock().unwrap().push(project.name.clone());
}
Err(e) => {
eprintln!("{prefix} {} {e}", "ERROR".red().bold());
failures.lock().unwrap().push(project.name.clone());
}
}
});
Ok(failures.into_inner().unwrap())
}
pub fn resolve_task_command(project: &kdo_core::Project, task_name: &str) -> Option<String> {
match project.language {
kdo_core::Language::Rust | kdo_core::Language::Anchor => match task_name {
"build" => Some("cargo build".into()),
"test" => Some("cargo test".into()),
"check" => Some("cargo check".into()),
"lint" => Some("cargo clippy".into()),
"fmt" => Some("cargo fmt".into()),
"clean" => Some("cargo clean".into()),
_ => None,
},
kdo_core::Language::TypeScript | kdo_core::Language::JavaScript => {
let pkg_path = project.manifest_path.clone();
if let Ok(content) = std::fs::read_to_string(&pkg_path) {
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
if pkg
.get("scripts")
.and_then(|s| s.get(task_name))
.and_then(|v| v.as_str())
.is_some()
{
let pm = detect_node_pm(&project.path);
return Some(format!("{pm} run {task_name}"));
}
}
}
match task_name {
"build" => Some("npm run build".into()),
"test" => Some("npm test".into()),
"lint" => Some("npm run lint".into()),
"dev" => Some("npm run dev".into()),
_ => None,
}
}
kdo_core::Language::Python => {
let py = detect_python();
match task_name {
"test" => Some(format!("{py} -m pytest")),
"lint" => Some("ruff check .".into()),
"fmt" => Some("ruff format .".into()),
"build" => Some(format!("{py} -c \"print('no build step for Python')\"")),
_ => None,
}
}
kdo_core::Language::Go => match task_name {
"build" => Some("go build ./...".into()),
"test" => Some("go test ./...".into()),
"lint" => Some("golangci-lint run".into()),
"fmt" => Some("gofmt -w .".into()),
"check" => Some("go vet ./...".into()),
_ => None,
},
}
}
pub fn detect_python() -> &'static str {
if std::process::Command::new("python3")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
"python3"
} else {
"python"
}
}
pub fn detect_node_pm(project_dir: &Path) -> &'static str {
if project_dir.join("bun.lockb").exists() || project_dir.join("bun.lock").exists() {
"bun"
} else if project_dir.join("pnpm-lock.yaml").exists() {
"pnpm"
} else if project_dir.join("yarn.lock").exists() {
"yarn"
} else {
"npm"
}
}
fn execute_in_dir(dir: &Path, command: &str, _prefix: &str) -> miette::Result<bool> {
use miette::IntoDiagnostic;
let status = std::process::Command::new("sh")
.args(["-c", command])
.current_dir(dir)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.into_diagnostic()?;
Ok(status.success())
}
fn format_prefix(name: &str, color_idx: usize) -> String {
let colored_name = match PROJECT_COLORS[color_idx] {
"cyan" => name.cyan().bold().to_string(),
"green" => name.green().bold().to_string(),
"yellow" => name.yellow().bold().to_string(),
"magenta" => name.magenta().bold().to_string(),
"blue" => name.blue().bold().to_string(),
"red" => name.red().bold().to_string(),
_ => name.bold().to_string(),
};
format!("[{colored_name}]")
}