o7 0.1.1

O7 workflow DSL runner
Documentation
use crate::harness::types::{ExecInvocation, HarnessEntry};
use crate::parser::ast::ExecBlock;
use std::collections::HashMap;

/// Build a command invocation from a harness entry and exec block.
///
/// Constructs the command array by:
/// 1. Starting with the harness command
/// 2. Inserting the prompt via the prompt_slot flag (skipped if empty)
/// 3. Adding the resolved prompt path
/// 4. Mapping merged defaults + exec block args to --key value flags
pub fn build_invocation(
    entry: &HarnessEntry,
    exec_block: &ExecBlock,
    resolved_prompt_path: &str,
    cwd: &str,
) -> ExecInvocation {
    let mut command = vec![entry.command.clone()];

    // Add prompt via prompt_slot (skip if None or empty — some harnesses don't use a flag)
    if let Some(ref slot) = entry.prompt_slot {
        if !slot.is_empty() {
            command.push(slot.clone());
        }
    }
    command.push(resolved_prompt_path.to_string());

    // Merge defaults with exec block args (exec block args override defaults)
    let mut all_args: HashMap<String, String> = HashMap::new();
    if let Some(defaults) = &entry.defaults {
        all_args.extend(defaults.clone());
    }
    if let Some(args) = &exec_block.args {
        all_args.extend(args.clone());
    }

    // Map args to --key value flags (sorted for deterministic output)
    let mut sorted_args: Vec<_> = all_args.into_iter().collect();
    sorted_args.sort_by(|a, b| a.0.cmp(&b.0));
    for (key, value) in sorted_args {
        command.push(format!("--{}", key));
        command.push(value);
    }

    ExecInvocation {
        command,
        cwd: cwd.to_string(),
        env: None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_build_basic_invocation() {
        let entry = HarnessEntry {
            command: "claude".to_string(),
            prompt_slot: Some("-p".to_string()),
            args_mapping: "cli".to_string(),
            output_mode: None,
            defaults: None,
        };
        let exec_block = ExecBlock {
            harness: "claude".to_string(),
            prompt: Some("do stuff".to_string()),
            prompt_file: None,
            args: None,
            line: 1,
            column: 1,
        };
        let inv = build_invocation(&entry, &exec_block, "/tmp/prompt.md", "/work");
        assert_eq!(inv.command, vec!["claude", "-p", "/tmp/prompt.md"]);
        assert_eq!(inv.cwd, "/work");
    }

    #[test]
    fn test_build_invocation_empty_prompt_slot() {
        let entry = HarnessEntry {
            command: "my-tool".to_string(),
            prompt_slot: Some("".to_string()),
            args_mapping: "cli".to_string(),
            output_mode: None,
            defaults: None,
        };
        let exec_block = ExecBlock {
            harness: "my-tool".to_string(),
            prompt: None,
            prompt_file: Some("prompt.md".to_string()),
            args: None,
            line: 1,
            column: 1,
        };
        let inv = build_invocation(&entry, &exec_block, "/tmp/prompt.md", "/work");
        // No prompt_slot flag, just command + path
        assert_eq!(inv.command, vec!["my-tool", "/tmp/prompt.md"]);
    }

    #[test]
    fn test_build_invocation_with_defaults() {
        let mut defaults = HashMap::new();
        defaults.insert("model".to_string(), "opus".to_string());
        defaults.insert("max-tokens".to_string(), "1000".to_string());

        let entry = HarnessEntry {
            command: "claude".to_string(),
            prompt_slot: Some("-p".to_string()),
            args_mapping: "cli".to_string(),
            output_mode: None,
            defaults: Some(defaults),
        };
        let exec_block = ExecBlock {
            harness: "claude".to_string(),
            prompt: Some("do stuff".to_string()),
            prompt_file: None,
            args: None,
            line: 1,
            column: 1,
        };
        let inv = build_invocation(&entry, &exec_block, "/tmp/prompt.md", "/work");
        // Should contain command, prompt_slot, path, then sorted args
        assert_eq!(inv.command[0], "claude");
        assert_eq!(inv.command[1], "-p");
        assert_eq!(inv.command[2], "/tmp/prompt.md");
        // Args are sorted: max-tokens before model
        assert!(inv.command.contains(&"--max-tokens".to_string()));
        assert!(inv.command.contains(&"1000".to_string()));
        assert!(inv.command.contains(&"--model".to_string()));
        assert!(inv.command.contains(&"opus".to_string()));
    }

    #[test]
    fn test_build_invocation_exec_args_override_defaults() {
        let mut defaults = HashMap::new();
        defaults.insert("model".to_string(), "opus".to_string());
        defaults.insert("max-tokens".to_string(), "1000".to_string());

        let mut exec_args = HashMap::new();
        exec_args.insert("model".to_string(), "sonnet".to_string());

        let entry = HarnessEntry {
            command: "claude".to_string(),
            prompt_slot: Some("-p".to_string()),
            args_mapping: "cli".to_string(),
            output_mode: None,
            defaults: Some(defaults),
        };
        let exec_block = ExecBlock {
            harness: "claude".to_string(),
            prompt: Some("do stuff".to_string()),
            prompt_file: None,
            args: Some(exec_args),
            line: 1,
            column: 1,
        };
        let inv = build_invocation(&entry, &exec_block, "/tmp/prompt.md", "/work");
        // model should be "sonnet" (overridden), max-tokens should be "1000" (from defaults)
        let model_idx = inv.command.iter().position(|s| s == "--model").unwrap();
        assert_eq!(inv.command[model_idx + 1], "sonnet");
        let tokens_idx = inv
            .command
            .iter()
            .position(|s| s == "--max-tokens")
            .unwrap();
        assert_eq!(inv.command[tokens_idx + 1], "1000");
    }

    #[test]
    fn test_build_invocation_exec_args_add_new() {
        let entry = HarnessEntry {
            command: "claude".to_string(),
            prompt_slot: Some("-p".to_string()),
            args_mapping: "cli".to_string(),
            output_mode: None,
            defaults: None,
        };
        let mut exec_args = HashMap::new();
        exec_args.insert("verbose".to_string(), "true".to_string());

        let exec_block = ExecBlock {
            harness: "claude".to_string(),
            prompt: Some("do stuff".to_string()),
            prompt_file: None,
            args: Some(exec_args),
            line: 1,
            column: 1,
        };
        let inv = build_invocation(&entry, &exec_block, "/tmp/prompt.md", "/work");
        assert_eq!(
            inv.command,
            vec!["claude", "-p", "/tmp/prompt.md", "--verbose", "true"]
        );
    }
}