karapace/crates/karapace-cli/src/commands/mod.rs
Marco Allegretti 8e90f45efc Add new and tui CLI commands
Add 'karapace new' to generate a manifest from templates or prompts.

Add 'karapace tui' to launch the terminal UI.

Improve env-id resolution errors in non-JSON output with suggestions.

Add dialoguer and toml as CLI dependencies.
2026-02-23 12:15:39 +01:00

280 lines
8.1 KiB
Rust

pub mod archive;
pub mod build;
pub mod commit;
pub mod completions;
pub mod destroy;
pub mod diff;
pub mod doctor;
pub mod enter;
pub mod exec;
pub mod freeze;
pub mod gc;
pub mod inspect;
pub mod list;
pub mod man_pages;
pub mod migrate;
pub mod new;
pub mod pull;
pub mod push;
pub mod rebuild;
pub mod rename;
pub mod restore;
pub mod snapshots;
pub mod stop;
pub mod tui;
pub mod verify_store;
use indicatif::{ProgressBar, ProgressStyle};
use karapace_core::Engine;
use std::time::Duration;
pub const EXIT_SUCCESS: u8 = 0;
pub const EXIT_FAILURE: u8 = 1;
pub const EXIT_MANIFEST_ERROR: u8 = 2;
pub const EXIT_STORE_ERROR: u8 = 3;
pub fn json_pretty(value: &impl serde::Serialize) -> Result<String, String> {
serde_json::to_string_pretty(value).map_err(|e| format!("JSON serialization failed: {e}"))
}
pub fn spinner(msg: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template("{spinner:.cyan} {msg}")
.expect("valid template")
.tick_strings(&["", "", "", "", "", "", "", "", "", ""]),
);
pb.set_message(msg.to_owned());
pb.enable_steady_tick(Duration::from_millis(80));
pb
}
pub fn spin_ok(pb: &ProgressBar, msg: &str) {
pb.set_style(ProgressStyle::with_template("{msg}").expect("valid template"));
pb.finish_with_message(format!("{msg}"));
}
pub fn spin_fail(pb: &ProgressBar, msg: &str) {
pb.set_style(ProgressStyle::with_template("{msg}").expect("valid template"));
pb.finish_with_message(format!("{msg}"));
}
pub fn colorize_state(state: &str) -> String {
use console::Style;
match state {
"built" => Style::new().green().apply_to(state).to_string(),
"running" => Style::new().cyan().bold().apply_to(state).to_string(),
"defined" => Style::new().yellow().apply_to(state).to_string(),
"frozen" => Style::new().blue().apply_to(state).to_string(),
"archived" => Style::new().dim().apply_to(state).to_string(),
other => other.to_owned(),
}
}
pub fn resolve_env_id(engine: &Engine, input: &str) -> Result<String, String> {
if input.len() == 64 {
return Ok(input.to_owned());
}
let envs = engine.list().map_err(|e| e.to_string())?;
for e in &envs {
if *e.env_id == *input || *e.short_id == *input || e.name.as_deref() == Some(input) {
return Ok(e.env_id.to_string());
}
}
let matches: Vec<_> = envs
.iter()
.filter(|e| e.env_id.starts_with(input) || e.short_id.starts_with(input))
.collect();
match matches.len() {
0 => Err(format!("no environment matching '{input}'")),
1 => Ok(matches[0].env_id.to_string()),
n => Err(format!(
"ambiguous env_id prefix '{input}': matches {n} environments"
)),
}
}
fn format_env_suggestion(meta: &karapace_store::EnvMetadata) -> String {
let label = meta
.name
.as_deref()
.unwrap_or_else(|| meta.short_id.as_str());
format!("{label} ({}) {}", meta.short_id, meta.state)
}
pub fn resolve_env_id_pretty(engine: &Engine, input: &str) -> Result<String, String> {
if input.len() == 64 {
return Ok(input.to_owned());
}
let envs = engine.list().map_err(|e| e.to_string())?;
for e in &envs {
if *e.env_id == *input || *e.short_id == *input || e.name.as_deref() == Some(input) {
return Ok(e.env_id.to_string());
}
}
let prefix_matches: Vec<_> = envs
.iter()
.filter(|e| e.env_id.starts_with(input) || e.short_id.starts_with(input))
.collect();
match prefix_matches.len() {
0 => {
let needle = input.to_lowercase();
let mut suggestions: Vec<_> = envs
.iter()
.filter(|e| {
e.name
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&needle)
|| e.short_id.to_lowercase().contains(&needle)
})
.collect();
suggestions.truncate(5);
if suggestions.is_empty() {
Err(format!("no environment matching '{input}'"))
} else {
let rendered = suggestions
.into_iter()
.map(|m| format!(" {}", format_env_suggestion(m)))
.collect::<Vec<_>>()
.join("\n");
Err(format!(
"no environment matching '{input}'\n\nDid you mean:\n{rendered}\n\nUse 'karapace list' to see all environments."
))
}
}
1 => Ok(prefix_matches[0].env_id.to_string()),
n => {
let rendered = prefix_matches
.iter()
.take(10)
.map(|m| format!(" {}", format_env_suggestion(m)))
.collect::<Vec<_>>()
.join("\n");
Err(format!(
"ambiguous env_id prefix '{input}': matches {n} environments\n\nMatches:\n{rendered}\n\nUse a longer prefix, a full env_id, or a unique name."
))
}
}
}
pub fn make_remote_backend(
remote_url: Option<&str>,
) -> Result<karapace_remote::http::HttpBackend, String> {
let config = if let Some(url) = remote_url {
karapace_remote::RemoteConfig::new(url)
} else {
karapace_remote::RemoteConfig::load_default()
.map_err(|e| format!("no --remote and no config: {e}"))?
};
Ok(karapace_remote::http::HttpBackend::new(config))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_pretty_serializes_string() {
let val = serde_json::json!({"key": "value"});
let result = json_pretty(&val).unwrap();
assert!(result.contains("\"key\""));
assert!(result.contains("\"value\""));
}
#[test]
fn json_pretty_serializes_array() {
let val = vec![1, 2, 3];
let result = json_pretty(&val).unwrap();
assert!(result.contains('1'));
}
#[test]
fn colorize_state_built() {
let result = colorize_state("built");
assert!(result.contains("built"));
}
#[test]
fn colorize_state_running() {
let result = colorize_state("running");
assert!(result.contains("running"));
}
#[test]
fn colorize_state_defined() {
let result = colorize_state("defined");
assert!(result.contains("defined"));
}
#[test]
fn colorize_state_frozen() {
let result = colorize_state("frozen");
assert!(result.contains("frozen"));
}
#[test]
fn colorize_state_archived() {
let result = colorize_state("archived");
assert!(result.contains("archived"));
}
#[test]
fn colorize_state_unknown() {
assert_eq!(colorize_state("unknown"), "unknown");
}
#[test]
fn resolve_env_id_64_char_passthrough() {
let dir = tempfile::tempdir().unwrap();
let engine = Engine::new(dir.path());
let id = "a".repeat(64);
assert_eq!(resolve_env_id(&engine, &id).unwrap(), id);
}
#[test]
fn resolve_env_id_not_found() {
let dir = tempfile::tempdir().unwrap();
let engine = Engine::new(dir.path());
karapace_store::StoreLayout::new(dir.path())
.initialize()
.unwrap();
let result = resolve_env_id(&engine, "nonexistent");
assert!(result.is_err());
assert!(result.unwrap_err().contains("no environment matching"));
}
#[test]
fn exit_codes_are_distinct() {
assert_ne!(EXIT_SUCCESS, EXIT_FAILURE);
assert_ne!(EXIT_FAILURE, EXIT_MANIFEST_ERROR);
assert_ne!(EXIT_MANIFEST_ERROR, EXIT_STORE_ERROR);
}
#[test]
fn make_remote_backend_with_url() {
let backend = make_remote_backend(Some("http://localhost:8080"));
assert!(backend.is_ok());
}
#[test]
fn spinner_creates_progress_bar() {
let pb = spinner("testing...");
spin_ok(&pb, "done");
}
#[test]
fn spinner_fail_creates_progress_bar() {
let pb = spinner("testing...");
spin_fail(&pb, "failed");
}
}