malvin 0.2.4

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

use crate::artifacts::RunArtifacts;
use crate::prompt_stratification::WorkflowRenderContext;
#[cfg(test)]
use crate::prompts::{PromptError, PromptStore};

pub(crate) fn insert_formatted(
    ctx: &mut HashMap<String, String>,
    key: &str,
    path: &Path,
    base: &Path,
) {
    ctx.insert(key.to_string(), format_prompt_path(path, base));
}

fn insert_quality_gates_log_paths(
    context: &mut HashMap<String, String>,
    artifacts: &RunArtifacts,
    base: &Path,
) {
    let path = format_prompt_path(&artifacts.quality_gates_log_path(), base);
    context.insert("quality_gates_log".to_string(), path.clone());
    context.insert("quality_gates_path".to_string(), path);
}

fn insert_review_artifact_paths(
    context: &mut HashMap<String, String>,
    artifacts: &RunArtifacts,
    base: &Path,
) {
    insert_formatted(
        context,
        "review_path",
        &artifacts.artifact_review_md(),
        base,
    );
    insert_formatted(
        context,
        "review_prep_path",
        &artifacts.review_prep_md(),
        base,
    );
    insert_formatted(
        context,
        "result_path",
        &artifacts.artifact_result_md(),
        base,
    );
    insert_formatted(
        context,
        "review_requirements_path",
        &crate::artifacts::review_requirements_json(artifacts),
        base,
    );
}

fn insert_run_meta_and_workspace_paths(
    context: &mut HashMap<String, String>,
    artifacts: &RunArtifacts,
    base: &Path,
) {
    let run_meta_dir = artifacts
        .run_dir
        .join("_run")
        .canonicalize()
        .unwrap_or_else(|_| artifacts.run_dir.join("_run"));
    insert_formatted(context, "run_meta_dir", &run_meta_dir, base);
    insert_formatted(context, "exp_log", &artifacts.exp_log_path(), base);
    insert_formatted(
        context,
        "advice_path",
        &crate::malvin_advice_path(base),
        base,
    );
    insert_formatted(context, "malvin_output_path", &artifacts.run_dir, base);
    insert_formatted(context, "workspace_dir", &artifacts.run_dir, base);
    insert_formatted(context, "logs_dir", &crate::malvin_logs_root(base), base);
}

fn insert_artifact_paths(context: &mut HashMap<String, String>, artifacts: &RunArtifacts) {
    let base = &artifacts.work_dir;
    insert_formatted(context, "plan_path", &artifacts.plan_path, base);
    insert_formatted(context, "user_request_path", &artifacts.plan_path, base);
    insert_run_meta_and_workspace_paths(context, artifacts, base);
    insert_review_artifact_paths(context, artifacts, base);
    insert_quality_gates_log_paths(context, artifacts, base);
}

fn insert_current_state(
    context: &mut HashMap<String, String>,
    artifacts: &RunArtifacts,
    base: &Path,
) {
    context.insert(
        "current_state".to_string(),
        crate::current_state::format_current_state(base, None, Some(artifacts)),
    );
}

#[must_use]
pub fn format_malvin_command(model: &str) -> String {
    format!("malvin --model={model}")
}

pub const GIT_EXTRA_ENABLED: &str = "You may run 'git commit'.";

#[derive(Clone, Copy, Debug)]
pub struct PromptModelOpts<'a> {
    pub model: &'a str,
    pub git: bool,
}

impl<'a> PromptModelOpts<'a> {
    #[must_use]
    pub const fn new(model: &'a str, git: bool) -> Self {
        Self { model, git }
    }
}

#[must_use]
pub const fn format_git_extra(git: bool) -> &'static str {
    if git { GIT_EXTRA_ENABLED } else { "" }
}

#[must_use]
pub fn workflow_context_paths_only(
    artifacts: &RunArtifacts,
    model: &str,
    git: bool,
) -> WorkflowRenderContext {
    let mut context = HashMap::new();
    insert_artifact_paths(&mut context, artifacts);
    insert_current_state(&mut context, artifacts, &artifacts.work_dir);
    context.insert("malvin_command".to_string(), format_malvin_command(model));
    context.insert("git_extra".to_string(), format_git_extra(git).to_string());
    WorkflowRenderContext::new(context)
}

#[cfg(test)]
pub fn workflow_context(
    artifacts: &RunArtifacts,
    prompts: &PromptStore,
    model: &str,
) -> Result<WorkflowRenderContext, PromptError> {
    let mut context = workflow_context_paths_only(artifacts, model, false);
    context.insert(
        "quality_gates".to_string(),
        crate::repo_gates::prompt_quality_gates_markdown_ephemeral(&artifacts.work_dir)
            .map_err(PromptError)?,
    );
    context.insert(
        "max_hypotheses".to_string(),
        crate::malvin_config_file::DEFAULT_MAX_HYPOTHESES.to_string(),
    );
    let _ = prompts;
    Ok(context)
}

fn resolve_path_against_base(path: &Path, base_r: &Path) -> PathBuf {
    let abs = if path.is_absolute() {
        path.to_path_buf()
    } else {
        base_r.join(path)
    };
    abs.canonicalize()
        .unwrap_or_else(|_| resolve_nonexistent_path(&abs))
}

fn resolve_nonexistent_path(abs: &Path) -> PathBuf {
    abs.ancestors()
        .find_map(|ancestor| {
            ancestor
                .canonicalize()
                .ok()
                .map(|canonical| match abs.strip_prefix(ancestor) {
                    Ok(tail) if !tail.as_os_str().is_empty() => canonical.join(tail),
                    _ => canonical,
                })
        })
        .unwrap_or_else(|| abs.to_path_buf())
}

#[cfg(test)]
pub(crate) fn resolve_prompt_context_path(
    context: &WorkflowRenderContext,
    key: &str,
    base: &Path,
    fallback: &Path,
) -> PathBuf {
    context.get(key).map_or_else(
        || fallback.to_path_buf(),
        |s| resolve_path_against_base(Path::new(s), base),
    )
}

#[cfg(test)]
#[must_use]
pub(crate) fn resolve_user_brief_path(
    artifacts: &RunArtifacts,
    context: &WorkflowRenderContext,
) -> PathBuf {
    resolve_prompt_context_path(
        context,
        "user_request_path",
        artifacts.work_dir.as_path(),
        &artifacts.plan_path,
    )
}

#[must_use]
pub fn format_prompt_path(path: &Path, base_dir: &Path) -> String {
    let base_r = base_dir
        .canonicalize()
        .unwrap_or_else(|_| base_dir.to_path_buf());
    let path_r = resolve_path_against_base(path, &base_r);
    path_r.strip_prefix(&base_r).map_or_else(
        |_| path_r.display().to_string(),
        |r| format!("./{}", r.display()),
    )
}

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