hen 0.9.0

Run API collections from the command line.
use std::collections::{HashMap, HashSet};

use petgraph::{algo, graphmap::DiGraphMap, Direction};

use super::Request;

#[derive(Debug)]
pub struct RequestPlanner {
    graph: DiGraphMap<usize, ()>,
    order: Vec<usize>,
    index_to_name: HashMap<usize, String>,
}

#[derive(Debug)]
pub enum PlanError {
    DuplicateRequestName(String),
    UnknownDependency { request: String, dependency: String },
    MissingRequiredDependency { request: String, dependency: String },
    CyclicDependency { cycle: Vec<String> },
    InvalidSelection(usize),
}

impl RequestPlanner {
    pub fn new(requests: &[Request]) -> Result<Self, PlanError> {
        let mut name_to_index: HashMap<String, usize> = HashMap::new();
        let mut index_to_name: HashMap<usize, String> = HashMap::new();

        for (idx, request) in requests.iter().enumerate() {
            if name_to_index
                .insert(request.description.clone(), idx)
                .is_some()
            {
                return Err(PlanError::DuplicateRequestName(request.description.clone()));
            }
            index_to_name.insert(idx, request.description.clone());
        }

        let mut graph: DiGraphMap<usize, ()> = DiGraphMap::new();

        for (idx, request) in requests.iter().enumerate() {
            graph.add_node(idx);

            let declared: HashSet<&str> = request
                .dependencies
                .iter()
                .map(|dep| dep.as_str())
                .collect();

            validate_capture_dependencies(request, &declared)?;

            for dependency in &request.dependencies {
                let dep_idx = name_to_index.get(dependency).copied().ok_or_else(|| {
                    PlanError::UnknownDependency {
                        request: request.description.clone(),
                        dependency: dependency.clone(),
                    }
                })?;
                graph.add_edge(dep_idx, idx, ());
            }
        }

        let order = algo::toposort(&graph, None).map_err(|cycle| {
            let start = cycle.node_id();
            let mut stack = vec![start];
            let mut visited = HashSet::new();
            let mut cycle_names = Vec::new();

            while let Some(node) = stack.pop() {
                if !visited.insert(node) {
                    continue;
                }
                if let Some(name) = index_to_name.get(&node) {
                    cycle_names.push(name.clone());
                }
                for neighbor in graph.neighbors(node) {
                    if neighbor == start || !visited.contains(&neighbor) {
                        stack.push(neighbor);
                    }
                }
            }

            PlanError::CyclicDependency { cycle: cycle_names }
        })?;

        Ok(Self {
            graph,
            order,
            index_to_name,
        })
    }

    pub fn order_all(&self) -> Vec<usize> {
        self.order.clone()
    }

    pub fn order_for(&self, index: usize) -> Result<Vec<usize>, PlanError> {
        if !self.index_to_name.contains_key(&index) {
            return Err(PlanError::InvalidSelection(index));
        }

        let mut required: HashSet<usize> = HashSet::new();
        self.collect_dependencies(index, &mut required);
        required.insert(index);

        let ordered = self
            .order
            .iter()
            .copied()
            .filter(|idx| required.contains(idx))
            .collect();
        Ok(ordered)
    }

    fn collect_dependencies(&self, node: usize, acc: &mut HashSet<usize>) {
        for dep in self.graph.neighbors_directed(node, Direction::Incoming) {
            if acc.insert(dep) {
                self.collect_dependencies(dep, acc);
            }
        }
    }
}

fn validate_capture_dependencies(
    request: &Request,
    declared: &HashSet<&str>,
) -> Result<(), PlanError> {
    for capture in &request.response_captures {
        if let Some(dep) = capture.required_dependency() {
            if !declared.contains(dep) {
                return Err(PlanError::MissingRequiredDependency {
                    request: request.description.clone(),
                    dependency: dep.to_string(),
                });
            }
        }
    }

    // Also ensure assertions do not reference dependency namespaces without declaring them
    Ok(())
}

impl std::fmt::Display for PlanError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PlanError::DuplicateRequestName(name) => {
                write!(f, "Multiple requests share the description '{}'.", name)
            }
            PlanError::UnknownDependency {
                request,
                dependency,
            } => write!(
                f,
                "Request '{}' declares unknown dependency '{}'.",
                request, dependency
            ),
            PlanError::MissingRequiredDependency {
                request,
                dependency,
            } => write!(
                f,
                "Request '{}' uses '&[{}]' but has not declared it with '> requires: {}'.",
                request, dependency, dependency
            ),
            PlanError::CyclicDependency { cycle } => {
                if cycle.is_empty() {
                    write!(f, "Detected a circular dependency in the request graph.")
                } else {
                    write!(
                        f,
                        "Detected a circular dependency involving: {}",
                        cycle.join(" -> ")
                    )
                }
            }
            PlanError::InvalidSelection(index) => {
                write!(f, "No request found at selection index {}.", index)
            }
        }
    }
}

impl std::error::Error for PlanError {}

#[cfg(test)]
mod tests {
    use super::*;
    use http::Method;
    use std::path::PathBuf;

    fn empty_request(name: &str) -> Request {
        Request {
            description: name.to_string(),
            method: Method::GET,
            url: "https://example.com".to_string(),
            headers: HashMap::new(),
            query_params: HashMap::new(),
            form_data: HashMap::new(),
            body: None,
            body_content_type: None,
            callback_src: vec![],
            response_captures: vec![],
            assertions: vec![],
            dependencies: vec![],
            context: HashMap::new(),
            working_dir: PathBuf::new(),
        }
    }

    #[test]
    fn orders_requests_in_dependency_sequence() {
        let a = empty_request("A");
        let mut b = empty_request("B");
        let mut c = empty_request("C");

        b.dependencies.push("A".to_string());
        c.dependencies.push("B".to_string());

        let requests = vec![a, b, c];
        let planner = RequestPlanner::new(&requests).expect("planner should build");
        assert_eq!(planner.order_all(), vec![0, 1, 2]);
    }

    #[test]
    fn capture_requires_declared_dependency() {
        let login = empty_request("Login");
        let capture = super::super::response_capture::ResponseCapture::parse(
            "&[Login].body.token > $TOKEN",
            &HashMap::new(),
        )
        .expect("capture should parse");

        let mut profile = empty_request("Profile");
        profile.response_captures.push(capture);

        let err = RequestPlanner::new(&[login, profile]).expect_err("planner should fail");

        match err {
            PlanError::MissingRequiredDependency {
                request,
                dependency,
            } => {
                assert_eq!(request, "Profile");
                assert_eq!(dependency, "Login");
            }
            other => panic!("unexpected error: {:?}", other),
        }
    }
}