use anyhow::{bail, Context, Result};
use console::style;
use serde::Deserialize;
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Instant;
use crate::run::detect_project_type;
#[derive(Debug, Deserialize)]
struct FledgeFileWithLanes {
#[serde(default)]
tasks: BTreeMap<String, TaskDef>,
#[serde(default)]
lanes: BTreeMap<String, LaneDef>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum TaskDef {
Short(String),
Full(TaskConfig),
}
#[derive(Debug, Deserialize)]
struct TaskConfig {
cmd: String,
#[serde(default)]
deps: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
#[serde(default)]
dir: Option<String>,
}
impl TaskDef {
fn cmd(&self) -> &str {
match self {
TaskDef::Short(s) => s,
TaskDef::Full(c) => &c.cmd,
}
}
fn deps(&self) -> &[String] {
match self {
TaskDef::Short(_) => &[],
TaskDef::Full(c) => &c.deps,
}
}
fn env(&self) -> &BTreeMap<String, String> {
static EMPTY: BTreeMap<String, String> = BTreeMap::new();
match self {
TaskDef::Short(_) => &EMPTY,
TaskDef::Full(c) => &c.env,
}
}
fn dir(&self) -> Option<&str> {
match self {
TaskDef::Short(_) => None,
TaskDef::Full(c) => c.dir.as_deref(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct LaneDef {
#[serde(default)]
description: Option<String>,
steps: Vec<Step>,
#[serde(default = "default_true")]
fail_fast: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Step {
TaskRef(String),
Inline { run: String },
Parallel { parallel: Vec<ParallelItem> },
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ParallelItem {
TaskRef(String),
Inline { run: String },
}
pub enum LaneAction {
Run {
name: String,
dry_run: bool,
},
List {
json: bool,
},
Init,
Search {
query: Option<String>,
author: Option<String>,
json: bool,
},
Import {
source: String,
},
}
pub fn run(action: LaneAction) -> Result<()> {
match action {
LaneAction::Search {
query,
author,
json,
} => search_lanes(query.as_deref(), author.as_deref(), json),
LaneAction::Import { source } => import_lanes(&source),
LaneAction::Init => init_lanes(),
LaneAction::List { json } => {
let config = load_lane_config()?;
list_lanes(&config.lanes, json)
}
LaneAction::Run { name, dry_run } => {
let config = load_lane_config()?;
let lane = config.lanes.get(&name).ok_or_else(|| {
let available: Vec<&str> = config.lanes.keys().map(|s| s.as_str()).collect();
anyhow::anyhow!(
"Unknown lane '{}'. Available lanes: {}",
name,
available.join(", ")
)
})?;
if lane.steps.is_empty() {
bail!("Lane '{}' has no steps defined", name);
}
validate_lane(&name, lane, &config.tasks)?;
if dry_run {
dry_run_lane(&name, lane)
} else {
let project_dir = std::env::current_dir().context("getting current directory")?;
execute_lane(&name, lane, &config.tasks, &project_dir)
}
}
}
}
fn load_lane_config() -> Result<FledgeFileWithLanes> {
let project_dir = std::env::current_dir().context("getting current directory")?;
let config_path = project_dir.join("fledge.toml");
if !config_path.exists() {
bail!(
"No fledge.toml found in current directory.\n Run {} to create one.",
style("fledge run --init").cyan()
);
}
let content = std::fs::read_to_string(&config_path).context("reading fledge.toml")?;
let mut config: FledgeFileWithLanes = toml::from_str(&content).map_err(|e| {
let msg = e.to_string();
if msg.contains("lanes") || msg.contains("steps") {
anyhow::anyhow!(
"Error parsing lanes in fledge.toml: {e}\n\n \
Lanes must use table syntax:\n \
[lanes.ci]\n \
steps = [\"lint\", \"test\", \"build\"]\n\n \
Not shorthand like: ci = [\"lint\", \"test\"]"
)
} else {
anyhow::anyhow!("parsing fledge.toml: {e}")
}
})?;
let lanes_dir = project_dir.join(".fledge").join("lanes");
if lanes_dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(&lanes_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "toml") {
let imported_content = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let imported: FledgeFileWithLanes = toml::from_str(&imported_content)
.with_context(|| format!("parsing {}", path.display()))?;
for (name, task) in imported.tasks {
config.tasks.entry(name).or_insert(task);
}
for (name, lane) in imported.lanes {
config.lanes.entry(name).or_insert(lane);
}
}
}
}
}
if config.lanes.is_empty() {
bail!(
"No lanes defined.\n Add lanes to fledge.toml, import with {}, or run {} to add defaults.",
style("fledge lanes import <source>").cyan(),
style("fledge lane init").cyan()
);
}
Ok(config)
}
fn list_lanes(lanes: &BTreeMap<String, LaneDef>, json: bool) -> Result<()> {
if json {
let entries: Vec<serde_json::Value> = lanes
.iter()
.map(|(name, lane)| {
serde_json::json!({
"name": name,
"description": lane.description,
"steps": lane.steps.len(),
"fail_fast": lane.fail_fast,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
println!("{}", style("Available lanes:").bold());
let max_name_len = lanes.keys().map(|k| k.len()).max().unwrap_or(0);
for (name, lane) in lanes {
let desc = lane.description.as_deref().unwrap_or("(no description)");
println!(
" {:<width$} {}",
style(name).green(),
style(desc).dim(),
width = max_name_len
);
}
Ok(())
}
fn validate_lane(lane_name: &str, lane: &LaneDef, tasks: &BTreeMap<String, TaskDef>) -> Result<()> {
for (i, step) in lane.steps.iter().enumerate() {
match step {
Step::TaskRef(name) => {
if !tasks.contains_key(name) {
bail!(
"Lane '{}' step {} references unknown task '{}'.\n Define it in [tasks] first.",
lane_name,
i + 1,
name
);
}
check_dep_cycle(name, tasks, &mut HashSet::new())
.map_err(|e| anyhow::anyhow!("Lane '{}' step {}: {}", lane_name, i + 1, e))?;
}
Step::Inline { .. } => {}
Step::Parallel { parallel } => {
for item in parallel {
if let ParallelItem::TaskRef(name) = item {
if !tasks.contains_key(name) {
bail!(
"Lane '{}' step {} parallel group references unknown task '{}'.\n Define it in [tasks] first.",
lane_name,
i + 1,
name
);
}
check_dep_cycle(name, tasks, &mut HashSet::new()).map_err(|e| {
anyhow::anyhow!("Lane '{}' step {}: {}", lane_name, i + 1, e)
})?;
}
}
}
}
}
Ok(())
}
fn check_dep_cycle(
name: &str,
tasks: &BTreeMap<String, TaskDef>,
visiting: &mut HashSet<String>,
) -> Result<()> {
if !visiting.insert(name.to_string()) {
bail!("circular dependency detected involving task '{}'", name);
}
if let Some(task) = tasks.get(name) {
for dep in task.deps() {
check_dep_cycle(dep, tasks, visiting)?;
}
}
visiting.remove(name);
Ok(())
}
fn dry_run_lane(lane_name: &str, lane: &LaneDef) -> Result<()> {
let desc = lane.description.as_deref().unwrap_or("(no description)");
println!(
"{} {} — {}",
style("Lane:").bold(),
style(lane_name).green(),
style(desc).dim()
);
if !lane.fail_fast {
println!(" {} fail_fast = false", style("⚙").dim());
}
for (i, step) in lane.steps.iter().enumerate() {
match step {
Step::TaskRef(name) => {
println!(
" {}. {} {}",
i + 1,
style(name).cyan(),
style("(task)").dim()
);
}
Step::Inline { run: cmd } => {
println!(
" {}. {} {}",
i + 1,
style(cmd).cyan(),
style("(inline)").dim()
);
}
Step::Parallel { parallel } => {
let names: Vec<String> = parallel
.iter()
.map(|item| match item {
ParallelItem::TaskRef(name) => name.clone(),
ParallelItem::Inline { run: cmd } => format!("run: {cmd}"),
})
.collect();
println!(
" {}. {} {}",
i + 1,
style(names.join(", ")).cyan(),
style("(parallel)").dim()
);
}
}
}
Ok(())
}
fn execute_lane(
lane_name: &str,
lane: &LaneDef,
tasks: &BTreeMap<String, TaskDef>,
project_dir: &Path,
) -> Result<()> {
let desc = lane.description.as_deref().unwrap_or("(no description)");
println!(
"{} {} — {}",
style("▶️ Lane:").cyan().bold(),
style(lane_name).bold(),
style(desc).dim()
);
let total_steps = lane.steps.len();
let mut failures: Vec<String> = Vec::new();
let lane_start = Instant::now();
for (i, step) in lane.steps.iter().enumerate() {
let step_start = Instant::now();
let result = match step {
Step::TaskRef(name) => execute_task_with_deps(name, tasks, project_dir),
Step::Inline { run: cmd } => execute_inline(cmd, project_dir),
Step::Parallel { parallel } => execute_parallel(parallel, tasks, project_dir),
};
let elapsed = step_start.elapsed();
if let Err(e) = result {
let step_desc = match step {
Step::TaskRef(name) => name.clone(),
Step::Inline { run: cmd } => cmd.clone(),
Step::Parallel { parallel } => {
let names: Vec<String> = parallel
.iter()
.map(|item| match item {
ParallelItem::TaskRef(name) => name.clone(),
ParallelItem::Inline { run: cmd } => cmd.clone(),
})
.collect();
format!("parallel({})", names.join(", "))
}
};
if lane.fail_fast {
bail!(
"Lane '{}' failed at step {} ({}) after {}: {}",
lane_name,
i + 1,
step_desc,
format_duration(elapsed),
e
);
}
eprintln!(
" {} Step {} ({}) failed after {}: {}",
style("❌").red().bold(),
i + 1,
step_desc,
format_duration(elapsed),
e
);
failures.push(step_desc);
} else {
println!(
" {} Step {} done {}",
style("✔").green(),
i + 1,
style(format!("({})", format_duration(elapsed))).dim()
);
}
}
let total_elapsed = lane_start.elapsed();
if failures.is_empty() {
println!(
"{} Lane {} completed ({} steps in {})",
style("✅").green().bold(),
style(lane_name).green(),
total_steps,
format_duration(total_elapsed)
);
} else {
bail!(
"Lane '{}' completed with {} failure(s) in {}: {}",
lane_name,
failures.len(),
format_duration(total_elapsed),
failures.join(", ")
);
}
Ok(())
}
fn format_duration(d: std::time::Duration) -> String {
let secs = d.as_secs();
let millis = d.subsec_millis();
if secs >= 60 {
let mins = secs / 60;
let remaining = secs % 60;
format!("{mins}m {remaining}.{millis:03}s")
} else if secs > 0 {
format!("{secs}.{millis:03}s")
} else {
format!("{millis}ms")
}
}
fn execute_task_with_deps(
name: &str,
tasks: &BTreeMap<String, TaskDef>,
project_dir: &Path,
) -> Result<()> {
let mut visited = HashSet::new();
execute_task_recursive(name, tasks, project_dir, &mut visited)
}
fn execute_task_recursive(
name: &str,
tasks: &BTreeMap<String, TaskDef>,
project_dir: &Path,
visited: &mut HashSet<String>,
) -> Result<()> {
if !visited.insert(name.to_string()) {
bail!(
"Circular dependency detected: task '{}' depends on itself (chain: {})",
name,
visited.iter().cloned().collect::<Vec<_>>().join(" → ")
);
}
let task = tasks
.get(name)
.ok_or_else(|| anyhow::anyhow!("Task '{}' not found", name))?;
for dep in task.deps() {
execute_task_recursive(dep, tasks, project_dir, visited)?;
}
execute_single_task(name, task, project_dir)
}
fn execute_single_task(name: &str, task: &TaskDef, project_dir: &Path) -> Result<()> {
println!(
" {} {}",
style("▶️").cyan().bold(),
style(format!("Running task: {name}")).bold()
);
let cmd_str = task.cmd();
let work_dir = match task.dir() {
Some(d) => project_dir.join(d),
None => project_dir.to_path_buf(),
};
let shell = if cfg!(windows) { "cmd" } else { "sh" };
let flag = if cfg!(windows) { "/C" } else { "-c" };
let mut command = Command::new(shell);
command.arg(flag).arg(cmd_str).current_dir(&work_dir);
for (key, value) in task.env() {
command.env(key, value);
}
let status = command
.status()
.with_context(|| format!("running task '{name}'"))?;
if !status.success() {
let code = status.code().unwrap_or(1);
bail!("Task '{}' failed with exit code {}", name, code);
}
Ok(())
}
fn execute_inline(cmd: &str, project_dir: &Path) -> Result<()> {
println!(
" {} {}",
style("▶️").cyan().bold(),
style(format!("Running: {cmd}")).bold()
);
let shell = if cfg!(windows) { "cmd" } else { "sh" };
let flag = if cfg!(windows) { "/C" } else { "-c" };
let status = Command::new(shell)
.arg(flag)
.arg(cmd)
.current_dir(project_dir)
.status()
.with_context(|| format!("running inline command: {cmd}"))?;
if !status.success() {
let code = status.code().unwrap_or(1);
bail!("Inline command failed with exit code {}: {}", code, cmd);
}
Ok(())
}
fn execute_parallel(
items: &[ParallelItem],
tasks: &BTreeMap<String, TaskDef>,
project_dir: &Path,
) -> Result<()> {
let names_display: Vec<String> = items
.iter()
.map(|item| match item {
ParallelItem::TaskRef(name) => name.clone(),
ParallelItem::Inline { run: cmd } => cmd.clone(),
})
.collect();
println!(
" {} {}",
style("▶️").cyan().bold(),
style(format!("Running parallel: {}", names_display.join(", "))).bold()
);
let errors: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
thread::scope(|s| {
let mut handles = Vec::new();
for item in items {
let errors = Arc::clone(&errors);
let handle = s.spawn(move || {
let result = match item {
ParallelItem::TaskRef(name) => execute_task_with_deps(name, tasks, project_dir),
ParallelItem::Inline { run: cmd } => execute_inline(cmd, project_dir),
};
if let Err(e) = result {
let label = match item {
ParallelItem::TaskRef(name) => name.clone(),
ParallelItem::Inline { run: cmd } => cmd.clone(),
};
if let Ok(mut errs) = errors.lock() {
errs.push(format!("{}: {}", label, e));
}
}
});
handles.push(handle);
}
for handle in handles {
let _ = handle.join();
}
});
let errs = errors.lock().unwrap_or_else(|e| e.into_inner());
if !errs.is_empty() {
bail!("Parallel step failed:\n {}", errs.join("\n "));
}
Ok(())
}
fn lane_defaults(project_type: &str) -> &'static str {
match project_type {
"rust" => {
r#"
[lanes.ci]
description = "Run full CI pipeline"
steps = ["fmt", "lint", "test", "build"]
[lanes.check]
description = "Quick quality check"
steps = [
{ parallel = ["fmt", "lint"] },
"test"
]
"#
}
"node" => {
r#"
[lanes.ci]
description = "Run full CI pipeline"
steps = ["lint", "test", "build"]
[lanes.check]
description = "Quick quality check"
steps = [
{ parallel = ["lint", "test"] },
]
"#
}
"go" => {
r#"
[lanes.ci]
description = "Run full CI pipeline"
steps = ["fmt", "lint", "test", "build"]
[lanes.check]
description = "Quick quality check"
steps = [
{ parallel = ["fmt", "lint"] },
"test"
]
"#
}
"python" => {
r#"
[lanes.ci]
description = "Run full CI pipeline"
steps = ["fmt", "lint", "test"]
[lanes.check]
description = "Quick quality check"
steps = [
{ parallel = ["fmt", "lint"] },
"test"
]
"#
}
"swift" => {
r#"
[lanes.ci]
description = "Run full CI pipeline"
steps = ["build", "test"]
"#
}
_ => {
r#"
# [lanes.ci]
# description = "Run full CI pipeline"
# steps = ["lint", "test", "build"]
"#
}
}
}
fn init_lanes() -> Result<()> {
let cwd = std::env::current_dir()?;
let path = cwd.join("fledge.toml");
if !path.exists() {
bail!(
"No fledge.toml found. Run {} first, then add lanes.",
style("fledge run --init").cyan()
);
}
let content = std::fs::read_to_string(&path).context("reading fledge.toml")?;
if content.contains("[lanes") {
bail!("Lanes already defined in fledge.toml. Edit them manually.");
}
let project_type = detect_project_type(&cwd);
let defaults = lane_defaults(project_type);
let new_content = format!("{}{}", content.trim_end(), defaults);
std::fs::write(&path, new_content).context("writing fledge.toml")?;
println!(
"{} Added default lanes to {}",
style("✅").green().bold(),
style("fledge.toml").cyan()
);
println!(" Run {} to see them.", style("fledge lane").cyan());
Ok(())
}
fn search_lanes(keyword: Option<&str>, author: Option<&str>, json: bool) -> Result<()> {
let config = crate::config::Config::load()?;
let token = config.github_token();
let query = crate::search::build_search_query_ex(keyword, author, "fledge-lane");
let sp = crate::spinner::Spinner::start("Searching GitHub for community lanes:");
let body = crate::github::github_api_get(
"/search/repositories",
token.as_deref(),
&[("q", &query), ("sort", "stars"), ("per_page", "30")],
)
.context("searching GitHub for lane repos")?;
sp.finish();
let results = crate::search::parse_search_response(&body)?;
if results.is_empty() {
if json {
println!("[]");
} else {
println!(
"{} No community lanes found{}.",
style("*").cyan().bold(),
keyword
.map(|q| format!(" matching '{q}'"))
.unwrap_or_default()
);
}
return Ok(());
}
if json {
println!("{}", serde_json::to_string_pretty(&results)?);
return Ok(());
}
println!("{}\n", style("Community lanes on GitHub:").bold());
let max_name = results
.iter()
.map(|r| r.full_name().len())
.max()
.unwrap_or(0);
for r in &results {
let stars = crate::search::format_stars(r.stars);
let desc = if r.description.len() > 60 {
format!("{}...", &r.description[..57])
} else {
r.description.clone()
};
let topic_str = if r.topics.is_empty() {
String::new()
} else {
format!(" [{}]", r.topics.join(", "))
};
println!(
" {:<width$} {} {}{}",
style(&r.full_name()).green(),
style(format!("(⭐ {})", stars)).dim(),
style(&desc).dim(),
style(&topic_str).cyan(),
width = max_name,
);
}
println!(
"\n{}",
style("Import with: fledge lane import <owner/repo[/path]>").dim()
);
Ok(())
}
fn import_lanes(source: &str) -> Result<()> {
let cwd = std::env::current_dir()?;
let local_path = cwd.join("fledge.toml");
if !local_path.exists() {
bail!(
"No fledge.toml found. Run {} first.",
style("fledge run --init").cyan()
);
}
let config = crate::config::Config::load()?;
let token = config.github_token();
let (owner, repo, subpath, git_ref) = parse_import_source(source);
let display_source = format!(
"{}/{}{}{}",
owner,
repo,
subpath
.as_ref()
.map(|p| format!("/{p}"))
.unwrap_or_default(),
git_ref
.as_ref()
.map(|r| format!("@{r}"))
.unwrap_or_default()
);
let sp = crate::spinner::Spinner::start(&format!("Fetching lanes from {}:", display_source,));
let ref_param = git_ref.as_deref().unwrap_or("HEAD");
let remote_path = match &subpath {
Some(p) => format!("{p}/fledge.toml"),
None => "fledge.toml".to_string(),
};
let body = crate::github::github_api_get(
&format!("/repos/{owner}/{repo}/contents/{remote_path}"),
token.as_deref(),
&[("ref", ref_param)],
)
.context(format!("fetching {remote_path} from remote repo"))?;
sp.finish();
let content_b64 = body
.get("content")
.and_then(|c| c.as_str())
.ok_or_else(|| anyhow::anyhow!("Remote repo has no fledge.toml or it's not a file"))?;
let cleaned: String = content_b64.chars().filter(|c| !c.is_whitespace()).collect();
let decoded = base64_decode(&cleaned).context("decoding fledge.toml content")?;
let remote_content = String::from_utf8(decoded).context("fledge.toml is not valid UTF-8")?;
let remote_config: FledgeFileWithLanes =
toml::from_str(&remote_content).context("parsing remote fledge.toml")?;
if remote_config.lanes.is_empty() {
bail!("Remote repo has no [lanes] defined in fledge.toml.");
}
let existing = load_lane_config()?;
let mut imported_lanes = Vec::new();
let mut skipped = Vec::new();
let mut import_content = String::new();
import_content.push_str(&format!("# Imported from {display_source}\n\n"));
for (task_name, task_def) in &remote_config.tasks {
if existing.tasks.contains_key(task_name) {
continue;
}
let cmd = task_def.cmd();
import_content.push_str(&format!("[tasks.{task_name}]\ncmd = \"{cmd}\"\n\n"));
}
for (lane_name, lane) in &remote_config.lanes {
if existing.lanes.contains_key(lane_name) {
skipped.push(lane_name.clone());
continue;
}
import_content.push_str(&format_lane_toml(lane_name, lane));
imported_lanes.push(lane_name.clone());
}
if imported_lanes.is_empty() {
println!(
"{} All lanes from {} already exist locally ({})",
style("*").cyan().bold(),
display_source,
skipped.join(", ")
);
return Ok(());
}
let lanes_dir = cwd.join(".fledge").join("lanes");
std::fs::create_dir_all(&lanes_dir).context("creating .fledge/lanes directory")?;
let safe_name = format!(
"{}-{}{}",
owner.to_lowercase(),
repo.to_lowercase(),
subpath
.as_ref()
.map(|p| format!("-{}", p.replace('/', "-").to_lowercase()))
.unwrap_or_default()
);
let import_path = lanes_dir.join(format!("{safe_name}.toml"));
std::fs::write(&import_path, import_content.trim_start()).context("writing imported lanes")?;
println!(
"{} Imported {} lane(s) from {}",
style("✅").green().bold(),
imported_lanes.len(),
display_source
);
for name in &imported_lanes {
println!(" {} {}", style("+").green(), style(name).cyan());
}
println!(
" {} Saved to {}",
style("→").dim(),
style(format!(".fledge/lanes/{safe_name}.toml")).cyan()
);
if !skipped.is_empty() {
println!(
" {} Skipped (already exist): {}",
style("*").dim(),
skipped.join(", ")
);
}
Ok(())
}
fn parse_import_source(source: &str) -> (String, String, Option<String>, Option<String>) {
let source = source
.strip_prefix("https://github.com/")
.unwrap_or(source)
.trim_end_matches(".git");
let (path, git_ref) = if let Some((p, r)) = source.split_once('@') {
(p, Some(r.to_string()))
} else {
(source, None)
};
let parts: Vec<&str> = path.splitn(3, '/').collect();
let owner = parts.first().unwrap_or(&"").to_string();
let repo = parts.get(1).unwrap_or(&"").to_string();
let subpath = parts
.get(2)
.map(|s| s.to_string())
.filter(|s| !s.is_empty());
(owner, repo, subpath, git_ref)
}
fn format_lane_toml(name: &str, lane: &LaneDef) -> String {
let mut out = format!("\n[lanes.{}]\n", name);
if let Some(ref desc) = lane.description {
out.push_str(&format!("description = \"{}\"\n", desc));
}
if !lane.fail_fast {
out.push_str("fail_fast = false\n");
}
out.push_str("steps = [");
for (i, step) in lane.steps.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
match step {
Step::TaskRef(name) => {
out.push_str(&format!("\"{}\"", name));
}
Step::Inline { run: cmd } => {
out.push_str(&format!("{{ run = \"{}\" }}", cmd));
}
Step::Parallel { parallel } => {
let items: Vec<String> = parallel
.iter()
.map(|item| match item {
ParallelItem::TaskRef(name) => format!("\"{}\"", name),
ParallelItem::Inline { run: cmd } => {
format!("{{ run = \"{}\" }}", cmd)
}
})
.collect();
out.push_str(&format!("{{ parallel = [{}] }}", items.join(", ")));
}
}
}
out.push_str("]\n");
out
}
fn base64_decode(input: &str) -> Result<Vec<u8>> {
let mut output = Vec::with_capacity(input.len() * 3 / 4);
let mut buf: u32 = 0;
let mut bits: u32 = 0;
for c in input.chars() {
let val = match c {
'A'..='Z' => c as u32 - b'A' as u32,
'a'..='z' => c as u32 - b'a' as u32 + 26,
'0'..='9' => c as u32 - b'0' as u32 + 52,
'+' => 62,
'/' => 63,
'=' => break,
_ => continue,
};
buf = (buf << 6) | val;
bits += 6;
if bits >= 8 {
bits -= 8;
output.push((buf >> bits) as u8);
buf &= (1 << bits) - 1;
}
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_config(toml_str: &str) -> FledgeFileWithLanes {
toml::from_str(toml_str).unwrap()
}
#[test]
fn parse_sequential_lane() {
let config = parse_config(
r#"
[tasks]
lint = "cargo clippy"
test = "cargo test"
build = "cargo build"
[lanes.ci]
description = "CI pipeline"
steps = ["lint", "test", "build"]
"#,
);
assert_eq!(config.lanes.len(), 1);
assert_eq!(config.lanes["ci"].steps.len(), 3);
assert!(config.lanes["ci"].fail_fast);
}
#[test]
fn parse_inline_step() {
let config = parse_config(
r#"
[tasks]
test = "cargo test"
[lanes.release]
description = "Release"
steps = [
"test",
{ run = "cargo build --release" },
]
"#,
);
assert_eq!(config.lanes["release"].steps.len(), 2);
match &config.lanes["release"].steps[1] {
Step::Inline { run: cmd } => assert_eq!(cmd, "cargo build --release"),
_ => panic!("expected inline step"),
}
}
#[test]
fn parse_parallel_step() {
let config = parse_config(
r#"
[tasks]
lint = "cargo clippy"
fmt = "cargo fmt --check"
test = "cargo test"
[lanes.check]
description = "Quick check"
steps = [
{ parallel = ["lint", "fmt"] },
"test"
]
"#,
);
assert_eq!(config.lanes["check"].steps.len(), 2);
match &config.lanes["check"].steps[0] {
Step::Parallel { parallel } => {
assert_eq!(parallel.len(), 2);
assert!(matches!(¶llel[0], ParallelItem::TaskRef(n) if n == "lint"));
assert!(matches!(¶llel[1], ParallelItem::TaskRef(n) if n == "fmt"));
}
_ => panic!("expected parallel step"),
}
}
#[test]
fn parse_fail_fast_false() {
let config = parse_config(
r#"
[tasks]
a = "echo a"
b = "echo b"
[lanes.audit]
description = "Audit"
fail_fast = false
steps = ["a", "b"]
"#,
);
assert!(!config.lanes["audit"].fail_fast);
}
#[test]
fn parse_fail_fast_default_true() {
let config = parse_config(
r#"
[tasks]
a = "echo a"
[lanes.ci]
steps = ["a"]
"#,
);
assert!(config.lanes["ci"].fail_fast);
}
#[test]
fn validate_unknown_task_ref() {
let config = parse_config(
r#"
[tasks]
lint = "cargo clippy"
[lanes.ci]
steps = ["lint", "nonexistent"]
"#,
);
let result = validate_lane("ci", &config.lanes["ci"], &config.tasks);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("nonexistent"));
}
#[test]
fn validate_unknown_parallel_ref() {
let config = parse_config(
r#"
[tasks]
lint = "cargo clippy"
[lanes.check]
steps = [{ parallel = ["lint", "ghost"] }]
"#,
);
let result = validate_lane("check", &config.lanes["check"], &config.tasks);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("ghost"));
}
#[test]
fn validate_inline_always_ok() {
let config = parse_config(
r#"
[tasks]
[lanes.ci]
steps = [{ run = "echo hello" }]
"#,
);
let result = validate_lane("ci", &config.lanes["ci"], &config.tasks);
assert!(result.is_ok());
}
#[test]
fn validate_all_valid_refs() {
let config = parse_config(
r#"
[tasks]
lint = "cargo clippy"
test = "cargo test"
build = "cargo build"
[lanes.ci]
steps = ["lint", "test", "build"]
"#,
);
let result = validate_lane("ci", &config.lanes["ci"], &config.tasks);
assert!(result.is_ok());
}
#[test]
fn validate_circular_deps() {
let config = parse_config(
r#"
[tasks.a]
cmd = "echo a"
deps = ["b"]
[tasks.b]
cmd = "echo b"
deps = ["a"]
[lanes.ci]
steps = ["a"]
"#,
);
let result = validate_lane("ci", &config.lanes["ci"], &config.tasks);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("circular"),
"expected circular error, got: {err}"
);
}
#[test]
fn validate_no_cycle_with_shared_deps() {
let config = parse_config(
r#"
[tasks]
common = "echo common"
[tasks.a]
cmd = "echo a"
deps = ["common"]
[tasks.b]
cmd = "echo b"
deps = ["common"]
[lanes.ci]
steps = ["a", "b"]
"#,
);
let result = validate_lane("ci", &config.lanes["ci"], &config.tasks);
assert!(result.is_ok());
}
#[test]
fn parse_multiple_lanes() {
let config = parse_config(
r#"
[tasks]
lint = "cargo clippy"
test = "cargo test"
build = "cargo build"
[lanes.ci]
description = "CI"
steps = ["lint", "test", "build"]
[lanes.quick]
description = "Quick"
steps = ["lint"]
"#,
);
assert_eq!(config.lanes.len(), 2);
assert!(config.lanes.contains_key("ci"));
assert!(config.lanes.contains_key("quick"));
}
#[test]
fn parse_no_lanes_section() {
let config = parse_config(
r#"
[tasks]
build = "cargo build"
"#,
);
assert!(config.lanes.is_empty());
}
#[test]
fn parse_empty_lanes_section() {
let config = parse_config(
r#"
[tasks]
build = "cargo build"
[lanes]
"#,
);
assert!(config.lanes.is_empty());
}
#[test]
fn parse_mixed_step_types() {
let config = parse_config(
r#"
[tasks]
test = "cargo test"
lint = "cargo clippy"
[lanes.full]
steps = [
"test",
{ run = "echo done" },
{ parallel = ["test", "lint"] },
]
"#,
);
assert_eq!(config.lanes["full"].steps.len(), 3);
assert!(matches!(&config.lanes["full"].steps[0], Step::TaskRef(_)));
assert!(matches!(
&config.lanes["full"].steps[1],
Step::Inline { .. }
));
assert!(matches!(
&config.lanes["full"].steps[2],
Step::Parallel { .. }
));
}
#[test]
fn execute_sequential_lane_echo() {
let config = parse_config(
r#"
[tasks]
a = "echo step-a"
b = "echo step-b"
[lanes.seq]
description = "Sequential"
steps = ["a", "b"]
"#,
);
let project_dir = std::env::current_dir().unwrap();
let result = execute_lane("seq", &config.lanes["seq"], &config.tasks, &project_dir);
assert!(result.is_ok());
}
#[test]
fn execute_inline_step() {
let config = parse_config(
r#"
[tasks]
[lanes.inline]
steps = [{ run = "echo inline-works" }]
"#,
);
let project_dir = std::env::current_dir().unwrap();
let result = execute_lane(
"inline",
&config.lanes["inline"],
&config.tasks,
&project_dir,
);
assert!(result.is_ok());
}
#[test]
fn execute_parallel_step() {
let config = parse_config(
r#"
[tasks]
a = "echo parallel-a"
b = "echo parallel-b"
[lanes.par]
steps = [{ parallel = ["a", "b"] }]
"#,
);
let project_dir = std::env::current_dir().unwrap();
let result = execute_lane("par", &config.lanes["par"], &config.tasks, &project_dir);
assert!(result.is_ok());
}
#[test]
fn parse_parallel_inline_items() {
let config = parse_config(
r#"
[tasks]
lint = "cargo clippy"
[lanes.mixed]
description = "Mixed parallel"
steps = [
{ parallel = ["lint", { run = "echo inline" }] },
]
"#,
);
match &config.lanes["mixed"].steps[0] {
Step::Parallel { parallel } => {
assert_eq!(parallel.len(), 2);
assert!(matches!(¶llel[0], ParallelItem::TaskRef(n) if n == "lint"));
assert!(
matches!(¶llel[1], ParallelItem::Inline { run } if run == "echo inline")
);
}
_ => panic!("expected parallel step"),
}
}
#[test]
fn execute_parallel_with_inline() {
let config = parse_config(
r#"
[tasks]
a = "echo task-a"
[lanes.mixed]
steps = [{ parallel = ["a", { run = "echo inline-b" }] }]
"#,
);
let project_dir = std::env::current_dir().unwrap();
let result = execute_lane("mixed", &config.lanes["mixed"], &config.tasks, &project_dir);
assert!(result.is_ok());
}
#[test]
fn execute_parallel_all_inline() {
let config = parse_config(
r#"
[tasks]
[lanes.inlines]
steps = [{ parallel = [{ run = "echo one" }, { run = "echo two" }] }]
"#,
);
let project_dir = std::env::current_dir().unwrap();
let result = execute_lane(
"inlines",
&config.lanes["inlines"],
&config.tasks,
&project_dir,
);
assert!(result.is_ok());
}
#[test]
fn validate_parallel_inline_no_task_check() {
let config = parse_config(
r#"
[tasks]
[lanes.ci]
steps = [{ parallel = [{ run = "echo hello" }] }]
"#,
);
let result = validate_lane("ci", &config.lanes["ci"], &config.tasks);
assert!(result.is_ok());
}
#[test]
fn format_lane_toml_with_parallel_inline() {
let lane = LaneDef {
description: None,
steps: vec![Step::Parallel {
parallel: vec![
ParallelItem::TaskRef("lint".to_string()),
ParallelItem::Inline {
run: "echo done".to_string(),
},
],
}],
fail_fast: true,
};
let toml = format_lane_toml("mixed", &lane);
assert!(toml.contains("\"lint\""));
assert!(toml.contains("{ run = \"echo done\" }"));
}
#[test]
fn execute_fail_fast_stops() {
let config = parse_config(
r#"
[tasks]
fail = "exit 1"
ok = "echo ok"
[lanes.ff]
fail_fast = true
steps = ["fail", "ok"]
"#,
);
let project_dir = std::env::current_dir().unwrap();
let result = execute_lane("ff", &config.lanes["ff"], &config.tasks, &project_dir);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("failed at step 1"));
}
#[test]
fn execute_no_fail_fast_continues() {
let config = parse_config(
r#"
[tasks]
fail = "exit 1"
ok = "echo ok"
[lanes.noff]
fail_fast = false
steps = ["fail", "ok"]
"#,
);
let project_dir = std::env::current_dir().unwrap();
let result = execute_lane("noff", &config.lanes["noff"], &config.tasks, &project_dir);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("1 failure"));
}
#[test]
fn execute_task_deps_in_lane() {
let config = parse_config(
r#"
[tasks.build]
cmd = "echo building"
deps = ["prep"]
[tasks.prep]
cmd = "echo preparing"
[lanes.ci]
steps = ["build"]
"#,
);
let project_dir = std::env::current_dir().unwrap();
let result = execute_lane("ci", &config.lanes["ci"], &config.tasks, &project_dir);
assert!(result.is_ok());
}
#[test]
fn lane_defaults_are_valid_toml() {
for project_type in &["rust", "node", "go", "python", "generic"] {
let tasks = match *project_type {
"rust" => {
"[tasks]\nfmt = \"cargo fmt\"\nlint = \"cargo clippy\"\ntest = \"cargo test\"\nbuild = \"cargo build\"\ntypecheck = \"echo ok\"\n"
}
"node" => {
"[tasks]\nlint = \"echo lint\"\ntest = \"echo test\"\nbuild = \"echo build\"\n"
}
"go" => {
"[tasks]\nfmt = \"echo fmt\"\nlint = \"echo lint\"\ntest = \"echo test\"\nbuild = \"echo build\"\n"
}
"python" => {
"[tasks]\nfmt = \"echo fmt\"\nlint = \"echo lint\"\ntypecheck = \"echo tc\"\ntest = \"echo test\"\n"
}
_ => {
"[tasks]\nlint = \"echo lint\"\ntest = \"echo test\"\nbuild = \"echo build\"\n"
}
};
let defaults = lane_defaults(project_type);
let toml_str = format!("{}{}", tasks, defaults);
let result: Result<FledgeFileWithLanes, _> = toml::from_str(&toml_str);
assert!(
result.is_ok(),
"Invalid TOML for {}: {:?}",
project_type,
result.err()
);
}
}
#[test]
fn parse_import_source_basic() {
let (owner, repo, subpath, git_ref) = parse_import_source("CorvidLabs/fledge-lanes");
assert_eq!(owner, "CorvidLabs");
assert_eq!(repo, "fledge-lanes");
assert!(subpath.is_none());
assert!(git_ref.is_none());
}
#[test]
fn parse_import_source_with_ref() {
let (owner, repo, subpath, git_ref) = parse_import_source("CorvidLabs/fledge-lanes@v1.0.0");
assert_eq!(owner, "CorvidLabs");
assert_eq!(repo, "fledge-lanes");
assert!(subpath.is_none());
assert_eq!(git_ref.unwrap(), "v1.0.0");
}
#[test]
fn parse_import_source_with_subpath() {
let (owner, repo, subpath, git_ref) = parse_import_source("CorvidLabs/fledge-lanes/rust");
assert_eq!(owner, "CorvidLabs");
assert_eq!(repo, "fledge-lanes");
assert_eq!(subpath.unwrap(), "rust");
assert!(git_ref.is_none());
}
#[test]
fn parse_import_source_with_subpath_and_ref() {
let (owner, repo, subpath, git_ref) =
parse_import_source("CorvidLabs/fledge-lanes/rust@main");
assert_eq!(owner, "CorvidLabs");
assert_eq!(repo, "fledge-lanes");
assert_eq!(subpath.unwrap(), "rust");
assert_eq!(git_ref.unwrap(), "main");
}
#[test]
fn parse_import_source_full_url() {
let (owner, repo, subpath, git_ref) =
parse_import_source("https://github.com/CorvidLabs/fledge-lanes.git");
assert_eq!(owner, "CorvidLabs");
assert_eq!(repo, "fledge-lanes");
assert!(subpath.is_none());
assert!(git_ref.is_none());
}
#[test]
fn parse_import_source_url_with_ref() {
let (owner, repo, subpath, git_ref) =
parse_import_source("https://github.com/CorvidLabs/fledge-lanes@main");
assert_eq!(owner, "CorvidLabs");
assert_eq!(repo, "fledge-lanes");
assert!(subpath.is_none());
assert_eq!(git_ref.unwrap(), "main");
}
#[test]
fn format_lane_toml_sequential() {
let lane = LaneDef {
description: Some("CI pipeline".to_string()),
steps: vec![
Step::TaskRef("lint".to_string()),
Step::TaskRef("test".to_string()),
],
fail_fast: true,
};
let toml = format_lane_toml("ci", &lane);
assert!(toml.contains("[lanes.ci]"));
assert!(toml.contains("description = \"CI pipeline\""));
assert!(toml.contains("\"lint\""));
assert!(toml.contains("\"test\""));
assert!(!toml.contains("fail_fast"));
}
#[test]
fn format_lane_toml_with_fail_fast_false() {
let lane = LaneDef {
description: None,
steps: vec![Step::TaskRef("audit".to_string())],
fail_fast: false,
};
let toml = format_lane_toml("audit", &lane);
assert!(toml.contains("fail_fast = false"));
}
#[test]
fn format_lane_toml_with_inline() {
let lane = LaneDef {
description: None,
steps: vec![Step::Inline {
run: "echo hello".to_string(),
}],
fail_fast: true,
};
let toml = format_lane_toml("test", &lane);
assert!(toml.contains("{ run = \"echo hello\" }"));
}
#[test]
fn format_lane_toml_with_parallel() {
let lane = LaneDef {
description: None,
steps: vec![Step::Parallel {
parallel: vec![
ParallelItem::TaskRef("lint".to_string()),
ParallelItem::TaskRef("fmt".to_string()),
],
}],
fail_fast: true,
};
let toml = format_lane_toml("check", &lane);
assert!(toml.contains("parallel"));
assert!(toml.contains("\"lint\""));
assert!(toml.contains("\"fmt\""));
}
#[test]
fn format_lane_toml_roundtrips() {
let lane = LaneDef {
description: Some("Full CI".to_string()),
steps: vec![
Step::TaskRef("lint".to_string()),
Step::TaskRef("test".to_string()),
Step::TaskRef("build".to_string()),
],
fail_fast: true,
};
let toml_str = format!(
"[tasks]\nlint = \"echo lint\"\ntest = \"echo test\"\nbuild = \"echo build\"\n{}",
format_lane_toml("ci", &lane)
);
let parsed: FledgeFileWithLanes = toml::from_str(&toml_str).unwrap();
assert!(parsed.lanes.contains_key("ci"));
assert_eq!(parsed.lanes["ci"].steps.len(), 3);
}
#[test]
fn base64_decode_basic() {
let encoded = "SGVsbG8gV29ybGQ=";
let decoded = base64_decode(encoded).unwrap();
assert_eq!(String::from_utf8(decoded).unwrap(), "Hello World");
}
#[test]
fn base64_decode_no_padding() {
let encoded = "Zm9v";
let decoded = base64_decode(encoded).unwrap();
assert_eq!(String::from_utf8(decoded).unwrap(), "foo");
}
#[test]
fn base64_decode_empty() {
let decoded = base64_decode("").unwrap();
assert!(decoded.is_empty());
}
#[test]
fn base64_decode_with_newlines() {
let encoded = "SGVs\nbG8=";
let cleaned: String = encoded.chars().filter(|c| !c.is_whitespace()).collect();
let decoded = base64_decode(&cleaned).unwrap();
assert_eq!(String::from_utf8(decoded).unwrap(), "Hello");
}
#[test]
fn format_duration_millis() {
let d = std::time::Duration::from_millis(42);
assert_eq!(format_duration(d), "42ms");
}
#[test]
fn format_duration_seconds() {
let d = std::time::Duration::from_millis(3456);
assert_eq!(format_duration(d), "3.456s");
}
#[test]
fn format_duration_minutes() {
let d = std::time::Duration::from_secs(125) + std::time::Duration::from_millis(100);
assert_eq!(format_duration(d), "2m 5.100s");
}
#[test]
fn format_duration_zero() {
let d = std::time::Duration::from_millis(0);
assert_eq!(format_duration(d), "0ms");
}
#[test]
fn merge_imported_lanes() {
let mut base = parse_config(
r#"
[tasks]
lint = "cargo clippy"
[lanes.ci]
steps = ["lint"]
"#,
);
let imported = parse_config(
r#"
[tasks]
lint = "overridden"
test = "cargo test"
[lanes.ci]
steps = ["lint", "test"]
[lanes.deploy]
steps = ["test"]
"#,
);
for (name, task) in imported.tasks {
base.tasks.entry(name).or_insert(task);
}
for (name, lane) in imported.lanes {
base.lanes.entry(name).or_insert(lane);
}
assert_eq!(base.tasks["lint"].cmd(), "cargo clippy");
assert_eq!(base.tasks["test"].cmd(), "cargo test");
assert_eq!(base.lanes["ci"].steps.len(), 1);
assert!(base.lanes.contains_key("deploy"));
}
}