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 {
pub v1_path: PathBuf,
#[arg(long, value_name = "PATH")]
pub out_tool: PathBuf,
#[arg(long, value_name = "PATH")]
pub out_provider: PathBuf,
#[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()),
)
})?;
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") {
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}"),
)
})
}
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)
}