openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! `publish [bump]` — bump the manifest's tool versions, write back, and
//! POST each one to `/api/v1/editor/tools`.

use std::collections::BTreeSet;
use std::path::PathBuf;

use clap::{Args, ValueEnum};

use crate::api::client::ApiClient;
use crate::api::editor;
use crate::cli::commands::shared::make_client;
use crate::cli::GlobalArgs;
use crate::config;
use crate::error::{
    OlError, OL_4210_SCHEMA_MISMATCH, OL_4280_PLATFORM_DUPLICATE_TOOL_SLUG,
    OL_4284_PREFLIGHT_VALIDATION_FAILED,
};
use crate::manifest::{self, version::Bump, Manifest};
use crate::ui::output::OutputConfig;

#[derive(ValueEnum, Debug, Clone, Copy)]
pub enum BumpKind {
    Major,
    Minor,
    Patch,
}

impl From<BumpKind> for Bump {
    fn from(b: BumpKind) -> Self {
        match b {
            BumpKind::Major => Bump::Major,
            BumpKind::Minor => Bump::Minor,
            BumpKind::Patch => Bump::Patch,
        }
    }
}

#[derive(Args, Debug)]
pub struct PublishArgs {
    /// Bump kind (mutually exclusive with `--version`).
    #[arg(value_enum)]
    pub bump: Option<BumpKind>,

    /// Set an explicit version (overrides `bump`).
    #[arg(long)]
    pub version: Option<String>,

    /// Path to a manifest file (default: resolved from `[profiles.<profile>]
    /// manifest_slug` in `~/.openlatch/provider/config.toml`).
    #[arg(long, value_name = "PATH")]
    pub manifest: Option<String>,

    /// Tag the resulting commit `<editor>/<tool>@vX.Y.Z` and push (no-op
    /// when not in a git repo). Off by default for v0.1.
    #[arg(long)]
    pub git_tag: bool,

    /// Skip the pre-flight `:validate` pass on tool slugs. Use only when
    /// recovering from a partial publish; default off.
    #[arg(long)]
    pub skip_preflight: bool,
}

