//! catalog: manage remote catalogs (issues #60 #67, axes 7 & 9). //! 'am catalog update' refreshes the official catalog and applies it as an //! include; 'am catalog add ' registers any external catalog (same //! YAML format) the same way. Everything goes through the probe-style //! cache in catalog_remote so offline runs keep working. use super::*; use crate::catalog_remote::{self, CatalogDiff}; use crate::cli::CatalogCmd; use crate::commands::config_cmd::register_include; use crate::events::{Event, EventKind}; use crate::output::print_json; use anyhow::{anyhow, Result}; pub fn run(app: &App, cmd: &CatalogCmd) -> Result { match cmd { CatalogCmd::Update => update(app), CatalogCmd::Add { url } => add(app, url), CatalogCmd::List => list(app), } } /// am catalog update: fetch the official catalog, show the diff against the /// local catalog, ask for confirmation, then register the URL as an include. fn update(app: &App) -> Result { let url = catalog_remote::official_url(app); app.log.info(&format!("fetching the official catalog: {url}")); let text = catalog_remote::fetch(app, &url, true)?; let remote = catalog_remote::parse_catalog(&text)?; let diff = catalog_remote::diff_catalogs(&app.config, &remote); if app.json() { print_json(&serde_json::json!({ "url": url, "agents": remote.agents.len(), "added": diff.added, "removed": diff.removed, "changed": diff.changed.iter().map(|(n, from, to)| serde_json::json!({"name": n, "run_from": from, "run_to": to})).collect::>(), })); return Ok(0); } print_diff(&diff); if diff.added.is_empty() && diff.removed.is_empty() && diff.changed.is_empty() { app.log .success(&format!("catalog is up to date ({} agents)", remote.agents.len())); return Ok(0); } if app.dry_run() { app.log.dry("would register the official catalog as an include"); return Ok(0); } if !app .confirm(&format!( "apply this catalog ({} agents, {} changes)?", remote.agents.len(), diff.added.len() + diff.removed.len() + diff.changed.len() ))? { app.log.info("catalog update cancelled"); return Ok(0); } register_include(app, &url)?; app.emit( &Event::now(EventKind::Catalog) .with_args(vec![url.clone()]) .with_reason("update"), ); app.log.success(&format!( "official catalog registered as an include ({url}) — it is effective from the next run" )); Ok(0) } /// am catalog add : validate and register an external catalog. fn add(app: &App, url: &str) -> Result { crate::download::validate_url(url)?; if config_cmd::user_includes(app).iter().any(|i| i == url) { app.log .info(&format!("catalog {url} is already registered as an include")); return Ok(0); } app.log.info(&format!("fetching catalog: {url}")); // force: 'catalog add' must validate the current content of the URL, // not a possibly stale cached copy. let text = catalog_remote::fetch(app, url, true)?; let remote = catalog_remote::parse_catalog(&text)?; if app.json() { print_json(&serde_json::json!({ "url": url, "agents": remote.agents.len(), "registered": false, })); return Ok(0); } if remote.agents.is_empty() { return Err(anyhow!( "the catalog at {url} defines no agents — refusing to register it" )); } if app.dry_run() { app.log.dry(&format!( "would register {url} ({:?} agents)", remote.agents.len() )); return Ok(0); } register_include(app, url)?; app.emit( &Event::now(EventKind::Catalog) .with_args(vec![url.to_string()]) .with_reason("add"), ); app.log.success(&format!( "catalog {url} registered ({} agents) — effective from the next run", remote.agents.len() )); Ok(0) } /// am catalog list: show the registered remote catalogs (includes). /// The resolved config consumes its includes at load time, so this reads /// the user config file directly (like 'config add'). fn list(app: &App) -> Result { let urls: Vec = config_cmd::user_includes(app) .iter() .filter(|i| { i.starts_with("http://") || i.starts_with("https://") || i.starts_with("file://") }) .cloned() .collect(); if app.json() { print_json(&urls); return Ok(0); } if urls.is_empty() { app.log.info( "no remote catalog registered — 'am catalog add ' or 'am catalog update'", ); return Ok(0); } println!("remote catalogs ({}):", urls.len()); for u in &urls { println!(" - {u}"); } Ok(0) } fn print_diff(diff: &CatalogDiff) { if !diff.added.is_empty() { println!(" + {} new agent(s): {}", diff.added.len(), diff.added.join(", ")); } if !diff.removed.is_empty() { println!( " - {} removed: {}", diff.removed.len(), diff.removed.join(", ") ); } if !diff.changed.is_empty() { println!(" ~ {} changed run command(s):", diff.changed.len()); for (name, from, to) in &diff.changed { println!(" {name}: '{from}' -> '{to}'"); } } }