diff --git a/crates/kebab-app/src/error_signal.rs b/crates/kebab-app/src/error_signal.rs index 9bc78d6..7d7ab6b 100644 --- a/crates/kebab-app/src/error_signal.rs +++ b/crates/kebab-app/src/error_signal.rs @@ -11,5 +11,5 @@ pub use crate::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal}; pub use kebab_llm_local::LlmError; -pub use kebab_config::ConfigInvalid; +pub use kebab_config::{ConfigInvalid, ConfigNotFound}; pub use kebab_store_sqlite::NotIndexed; diff --git a/crates/kebab-app/src/error_wire.rs b/crates/kebab-app/src/error_wire.rs index 3df41d9..c0397cf 100644 --- a/crates/kebab-app/src/error_wire.rs +++ b/crates/kebab-app/src/error_wire.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use crate::error_signal::{ConfigInvalid, LlmError, NotIndexed}; +use crate::error_signal::{ConfigInvalid, ConfigNotFound, LlmError, NotIndexed}; // p9-fb-34: `stale_cursor` is constructed directly by `cursor::decode` // and surfaced through `StructuredError` (an anyhow-friendly wrapper @@ -65,6 +65,20 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { hint: Some("check `--config ` and TOML syntax".to_string()), }; } + if let Some(s) = err.downcast_ref::() { + return ErrorV1 { + schema_version: ERROR_V1_ID.to_string(), + code: "config_not_found".to_string(), + message: s.to_string(), + details: json!({ + "path": s.path.to_string_lossy(), + }), + hint: Some( + "verify --config ; pass an existing toml file or omit --config to use XDG default" + .to_string(), + ), + }; + } if let Some(s) = err.downcast_ref::() { return ErrorV1 { schema_version: ERROR_V1_ID.to_string(), diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index 791869c..1761551 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -71,6 +71,7 @@ pub use app::{App, SearchResponse, short_query_hint}; pub use ingest_progress::{AggregateCounts, IngestEvent, render_skipped_breakdown}; pub use reset::{ResetReport, ResetScope, enumerate_orphans}; pub use error_wire::{ERROR_V1_ID, ErrorV1, StructuredError, classify}; +pub use kebab_config::{ConfigInvalid, ConfigNotFound}; pub use fetch::fetch_with_config; #[doc(hidden)] pub use bulk::{BULK_QUERIES_MAX, bulk_search_with_config}; diff --git a/crates/kebab-cli/tests/cli_config_not_found.rs b/crates/kebab-cli/tests/cli_config_not_found.rs new file mode 100644 index 0000000..a782621 --- /dev/null +++ b/crates/kebab-cli/tests/cli_config_not_found.rs @@ -0,0 +1,51 @@ +//! Integration tests for Bug #10: explicit --config that does not exist +//! must fail with exit≠0 and error.v1 code=config_not_found (not silently fall +//! back to XDG defaults). + +use std::process::Command; +use serde_json::Value; + +fn kebab_bin() -> String { + env!("CARGO_BIN_EXE_kebab").to_string() +} + +fn parse_error_v1(stderr: &str) -> Value { + let last = stderr.lines().last().expect("expected error.v1 ndjson on stderr"); + serde_json::from_str(last) + .unwrap_or_else(|e| panic!("expected ndjson on stderr: {e}\nstderr={stderr}")) +} + +#[test] +fn invalid_config_path_emits_error_v1_with_nonzero_exit() { + let absent = "/tmp/__kebab_bugfix3_absolute_nonexistent.toml"; + assert!(!std::path::Path::new(absent).exists()); + + let out = Command::new(kebab_bin()) + .args(["search", "rust", "--config", absent, "--json"]) + .output() + .expect("spawn kebab"); + + assert_ne!(out.status.code(), Some(0), "exit must be nonzero on missing --config"); + let stderr = String::from_utf8_lossy(&out.stderr); + let v = parse_error_v1(&stderr); + assert_eq!(v["schema_version"], "error.v1"); + assert_eq!(v["code"], "config_not_found"); + assert!(v["hint"].is_string(), "hint must be present"); +} + +#[test] +fn invalid_relative_config_path_emits_config_not_found() { + // Bug #10 spec §6 R-1: relative path も cwd-relative で cover. + let tmp = tempfile::tempdir().unwrap(); + let out = Command::new(kebab_bin()) + .args(["search", "rust", "--config", "nonexistent-rel.toml", "--json"]) + .current_dir(tmp.path()) + .output() + .expect("spawn kebab"); + + assert_ne!(out.status.code(), Some(0)); + let stderr = String::from_utf8_lossy(&out.stderr); + let v = parse_error_v1(&stderr); + assert_eq!(v["schema_version"], "error.v1"); + assert_eq!(v["code"], "config_not_found"); +} diff --git a/crates/kebab-cli/tests/cli_error_wire.rs b/crates/kebab-cli/tests/cli_error_wire.rs index b89ddc7..825ce9c 100644 --- a/crates/kebab-cli/tests/cli_error_wire.rs +++ b/crates/kebab-cli/tests/cli_error_wire.rs @@ -2,11 +2,10 @@ //! 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`. +//! malformed TOML file via `--config`. A file that exists but fails TOML +//! parsing is the reliable path to `config_invalid`. Supplying a path that +//! does not exist emits `config_not_found` instead (Bug #10 fix, v0.20.0 +//! bugfix3); see `cli_config_not_found.rs` for those tests. use std::process::Command; diff --git a/crates/kebab-config/src/lib.rs b/crates/kebab-config/src/lib.rs index 672bc4c..aaaf194 100644 --- a/crates/kebab-config/src/lib.rs +++ b/crates/kebab-config/src/lib.rs @@ -24,6 +24,15 @@ pub struct ConfigInvalid { pub cause: String, } +/// p20-bugfix3 Bug #10: explicit `--config ` was missing → silent +/// fallback to defaults instead of fail-fast. `kebab-app::error_wire::classify` +/// downcasts → `code: "config_not_found"` ErrorV1. +#[derive(Debug, thiserror::Error)] +#[error("config file does not exist: {path}")] +pub struct ConfigNotFound { + pub path: PathBuf, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Config { pub schema_version: u32, @@ -688,7 +697,11 @@ impl Config { pub fn load(path: Option<&Path>) -> anyhow::Result { let from_disk = match path { Some(p) if p.exists() => Self::from_file(p)?, - Some(_) => Self::defaults(), + Some(p) => { + return Err(anyhow::Error::new(ConfigNotFound { + path: p.to_path_buf(), + })); + } None => { let p = Self::xdg_config_path(); if p.exists() { @@ -1817,4 +1830,17 @@ mod fb27_tests { signal.cause ); } + + #[test] + fn config_load_explicit_nonexistent_path_returns_config_not_found() { + // Bug #10: --config /tmp/nonexistent.toml → silent fallback 금지. + let p = std::path::Path::new("/tmp/__kebab_bugfix3_nonexistent.toml"); + assert!(!p.exists(), "test precondition: path must not exist"); + + let err = Config::load(Some(p)).expect_err("expected ConfigNotFound"); + let signal = err + .downcast_ref::() + .expect("from_load error should downcast to ConfigNotFound"); + assert_eq!(signal.path, p.to_path_buf()); + } }