o7 0.1.1

O7 workflow DSL runner
Documentation
use std::path::Path;

/// Resolve a prompt reference to an absolute file path.
///
/// - If `prompt_ref` contains `/`: resolve as relative path from `root`.
/// - If bare name: check `prompts/<name>`, then `.7/prompts/<name>`.
/// - Returns error if not found at any location.
/// - Returns error if the resolved path escapes the project root.
pub fn resolve_prompt(root: &str, prompt_ref: &str) -> Result<String, String> {
    let resolved_root = Path::new(root)
        .canonicalize()
        .map_err(|e| format!("Cannot resolve project root: {}", e))?;

    if prompt_ref.contains('/') {
        let full_path = resolved_root.join(prompt_ref);
        // Path traversal check
        if let Ok(canonical) = full_path.canonicalize() {
            if !canonical.starts_with(&resolved_root) {
                return Err(format!("Prompt path escapes project root: {}", prompt_ref));
            }
            return Ok(canonical.to_string_lossy().to_string());
        }
        // File doesn't exist yet — check if it would be under root
        let normalized = resolved_root.join(prompt_ref);
        if normalized.exists() {
            return Ok(normalized.to_string_lossy().to_string());
        }
        return Err(format!("Prompt file not found: {}", full_path.display()));
    }

    // Bare name: check standard locations
    let candidates = [
        resolved_root.join("prompts").join(prompt_ref),
        resolved_root.join(".7").join("prompts").join(prompt_ref),
    ];

    for candidate in &candidates {
        if candidate.exists() {
            return Ok(candidate.to_string_lossy().to_string());
        }
    }

    Err(format!(
        "Prompt \"{}\" not found in: {}",
        prompt_ref,
        candidates
            .iter()
            .map(|c| c.display().to_string())
            .collect::<Vec<_>>()
            .join(", ")
    ))
}

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

    #[test]
    fn test_resolve_prompt_relative_path() {
        let dir = TempDir::new().unwrap();
        let prompts_dir = dir.path().join("prompts");
        fs::create_dir_all(&prompts_dir).unwrap();
        fs::write(prompts_dir.join("test.md"), "prompt content").unwrap();

        let result = resolve_prompt(dir.path().to_str().unwrap(), "prompts/test.md");
        assert!(result.is_ok());
    }

    #[test]
    fn test_resolve_prompt_bare_name_primary() {
        let dir = TempDir::new().unwrap();
        let prompts_dir = dir.path().join("prompts");
        fs::create_dir_all(&prompts_dir).unwrap();
        fs::write(prompts_dir.join("test.md"), "content").unwrap();

        let result = resolve_prompt(dir.path().to_str().unwrap(), "test.md");
        assert!(result.is_ok());
    }

    #[test]
    fn test_resolve_prompt_bare_name_secondary() {
        let dir = TempDir::new().unwrap();
        let dot7_prompts = dir.path().join(".7").join("prompts");
        fs::create_dir_all(&dot7_prompts).unwrap();
        fs::write(dot7_prompts.join("test.md"), "content").unwrap();

        let result = resolve_prompt(dir.path().to_str().unwrap(), "test.md");
        assert!(result.is_ok());
    }

    #[test]
    fn test_resolve_prompt_not_found() {
        let dir = TempDir::new().unwrap();
        let result = resolve_prompt(dir.path().to_str().unwrap(), "nonexistent.md");
        assert!(result.is_err());
    }
}