use crate::error::Result;
use crate::models::{Issue, Pipeline, Severity};
use petgraph::algo::tarjan_scc;
use petgraph::graph::DiGraph;
use std::collections::HashMap;
pub fn audit(pipeline: &Pipeline) -> Result<Vec<Issue>> {
let mut issues = Vec::new();
let mut graph = DiGraph::new();
let mut job_indices = HashMap::new();
for job in &pipeline.jobs {
let idx = graph.add_node(job.id.clone());
job_indices.insert(job.id.clone(), idx);
}
for job in &pipeline.jobs {
let from_idx = job_indices[&job.id];
for dep in &job.depends_on {
if let Some(&to_idx) = job_indices.get(dep) {
graph.add_edge(from_idx, to_idx, ());
} }
}
let sccs = tarjan_scc(&graph);
for scc in sccs {
if scc.len() > 1 {
let job_names: Vec<_> = scc.iter().map(|&idx| graph[idx].clone()).collect();
let first_job = &job_names[0];
let (line, col) = pipeline.find_job_line(first_job, "runs-on");
issues.push(Issue::for_job(
Severity::Error,
&format!("Circular dependency detected: {}", job_names.join(" -> ")),
first_job,
line,
col,
Some("Remove one of the dependencies to break the cycle".to_string()),
));
}
}
Ok(issues)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parsers::github;
#[test]
fn test_missing_dependency() {
let yaml = r#"on:
push: {}
jobs:
build:
needs: nonexistent
runs-on: ubuntu-latest
steps: []
"#;
let pipeline = github::parse(yaml).unwrap();
let issues = audit(&pipeline).unwrap();
assert!(issues.is_empty());
}
#[test]
fn test_circular_dependency() {
let yaml = r#"on:
push: {}
jobs:
a:
needs: b
runs-on: ubuntu-latest
steps: []
b:
needs: a
runs-on: ubuntu-latest
steps: []
"#;
let pipeline = github::parse(yaml).unwrap();
let issues = audit(&pipeline).unwrap();
assert!(issues
.iter()
.any(|i| i.message.contains("Circular dependency detected")));
}
}