From 3725986af76a13843e9a2d899167f0388d32be0f Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 12:33:46 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AA=20test(kebab-cli):=20integration?= =?UTF-8?q?=20coverage=20for=20kebab=20schema=20+=20error.v1=20(fb-27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli_schema: exercises `kebab schema` (text + --json) on a fresh-but-init'd KB. Pins schema_version, kebab_version non-empty, capabilities.json_mode true, capabilities.mcp_server false (future placeholder). cli_error_wire: spawns `kebab --json --config ingest` and verifies stderr emits a single error.v1 ndjson line with code == "config_invalid". Non-JSON mode regression-pinned to keep the legacy `error:` prefix. Note: --config /nonexistent silently falls back to defaults (by design); a file that exists but fails TOML parsing is the reliable trigger for config_invalid. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-cli/tests/cli_error_wire.rs | 105 ++++++++++++++++++ crates/kebab-cli/tests/cli_schema.rs | 134 +++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 crates/kebab-cli/tests/cli_error_wire.rs create mode 100644 crates/kebab-cli/tests/cli_schema.rs diff --git a/crates/kebab-cli/tests/cli_error_wire.rs b/crates/kebab-cli/tests/cli_error_wire.rs new file mode 100644 index 0000000..b89ddc7 --- /dev/null +++ b/crates/kebab-cli/tests/cli_error_wire.rs @@ -0,0 +1,105 @@ +//! Integration: spawn kebab and verify --json mode emits error.v1 ndjson +//! on stderr while non-json mode emits the legacy `error:` text prefix. +//! +//! The `config_invalid` code is triggered by supplying an *existing* but +//! malformed TOML file via `--config`. Note: supplying a *non-existent* +//! path does NOT trigger this error — Config::load silently falls back to +//! defaults when the specified config file is absent (by design, so that +//! `kebab doctor` runs before `kebab init` is ever called). A file that +//! exists but fails TOML parsing is the reliable path to `config_invalid`. + +use std::process::Command; + +fn kebab_bin() -> std::path::PathBuf { + let manifest = env!("CARGO_MANIFEST_DIR"); + std::path::PathBuf::from(manifest) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target/debug/kebab") +} + +fn xdg_envs(tmp: &std::path::Path) -> [(&'static str, std::path::PathBuf); 4] { + [ + ("XDG_CONFIG_HOME", tmp.join("cfg")), + ("XDG_DATA_HOME", tmp.join("data")), + ("XDG_CACHE_HOME", tmp.join("cache")), + ("XDG_STATE_HOME", tmp.join("state")), + ] +} + +#[test] +fn json_mode_emits_error_v1_on_config_invalid() { + let tmp = tempfile::tempdir().unwrap(); + // Write a file that exists but is not valid TOML. + let bad_config = tmp.path().join("bad.toml"); + std::fs::write(&bad_config, b"this is not { valid toml !!!").unwrap(); + + let mut cmd = Command::new(kebab_bin()); + cmd.args([ + "--json", + "--config", + bad_config.to_str().unwrap(), + "ingest", + ]); + for (k, v) in xdg_envs(tmp.path()) { + cmd.env(k, v); + } + + let out = cmd.output().unwrap(); + assert!( + !out.status.success(), + "expected non-zero exit for config_invalid" + ); + let exit_code = out.status.code().unwrap_or(-1); + assert_eq!(exit_code, 2, "expected exit code 2, got {exit_code}"); + + let stderr = String::from_utf8(out.stderr).unwrap(); + let first_line = stderr.lines().next().expect("stderr must have at least one line"); + let v: serde_json::Value = + serde_json::from_str(first_line).expect("stderr first line must be valid JSON"); + + assert_eq!( + v.get("schema_version").and_then(|s| s.as_str()), + Some("error.v1"), + "schema_version must be error.v1" + ); + assert_eq!( + v.get("code").and_then(|s| s.as_str()), + Some("config_invalid"), + "code must be config_invalid" + ); +} + +#[test] +fn text_mode_emits_legacy_error_format() { + let tmp = tempfile::tempdir().unwrap(); + // Same trigger: an existing file with malformed TOML. + let bad_config = tmp.path().join("bad.toml"); + std::fs::write(&bad_config, b"this is not { valid toml !!!").unwrap(); + + let mut cmd = Command::new(kebab_bin()); + cmd.args(["--config", bad_config.to_str().unwrap(), "ingest"]); + for (k, v) in xdg_envs(tmp.path()) { + cmd.env(k, v); + } + + let out = cmd.output().unwrap(); + assert!( + !out.status.success(), + "expected non-zero exit for config_invalid" + ); + let exit_code = out.status.code().unwrap_or(-1); + assert_eq!(exit_code, 2, "expected exit code 2, got {exit_code}"); + + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!( + stderr.starts_with("error:"), + "text mode stderr must start with 'error:', got: {stderr:?}" + ); + assert!( + !stderr.trim_start().starts_with('{'), + "text mode stderr must NOT be JSON, got: {stderr:?}" + ); +} diff --git a/crates/kebab-cli/tests/cli_schema.rs b/crates/kebab-cli/tests/cli_schema.rs new file mode 100644 index 0000000..f084d4d --- /dev/null +++ b/crates/kebab-cli/tests/cli_schema.rs @@ -0,0 +1,134 @@ +//! Integration: spawn the kebab binary and parse `kebab schema [--json]`. +//! +//! Each test builds an isolated TempDir-rooted XDG layout, runs +//! `kebab ingest` over an empty workspace (which creates and migrates +//! kebab.sqlite), then exercises `kebab schema` in JSON and text modes. +//! Using an empty workspace avoids the embedding model dependency while +//! still seeding the DB so `open_existing` inside schema_with_config +//! succeeds (a NotIndexed error fires when the DB file is absent). + +use std::process::Command; + +fn kebab_bin() -> std::path::PathBuf { + let manifest = env!("CARGO_MANIFEST_DIR"); + std::path::PathBuf::from(manifest) + .parent() + .unwrap() + .parent() + .unwrap() + .join("target/debug/kebab") +} + +fn xdg_envs(tmp: &std::path::Path) -> [(&'static str, std::path::PathBuf); 4] { + [ + ("XDG_CONFIG_HOME", tmp.join("cfg")), + ("XDG_DATA_HOME", tmp.join("data")), + ("XDG_CACHE_HOME", tmp.join("cache")), + ("XDG_STATE_HOME", tmp.join("state")), + ] +} + +/// Seed kebab.sqlite by running `kebab ingest` over an empty workspace dir. +/// This is the minimum required for `kebab schema` to succeed: the store +/// uses `open_existing`, which errors when the DB file is absent. +fn seed_db(tmp: &tempfile::TempDir) { + let ws = tmp.path().join("ws"); + std::fs::create_dir_all(&ws).unwrap(); + + let mut cmd = Command::new(kebab_bin()); + cmd.args(["ingest", "--root", ws.to_str().unwrap(), "--summary-only"]); + for (k, v) in xdg_envs(tmp.path()) { + cmd.env(k, v); + } + let out = cmd.output().unwrap(); + assert!( + out.status.success(), + "seed ingest failed: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn cli_schema_json_emits_schema_v1() { + let tmp = tempfile::tempdir().unwrap(); + seed_db(&tmp); + + let mut cmd = Command::new(kebab_bin()); + cmd.args(["--json", "schema"]); + for (k, v) in xdg_envs(tmp.path()) { + cmd.env(k, v); + } + let out = cmd.output().unwrap(); + assert!( + out.status.success(), + "kebab --json schema failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let stdout = String::from_utf8(out.stdout).unwrap(); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("stdout must be valid JSON"); + + assert_eq!( + v.get("schema_version").and_then(|s| s.as_str()), + Some("schema.v1"), + "schema_version must be schema.v1" + ); + assert!( + v.get("kebab_version") + .and_then(|s| s.as_str()) + .map(|s| !s.is_empty()) + .unwrap_or(false), + "kebab_version must be a non-empty string" + ); + + let caps = v + .get("capabilities") + .and_then(|c| c.as_object()) + .expect("capabilities must be a JSON object"); + assert_eq!( + caps.get("json_mode").and_then(|b| b.as_bool()), + Some(true), + "capabilities.json_mode must be true" + ); + assert_eq!( + caps.get("mcp_server").and_then(|b| b.as_bool()), + Some(false), + "capabilities.mcp_server must be false (not yet shipped)" + ); +} + +#[test] +fn cli_schema_text_mode_runs() { + let tmp = tempfile::tempdir().unwrap(); + seed_db(&tmp); + + let mut cmd = Command::new(kebab_bin()); + cmd.args(["schema"]); + for (k, v) in xdg_envs(tmp.path()) { + cmd.env(k, v); + } + let out = cmd.output().unwrap(); + assert!( + out.status.success(), + "kebab schema (text) failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!( + stdout.contains("kebab v"), + "text output must contain 'kebab v', got: {stdout}" + ); + assert!( + stdout.contains("capabilities"), + "text output must contain 'capabilities', got: {stdout}" + ); + assert!( + stdout.contains("models"), + "text output must contain 'models', got: {stdout}" + ); + assert!( + stdout.contains("stats"), + "text output must contain 'stats', got: {stdout}" + ); +}