harn-vm 0.8.3

Async bytecode virtual machine for the Harn programming language
Documentation
//! Harn-owned plan artifact normalization and tool helpers.

use sha2::{Digest, Sha256};

pub const PLAN_SCHEMA_VERSION: &str = "harn.plan.v1";
pub const EMIT_PLAN_TOOL: &str = "emit_plan";
pub const UPDATE_PLAN_TOOL: &str = "update_plan";

pub fn is_plan_tool(name: &str) -> bool {
    matches!(name, EMIT_PLAN_TOOL | UPDATE_PLAN_TOOL)
}

pub fn normalize_plan_tool_call(tool_name: &str, args: &serde_json::Value) -> serde_json::Value {
    let source = args.get("plan").unwrap_or(args);
    let mut plan = serde_json::json!({
        "_type": "plan_artifact",
        "schema_version": PLAN_SCHEMA_VERSION,
        "tool": tool_name,
        "title": string_field(source, &["title", "name"]).unwrap_or_else(|| "Plan".to_string()),
        "summary": string_field(args, &["explanation", "summary", "direction"])
            .or_else(|| string_field(source, &["explanation", "summary", "direction"]))
            .unwrap_or_default(),
        "steps": normalize_steps(source),
        "assumptions": string_list_field(source, &["assumptions"]),
        "open_questions": string_list_field(source, &["open_questions", "questions", "unknowns"]),
        "verification_commands": string_list_field(
            source,
            &["verification_commands", "verification", "verify_commands"],
        ),
        "approval": normalize_approval(source.get("approval").or_else(|| args.get("approval"))),
    });

    let digest_input = serde_json::to_vec(&plan).unwrap_or_default();
    let digest = hex::encode(Sha256::digest(digest_input));
    plan["id"] = serde_json::Value::String(format!("plan_{}", &digest[..12]));
    plan
}

pub fn plan_entries(plan: &serde_json::Value) -> serde_json::Value {
    let entries = plan
        .get("steps")
        .and_then(serde_json::Value::as_array)
        .map(|steps| {
            steps
                .iter()
                .filter_map(|step| {
                    let content = step.get("content")?.as_str()?.trim();
                    if content.is_empty() {
                        return None;
                    }
                    let mut entry = serde_json::json!({
                        "content": content,
                        "status": step.get("status").and_then(serde_json::Value::as_str).unwrap_or("pending"),
                    });
                    if let Some(priority) = step.get("priority") {
                        if !priority.is_null() {
                            entry["priority"] = priority.clone();
                        }
                    }
                    Some(entry)
                })
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();

    if entries.is_empty() {
        let summary = plan
            .get("summary")
            .and_then(serde_json::Value::as_str)
            .unwrap_or_default()
            .trim();
        let content = if summary.is_empty() {
            "Plan emitted"
        } else {
            summary
        };
        return serde_json::json!([{ "content": content, "status": "pending" }]);
    }
    serde_json::Value::Array(entries)
}

pub fn render_plan(plan: &serde_json::Value) -> String {
    let mut lines = Vec::new();
    let title = plan
        .get("title")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("Plan");
    lines.push(format!("# {title}"));
    if let Some(summary) = plan.get("summary").and_then(serde_json::Value::as_str) {
        if !summary.trim().is_empty() {
            lines.push(String::new());
            lines.push(summary.trim().to_string());
        }
    }
    if let Some(steps) = plan.get("steps").and_then(serde_json::Value::as_array) {
        if !steps.is_empty() {
            lines.push(String::new());
            lines.push("## Steps".to_string());
            for step in steps {
                let status = step
                    .get("status")
                    .and_then(serde_json::Value::as_str)
                    .unwrap_or("pending");
                let content = step
                    .get("content")
                    .and_then(serde_json::Value::as_str)
                    .unwrap_or_default();
                lines.push(format!("- [{status}] {content}"));
            }
        }
    }
    append_list_section(&mut lines, plan, "assumptions", "Assumptions");
    append_list_section(&mut lines, plan, "open_questions", "Open questions");
    append_list_section(
        &mut lines,
        plan,
        "verification_commands",
        "Verification commands",
    );
    let approval_state = plan
        .pointer("/approval/state")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("unrequested");
    lines.push(String::new());
    lines.push(format!("Approval: {approval_state}"));
    lines.join("\n")
}

fn append_list_section(lines: &mut Vec<String>, plan: &serde_json::Value, key: &str, title: &str) {
    let Some(items) = plan.get(key).and_then(serde_json::Value::as_array) else {
        return;
    };
    if items.is_empty() {
        return;
    }
    lines.push(String::new());
    lines.push(format!("## {title}"));
    for item in items {
        if let Some(text) = item.as_str() {
            lines.push(format!("- {text}"));
        }
    }
}

fn normalize_steps(source: &serde_json::Value) -> serde_json::Value {
    let steps_value = source
        .get("steps")
        .or_else(|| source.get("plan"))
        .or_else(|| source.get("tasks"))
        .unwrap_or(source);
    let Some(items) = steps_value.as_array() else {
        return serde_json::Value::Array(Vec::new());
    };
    let steps = items
        .iter()
        .enumerate()
        .filter_map(|(idx, item)| normalize_step(idx, item))
        .collect::<Vec<_>>();
    serde_json::Value::Array(steps)
}

fn normalize_step(idx: usize, item: &serde_json::Value) -> Option<serde_json::Value> {
    if let Some(text) = item.as_str() {
        let content = text.trim();
        if content.is_empty() {
            return None;
        }
        return Some(serde_json::json!({
            "id": format!("step-{}", idx + 1),
            "content": content,
            "status": "pending",
            "priority": null,
        }));
    }
    let object = item.as_object()?;
    let content = string_field(item, &["content", "step", "task", "goal", "description"])?;
    if content.trim().is_empty() {
        return None;
    }
    Some(serde_json::json!({
        "id": string_field(item, &["id"]).unwrap_or_else(|| format!("step-{}", idx + 1)),
        "content": content.trim(),
        "status": normalize_status(object.get("status")),
        "priority": object.get("priority").cloned().unwrap_or(serde_json::Value::Null),
    }))
}

fn normalize_status(value: Option<&serde_json::Value>) -> &'static str {
    match value
        .and_then(serde_json::Value::as_str)
        .unwrap_or("pending")
    {
        "pending" | "todo" | "not_started" => "pending",
        "in_progress" | "active" | "running" | "started" => "in_progress",
        "completed" | "complete" | "done" => "completed",
        "blocked" | "waiting" => "blocked",
        "cancelled" | "canceled" | "dropped" | "skipped" => "cancelled",
        _ => "pending",
    }
}

fn normalize_approval(value: Option<&serde_json::Value>) -> serde_json::Value {
    let Some(value) = value else {
        return serde_json::json!({"state": "unrequested"});
    };
    if let Some(object) = value.as_object() {
        let mut approval = serde_json::json!({
            "state": approval_state(value),
        });
        for key in [
            "request_id",
            "reviewer",
            "reviewers",
            "approved_at",
            "reason",
        ] {
            if let Some(field) = object.get(key) {
                approval[key] = field.clone();
            }
        }
        return approval;
    }
    serde_json::json!({"state": approval_state(value)})
}

fn approval_state(value: &serde_json::Value) -> &'static str {
    if let Some(state) = value
        .get("state")
        .or_else(|| value.get("status"))
        .and_then(serde_json::Value::as_str)
    {
        return match state {
            "approved" => "approved",
            "rejected" | "denied" => "rejected",
            "requested" | "pending" => "requested",
            _ => "unrequested",
        };
    }
    match value.get("approved").and_then(serde_json::Value::as_bool) {
        Some(true) => "approved",
        Some(false) => "rejected",
        None => "unrequested",
    }
}

