o7 0.1.1

O7 workflow DSL runner
Documentation
//! Wizard config writers — atomic writes for harness and global config.

use crate::wiz::global_config::global_config_path;
use crate::wiz::types::{WizState, WrittenGlobal, WrittenHarness};
use std::io::Write;
use std::path::Path;
use tempfile::NamedTempFile;

/// Write .7/harnesses.toml atomically (NamedTempFile + persist).
pub fn write_harness_config(project_root: &str, harness: &WrittenHarness) -> Result<(), String> {
    let dir = Path::new(project_root).join(".7");
    std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create .7 dir: {}", e))?;

    let path = dir.join("harnesses.toml");

    let mut lines = vec![
        format!("[harness.{}]", harness.name),
        format!("command = {:?}", harness.command),
        r#"args_mapping = "flags""#.to_string(),
    ];
    if let Some(ref slot) = harness.prompt_slot {
        lines.push(format!("prompt_slot = {:?}", slot));
    }

    let content = lines.join("\n") + "\n";
    let mut tmp = NamedTempFile::new_in(&dir)
        .map_err(|e| format!("Failed to create temp file for harness config: {}", e))?;
    tmp.write_all(content.as_bytes())
        .map_err(|e| format!("Failed to write harness config: {}", e))?;
    tmp.persist(&path)
        .map_err(|e| format!("Failed to persist harness config: {}", e))?;
    Ok(())
}

/// Write ~/.config/o7/config.toml atomically (NamedTempFile + persist).
pub fn write_global_config(global: &WrittenGlobal) -> Result<(), String> {
    let path = global_config_path();
    let dir = path.parent().ok_or("Global config path has no parent")?;
    std::fs::create_dir_all(dir).map_err(|e| format!("Failed to create config dir: {}", e))?;

    let mut lines: Vec<String> = Vec::new();
    if let Some(ref root) = global.default_project_root {
        lines.push(format!("default_project_root = {:?}", root));
    }
    if let Some(ref name) = global.default_harness {
        lines.push(format!("default_harness = {:?}", name));
    }
    let content = lines.join("\n") + if lines.is_empty() { "" } else { "\n" };

    let mut tmp = NamedTempFile::new_in(dir)
        .map_err(|e| format!("Failed to create temp file for global config: {}", e))?;
    tmp.write_all(content.as_bytes())
        .map_err(|e| format!("Failed to write global config: {}", e))?;
    tmp.persist(&path)
        .map_err(|e| format!("Failed to persist global config: {}", e))?;
    Ok(())
}

/// Resolve WizState answers into structs ready for the writers.
pub fn resolve_written_config(state: &WizState) -> (WrittenHarness, WrittenGlobal) {
    let name = state
        .answers
        .get("harness-name")
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "default".to_string());
    let command = state
        .answers
        .get("harness-command")
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "echo".to_string());
    let prompt_slot = state
        .answers
        .get("harness-prompt-slot")
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty());
    let default_project_root = state
        .answers
        .get("global-default-project-root")
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty());
    let default_harness = state
        .answers
        .get("global-default-harness")
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty());

    (
        WrittenHarness {
            name,
            command,
            prompt_slot,
        },
        WrittenGlobal {
            default_project_root,
            default_harness,
        },
    )
}

