Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <stage>` is used, FORGE resolves and executes that stage along with any stages it depends on.
When `--filter <stage>.<step>` 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
Expand Down
316 changes: 316 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, String> {
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<dyn std::error::Error + Send + Sync>> {
let available_stages: Vec<String> = config.stages.iter().map(|s| s.name.clone()).collect();
let stage_map: HashMap<String, &Stage> =
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<String> = 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(&current).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<String> = 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"));
}
}
Loading
Loading