malvin 0.2.5

Non-interactive research and coding agent
use std::collections::HashMap;
use std::path::{Path, PathBuf};

use crate::artifacts::resolve_user_md_request;
use crate::cli::cli_request::require_cli_request;
use crate::cli::default_output_path::allocate_default_tex_pdf_pair;
use crate::artifacts::RunArtifacts;
use crate::cli::session_header::{WriteInitialHeaderInput, build_write_initial_prompt};
use crate::prompts::{PromptError, PromptStore, WRITE_A_MD, WRITE_B_MD};

pub(crate) const WRITE_TEX_BASENAME: &str = "write.tex";
pub(crate) const WRITE_PDF_BASENAME: &str = "write.pdf";

#[derive(Debug)]
pub(crate) struct WriteResolvedOutputs {
    pub tex_path: PathBuf,
    pub pdf_path: PathBuf,
}

pub(crate) fn write_pdf_path_from_tex(tex_path: &Path) -> PathBuf {
    tex_path.with_extension("pdf")
}

fn resolve_write_output_in_cwd(work_dir: &Path, basename: &str, cwd: &Path) -> PathBuf {
    if work_dir.as_os_str() == "." {
        return cwd.join(basename);
    }
    let rel = work_dir.join(basename);
    if rel.is_absolute() {
        rel
    } else {
        cwd.join(rel)
    }
}

pub(crate) fn write_resolved_output_paths(
    request_work_dir: &Path,
    out_path: &str,
) -> Result<WriteResolvedOutputs, String> {
    let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
    let tex_path = if out_path == WRITE_TEX_BASENAME {
        resolve_write_output_in_cwd(request_work_dir, WRITE_TEX_BASENAME, &cwd)
    } else {
        let path = Path::new(out_path);
        if path.is_absolute() {
            path.to_path_buf()
        } else {
            cwd.join(path)
        }
    };
    let pdf_path = if out_path == WRITE_TEX_BASENAME {
        resolve_write_output_in_cwd(request_work_dir, WRITE_PDF_BASENAME, &cwd)
    } else {
        write_pdf_path_from_tex(&tex_path)
    };
    Ok(WriteResolvedOutputs { tex_path, pdf_path })
}

pub(crate) fn compose_write_a_prompt(
    store: &PromptStore,
    request_text: &str,
    workspace_dir: &str,
) -> Result<String, String> {
    let ctx = HashMap::from([
        ("workspace_dir".to_string(), workspace_dir.to_string()),
        ("request_text".to_string(), request_text.to_string()),
    ]);
    store
        .render_prompt_only(WRITE_A_MD, &ctx)
        .map_err(|e: PromptError| e.0)
}

pub(crate) fn compose_write_b_prompt(
    store: &PromptStore,
    tex_display: &str,
    pdf_display: &str,
    workspace_dir: &str,
) -> Result<String, String> {
    let ctx = HashMap::from([
        ("tex_display".to_string(), tex_display.to_string()),
        ("pdf_display".to_string(), pdf_display.to_string()),
        ("workspace_dir".to_string(), workspace_dir.to_string()),
    ]);
    store
        .render_prompt_only(WRITE_B_MD, &ctx)
        .map_err(|e: PromptError| e.0)
}

pub(crate) fn write_preflight(
    request: Option<&String>,
    out_path: &str,
    out_path_explicit: bool,
) -> Result<(String, PathBuf, WriteResolvedOutputs), String> {
    let raw = require_cli_request(request, "write")?;
    let (text, request_work_dir) = resolve_user_md_request(&raw)?;
    let mut outputs = write_resolved_output_paths(&request_work_dir, out_path)?;
    if out_path == WRITE_TEX_BASENAME || !out_path_explicit {
        let (tex, pdf) =
            allocate_default_tex_pdf_pair(&outputs.tex_path, &outputs.pdf_path, "write")?;
        outputs.tex_path = tex;
        outputs.pdf_path = pdf;
    } else {
        for path in [&outputs.tex_path, &outputs.pdf_path] {
            if path.exists() {
                return Err(format!(
                    "malvin write: `{}` already exists; refusing to overwrite",
                    path.display()
                ));
            }
        }
    }
    for path in [&outputs.tex_path, &outputs.pdf_path] {
        if let Some(parent) = path.parent()
            && !parent.as_os_str().is_empty()
        {
            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
        }
    }
    Ok((text, request_work_dir, outputs))
}