fn string_list_field(source: &serde_json::Value, keys: &[&str]) -> serde_json::Value {
    for key in keys {
        if let Some(value) = source.get(*key) {
            return serde_json::Value::Array(string_list(value));
        }
    }
    serde_json::Value::Array(Vec::new())
}

fn string_list(value: &serde_json::Value) -> Vec<serde_json::Value> {
    match value {
        serde_json::Value::Array(items) => items
            .iter()
            .filter_map(|item| scalar_text(item).map(serde_json::Value::String))
            .filter(|item| item.as_str().is_some_and(|text| !text.trim().is_empty()))
            .map(|item| {
                serde_json::Value::String(item.as_str().unwrap_or_default().trim().to_string())
            })
            .collect(),
        _ => scalar_text(value)
            .filter(|text| !text.trim().is_empty())
            .map(|text| serde_json::Value::String(text.trim().to_string()))
            .into_iter()
            .collect(),
    }
}

fn string_field(source: &serde_json::Value, keys: &[&str]) -> Option<String> {
    keys.iter()
        .find_map(|key| source.get(*key).and_then(scalar_text))
}

fn scalar_text(value: &serde_json::Value) -> Option<String> {
    match value {
        serde_json::Value::String(text) => Some(text.to_string()),
        serde_json::Value::Number(number) => Some(number.to_string()),
        serde_json::Value::Bool(value) => Some(value.to_string()),
        _ => None,
    }
}

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

    #[test]
    fn update_plan_shape_normalizes_to_artifact_and_acp_entries() {
        let plan = normalize_plan_tool_call(
            UPDATE_PLAN_TOOL,
            &json!({
                "explanation": "Adjust after reading the parser.",
                "plan": [
                    {"step": "Read parser tests.", "status": "completed"},
                    {"step": "Patch parser.", "status": "in_progress"}
                ],
                "verification_commands": ["cargo test -p harn-parser"],
                "approval": {"approved": true, "reviewers": ["lead"]}
            }),
        );
        assert_eq!(plan["schema_version"], PLAN_SCHEMA_VERSION);
        assert_eq!(plan["steps"][0]["content"], "Read parser tests.");
        assert_eq!(plan["steps"][1]["status"], "in_progress");
        assert_eq!(plan["approval"]["state"], "approved");

        let entries = plan_entries(&plan);
        assert_eq!(entries[0]["content"], "Read parser tests.");
        assert_eq!(entries[0]["status"], "completed");
    }

    #[test]
    fn empty_plan_entries_use_non_empty_fallback() {
        let plan = normalize_plan_tool_call(EMIT_PLAN_TOOL, &json!({"summary": ""}));

        let entries = plan_entries(&plan);
        assert_eq!(entries[0]["content"], "Plan emitted");
        assert_eq!(entries[0]["status"], "pending");
    }
}