o7 0.1.1

O7 workflow DSL runner
Documentation
//! O7 DSL Linker
//!
//! Merges multi-file parsed ASTs, resolves cross-file workflow references,
//! detects duplicate workflow names, and detects circular references.

use std::collections::{HashMap, HashSet};

use crate::parser::ast::{ParseResult, Statement, WorkflowDecl};
use crate::parser::errors::ParseError;

/// Input to the linker: parsed workflows from a single file.
pub struct FileWorkflows {
    pub filename: String,
    pub workflows: Vec<WorkflowDecl>,
}

/// Link multiple parsed files into a unified set of workflows.
///
/// Steps:
///   1. Merge all WorkflowDecl arrays into one, detecting duplicate names.
///   2. Resolve all workflow references (run, if, while, par-and targets).
///   3. Detect circular references via DFS.
///
/// Returns a ParseResult with either the fully validated workflow list or
/// all accumulated errors.
pub fn link(files: Vec<FileWorkflows>) -> ParseResult {
    let mut errors: Vec<ParseError> = Vec::new();

    // Step 1: Merge and detect duplicates
    let merged = merge_workflows(&files, &mut errors);

    // Step 2: Resolve references
    let name_set: HashSet<&str> = merged.iter().map(|w| w.name.as_str()).collect();
    resolve_references(&merged, &name_set, &mut errors);

    // Step 3: Detect cycles
    detect_cycles(&merged, &mut errors);

    if !errors.is_empty() {
        return ParseResult::Err { errors };
    }

    ParseResult::Ok { workflows: merged }
}

/// Merge workflows from multiple files into a single list.
/// Detects duplicate workflow names both within and across files.
fn merge_workflows(files: &[FileWorkflows], errors: &mut Vec<ParseError>) -> Vec<WorkflowDecl> {
    let mut merged: Vec<WorkflowDecl> = Vec::new();
    let mut seen: HashMap<String, &WorkflowDecl> = HashMap::new();

    for file in files {
        for wf in &file.workflows {
            if let Some(existing) = seen.get(&wf.name) {
                let loc_existing = if existing.file == wf.file {
                    format!("line {}", existing.line)
                } else {
                    format!("{}:{}", existing.file, existing.line)
                };
                errors.push(ParseError::new(
                    &wf.file,
                    wf.line,
                    wf.column,
                    format!(
                        "duplicate workflow name \"{}\" (first defined at {})",
                        wf.name, loc_existing
                    ),
                ));
            } else {
                seen.insert(wf.name.clone(), wf);
                merged.push(wf.clone());
            }
        }
    }

    merged
}

/// Walk all statements in all workflows and verify that every referenced
/// workflow name exists in the merged name set.
fn resolve_references(
    workflows: &[WorkflowDecl],
    name_set: &HashSet<&str>,
    errors: &mut Vec<ParseError>,
) {
    for wf in workflows {
        resolve_statements_refs(&wf.body, name_set, &wf.file, errors);
    }
}

/// Recursively walk statements to find all workflow name references
/// and verify they exist.
fn resolve_statements_refs(
    statements: &[Statement],
    name_set: &HashSet<&str>,
    file: &str,
    errors: &mut Vec<ParseError>,
) {
    for stmt in statements {
        for name in stmt.referenced_names() {
            if !name_set.contains(name) {
                errors.push(ParseError::new(
                    file,
                    stmt.line(),
                    stmt.column(),
                    format!("unresolved workflow reference \"{}\"", name),
                ));
            }
        }
        if let Some(body) = stmt.body() {
            resolve_statements_refs(body, name_set, file, errors);
        }
    }
}

