openlatch-provider 0.2.1

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! `migrate` — split a v1 manifest into a v2 (tool, provider) pair.
//!
//! v1 manifest shape (input):
//!
//! ```yaml
//! schema_version: 1
//! editor: { ... }
//! tools: [ ... ]
//! providers: [ ... ]
//! bindings: [ { tool, provider, process: { ... }, ... } ]
//! ```
//!
//! v2 output:
//!
//! - **Tool manifest** (`--out-tool <path>`): `schema_version: 2`,
//!   `kind: Tool`, `editor`, `tools[]` — each tool gets the binding's
//!   `process:` block hoisted into it (matched by tool slug).
//! - **Provider manifest** (`--out-provider <path>`): `schema_version: 2`,
//!   `kind: Provider`, `tool_paths: ["./<basename-of-tool-file>"]` (so the
//!   provider picks up the tool manifest sibling), `providers[]`,
//!   `bindings[]` (each binding's `tool:` becomes the qualified ref
//!   `<editor>/<tool>@<version>`; `process_override` is left empty).
//!
//! The migration is idempotent and lossless for the supported v1 shapes.

use std::path::PathBuf;

use clap::Args;

use crate::cli::GlobalArgs;
use crate::error::{
    OlError, OL_4210_SCHEMA_MISMATCH, OL_4273_MANIFEST_UNREADABLE, OL_4274_MANIFEST_WRITE_FAILED,
};
use crate::manifest;
use crate::ui::output::OutputConfig;

#[derive(Args, Debug)]
pub struct MigrateArgs {
    /// Path to a v1 manifest file (`schema_version: 1`).
    pub v1_path: PathBuf,

    /// Where to write the v2 tool manifest (`kind: Tool`).
    #[arg(long, value_name = "PATH")]
    pub out_tool: PathBuf,

    /// Where to write the v2 provider manifest (`kind: Provider`).
    #[arg(long, value_name = "PATH")]
    pub out_provider: PathBuf,

    /// Print the two files to stdout instead of writing to disk.
    #[arg(long)]
    pub dry_run: bool,
}

