feat: karapace-cli — 23 commands, thin dispatcher, progress indicators

- 23 commands, each in its own module under commands/
- Thin main.rs dispatcher with clap subcommand routing
- Progress spinners (indicatif) and colored state output (console)
- Environment resolution by env_id, short_id, name, or prefix
- Structured JSON output (--json) on all query commands
- --verbose/-v for debug, --trace for trace-level logging
- KARAPACE_LOG env var for fine-grained log control
- Exit codes: 0 success, 1 failure, 2 manifest error, 3 store error
- Prerequisite check before runtime operations
- Shell completions (bash/zsh/fish/elvish/powershell) and man page generation
This commit is contained in:
Marco Allegretti 2026-02-22 18:37:54 +01:00
parent 4a90300807
commit 1416b0fc99
28 changed files with 6403 additions and 0 deletions

View file

@ -0,0 +1,37 @@
[package]
name = "karapace-cli"
description = "CLI interface for Karapace deterministic environment engine"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[lints]
workspace = true
[[bin]]
name = "karapace"
path = "src/main.rs"
[dependencies]
clap.workspace = true
clap_complete.workspace = true
clap_mangen.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
indicatif.workspace = true
console.workspace = true
libc.workspace = true
karapace-schema = { path = "../karapace-schema" }
karapace-core = { path = "../karapace-core" }
karapace-store = { path = "../karapace-store" }
karapace-runtime = { path = "../karapace-runtime" }
karapace-tui = { path = "../karapace-tui" }
karapace-remote = { path = "../karapace-remote" }
[dev-dependencies]
tempfile.workspace = true
serde_json.workspace = true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
use super::{resolve_env_id, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result<u8, String> {
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
engine.archive(&resolved).map_err(|e| e.to_string())?;
println!("archived environment {env_id}");
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,57 @@
use super::{json_pretty, spin_fail, spin_ok, spinner, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
pub fn run(
engine: &Engine,
store_path: &Path,
manifest: &Path,
name: Option<&str>,
json: bool,
) -> Result<u8, String> {
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let pb = if json {
None
} else {
Some(spinner("building environment..."))
};
let result = match engine.build(manifest) {
Ok(r) => {
if let Some(ref pb) = pb {
spin_ok(pb, "environment built");
}
r
}
Err(e) => {
if let Some(ref pb) = pb {
spin_fail(pb, "build failed");
}
return Err(e.to_string());
}
};
if let Some(n) = name {
engine
.set_name(&result.identity.env_id, Some(n.to_owned()))
.map_err(|e| e.to_string())?;
}
if json {
let payload = serde_json::json!({
"env_id": result.identity.env_id,
"short_id": result.identity.short_id,
"name": name,
"status": "built"
});
println!("{}", json_pretty(&payload)?);
} else {
if let Some(n) = name {
println!("built environment '{}' ({})", n, result.identity.short_id);
} else {
println!("built environment {}", result.identity.short_id);
}
println!("env_id: {}", result.identity.env_id);
}
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,22 @@
use super::{json_pretty, resolve_env_id, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
pub fn run(engine: &Engine, store_path: &Path, env_id: &str, json: bool) -> Result<u8, String> {
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
let tar_hash = engine.commit(&resolved).map_err(|e| e.to_string())?;
if json {
let payload = serde_json::json!({
"env_id": resolved,
"snapshot_hash": tar_hash,
});
println!("{}", json_pretty(&payload)?);
} else {
println!("committed snapshot {tar_hash} for {env_id}");
}
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,9 @@
use super::EXIT_SUCCESS;
use clap::CommandFactory;
use clap_complete::Shell;
#[allow(clippy::unnecessary_wraps)]
pub fn run<C: CommandFactory>(shell: Shell) -> Result<u8, String> {
clap_complete::generate(shell, &mut C::command(), "karapace", &mut std::io::stdout());
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,14 @@
use super::{resolve_env_id, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result<u8, String> {
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
engine.destroy(&resolved).map_err(|e| e.to_string())?;
println!("destroyed environment {env_id}");
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,26 @@
use super::{json_pretty, resolve_env_id, EXIT_SUCCESS};
use karapace_core::Engine;
pub fn run(engine: &Engine, env_id: &str, json: bool) -> Result<u8, String> {
let resolved = resolve_env_id(engine, env_id)?;
let report =
karapace_core::diff_overlay(engine.store_layout(), &resolved).map_err(|e| e.to_string())?;
if json {
println!("{}", json_pretty(&report)?);
} else if report.has_drift {
println!("drift detected in environment {env_id}:");
for f in &report.added {
println!(" + {f}");
}
for f in &report.modified {
println!(" ~ {f}");
}
for f in &report.removed {
println!(" - {f}");
}
} else {
println!("no drift detected in environment {env_id}");
}
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,255 @@
use super::{EXIT_FAILURE, EXIT_SUCCESS};
use karapace_store::StoreLayout;
use std::path::Path;
pub fn run(store_path: &Path, json_output: bool) -> Result<u8, String> {
let mut checks: Vec<Check> = Vec::new();
let mut all_pass = true;
check_prereqs(&mut checks, &mut all_pass);
let layout = StoreLayout::new(store_path);
if store_path.join("store").exists() {
checks.push(Check::pass("store_exists", "Store directory exists"));
check_store(&layout, &mut checks, &mut all_pass);
check_disk_space(store_path, &mut checks);
} else {
checks.push(Check::info(
"store_exists",
"Store not initialized (will be created on first build)",
));
}
print_results(&checks, all_pass, json_output)
}
fn check_prereqs(checks: &mut Vec<Check>, all_pass: &mut bool) {
let missing = karapace_runtime::check_namespace_prereqs();
if missing.is_empty() {
checks.push(Check::pass(
"runtime_prereqs",
"Runtime prerequisites satisfied",
));
} else {
*all_pass = false;
checks.push(Check::fail(
"runtime_prereqs",
&format!(
"Missing prerequisites: {}",
karapace_runtime::format_missing(&missing)
),
));
}
}
fn check_store(layout: &StoreLayout, checks: &mut Vec<Check>, all_pass: &mut bool) {
// Version
match layout.initialize() {
Ok(()) => checks.push(Check::pass("store_version", "Store format version valid")),
Err(e) => {
*all_pass = false;
checks.push(Check::fail(
"store_version",
&format!("Store version check failed: {e}"),
));
}
}
// Integrity
match karapace_store::verify_store_integrity(layout) {
Ok(report) if report.failed.is_empty() => {
checks.push(Check::pass(
"store_integrity",
&format!("Store integrity OK ({} objects checked)", report.checked),
));
}
Ok(report) => {
*all_pass = false;
checks.push(Check::fail(
"store_integrity",
&format!(
"{} of {} objects corrupted",
report.failed.len(),
report.checked
),
));
}
Err(e) => {
*all_pass = false;
checks.push(Check::fail(
"store_integrity",
&format!("Integrity check failed: {e}"),
));
}
}
// WAL
let wal = karapace_store::WriteAheadLog::new(layout);
match wal.list_incomplete() {
Ok(entries) if entries.is_empty() => {
checks.push(Check::pass(
"wal_clean",
"WAL is clean (no incomplete entries)",
));
}
Ok(entries) => {
checks.push(Check::warn(
"wal_clean",
&format!(
"WAL has {} incomplete entries (will recover on next start)",
entries.len()
),
));
}
Err(e) => checks.push(Check::warn("wal_clean", &format!("Cannot read WAL: {e}"))),
}
// Lock
match karapace_core::StoreLock::try_acquire(&layout.lock_file()) {
Ok(Some(_)) => checks.push(Check::pass("store_lock", "Store lock is free")),
Ok(None) => checks.push(Check::warn(
"store_lock",
"Store lock is held by another process",
)),
Err(e) => {
*all_pass = false;
checks.push(Check::fail(
"store_lock",
&format!("Cannot check store lock: {e}"),
));
}
}
// Environments
let meta_store = karapace_store::MetadataStore::new(layout.clone());
match meta_store.list() {
Ok(envs) => {
let running = envs
.iter()
.filter(|e| e.state == karapace_store::EnvState::Running)
.count();
checks.push(Check::info(
"environments",
&format!("{} environments ({running} running)", envs.len()),
));
}
Err(e) => checks.push(Check::warn(
"environments",
&format!("Cannot list environments: {e}"),
)),
}
}
fn print_results(checks: &[Check], all_pass: bool, json_output: bool) -> Result<u8, String> {
if json_output {
let json = serde_json::json!({
"healthy": all_pass,
"checks": checks.iter().map(|c| serde_json::json!({
"name": c.name,
"status": c.status,
"message": c.message,
})).collect::<Vec<_>>(),
});
println!(
"{}",
serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?
);
} else {
println!("Karapace Doctor\n");
for check in checks {
let icon = match check.status.as_str() {
"pass" => "",
"fail" => "",
"warn" => "",
_ => "",
};
println!(" {icon} {}", check.message);
}
println!();
if all_pass {
println!("All checks passed.");
} else {
println!("Some checks failed. See above for details.");
}
}
Ok(if all_pass { EXIT_SUCCESS } else { EXIT_FAILURE })
}
struct Check {
name: String,
status: String,
message: String,
}
impl Check {
fn pass(name: &str, message: &str) -> Self {
Self {
name: name.to_owned(),
status: "pass".to_owned(),
message: message.to_owned(),
}
}
fn fail(name: &str, message: &str) -> Self {
Self {
name: name.to_owned(),
status: "fail".to_owned(),
message: message.to_owned(),
}
}
fn warn(name: &str, message: &str) -> Self {
Self {
name: name.to_owned(),
status: "warn".to_owned(),
message: message.to_owned(),
}
}
fn info(name: &str, message: &str) -> Self {
Self {
name: name.to_owned(),
status: "info".to_owned(),
message: message.to_owned(),
}
}
}
fn check_disk_space(store_path: &Path, checks: &mut Vec<Check>) {
let Ok(c_path) = std::ffi::CString::new(store_path.to_string_lossy().as_bytes()) else {
return;
};
// SAFETY: zeroed statvfs is a valid initial state for the struct.
#[allow(unsafe_code, clippy::undocumented_unsafe_blocks)]
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
// SAFETY: statvfs with a valid, NUL-terminated path and a properly
// zeroed output struct is well-defined. The struct is stack-allocated
// and only read after the call succeeds (ret == 0).
#[allow(unsafe_code, clippy::undocumented_unsafe_blocks)]
let ret = unsafe { libc::statvfs(c_path.as_ptr(), &raw mut stat) };
if ret != 0 {
return;
}
let avail_bytes = stat.f_bavail * stat.f_frsize;
let avail_mb = avail_bytes / (1024 * 1024);
if avail_mb < 100 {
checks.push(Check::fail(
"disk_space",
&format!("Low disk space: {avail_mb} MB available"),
));
} else if avail_mb < 1024 {
checks.push(Check::warn(
"disk_space",
&format!("Disk space: {avail_mb} MB available (consider freeing space)"),
));
} else {
let free_gb = avail_mb / 1024;
checks.push(Check::pass(
"disk_space",
&format!("Disk space: {free_gb} GB available"),
));
}
}

View file

@ -0,0 +1,22 @@
use super::{resolve_env_id, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
pub fn run(
engine: &Engine,
store_path: &Path,
env_id: &str,
command: &[String],
) -> Result<u8, String> {
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
if command.is_empty() {
engine.enter(&resolved).map_err(|e| e.to_string())?;
} else {
engine.exec(&resolved, command).map_err(|e| e.to_string())?;
}
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,19 @@
use super::{resolve_env_id, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
pub fn run(
engine: &Engine,
store_path: &Path,
env_id: &str,
command: &[String],
_json: bool,
) -> Result<u8, String> {
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
engine.exec(&resolved, command).map_err(|e| e.to_string())?;
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,14 @@
use super::{resolve_env_id, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result<u8, String> {
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
engine.freeze(&resolved).map_err(|e| e.to_string())?;
println!("frozen environment {env_id}");
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,33 @@
use super::{json_pretty, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
pub fn run(engine: &Engine, store_path: &Path, dry_run: bool, json: bool) -> Result<u8, String> {
let layout = StoreLayout::new(store_path);
let lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let report = engine.gc(&lock, dry_run).map_err(|e| e.to_string())?;
if json {
let payload = serde_json::json!({
"dry_run": dry_run,
"orphaned_envs": report.orphaned_envs,
"orphaned_layers": report.orphaned_layers,
"orphaned_objects": report.orphaned_objects,
"removed_envs": report.removed_envs,
"removed_layers": report.removed_layers,
"removed_objects": report.removed_objects,
});
println!("{}", json_pretty(&payload)?);
} else {
let prefix = if dry_run { "would remove" } else { "removed" };
println!(
"gc: {prefix} {} envs, {} layers, {} objects",
report.removed_envs, report.removed_layers, report.removed_objects
);
if dry_run && !report.orphaned_envs.is_empty() {
println!("orphaned envs: {:?}", report.orphaned_envs);
}
}
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,21 @@
use super::{colorize_state, json_pretty, resolve_env_id, EXIT_SUCCESS};
use karapace_core::Engine;
pub fn run(engine: &Engine, env_id: &str, json: bool) -> Result<u8, String> {
let resolved = resolve_env_id(engine, env_id)?;
let meta = engine.inspect(&resolved).map_err(|e| e.to_string())?;
if json {
println!("{}", json_pretty(&meta)?);
} else {
println!("env_id: {}", meta.env_id);
println!("short_id: {}", meta.short_id);
println!("name: {}", meta.name.as_deref().unwrap_or("(none)"));
println!("state: {}", colorize_state(&meta.state.to_string()));
println!("base_layer: {}", meta.base_layer);
println!("deps: {}", meta.dependency_layers.len());
println!("ref_count: {}", meta.ref_count);
println!("created_at: {}", meta.created_at);
println!("updated_at: {}", meta.updated_at);
}
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,22 @@
use super::{colorize_state, json_pretty, EXIT_SUCCESS};
use karapace_core::Engine;
pub fn run(engine: &Engine, json: bool) -> Result<u8, String> {
let envs = engine.list().map_err(|e| e.to_string())?;
if json {
println!("{}", json_pretty(&envs)?);
} else if envs.is_empty() {
println!("no environments found");
} else {
println!("{:<14} {:<16} {:<10} ENV_ID", "SHORT_ID", "NAME", "STATE");
for env in &envs {
let name_display = env.name.as_deref().unwrap_or("");
let state_str = colorize_state(&env.state.to_string());
println!(
"{:<14} {:<16} {:<10} {}",
env.short_id, name_display, state_str, env.env_id
);
}
}
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,26 @@
use super::EXIT_SUCCESS;
use clap::CommandFactory;
use std::path::Path;
pub fn run<C: CommandFactory>(dir: &Path) -> Result<u8, String> {
std::fs::create_dir_all(dir).map_err(|e| format!("failed to create dir: {e}"))?;
let cmd = C::command();
let man = clap_mangen::Man::new(cmd.clone());
let mut buf = Vec::new();
man.render(&mut buf)
.map_err(|e| format!("man page render failed: {e}"))?;
let path = dir.join("karapace.1");
std::fs::write(&path, &buf).map_err(|e| format!("failed to write {}: {e}", path.display()))?;
for sub in cmd.get_subcommands() {
let sub_name = format!("karapace-{}", sub.get_name());
let man = clap_mangen::Man::new(sub.clone());
let mut buf = Vec::new();
man.render(&mut buf)
.map_err(|e| format!("man page render failed: {e}"))?;
let path = dir.join(format!("{sub_name}.1"));
std::fs::write(&path, &buf)
.map_err(|e| format!("failed to write {}: {e}", path.display()))?;
}
println!("man pages written to {}", dir.display());
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,100 @@
use super::{EXIT_FAILURE, EXIT_SUCCESS};
use std::path::Path;
pub fn run(store_path: &Path, json_output: bool) -> Result<u8, String> {
let store_dir = store_path.join("store");
if !store_dir.exists() {
msg(
json_output,
r#"{"status": "no_store", "message": "No store found."}"#,
&format!(
"No store found at {}. Nothing to migrate.",
store_path.display()
),
);
return Ok(EXIT_SUCCESS);
}
let version_path = store_dir.join("version");
if !version_path.exists() {
msg(json_output,
r#"{"status": "error", "message": "Store exists but has no version file."}"#,
&format!("Store at {} has no version file. May be corrupted or very old.\nRecommended: back up and rebuild.", store_path.display()));
return Ok(EXIT_FAILURE);
}
let content = std::fs::read_to_string(&version_path)
.map_err(|e| format!("failed to read version file: {e}"))?;
let ver: serde_json::Value =
serde_json::from_str(&content).map_err(|e| format!("invalid version file: {e}"))?;
let found = ver
.get("format_version")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let current = u64::from(karapace_store::STORE_FORMAT_VERSION);
if found == current {
msg(
json_output,
&format!(r#"{{"status": "current", "format_version": {current}}}"#),
&format!("Store format version: {current} (current)\nNo migration needed."),
);
return Ok(EXIT_SUCCESS);
}
if found > current {
msg(json_output,
&format!(r#"{{"status": "newer", "found": {found}, "supported": {current}}}"#),
&format!("Store format version: {found}\nSupported: {current}\n\nCreated by a newer Karapace. Please upgrade."));
return Ok(EXIT_FAILURE);
}
// Attempt automatic migration
match karapace_store::migrate_store(store_path) {
Ok(Some(result)) => {
msg(
json_output,
&format!(
r#"{{"status": "migrated", "from": {}, "to": {}, "environments": {}, "backup": "{}"}}"#,
result.from_version,
result.to_version,
result.environments_migrated,
result.backup_path.display()
),
&format!(
"Migrated store from v{} to v{}.\n{} environments updated.\nBackup: {}",
result.from_version,
result.to_version,
result.environments_migrated,
result.backup_path.display()
),
);
Ok(EXIT_SUCCESS)
}
Ok(None) => {
// Should not reach here (we already checked found != current above)
msg(
json_output,
&format!(r#"{{"status": "current", "format_version": {current}}}"#),
&format!("Store format version: {current} (current)\nNo migration needed."),
);
Ok(EXIT_SUCCESS)
}
Err(e) => {
msg(
json_output,
&format!(r#"{{"status": "error", "message": "{e}"}}"#),
&format!("Migration failed: {e}"),
);
Ok(EXIT_FAILURE)
}
}
}
fn msg(json_output: bool, json: &str, human: &str) {
if json_output {
println!("{json}");
} else {
println!("{human}");
}
}

View file

@ -0,0 +1,209 @@
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 pull;
pub mod push;
pub mod rebuild;
pub mod rename;
pub mod restore;
pub mod snapshots;
pub mod stop;
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"
)),
}
}
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");
}
}

View file

@ -0,0 +1,44 @@
use super::{json_pretty, make_remote_backend, spin_fail, spin_ok, spinner, EXIT_SUCCESS};
use karapace_core::Engine;
pub fn run(
engine: &Engine,
reference: &str,
remote_url: Option<&str>,
json: bool,
) -> Result<u8, String> {
let backend = make_remote_backend(remote_url)?;
// Resolve reference: try as registry ref first, fall back to raw env_id
let env_id = match Engine::resolve_remote_ref(&backend, reference) {
Ok(id) => id,
Err(_) => reference.to_owned(),
};
let pb = spinner("pulling environment…");
let result = engine.pull(&env_id, &backend).map_err(|e| {
spin_fail(&pb, "pull failed");
e.to_string()
})?;
spin_ok(&pb, "pull complete");
if json {
let payload = serde_json::json!({
"env_id": env_id,
"objects_pulled": result.objects_pulled,
"layers_pulled": result.layers_pulled,
"objects_skipped": result.objects_skipped,
"layers_skipped": result.layers_skipped,
});
println!("{}", json_pretty(&payload)?);
} else {
println!(
"pulled {} ({} objects, {} layers; {} skipped)",
&env_id[..12.min(env_id.len())],
result.objects_pulled,
result.layers_pulled,
result.objects_skipped + result.layers_skipped,
);
}
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,46 @@
use super::{
json_pretty, make_remote_backend, resolve_env_id, spin_fail, spin_ok, spinner, EXIT_SUCCESS,
};
use karapace_core::Engine;
pub fn run(
engine: &Engine,
env_id: &str,
tag: Option<&str>,
remote_url: Option<&str>,
json: bool,
) -> Result<u8, String> {
let resolved = resolve_env_id(engine, env_id)?;
let backend = make_remote_backend(remote_url)?;
let pb = spinner("pushing environment…");
let result = engine.push(&resolved, &backend, tag).map_err(|e| {
spin_fail(&pb, "push failed");
e.to_string()
})?;
spin_ok(&pb, "push complete");
if json {
let payload = serde_json::json!({
"env_id": resolved,
"tag": tag,
"objects_pushed": result.objects_pushed,
"layers_pushed": result.layers_pushed,
"objects_skipped": result.objects_skipped,
"layers_skipped": result.layers_skipped,
});
println!("{}", json_pretty(&payload)?);
} else {
println!(
"pushed {} ({} objects, {} layers; {} skipped)",
&resolved[..12],
result.objects_pushed,
result.layers_pushed,
result.objects_skipped + result.layers_skipped,
);
if let Some(t) = tag {
println!("tagged as '{t}'");
}
}
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,57 @@
use super::{json_pretty, spin_fail, spin_ok, spinner, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
pub fn run(
engine: &Engine,
store_path: &Path,
manifest: &Path,
name: Option<&str>,
json: bool,
) -> Result<u8, String> {
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let pb = if json {
None
} else {
Some(spinner("rebuilding environment..."))
};
let result = match engine.rebuild(manifest) {
Ok(r) => {
if let Some(ref pb) = pb {
spin_ok(pb, "environment rebuilt");
}
r
}
Err(e) => {
if let Some(ref pb) = pb {
spin_fail(pb, "rebuild failed");
}
return Err(e.to_string());
}
};
if let Some(n) = name {
engine
.set_name(&result.identity.env_id, Some(n.to_owned()))
.map_err(|e| e.to_string())?;
}
if json {
let payload = serde_json::json!({
"env_id": result.identity.env_id,
"short_id": result.identity.short_id,
"name": name,
"status": "rebuilt"
});
println!("{}", json_pretty(&payload)?);
} else {
if let Some(n) = name {
println!("rebuilt environment '{}' ({})", n, result.identity.short_id);
} else {
println!("rebuilt environment {}", result.identity.short_id);
}
println!("env_id: {}", result.identity.env_id);
}
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,16 @@
use super::{resolve_env_id, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
pub fn run(engine: &Engine, store_path: &Path, env_id: &str, new_name: &str) -> Result<u8, String> {
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
engine
.rename(&resolved, new_name)
.map_err(|e| e.to_string())?;
println!("renamed {} → '{}'", &resolved[..12], new_name);
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,31 @@
use super::{json_pretty, resolve_env_id, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
pub fn run(
engine: &Engine,
store_path: &Path,
env_id: &str,
snapshot_hash: &str,
json: bool,
) -> Result<u8, String> {
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
engine
.restore(&resolved, snapshot_hash)
.map_err(|e| e.to_string())?;
if json {
let payload = serde_json::json!({
"env_id": resolved,
"restored_snapshot": snapshot_hash,
});
println!("{}", json_pretty(&payload)?);
} else {
println!("restored {env_id} from snapshot {snapshot_hash}");
}
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,39 @@
use super::{json_pretty, resolve_env_id, EXIT_SUCCESS};
use karapace_core::Engine;
use karapace_store::StoreLayout;
use std::path::Path;
pub fn run(engine: &Engine, store_path: &Path, env_id: &str, json: bool) -> Result<u8, String> {
let _layout = StoreLayout::new(store_path);
let resolved = resolve_env_id(engine, env_id)?;
let snapshots = engine
.list_snapshots(&resolved)
.map_err(|e| e.to_string())?;
if json {
let entries: Vec<_> = snapshots
.iter()
.map(|s| {
serde_json::json!({
"hash": s.hash,
"tar_hash": s.tar_hash,
"parent": s.parent,
})
})
.collect();
let payload = serde_json::json!({
"env_id": resolved,
"snapshots": entries,
});
println!("{}", json_pretty(&payload)?);
} else if snapshots.is_empty() {
println!("no snapshots for {env_id}");
} else {
println!("snapshots for {env_id}:");
for s in &snapshots {
println!(" {} (tar: {})", &s.hash[..12], &s.tar_hash[..12]);
}
}
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,14 @@
use super::{resolve_env_id, EXIT_SUCCESS};
use karapace_core::{Engine, StoreLock};
use karapace_store::StoreLayout;
use std::path::Path;
pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result<u8, String> {
let layout = StoreLayout::new(store_path);
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
let resolved = resolve_env_id(engine, env_id)?;
engine.stop(&resolved).map_err(|e| e.to_string())?;
println!("stopped environment {env_id}");
Ok(EXIT_SUCCESS)
}

View file

@ -0,0 +1,30 @@
use super::{json_pretty, EXIT_STORE_ERROR, EXIT_SUCCESS};
use karapace_core::Engine;
use karapace_store::verify_store_integrity;
pub fn run(engine: &Engine, json: bool) -> Result<u8, String> {
let report = verify_store_integrity(engine.store_layout()).map_err(|e| e.to_string())?;
if json {
let payload = serde_json::json!({
"checked": report.checked,
"passed": report.passed,
"failed": report.failed.len(),
});
println!("{}", json_pretty(&payload)?);
} else {
println!(
"store integrity: {}/{} objects passed",
report.passed, report.checked
);
for f in &report.failed {
println!(" FAIL {}: {}", f.hash, f.reason);
}
}
if report.failed.is_empty() {
Ok(EXIT_SUCCESS)
} else {
Ok(EXIT_STORE_ERROR)
}
}

View file

@ -0,0 +1,302 @@
mod commands;
use clap::{Parser, Subcommand};
use clap_complete::Shell;
use commands::{EXIT_FAILURE, EXIT_MANIFEST_ERROR, EXIT_STORE_ERROR};
use karapace_core::{install_signal_handler, Engine};
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Debug, Parser)]
#[command(
name = "karapace",
version,
about = "Deterministic environment engine for immutable systems"
)]
struct Cli {
/// Path to the Karapace store directory.
#[arg(long, default_value = "~/.local/share/karapace")]
store: String,
/// Output results as structured JSON.
#[arg(long, default_value_t = false, global = true)]
json: bool,
/// Enable verbose (debug) logging output.
#[arg(short, long, default_value_t = false, global = true)]
verbose: bool,
/// Enable trace-level logging (more detailed than --verbose).
#[arg(long, default_value_t = false, global = true)]
trace: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
/// Build an environment from a manifest.
Build {
/// Path to manifest TOML file.
#[arg(default_value = "karapace.toml")]
manifest: PathBuf,
/// Human-readable name for the environment.
#[arg(long)]
name: Option<String>,
},
/// Destroy and rebuild an environment from manifest.
Rebuild {
/// Path to manifest TOML file.
#[arg(default_value = "karapace.toml")]
manifest: PathBuf,
/// Human-readable name for the environment.
#[arg(long)]
name: Option<String>,
},
/// Enter a built environment (use -- to pass a command instead of interactive shell).
Enter {
/// Environment ID (full or short).
env_id: String,
/// Command to run inside the environment (after --).
#[arg(last = true)]
command: Vec<String>,
},
/// Execute a command inside a built environment (non-interactive).
Exec {
/// Environment ID (full or short).
env_id: String,
/// Command and arguments to run.
#[arg(required = true, last = true)]
command: Vec<String>,
},
/// Destroy an environment and its overlay.
Destroy {
/// Environment ID.
env_id: String,
},
/// Stop a running environment.
Stop {
/// Environment ID.
env_id: String,
},
/// Freeze an environment (prevent further writes).
Freeze {
/// Environment ID.
env_id: String,
},
/// Archive an environment (preserve but prevent entry).
Archive {
/// Environment ID.
env_id: String,
},
/// List all known environments.
List,
/// Inspect environment metadata.
Inspect {
/// Environment ID.
env_id: String,
},
/// Show drift in the writable overlay of an environment.
Diff {
/// Environment ID.
env_id: String,
},
/// List snapshots for an environment.
Snapshots {
/// Environment ID.
env_id: String,
},
/// Commit overlay drift into the content store as a snapshot.
Commit {
/// Environment ID.
env_id: String,
},
/// Restore an environment's overlay from a snapshot.
Restore {
/// Environment ID.
env_id: String,
/// Snapshot layer hash to restore from.
snapshot: String,
},
/// Run garbage collection on the store.
Gc {
/// Only report what would be removed.
#[arg(long, default_value_t = false)]
dry_run: bool,
},
/// Verify store integrity.
VerifyStore,
/// Push an environment to a remote store.
Push {
/// Environment ID, short ID, or name.
env_id: String,
/// Registry tag (e.g. "my-env@latest"). If omitted, pushed without a tag.
#[arg(long)]
tag: Option<String>,
/// Remote store URL (overrides config file).
#[arg(long)]
remote: Option<String>,
},
/// Pull an environment from a remote store.
Pull {
/// Registry reference (e.g. "my-env@latest") or raw env_id.
reference: String,
/// Remote store URL (overrides config file).
#[arg(long)]
remote: Option<String>,
},
/// Rename an environment.
Rename {
/// Environment ID or current name.
env_id: String,
/// New name for the environment.
new_name: String,
},
/// Generate shell completions for bash, zsh, fish, elvish, or powershell.
Completions {
/// Shell to generate completions for.
shell: Shell,
},
/// Generate man pages in the specified directory.
ManPages {
/// Output directory for man pages.
#[arg(default_value = "man")]
dir: PathBuf,
},
/// Run diagnostic checks on the system and store.
Doctor,
/// Check store version and show migration guidance.
Migrate,
}
#[allow(clippy::too_many_lines)]
fn main() -> ExitCode {
let cli = Cli::parse();
let default_level = if cli.trace {
"trace"
} else if cli.verbose {
"debug"
} else {
"warn"
};
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_env("KARAPACE_LOG")
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level)),
)
.with_target(false)
.without_time()
.init();
install_signal_handler();
let store_path = expand_tilde(&cli.store);
let engine = Engine::new(&store_path);
let json_output = cli.json;
let needs_runtime = matches!(
cli.command,
Commands::Build { .. }
| Commands::Enter { .. }
| Commands::Exec { .. }
| Commands::Rebuild { .. }
);
if needs_runtime {
let missing = karapace_runtime::check_namespace_prereqs();
if !missing.is_empty() {
eprintln!("error: {}", karapace_runtime::format_missing(&missing));
return ExitCode::from(EXIT_FAILURE);
}
}
let result = match cli.command {
Commands::Build { manifest, name } => commands::build::run(
&engine,
&store_path,
&manifest,
name.as_deref(),
json_output,
),
Commands::Rebuild { manifest, name } => commands::rebuild::run(
&engine,
&store_path,
&manifest,
name.as_deref(),
json_output,
),
Commands::Enter { env_id, command } => {
commands::enter::run(&engine, &store_path, &env_id, &command)
}
Commands::Exec { env_id, command } => {
commands::exec::run(&engine, &store_path, &env_id, &command, json_output)
}
Commands::Destroy { env_id } => commands::destroy::run(&engine, &store_path, &env_id),
Commands::Stop { env_id } => commands::stop::run(&engine, &store_path, &env_id),
Commands::Freeze { env_id } => commands::freeze::run(&engine, &store_path, &env_id),
Commands::Archive { env_id } => commands::archive::run(&engine, &store_path, &env_id),
Commands::List => commands::list::run(&engine, json_output),
Commands::Inspect { env_id } => commands::inspect::run(&engine, &env_id, json_output),
Commands::Diff { env_id } => commands::diff::run(&engine, &env_id, json_output),
Commands::Snapshots { env_id } => {
commands::snapshots::run(&engine, &store_path, &env_id, json_output)
}
Commands::Commit { env_id } => {
commands::commit::run(&engine, &store_path, &env_id, json_output)
}
Commands::Restore { env_id, snapshot } => {
commands::restore::run(&engine, &store_path, &env_id, &snapshot, json_output)
}
Commands::Gc { dry_run } => commands::gc::run(&engine, &store_path, dry_run, json_output),
Commands::VerifyStore => commands::verify_store::run(&engine, json_output),
Commands::Push {
env_id,
tag,
remote,
} => commands::push::run(
&engine,
&env_id,
tag.as_deref(),
remote.as_deref(),
json_output,
),
Commands::Pull { reference, remote } => {
commands::pull::run(&engine, &reference, remote.as_deref(), json_output)
}
Commands::Rename { env_id, new_name } => {
commands::rename::run(&engine, &store_path, &env_id, &new_name)
}
Commands::Completions { shell } => commands::completions::run::<Cli>(shell),
Commands::ManPages { dir } => commands::man_pages::run::<Cli>(&dir),
Commands::Doctor => commands::doctor::run(&store_path, json_output),
Commands::Migrate => commands::migrate::run(&store_path, json_output),
};
match result {
Ok(code) => ExitCode::from(code),
Err(msg) => {
eprintln!("error: {msg}");
let code = if msg.starts_with("manifest error:")
|| msg.starts_with("failed to parse manifest")
|| msg.starts_with("failed to read manifest")
{
EXIT_MANIFEST_ERROR
} else if msg.starts_with("store error:") || msg.starts_with("store lock:") {
EXIT_STORE_ERROR
} else {
EXIT_FAILURE
};
ExitCode::from(code)
}
}
}
fn expand_tilde(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(stripped);
}
}
PathBuf::from(path)
}

View file

@ -0,0 +1,310 @@
//! CLI subprocess integration tests.
//!
//! These tests invoke the `karapace` binary as a subprocess and verify
//! exit codes, stdout content, and JSON output stability.
use std::process::Command;
fn karapace_bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_karapace"))
}
fn temp_store() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
fn write_test_manifest(dir: &std::path::Path) -> std::path::PathBuf {
let path = dir.join("karapace.toml");
std::fs::write(
&path,
r#"manifest_version = 1
[base]
image = "rolling"
[system]
packages = ["git"]
[runtime]
backend = "mock"
"#,
)
.unwrap();
path
}
// A5: CLI Validation — version flag
#[test]
fn cli_version_exits_zero() {
let output = karapace_bin().arg("--version").output().unwrap();
assert!(output.status.success(), "karapace --version must exit 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("karapace"),
"version output must contain 'karapace': {stdout}"
);
}
// A5: CLI Validation — help flag
#[test]
fn cli_help_exits_zero() {
let output = karapace_bin().arg("--help").output().unwrap();
assert!(output.status.success(), "karapace --help must exit 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("build"), "help must list 'build' command");
assert!(
stdout.contains("destroy"),
"help must list 'destroy' command"
);
}
// A5: CLI Validation — build with mock backend
#[test]
fn cli_build_succeeds_with_mock() {
let store = temp_store();
let project = tempfile::tempdir().unwrap();
let manifest = write_test_manifest(project.path());
let output = karapace_bin()
.args([
"--store",
&store.path().to_string_lossy(),
"build",
&manifest.to_string_lossy(),
])
.output()
.unwrap();
assert!(
output.status.success(),
"build must exit 0. stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
// A5: CLI Validation — list with JSON output
#[test]
fn cli_list_json_output_stable() {
let store = temp_store();
let project = tempfile::tempdir().unwrap();
let manifest = write_test_manifest(project.path());
// Build first
let build_out = karapace_bin()
.args([
"--store",
&store.path().to_string_lossy(),
"build",
&manifest.to_string_lossy(),
])
.output()
.unwrap();
assert!(build_out.status.success());
// List with --json
let output = karapace_bin()
.args(["--store", &store.path().to_string_lossy(), "--json", "list"])
.output()
.unwrap();
assert!(
output.status.success(),
"list --json must exit 0. stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
// Must be valid JSON
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("list --json must produce valid JSON: {e}\nstdout: {stdout}"));
assert!(parsed.is_array(), "list output must be a JSON array");
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1, "should have exactly 1 environment");
// Verify expected fields exist
assert!(arr[0]["env_id"].is_string());
assert!(arr[0]["state"].is_string());
}
// A5: CLI Validation — inspect with JSON output
#[test]
fn cli_inspect_json_output_stable() {
let store = temp_store();
let project = tempfile::tempdir().unwrap();
let manifest = write_test_manifest(project.path());
// Build first
let build_out = karapace_bin()
.args([
"--store",
&store.path().to_string_lossy(),
"--json",
"build",
&manifest.to_string_lossy(),
])
.output()
.unwrap();
assert!(build_out.status.success());
// Parse build JSON to get env_id
let build_stdout = String::from_utf8_lossy(&build_out.stdout);
let build_json: serde_json::Value = serde_json::from_str(&build_stdout)
.unwrap_or_else(|e| panic!("build --json must produce valid JSON: {e}\n{build_stdout}"));
let env_id = build_json["env_id"].as_str().unwrap();
// Inspect
let output = karapace_bin()
.args([
"--store",
&store.path().to_string_lossy(),
"--json",
"inspect",
env_id,
])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let inspect_json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("inspect --json must produce valid JSON: {e}\n{stdout}"));
assert_eq!(inspect_json["env_id"].as_str().unwrap(), env_id);
assert_eq!(inspect_json["state"].as_str().unwrap(), "Built");
}
// A5: CLI Validation — destroy succeeds
#[test]
fn cli_destroy_succeeds() {
let store = temp_store();
let project = tempfile::tempdir().unwrap();
let manifest = write_test_manifest(project.path());
// Build
let build_out = karapace_bin()
.args([
"--store",
&store.path().to_string_lossy(),
"--json",
"build",
&manifest.to_string_lossy(),
])
.output()
.unwrap();
assert!(build_out.status.success());
let build_json: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&build_out.stdout)).unwrap();
let env_id = build_json["env_id"].as_str().unwrap();
// Destroy
let output = karapace_bin()
.args([
"--store",
&store.path().to_string_lossy(),
"destroy",
env_id,
])
.output()
.unwrap();
assert!(
output.status.success(),
"destroy must exit 0. stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
// A5: CLI Validation — build with nonexistent manifest fails
#[test]
fn cli_build_nonexistent_manifest_fails() {
let store = temp_store();
let output = karapace_bin()
.args([
"--store",
&store.path().to_string_lossy(),
"build",
"/tmp/nonexistent_karapace_manifest_12345.toml",
])
.output()
.unwrap();
assert!(
!output.status.success(),
"build with missing manifest must fail"
);
}
// A5: CLI Validation — gc on empty store succeeds
#[test]
fn cli_gc_empty_store_succeeds() {
let store = temp_store();
// Initialize store first via a build
let project = tempfile::tempdir().unwrap();
let manifest = write_test_manifest(project.path());
let _ = karapace_bin()
.args([
"--store",
&store.path().to_string_lossy(),
"build",
&manifest.to_string_lossy(),
])
.output()
.unwrap();
let output = karapace_bin()
.args([
"--store",
&store.path().to_string_lossy(),
"--json",
"gc",
"--dry-run",
])
.output()
.unwrap();
assert!(
output.status.success(),
"gc --dry-run must exit 0. stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
// A5: CLI Validation — verify-store on clean store
#[test]
fn cli_verify_store_clean() {
let store = temp_store();
let project = tempfile::tempdir().unwrap();
let manifest = write_test_manifest(project.path());
// Build to populate store
let _ = karapace_bin()
.args([
"--store",
&store.path().to_string_lossy(),
"build",
&manifest.to_string_lossy(),
])
.output()
.unwrap();
let output = karapace_bin()
.args([
"--store",
&store.path().to_string_lossy(),
"--json",
"verify-store",
])
.output()
.unwrap();
assert!(
output.status.success(),
"verify-store must exit 0. stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("verify-store --json must produce valid JSON: {e}\n{stdout}"));
assert_eq!(json["failed"].as_u64().unwrap(), 0);
}