/// Build a call graph from the merged AST and run DFS cycle detection.
fn detect_cycles(workflows: &[WorkflowDecl], errors: &mut Vec<ParseError>) {
    // Build adjacency list: workflow name -> set of called workflow names
    let mut graph: HashMap<&str, HashSet<&str>> = HashMap::new();
    let mut decl_map: HashMap<&str, &WorkflowDecl> = HashMap::new();

    for wf in workflows {
        decl_map.insert(&wf.name, wf);
        let mut callees: HashSet<&str> = HashSet::new();
        collect_callees(&wf.body, &mut callees);
        graph.insert(&wf.name, callees);
    }

    // DFS cycle detection
    // States: 0 = unvisited, 1 = in current path (gray), 2 = fully visited (black)
    let mut state: HashMap<&str, u8> = HashMap::new();
    let mut path: Vec<&str> = Vec::new();

    for wf in workflows {
        state.insert(&wf.name, 0);
    }

    for wf in workflows {
        if state.get(wf.name.as_str()) == Some(&0) {
            dfs(&wf.name, &graph, &mut state, &mut path, &decl_map, errors);
        }
    }
}

/// DFS traversal for cycle detection.
fn dfs<'a>(
    node: &'a str,
    graph: &HashMap<&'a str, HashSet<&'a str>>,
    state: &mut HashMap<&'a str, u8>,
    path: &mut Vec<&'a str>,
    decl_map: &HashMap<&'a str, &WorkflowDecl>,
    errors: &mut Vec<ParseError>,
) {
    state.insert(node, 1); // gray: in current path
    path.push(node);

    if let Some(callees) = graph.get(node) {
        for &callee in callees {
            let callee_state = state.get(callee).copied().unwrap_or(0);
            if callee_state == 1 {
                // Found a cycle: extract the cycle from the path
                if let Some(cycle_start) = path.iter().position(|&n| n == callee) {
                    let mut cycle: Vec<&str> = path[cycle_start..].to_vec();
                    cycle.push(callee); // close the cycle

                    if let Some(decl) = decl_map.get(node) {
                        errors.push(ParseError::new(
                            &decl.file,
                            decl.line,
                            decl.column,
                            format!("circular workflow reference: {}", cycle.join(" -> ")),
                        ));
                    }
                }
            } else if callee_state == 0 {
                dfs(callee, graph, state, path, decl_map, errors);
            }
            // If callee_state == 2, already fully visited -- skip
        }
    }

    path.pop();
    state.insert(node, 2); // black: fully visited
}