pub async fn run(g: &GlobalArgs, args: MigrateArgs) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);

    let bytes = std::fs::read(&args.v1_path).map_err(|e| {
        OlError::new(
            OL_4273_MANIFEST_UNREADABLE,
            format!("cannot read `{}`: {e}", args.v1_path.display()),
        )
    })?;
    let yaml: serde_yaml::Value = serde_yaml::from_slice(&bytes).map_err(|e| {
        OlError::new(
            OL_4210_SCHEMA_MISMATCH,
            format!("`{}`: YAML parse: {e}", args.v1_path.display()),
        )
    })?;
    let json = serde_json::to_value(&yaml).map_err(|e| {
        OlError::new(
            OL_4210_SCHEMA_MISMATCH,
            format!("`{}`: YAML→JSON: {e}", args.v1_path.display()),
        )
    })?;

    // The rest of this function assumes the v1 shape.
    manifest::schema::validate(&json)?;

    let editor = json
        .get("editor")
        .cloned()
        .unwrap_or(serde_json::Value::Null);
    let editor_slug = editor
        .get("slug")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let tools_v1 = json
        .get("tools")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    let providers_v1 = json
        .get("providers")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    let bindings_v1 = json
        .get("bindings")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();

    if editor_slug.is_empty() {
        return Err(OlError::new(
            OL_4210_SCHEMA_MISMATCH,
            "v1 manifest has no editor.slug — cannot synthesize qualified tool refs",
        ));
    }

    let mut process_by_tool: std::collections::BTreeMap<String, serde_json::Value> =
        std::collections::BTreeMap::new();
    for b in &bindings_v1 {
        let tool_slug = b
            .get("tool")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        if tool_slug.is_empty() {
            continue;
        }
        if let Some(process) = b.get("process") {
            // First binding wins for a tool slug. Multi-binding tools with
            // diverging process blocks would need manual reconciliation in v2
            // anyway (one process per tool).
            process_by_tool
                .entry(tool_slug)
                .or_insert_with(|| process.clone());
        }
    }

    let mut tools_v2: Vec<serde_json::Value> = Vec::new();
    for t in &tools_v1 {
        let slug = t
            .get("slug")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        let mut item = t.clone();
        if let Some(map) = item.as_object_mut() {
            if let Some(process) = process_by_tool.get(&slug) {
                map.insert("process".into(), v1_process_to_v2(process));
            }
        }
        tools_v2.push(item);
    }
    if tools_v2.is_empty() {
        return Err(OlError::new(
            OL_4210_SCHEMA_MISMATCH,
            "v1 manifest has no tools[] — nothing to migrate",
        ));
    }

    let tool_manifest = serde_json::json!({
        "schema_version": 2,
        "kind": "Tool",
        "editor": editor,
        "tools": tools_v2,
    });

    let tool_basename = args
        .out_tool
        .file_name()
        .and_then(|s| s.to_str())
        .map(|s| s.to_string())
        .unwrap_or_else(|| "openlatch-tool.yaml".to_string());
    let mut bindings_v2: Vec<serde_json::Value> = Vec::new();
    for b in &bindings_v1 {
        let tool_slug = b
            .get("tool")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        let version = tools_v1
            .iter()
            .find(|t| t.get("slug").and_then(|v| v.as_str()) == Some(&tool_slug))
            .and_then(|t| t.get("version").and_then(|v| v.as_str()))
            .unwrap_or("0.0.0")
            .to_string();
        let qualified = format!("{editor_slug}/{tool_slug}@{version}");
        let mut new_b = serde_json::Map::new();
        new_b.insert("tool".into(), serde_json::Value::String(qualified));
        for (k, v) in b.as_object().into_iter().flatten() {
            if k == "tool" || k == "process" {
                continue;
            }
            new_b.insert(k.clone(), v.clone());
        }
        bindings_v2.push(serde_json::Value::Object(new_b));
    }

    let provider_manifest = serde_json::json!({
        "schema_version": 2,
        "kind": "Provider",
        "tool_paths": [format!("./{tool_basename}")],
        "providers": providers_v1,
        "bindings": bindings_v2,
    });

    let tool_yaml = to_yaml(&tool_manifest)?;
    let provider_yaml = to_yaml(&provider_manifest)?;

    if args.dry_run {
        out.print_info("--- tool manifest (v2) ---");
        println!("{tool_yaml}");
        out.print_info("--- provider manifest (v2) ---");
        println!("{provider_yaml}");
        return Ok(());
    }

    if let Some(parent) = args.out_tool.parent() {
        if !parent.as_os_str().is_empty() {
            std::fs::create_dir_all(parent).map_err(|e| {
                OlError::new(
                    OL_4274_MANIFEST_WRITE_FAILED,
                    format!("mkdir -p `{}`: {e}", parent.display()),
                )
            })?;
        }
    }
    if let Some(parent) = args.out_provider.parent() {
        if !parent.as_os_str().is_empty() {
            std::fs::create_dir_all(parent).map_err(|e| {
                OlError::new(
                    OL_4274_MANIFEST_WRITE_FAILED,
                    format!("mkdir -p `{}`: {e}", parent.display()),
                )
            })?;
        }
    }
    std::fs::write(&args.out_tool, &tool_yaml).map_err(|e| {
        OlError::new(
            OL_4274_MANIFEST_WRITE_FAILED,
            format!("write `{}`: {e}", args.out_tool.display()),
        )
    })?;
    std::fs::write(&args.out_provider, &provider_yaml).map_err(|e| {
        OlError::new(
            OL_4274_MANIFEST_WRITE_FAILED,
            format!("write `{}`: {e}", args.out_provider.display()),
        )
    })?;

    out.print_info(&format!("Wrote tool manifest: {}", args.out_tool.display()));
    out.print_info(&format!(
        "Wrote provider manifest: {}",
        args.out_provider.display()
    ));
    crate::telemetry::capture_global(crate::telemetry::Event::manifest_migrated(
        tools_v1.len() as u32,
        bindings_v1.len() as u32,
        args.dry_run,
    ));
    Ok(())
}

fn to_yaml(value: &serde_json::Value) -> Result<String, OlError> {
    serde_yaml::to_string(value).map_err(|e| {
        OlError::new(
            OL_4274_MANIFEST_WRITE_FAILED,
            format!("YAML serialize: {e}"),
        )
    })
}

/// Translate a v1 `process:` block into the v2 shape. v1 stores
/// `restart_policy: {max_restarts, window_ms}` plus an enum
/// `restart: always|on-failure|no`; v2 stores `restart: {max_restarts,
/// window_seconds}` and has no enum equivalent. We keep the rate-limit
/// window and drop the enum — the v2 supervisor's default is `always`.
fn v1_process_to_v2(v1_process: &serde_json::Value) -> serde_json::Value {
    let mut out = serde_json::Map::new();
    let obj = match v1_process.as_object() {
        Some(m) => m,
        None => return serde_json::Value::Null,
    };

    for key in [
        "command",
        "cwd",
        "env",
        "start_timeout_ms",
        "kill_timeout_ms",
        "health_check",
    ] {
        if let Some(v) = obj.get(key) {
            out.insert(key.to_string(), v.clone());
        }
    }
    if let Some(rp) = obj.get("restart_policy").and_then(|v| v.as_object()) {
        let mut new_rp = serde_json::Map::new();
        if let Some(n) = rp.get("max_restarts") {
            new_rp.insert("max_restarts".into(), n.clone());
        }
        if let Some(ms) = rp.get("window_ms").and_then(|v| v.as_u64()) {
            new_rp.insert(
                "window_seconds".into(),
                serde_json::Value::from(ms.div_ceil(1000)),
            );
        }
        if !new_rp.is_empty() {
            out.insert("restart".into(), serde_json::Value::Object(new_rp));
        }
    }
    serde_json::Value::Object(out)
}