pub async fn run(g: &GlobalArgs, args: PublishArgs) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);
    let manifest_path: PathBuf = match args.manifest {
        Some(p) => PathBuf::from(p),
        None => config::active_manifest_path(g.profile.as_deref())?,
    };

    let mut m = manifest::load(&manifest_path)?;

    if m.tools.is_empty() {
        out.print_info(
            "No tools declared in manifest — nothing to publish. Add a `tools[]` entry first.",
        );
        return Ok(());
    }

    let bump_label = match (args.bump, args.version.as_ref()) {
        (Some(_), Some(_)) => {
            return Err(OlError::new(
                OL_4210_SCHEMA_MISMATCH,
                "pass either a bump kind or --version=X, not both",
            ));
        }
        (Some(b), None) => {
            for t in m.tools.iter_mut() {
                let new = manifest::version::bump(&t.version, b.into())?;
                let typed: crate::generated::ManifestToolVersion = serde_json::from_value(
                    serde_json::Value::String(new.clone()),
                )
                .map_err(|e| {
                    OlError::new(
                        OL_4210_SCHEMA_MISMATCH,
                        format!("typify-deserialise version `{new}`: {e}"),
                    )
                })?;
                t.version = typed;
            }
            match b {
                BumpKind::Major => "major",
                BumpKind::Minor => "minor",
                BumpKind::Patch => "patch",
            }
        }
        (None, Some(explicit)) => {
            for t in m.tools.iter_mut() {
                let typed: crate::generated::ManifestToolVersion = serde_json::from_value(
                    serde_json::Value::String(explicit.clone()),
                )
                .map_err(|e| {
                    OlError::new(
                        OL_4210_SCHEMA_MISMATCH,
                        format!("typify-deserialise version `{explicit}`: {e}"),
                    )
                })?;
                t.version = typed;
            }
            "explicit"
        }
        (None, None) => "no-bump",
    };

    // Write updated manifest back to disk before contacting the platform —
    // if the network call fails the user can still re-run the publish.
    if !matches!(bump_label, "no-bump") {
        manifest::editor::save(&manifest_path, &m)?;
        out.print_step(&format!(
            "Bumped {} tool version(s) ({bump_label})",
            m.tools.len()
        ));
    }

    if g.dry_run {
        out.print_step("Dry run — would POST:");
        for t in &m.tools {
            out.print_substep(&format!(
                "POST /api/v1/editor/tools  ({} v{})",
                *t.slug, *t.version
            ));
        }
        return Ok(());
    }

    let client = make_client().await?;

    if !args.skip_preflight {
        out.print_step("Pre-flight: validating tool slugs against platform");
        preflight_validate_tools(&client, &m).await?;
        out.print_substep("✓ pre-flight passed");
    } else {
        out.print_info(
            "⚠  --skip-preflight set: bypassing platform :validate. Slug conflicts will surface mid-upsert.",
        );
    }

    let total_categories: u32 = m.tools.iter().map(|t| t.capabilities.len() as u32).sum();
    for t in &m.tools {
        let body = serde_json::to_value(t).map_err(|e| {
            OlError::new(
                OL_4210_SCHEMA_MISMATCH,
                format!("serialise tool `{}`: {e}", *t.slug),
            )
        })?;
        editor::upsert_tool(&client, &body).await?;
        out.print_step(&format!("Published `{}` v{}", *t.slug, *t.version));
    }

    crate::telemetry::capture_global(crate::telemetry::Event::tool_published(
        bump_label,
        false,
        total_categories,
    ));

    if args.git_tag {
        out.print_info("--git-tag: deferred to v0.2 (no-op for now).");
    }
    Ok(())
}

/// Pre-flight: walk every tool slug in the manifest and `:validate` it. Skip
/// tools the editor already owns (the `version-bump-then-publish` happy path).
/// Aggregates conflicts and surfaces a single composite `OL-4284` so vendors
/// who edit `tools[]` by hand learn about every collision at once.
async fn preflight_validate_tools(client: &ApiClient, m: &Manifest) -> Result<(), OlError> {
    let live_tools = editor::list_tools(client).await.unwrap_or_default();
    let owned_tools: BTreeSet<String> = live_tools.iter().map(|t| t.slug.clone()).collect();

    let mut conflicts: Vec<serde_json::Value> = Vec::new();

    for t in &m.tools {
        let slug = (*t.slug).to_string();
        if owned_tools.contains(&slug) {
            continue;
        }
        let body = serde_json::to_value(t).map_err(|e| {
            OlError::new(
                OL_4210_SCHEMA_MISMATCH,
                format!("serialise tool `{slug}`: {e}"),
            )
        })?;
        if let Err(e) = editor::validate_tool(client, &body).await {
            if e.is(OL_4280_PLATFORM_DUPLICATE_TOOL_SLUG) {
                conflicts.push(serde_json::json!({
                    "kind": "tool",
                    "slug": slug,
                    "message": e.message,
                }));
            } else {
                return Err(e);
            }
        }
    }

    if conflicts.is_empty() {
        return Ok(());
    }

    let summary = conflicts
        .iter()
        .filter_map(|c| c.get("slug").and_then(|v| v.as_str()))
        .collect::<Vec<_>>()
        .join(", ");

    Err(OlError::new(
        OL_4284_PREFLIGHT_VALIDATION_FAILED,
        format!(
            "pre-flight validation found {} tool slug conflict(s): {}",
            conflicts.len(),
            summary
        ),
    )
    .with_context(serde_json::json!({ "conflicts": conflicts }))
    .with_suggestion(
        "Pick different tool slug(s) in the manifest, or pass --skip-preflight to retry mid-publish.",
    ))
}