/// Inputs for write's aggregated spawn prompt (`header.md` + `write_a.md`).
pub(crate) struct WriteInitialPromptInput<'a> {
    pub store: &'a PromptStore,
    pub artifacts: &'a RunArtifacts,
    pub model: &'a str,
    pub git: bool,
    pub request_text: &'a str,
    pub workspace_dir: &'a str,
}

pub(crate) struct WriteInitialPrompt {
    pub body: String,
    pub stdout_label: String,
    pub log_who: &'static str,
}

/// Aggregate `header.md` and `write_a.md` for one host send at session start.
///
/// Write does not fold router-only options (`--creative`, `--gates`, `--no-kpop`,
/// `--max-hypotheses`) into this prompt; those affect the default router only.
/// `--out-path` is applied later in `write_b.md`.
pub(crate) fn build_write_workflow_initial_prompt(
    input: WriteInitialPromptInput<'_>,
) -> Result<WriteInitialPrompt, String> {
    let write_a = compose_write_a_prompt(input.store, input.request_text, input.workspace_dir)?;
    let aggregated = build_write_initial_prompt(WriteInitialHeaderInput {
        store: input.store,
        artifacts: input.artifacts,
        model: input.model,
        git: input.git,
        write_a_body: write_a,
    })?;
    Ok(WriteInitialPrompt {
        body: aggregated.body,
        stdout_label: aggregated.stdout_label,
        log_who: aggregated.log_who,
    })
}

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

    #[test]
    fn compose_write_a_embeds_request_and_workspace() {
        let store = PromptStore::default_store();
        let body = compose_write_a_prompt(&store, "how gates exit", "./.malvin_home/logs/run")
            .expect("compose write_a");
        let expected = crate::prompts::render_template(
            include_str!("../../../default_prompts/write_a.md"),
            &HashMap::from([
                (
                    "workspace_dir".to_string(),
                    "./.malvin_home/logs/run".to_string(),
                ),
                ("request_text".to_string(), "how gates exit".to_string()),
            ]),
        );
        assert_eq!(body, expected);
        assert!(body.contains("how gates exit"));
        assert!(body.contains("notes.tex"));
        assert!(body.contains("./.malvin_home/logs/run"));
    }

    #[test]
    fn compose_write_b_embeds_out_paths_and_workspace() {
        let store = PromptStore::default_store();
        let body = compose_write_b_prompt(
            &store,
            "write.tex",
            "write.pdf",
            "./.malvin_home/logs/run",
        )
        .expect("compose write_b");
        let expected = crate::prompts::render_template(
            include_str!("../../../default_prompts/write_b.md"),
            &HashMap::from([
                ("tex_display".to_string(), "write.tex".to_string()),
                ("pdf_display".to_string(), "write.pdf".to_string()),
                (
                    "workspace_dir".to_string(),
                    "./.malvin_home/logs/run".to_string(),
                ),
            ]),
        );
        assert_eq!(body, expected);
        assert!(body.contains("`write.tex`"));
        assert!(body.contains("`write.pdf`"));
        assert!(body.contains("notes.tex"));
        assert!(body.contains("./.malvin_home/logs/run"));
        assert!(body.contains("Write to the output paths given above while you work"));
        assert!(body.contains("all lowercase, snake_case"));
        assert!(body.contains("At the end, rename both"));
    }

    #[test]
    fn write_preflight_requires_request() {
        let err = write_preflight(None, WRITE_TEX_BASENAME, false).unwrap_err();
        assert!(err.contains("write") && err.contains("REQUEST"));
    }
}

#[cfg(test)]
#[path = "initial_tests.rs"]
mod initial_tests;