/// Recursively collect all workflow names called by a list of statements.
fn collect_callees<'a>(statements: &'a [Statement], callees: &mut HashSet<&'a str>) {
    for stmt in statements {
        for name in stmt.referenced_names() {
            callees.insert(name);
        }
        if let Some(body) = stmt.body() {
            collect_callees(body, callees);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::parser::ast::{ExecBlock, RunStatement};
    use crate::parser::lexer::lex;
    use crate::parser::parser::parse;

    fn make_exec_workflow(name: &str, file: &str) -> WorkflowDecl {
        WorkflowDecl {
            name: name.to_string(),
            body: vec![Statement::Exec(ExecBlock {
                harness: "h".to_string(),
                prompt: Some("test".to_string()),
                prompt_file: None,
                args: None,
                line: 2,
                column: 3,
            })],
            file: file.to_string(),
            line: 1,
            column: 1,
        }
    }

    fn make_run_workflow(name: &str, target: &str, file: &str) -> WorkflowDecl {
        WorkflowDecl {
            name: name.to_string(),
            body: vec![Statement::Run(RunStatement {
                workflow_name: target.to_string(),
                line: 2,
                column: 3,
            })],
            file: file.to_string(),
            line: 1,
            column: 1,
        }
    }

    /// Helper: lex + parse + link a single source string.
    fn parse_and_link(source: &str) -> ParseResult {
        let lex_result = lex(source, "test.7");
        assert!(
            lex_result.errors.is_empty(),
            "Lex errors: {:?}",
            lex_result.errors
        );
        let parse_result = parse(lex_result.tokens, "test.7");
        let wfs = match parse_result {
            ParseResult::Ok { workflows } => workflows,
            ParseResult::Err { errors } => panic!("Parse failed: {:?}", errors),
        };
        link(vec![FileWorkflows {
            filename: "test.7".to_string(),
            workflows: wfs,
        }])
    }

    // --- Unit tests with hand-crafted AST nodes ---

    #[test]
    fn test_link_single_file_ok() {
        let files = vec![FileWorkflows {
            filename: "test.7".to_string(),
            workflows: vec![make_exec_workflow("main", "test.7")],
        }];
        let result = link(files);
        assert!(result.is_ok());
        assert_eq!(result.workflows().unwrap().len(), 1);
    }

    #[test]
    fn test_link_multi_file_ok() {
        let files = vec![
            FileWorkflows {
                filename: "a.7".to_string(),
                workflows: vec![make_exec_workflow("greet", "a.7")],
            },
            FileWorkflows {
                filename: "b.7".to_string(),
                workflows: vec![make_run_workflow("main", "greet", "b.7")],
            },
        ];
        let result = link(files);
        assert!(result.is_ok());
        assert_eq!(result.workflows().unwrap().len(), 2);
    }

    #[test]
    fn test_link_duplicate_name() {
        let files = vec![
            FileWorkflows {
                filename: "a.7".to_string(),
                workflows: vec![make_exec_workflow("dup", "a.7")],
            },
            FileWorkflows {
                filename: "b.7".to_string(),
                workflows: vec![make_exec_workflow("dup", "b.7")],
            },
        ];
        let result = link(files);
        assert!(!result.is_ok());
        let errors = result.errors().unwrap();
        assert_eq!(errors.len(), 1);
        assert!(errors[0].message.contains("duplicate workflow name"));
    }

    #[test]
    fn test_link_unresolved_reference() {
        let files = vec![FileWorkflows {
            filename: "test.7".to_string(),
            workflows: vec![make_run_workflow("main", "nonexistent", "test.7")],
        }];
        let result = link(files);
        assert!(!result.is_ok());
        let errors = result.errors().unwrap();
        assert!(errors[0].message.contains("unresolved workflow reference"));
    }

    #[test]
    fn test_link_circular_reference() {
        let files = vec![FileWorkflows {
            filename: "test.7".to_string(),
            workflows: vec![
                make_run_workflow("a", "b", "test.7"),
                make_run_workflow("b", "a", "test.7"),
            ],
        }];
        let result = link(files);
        assert!(!result.is_ok());
        let errors = result.errors().unwrap();
        assert!(errors.iter().any(|e| e.message.contains("circular")));
    }

    // --- Integration tests via lex + parse + link ---

    #[test]
    fn test_link_valid_e2e() {
        let result = parse_and_link(
            "version 1\nworkflow greet\n  exec\n    harness: h\n    prompt: \"hi\"\nworkflow main\n  run greet\n",
        );
        assert!(result.is_ok());
    }

    #[test]
    fn test_link_unknown_reference_e2e() {
        let result = parse_and_link("version 1\nworkflow main\n  run nonexistent\n");
        assert!(!result.is_ok());
        assert!(result.errors().unwrap()[0].message.contains("unresolved"));
    }

    #[test]
    fn test_link_duplicate_name_e2e() {
        let lex1 = lex(
            "version 1\nworkflow dup\n  exec\n    harness: h\n    prompt: \"a\"\n",
            "a.7",
        );
        let parse1 = parse(lex1.tokens, "a.7");
        let wfs1 = parse1.workflows().unwrap().clone();

        let lex2 = lex(
            "version 1\nworkflow dup\n  exec\n    harness: h\n    prompt: \"b\"\n",
            "b.7",
        );
        let parse2 = parse(lex2.tokens, "b.7");
        let wfs2 = parse2.workflows().unwrap().clone();

        let result = link(vec![
            FileWorkflows {
                filename: "a.7".to_string(),
                workflows: wfs1,
            },
            FileWorkflows {
                filename: "b.7".to_string(),
                workflows: wfs2,
            },
        ]);
        assert!(!result.is_ok());
        assert!(result.errors().unwrap()[0].message.contains("duplicate"));
    }

    #[test]
    fn test_link_circular_reference_e2e() {
        let result = parse_and_link("version 1\nworkflow a\n  run b\nworkflow b\n  run a\n");
        assert!(!result.is_ok());
        assert!(result.errors().unwrap()[0].message.contains("circular"));
    }
}