fix(config): emit error.v1 code=config_not_found for missing --config path (Bug #10)

이전: `kebab search "rust" --config /tmp/nonexistent.toml --json` 가 exit=0 +
`{"hits":[]}` silent fallback to XDG default. typo / wrong path 가 0-hit 으로만
surface — debugging nightmare.

이후: kebab_config::ConfigNotFound thiserror::Error 추가, Config::load 의
`Some(p) if !p.exists()` arm 이 anyhow::Error::new(ConfigNotFound { path })
return. kebab_app::error_wire::classify 가 downcast → ErrorV1 code=config_not_found,
hint, details.path 채워서 stderr 에 ndjson 으로 emit.

R-1 (relative path): std::path::Path::exists() 는 cwd-relative — 별도 작업 없이
absolute + relative 모두 cover. integration test 두 개로 검증.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:14:54 +00:00
parent 760eee89c8
commit 28f513795e
6 changed files with 99 additions and 8 deletions

View File

@@ -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;

View File

@@ -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 <path>` and TOML syntax".to_string()),
};
}
if let Some(s) = err.downcast_ref::<ConfigNotFound>() {
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 <path>; pass an existing toml file or omit --config to use XDG default"
.to_string(),
),
};
}
if let Some(s) = err.downcast_ref::<NotIndexed>() {
return ErrorV1 {
schema_version: ERROR_V1_ID.to_string(),

View File

@@ -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};

View File

@@ -0,0 +1,51 @@
//! Integration tests for Bug #10: explicit --config <path> 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");
}

View File

@@ -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;

View File

@@ -24,6 +24,15 @@ pub struct ConfigInvalid {
pub cause: String,
}
/// p20-bugfix3 Bug #10: explicit `--config <path>` 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<Self> {
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::<ConfigNotFound>()
.expect("from_load error should downcast to ConfigNotFound");
assert_eq!(signal.path, p.to_path_buf());
}
}