From caff5e9c82066505dd7bfc8b554830666cd79deb Mon Sep 17 00:00:00 2001 From: Anna <0xrelogic@gmail.com> Date: Sat, 26 Sep 2026 19:31:18 +0000 Subject: [PATCH] feat: add --filter flag to forge run with validation, tests, and docs - Add --filter flag to forge run for stage () and step (.) filtering - Automatically resolve and include stage dependencies (DAG) - Validate filter targets and produce user-friendly errors with available options - Add comprehensive unit and integration test coverage - Update README and usage documentation Closes #69, Closes #70, Closes #71, Closes #72, Closes #73 --- README.md | 6 + docs/usage.md | 13 ++ src/config/mod.rs | 316 +++++++++++++++++++++++++++++++++++++ src/main.rs | 78 +++++---- tests/integration_tests.rs | 154 ++++++++++++++++++ 5 files changed, 526 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 920e1b9..fe764bb 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,12 @@ forge validate # Run the pipeline forge run +# Run a specific stage (includes its dependencies) +forge run --filter test + +# Run a specific step in a stage +forge run --filter test.unit-tests + # Machine-readable JSON output forge run --format json diff --git a/docs/usage.md b/docs/usage.md index 61a1a05..9c66c7e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -88,6 +88,19 @@ forge run --stage build When `--stage` is used, FORGE runs the selected stage and any stages it depends on. +Filter pipeline execution by stage or step: + +```bash +# Run a specific stage and its dependencies +forge run --filter test + +# Run only a specific step within a stage +forge run --filter test.unit-tests +``` + +When `--filter ` is used, FORGE resolves and executes that stage along with any stages it depends on. +When `--filter .` is used, FORGE runs only that specific step. If the filter target stage or step does not exist, FORGE displays available options and exits with error code 2. + Enable or disable caching: ```bash diff --git a/src/config/mod.rs b/src/config/mod.rs index 193fda7..c9ef508 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -194,3 +194,319 @@ pub fn validate_parallel_stages( Ok(()) } + +/// Target filter specification for selective pipeline execution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FilterTarget { + /// Filter to run a specific stage (and its transitive dependencies). + Stage(String), + /// Filter to run a specific step within a specific stage. + Step { stage: String, step: String }, +} + +impl FilterTarget { + /// Parses a filter string such as "stage" or "stage.step". + pub fn parse(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err("Filter string cannot be empty".to_string()); + } + + if let Some((stage, step)) = trimmed.split_once('.') { + let stage = stage.trim(); + let step = step.trim(); + if stage.is_empty() { + return Err("Stage name before '.' cannot be empty".to_string()); + } + if step.is_empty() { + return Err(format!( + "Step name after '.' cannot be empty in filter '{}'", + trimmed + )); + } + Ok(Self::Step { + stage: stage.to_string(), + step: step.to_string(), + }) + } else { + Ok(Self::Stage(trimmed.to_string())) + } + } +} + +/// Filters the pipeline to the specified stage or step, preserving dependencies. +pub fn apply_filter( + config: &mut ForgeConfig, + filter: &FilterTarget, +) -> Result<(), Box> { + let available_stages: Vec = config.stages.iter().map(|s| s.name.clone()).collect(); + let stage_map: HashMap = + config.stages.iter().map(|s| (s.name.clone(), s)).collect(); + + let target_stage_name = match filter { + FilterTarget::Stage(name) => name, + FilterTarget::Step { stage, .. } => stage, + }; + + if !stage_map.contains_key(target_stage_name) { + let available_str = if available_stages.is_empty() { + "none".to_string() + } else { + available_stages.join(", ") + }; + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "stage '{}' not found. Available stages: {}", + target_stage_name, available_str + ), + ))); + } + + if let FilterTarget::Step { stage, step } = filter { + let target_stage = stage_map.get(stage).unwrap(); + let available_steps: Vec = target_stage + .steps + .iter() + .map(|st| st.name.clone()) + .filter(|n| !n.is_empty()) + .collect(); + + let step_exists = target_stage.steps.iter().any(|st| st.name == *step); + if !step_exists { + let available_str = if available_steps.is_empty() { + "none".to_string() + } else { + available_steps.join(", ") + }; + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "step '{}' not found in stage '{}'. Available steps: {}", + step, stage, available_str + ), + ))); + } + } + + // Resolve dependencies for target stage + let mut required = HashSet::new(); + let mut stack = vec![target_stage_name.clone()]; + + while let Some(current) = stack.pop() { + if !required.insert(current.clone()) { + continue; + } + let s = stage_map.get(¤t).ok_or_else(|| { + Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "Stage '{}' depends on '{}', but '{}' is not defined.\nAvailable stages: {}", + target_stage_name, + current, + current, + available_stages.join(", ") + ), + )) + })?; + for dep in &s.depends_on { + stack.push(dep.clone()); + } + } + + // Retain only required stages + config.stages.retain(|s| required.contains(&s.name)); + + // If filtering to a specific step, filter that stage's steps + if let FilterTarget::Step { stage, step } = filter { + for s in &mut config.stages { + if s.name == *stage { + s.steps.retain(|st| st.name == *step); + // When running a single step, disable parallel flag if it was set + s.parallel = false; + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filter_target_parse() { + assert_eq!( + FilterTarget::parse("test").unwrap(), + FilterTarget::Stage("test".to_string()) + ); + assert_eq!( + FilterTarget::parse("test.unit-test").unwrap(), + FilterTarget::Step { + stage: "test".to_string(), + step: "unit-test".to_string() + } + ); + assert!(FilterTarget::parse("").is_err()); + assert!(FilterTarget::parse(" ").is_err()); + assert!(FilterTarget::parse(".unit").is_err()); + assert!(FilterTarget::parse("test.").is_err()); + } + + #[test] + fn test_apply_filter_stage_with_dependencies() { + let mut config = ForgeConfig { + version: "1.0".to_string(), + stages: vec![ + Stage { + name: "setup".to_string(), + steps: vec![Step { + name: "init".to_string(), + command: "echo init".to_string(), + image: "".to_string(), + working_dir: "".to_string(), + env: HashMap::new(), + depends_on: vec![], + }], + parallel: false, + depends_on: vec![], + }, + Stage { + name: "build".to_string(), + steps: vec![Step { + name: "compile".to_string(), + command: "echo compile".to_string(), + image: "".to_string(), + working_dir: "".to_string(), + env: HashMap::new(), + depends_on: vec![], + }], + parallel: false, + depends_on: vec!["setup".to_string()], + }, + Stage { + name: "deploy".to_string(), + steps: vec![Step { + name: "push".to_string(), + command: "echo push".to_string(), + image: "".to_string(), + working_dir: "".to_string(), + env: HashMap::new(), + depends_on: vec![], + }], + parallel: false, + depends_on: vec!["build".to_string()], + }, + ], + steps: vec![], + cache: CacheConfig::default(), + secrets: vec![], + }; + + let filter = FilterTarget::Stage("build".to_string()); + apply_filter(&mut config, &filter).unwrap(); + + let stage_names: Vec = config.stages.iter().map(|s| s.name.clone()).collect(); + assert_eq!(stage_names, vec!["setup", "build"]); + } + + #[test] + fn test_apply_filter_step() { + let mut config = ForgeConfig { + version: "1.0".to_string(), + stages: vec![Stage { + name: "test".to_string(), + steps: vec![ + Step { + name: "unit".to_string(), + command: "cargo test --lib".to_string(), + image: "".to_string(), + working_dir: "".to_string(), + env: HashMap::new(), + depends_on: vec![], + }, + Step { + name: "integration".to_string(), + command: "cargo test --test it".to_string(), + image: "".to_string(), + working_dir: "".to_string(), + env: HashMap::new(), + depends_on: vec![], + }, + ], + parallel: true, + depends_on: vec![], + }], + steps: vec![], + cache: CacheConfig::default(), + secrets: vec![], + }; + + let filter = FilterTarget::Step { + stage: "test".to_string(), + step: "unit".to_string(), + }; + apply_filter(&mut config, &filter).unwrap(); + + assert_eq!(config.stages.len(), 1); + assert_eq!(config.stages[0].steps.len(), 1); + assert_eq!(config.stages[0].steps[0].name, "unit"); + assert!(!config.stages[0].parallel); + } + + #[test] + fn test_apply_filter_nonexistent_stage() { + let mut config = ForgeConfig { + version: "1.0".to_string(), + stages: vec![Stage { + name: "build".to_string(), + steps: vec![], + parallel: false, + depends_on: vec![], + }], + steps: vec![], + cache: CacheConfig::default(), + secrets: vec![], + }; + + let filter = FilterTarget::Stage("unknown".to_string()); + let err = apply_filter(&mut config, &filter).unwrap_err(); + assert!(err.to_string().contains("stage 'unknown' not found")); + assert!(err.to_string().contains("Available stages: build")); + } + + #[test] + fn test_apply_filter_nonexistent_step() { + let mut config = ForgeConfig { + version: "1.0".to_string(), + stages: vec![Stage { + name: "test".to_string(), + steps: vec![Step { + name: "unit".to_string(), + command: "echo test".to_string(), + image: "".to_string(), + working_dir: "".to_string(), + env: HashMap::new(), + depends_on: vec![], + }], + parallel: false, + depends_on: vec![], + }], + steps: vec![], + cache: CacheConfig::default(), + secrets: vec![], + }; + + let filter = FilterTarget::Step { + stage: "test".to_string(), + step: "integration".to_string(), + }; + let err = apply_filter(&mut config, &filter).unwrap_err(); + assert!( + err.to_string() + .contains("step 'integration' not found in stage 'test'") + ); + assert!(err.to_string().contains("Available steps: unit")); + } +} diff --git a/src/main.rs b/src/main.rs index 8ca2710..f748199 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,7 @@ use cache::{compute_cache_key, default_cache_dir, ensure_git_excludes_forge_dir} use chrono::Utc; use clap::{Parser, Subcommand}; use colored::*; -use config::{Stage, read_forge_config, validate_parallel_stages}; +use config::{FilterTarget, Stage, apply_filter, read_forge_config, validate_parallel_stages}; use logger::Timer; use output::{write_human_summary, write_json, write_junit}; use persist::{RunMetadata, persist_run, read_git_info}; @@ -27,7 +27,7 @@ use runner::{ resolve_stage_dependencies, run_command_in_container, run_stage_parallel, }; use secrets::collect_secrets_env; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::env; use std::fs::File; use std::path::Path; @@ -79,6 +79,10 @@ enum Commands { # Run only the 'build' stage forge run --stage build + # Run with a filter (stage or stage.step) + forge run --filter test + forge run --filter test.cargo-test + # Run with verbose output and caching disabled forge run --verbose --no-cache @@ -114,6 +118,13 @@ enum Commands { #[arg(short, long)] stage: Option, + #[arg( + long, + help = "Filter pipeline execution by stage ('') or step ('.')", + conflicts_with = "stage" + )] + filter: Option, + #[arg( long, help = "Validate pipeline and print what would run, without execution" @@ -306,6 +317,7 @@ async fn forge_main() -> Result<(), Box> { cache, no_cache, stage, + filter, dry_run, tui, format, @@ -431,49 +443,33 @@ async fn forge_main() -> Result<(), Box> { }); } - // Stage filtering - if let Some(stage_name) = stage { - let available_stages: Vec = - config.stages.iter().map(|s| s.name.clone()).collect(); - - let stage_map: HashMap = - config.stages.iter().map(|s| (s.name.clone(), s)).collect(); - - if stage_map.contains_key(&stage_name) { - let mut required = HashSet::new(); - let mut stack = vec![stage_name.clone()]; - - while let Some(current) = stack.pop() { - if !required.insert(current.clone()) { - continue; - } - let s = stage_map.get(¤t).ok_or_else(|| { - Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!( - "Stage '{}' depends on '{}', but '{}' is not defined.\nAvailable stages: {}", - stage_name, current, current, available_stages.join(", ") - ), - )) - })?; - for dep in &s.depends_on { - stack.push(dep.clone()); - } + // Target filtering (--filter or --stage) + let active_filter = if let Some(ref filter_str) = filter { + match FilterTarget::parse(filter_str) { + Ok(target) => Some(target), + Err(e) => { + let reason = FailureReason::ConfigError { + message: format!("Invalid filter '{filter_str}': {e}"), + }; + let code = ExitCode::from_failure_reason(&reason); + eprintln!( + "{}", + format!("Error: {}", reason.short_description()) + .red() + .bold() + ); + std::process::exit(code.as_i32()); } - config.stages.retain(|s| required.contains(&s.name)); } + } else { + stage.as_ref().map(|s| FilterTarget::Stage(s.clone())) + }; - if config.stages.is_empty() { + if let Some(target) = active_filter { + let filter_result = apply_filter(&mut config, &target); + if let Err(e) = filter_result { let reason = FailureReason::ConfigError { - message: format!( - "Stage '{}' not found. Available: {}", - stage_name, - if available_stages.is_empty() { - "none".to_string() - } else { - available_stages.join(", ") - } - ), + message: e.to_string(), }; let code = ExitCode::from_failure_reason(&reason); eprintln!( diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 6faef0c..d2f9381 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -670,6 +670,160 @@ stages: assert!(!output.contains("DEPLOY")); } +#[test] +fn test_filter_stage_includes_dependencies() { + let dir = tempdir().unwrap(); + + let config = r#" +version: "1.0" +stages: + - name: setup + steps: + - name: setup + image: alpine:latest + command: echo "FILTER_SETUP" + - name: test + depends_on: + - setup + steps: + - name: test + image: alpine:latest + command: echo "FILTER_TEST" + - name: deploy + depends_on: + - test + steps: + - name: deploy + image: alpine:latest + command: echo "FILTER_DEPLOY" +"#; + + let config_path = create_test_config(dir.path(), "filter-deps.yaml", config); + + let result = run_forge_cli_with( + &[ + "run", + "--file", + config_path.to_str().unwrap(), + "--filter", + "test", + ], + Some(dir.path()), + &[], + ); + + assert!(result.is_ok(), "Filter stage failed: {:?}", result.err()); + let output = result.unwrap(); + assert!(output.contains("FILTER_SETUP")); + assert!(output.contains("FILTER_TEST")); + assert!(!output.contains("FILTER_DEPLOY")); +} + +#[test] +fn test_filter_specific_step() { + let dir = tempdir().unwrap(); + + let config = r#" +version: "1.0" +stages: + - name: test + steps: + - name: unit-test + image: alpine:latest + command: echo "RUNNING_UNIT" + - name: e2e-test + image: alpine:latest + command: echo "RUNNING_E2E" +"#; + + let config_path = create_test_config(dir.path(), "filter-step.yaml", config); + + let result = run_forge_cli_with( + &[ + "run", + "--file", + config_path.to_str().unwrap(), + "--filter", + "test.unit-test", + ], + Some(dir.path()), + &[], + ); + + assert!(result.is_ok(), "Filter step failed: {:?}", result.err()); + let output = result.unwrap(); + assert!(output.contains("RUNNING_UNIT")); + assert!(!output.contains("RUNNING_E2E")); +} + +#[test] +fn test_filter_nonexistent_stage_fails_with_helpful_error() { + let dir = tempdir().unwrap(); + + let config = r#" +version: "1.0" +stages: + - name: build + steps: + - name: compile + image: alpine:latest + command: echo "compile" +"#; + + let config_path = create_test_config(dir.path(), "filter-err.yaml", config); + + let result = run_forge_cli_with( + &[ + "run", + "--file", + config_path.to_str().unwrap(), + "--filter", + "nonexistent", + ], + Some(dir.path()), + &[], + ); + + assert!(result.is_err(), "Expected failure for nonexistent stage"); + let error = result.err().unwrap(); + assert!(error.contains("stage 'nonexistent' not found")); + assert!(error.contains("Available stages: build")); +} + +#[test] +fn test_filter_nonexistent_step_fails_with_helpful_error() { + let dir = tempdir().unwrap(); + + let config = r#" +version: "1.0" +stages: + - name: test + steps: + - name: unit + image: alpine:latest + command: echo "unit" +"#; + + let config_path = create_test_config(dir.path(), "filter-step-err.yaml", config); + + let result = run_forge_cli_with( + &[ + "run", + "--file", + config_path.to_str().unwrap(), + "--filter", + "test.integration", + ], + Some(dir.path()), + &[], + ); + + assert!(result.is_err(), "Expected failure for nonexistent step"); + let error = result.err().unwrap(); + assert!(error.contains("step 'integration' not found in stage 'test'")); + assert!(error.contains("Available steps: unit")); +} + #[test] fn test_validation_detects_circular_stage_dependencies() { let dir = tempdir().unwrap();