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}"
+ );
+}