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 {
#[arg(value_enum)]
pub bump: Option<BumpKind>,
#[arg(long)]
pub version: Option<String>,
#[arg(long, value_name = "PATH")]
pub manifest: Option<String>,
#[arg(long)]
pub git_tag: bool,
#[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",
};
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(())
}
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.",
))
}