/// Perform the actual config write from wizard state.
pub fn perform_write(state: &WizState, project_root: &str) -> Result<(), String> {
    let (harness, global) = resolve_written_config(state);
    write_harness_config(project_root, &harness)?;
    write_global_config(&global)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn make_state_with_answers(answers: Vec<(&str, &str)>) -> WizState {
        use crate::wiz::types::{WizPhase, WizQuestion, WizSection};
        let mut map = std::collections::HashMap::new();
        for (k, v) in answers {
            map.insert(k.to_string(), v.to_string());
        }
        WizState {
            questions: vec![WizQuestion {
                id: "harness-name".to_string(),
                prompt: "test".to_string(),
                section: WizSection::Harness,
                prefill: "".to_string(),
            }],
            answers: map,
            selected_tab: 0,
            show_review: false,
            text_buffer: String::new(),
            text_cursor: 0,
            phase: WizPhase::Writing,
            error: None,
        }
    }

    #[test]
    fn test_write_harness_config_creates_toml() {
        let dir = TempDir::new().unwrap();
        let harness = WrittenHarness {
            name: "claude".to_string(),
            command: "claude".to_string(),
            prompt_slot: Some("--prompt".to_string()),
        };
        write_harness_config(dir.path().to_str().unwrap(), &harness).unwrap();
        let content = fs::read_to_string(dir.path().join(".7").join("harnesses.toml")).unwrap();
        assert!(content.contains("[harness.claude]"));
        assert!(content.contains("command = \"claude\""));
        assert!(content.contains("args_mapping = \"flags\""));
        assert!(content.contains("prompt_slot = \"--prompt\""));
    }

    #[test]
    fn test_write_harness_config_omits_prompt_slot_when_none() {
        let dir = TempDir::new().unwrap();
        let harness = WrittenHarness {
            name: "mybot".to_string(),
            command: "mybot".to_string(),
            prompt_slot: None,
        };
        write_harness_config(dir.path().to_str().unwrap(), &harness).unwrap();
        let content = fs::read_to_string(dir.path().join(".7").join("harnesses.toml")).unwrap();
        assert!(!content.contains("prompt_slot"));
    }

    #[test]
    fn test_no_tmp_files_remain_after_harness_write() {
        let dir = TempDir::new().unwrap();
        let harness = WrittenHarness {
            name: "x".to_string(),
            command: "x".to_string(),
            prompt_slot: None,
        };
        write_harness_config(dir.path().to_str().unwrap(), &harness).unwrap();
        let files: Vec<_> = fs::read_dir(dir.path().join(".7"))
            .unwrap()
            .filter_map(|e| e.ok())
            .map(|e| e.file_name().to_string_lossy().to_string())
            .collect();
        assert!(!files.iter().any(|f| f.contains(".tmp")));
    }

    #[test]
    fn test_write_global_config_creates_toml() {
        let dir = TempDir::new().unwrap();
        unsafe {
            std::env::set_var("HOME", dir.path());
        }
        let global = WrittenGlobal {
            default_project_root: Some("/tmp/proj".to_string()),
            default_harness: Some("claude".to_string()),
        };
        write_global_config(&global).unwrap();
        let content =
            fs::read_to_string(dir.path().join(".config").join("o7").join("config.toml")).unwrap();
        assert!(content.contains("default_project_root = \"/tmp/proj\""));
        assert!(content.contains("default_harness = \"claude\""));
    }

    #[test]
    fn test_write_global_config_omits_none_fields() {
        let dir = TempDir::new().unwrap();
        unsafe {
            std::env::set_var("HOME", dir.path());
        }
        let global = WrittenGlobal {
            default_project_root: None,
            default_harness: None,
        };
        write_global_config(&global).unwrap();
        let content =
            fs::read_to_string(dir.path().join(".config").join("o7").join("config.toml")).unwrap();
        assert!(!content.contains("default_project_root"));
        assert!(!content.contains("default_harness"));
    }

    #[test]
    fn test_resolve_written_config_uses_fallbacks_for_empty_answers() {
        let state = make_state_with_answers(vec![]);
        let (harness, _global) = resolve_written_config(&state);
        assert_eq!(harness.name, "default");
        assert_eq!(harness.command, "echo");
    }

    #[test]
    fn test_resolve_written_config_extracts_all_fields() {
        let state = make_state_with_answers(vec![
            ("harness-name", "claude"),
            ("harness-command", "claude"),
            ("harness-prompt-slot", "--prompt"),
            ("global-default-project-root", "/tmp"),
            ("global-default-harness", "claude"),
        ]);
        let (harness, global) = resolve_written_config(&state);
        assert_eq!(harness.name, "claude");
        assert_eq!(harness.prompt_slot.as_deref(), Some("--prompt"));
        assert_eq!(global.default_project_root.as_deref(), Some("/tmp"));
        assert_eq!(global.default_harness.as_deref(), Some("claude"));
    }
}