From f6cc612dbe379178d32d30645222c3cebd4751eb Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 3 May 2026 04:20:06 +0000 Subject: [PATCH 1/2] feat(kebab-config + kebab-app): p9-fb-05 workspace.root path policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도그푸딩 item 3 — `workspace.root` 의 허용 형식이 명문화 안 돼 사용자가 \"상대 경로면 어디 기준?\" 가 불명확. 이제 절대/tilde/env/상대 모두 지원하되, 상대 경로의 base 는 **config.toml 자체가 위치한 디렉토리** (사용자의 cwd 와 무관) 로 일관 정책. ## 핵심 변경 - **`kebab_config::expand_path_with_base(raw, data_dir, base_dir)`** 신규. 기존 `expand_path` (tilde + env 만) 위에 relative-path resolution 추가: - tilde / 절대 / `${VAR}` 입력은 base_dir 무시 (이미 absolute) - relative 입력만 `base_dir.join(...)` 로 절대화 - **`Config.source_dir: Option`** 신규 (`#[serde(skip)]`). `Config::from_file` / `load` 가 `path.parent()` 로 stamp. defaults 는 None (cwd fallback). - **`Config::resolve_workspace_root()`** helper: source_dir 있으면 그것 기준, 없으면 cwd 기준. - **callsite 정리**: - `kebab-app::lib.rs` 의 3 군데 `expand_tilde(&app.config.workspace .root)` → `app.config.resolve_workspace_root()` - `kebab-app::init_workspace` 도 동일 - `kebab-source-fs::FsSourceConnector::new` → 동일 - kebab-source-fs 의 fork 된 local `expand_tilde` + `dirs_home` 헬퍼 제거 (kebab-config 가 canonical) - **`kebab init`** 가 생성하는 `config.toml` 위에 path policy 안내 헤더 코멘트 prepend (절대/tilde/env/상대 + 상대 base = config dir). 기존 `expand_tilde` 가 kebab-app/lib.rs 에 한 군데 (storage.data_dir) 남음 — spec out-of-scope (\"expand_tilde 통일 P+\") 라 보류. ## 테스트 - `expand_path_with_base` 에 신규 4 unit (relative→base, absolute ignores base, tilde ignores base, ${XDG} ignores base) - 기존 27 kebab-config tests + workspace 전체 (`cargo test --workspace --no-fail-fast -j 1` exit 0) 모두 통과 - `cargo clippy --workspace --all-targets -- -D warnings` clean ## 문서 - README Configuration 절: workspace.root 형식 + relative base 규칙 한 줄 추가 - HANDOFF: 2026-05-03 entry - spec status planned → in_progress ## 영향 기존 사용자: 영향 없음 (defaults 의 `~/KnowledgeBase` 는 tilde-rooted, relative path 분기 안 탐). 새 사용자가 `--config /tmp/cfg.toml` + `root = "kb"` 같이 쓰면 cwd 무관하게 `/tmp/kb` 가 워크스페이스가 됨 — 이전엔 이 케이스가 cwd 기준이라 invisible foot-gun. Co-Authored-By: Claude Opus 4.7 (1M context) --- HANDOFF.md | 1 + README.md | 1 + crates/kebab-app/src/lib.rs | 35 +++++++++-- crates/kebab-config/src/lib.rs | 44 ++++++++++++- crates/kebab-config/src/paths.rs | 82 ++++++++++++++++++++++++- crates/kebab-source-fs/src/connector.rs | 39 +++--------- tasks/p9/p9-fb-05-config-path-policy.md | 2 +- 7 files changed, 167 insertions(+), 37 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 54c5163..83d78da 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -52,6 +52,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-14)** — TUI color theme module. `kebab-tui::theme::{Theme, Role, Palette}` 신규 — 16 개 Role (BorderActive/Title/Path/ModeLexical/ModeVector/ModeHybrid/Selected/Hint/Heading/Warning/Error/Success/CitationMarker/Bullet/Body/BorderInactive) 을 dark + light 두 팔레트가 exhaustive match 로 매핑. 모든 Pane (library/search/ask/inspect/run/error_popup) 의 inline `Style::default().fg(Color::*)` 호출이 `theme.style(Role::X)` 로 격리됨. `Config.ui.theme: String` (default `"dark"`) 신규. `App.theme: Theme` 가 `App::new` 에서 `Theme::from_name(&config.ui.theme)` 로 build — 알 수 없는 값은 dark fallback (config 가 typo 로 죽지 않음). `T` 키 runtime toggle 은 mode machine (p9-fb-12) 미진행이라 skip — config 만으로 결정. p9-fb-11 (ask markdown render) 의 Theme 의존성 unblock. spec: `tasks/p9/p9-fb-14-tui-color-theme.md`. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-11)** — TUI Ask 답변 본문 markdown 렌더. `kebab-tui::markdown::render(text, &Theme) -> Vec>` 신규 — `pulldown-cmark = "0.13"` 위에서 inline (bold/italic/strikethrough/inline code/link)·block (heading H1-H6, ordered/unordered list with nesting, fenced code block, table, blockquote `▎`, horizontal rule) 변환. heading H1/H2 = `Role::Heading`, H3+ = `Role::Title`, link = `Role::CitationMarker + UNDERLINE`, code = `Role::Hint`. ask `push_turn_lines` 가 grounded 답변에서만 markdown 렌더; refusal (`Role::Warning`) / streaming (`Role::Hint`) 은 raw 로 두어 role color 시그널 보존. CLI `kebab ask` 출력은 raw markdown 그대로 (terminal 호환성). 매 frame 재 parse — pulldown 토크나이저가 µs/KB 라 비용 무시. spec: `tasks/p9/p9-fb-11-ask-markdown-render.md`. - **2026-05-03 P9 도그푸딩 후속 (p9-fb-08)** — TUI search async worker + generation counter. 기존 200ms debounce 후 `kebab_app::search_with_config` 동기 호출이 vector/hybrid 모드 50-200ms 동안 UI freeze 시키던 문제 해소. `SearchState` 에 `generation: u64` + `worker_thread: Option` + `worker_rx: Option>` 신규. `fire_search` 가 spawn 만 하고 즉시 return — worker 가 별 thread 에서 검색 후 `(generation, Result)` 를 channel 로 post. run loop 가 매 tick `poll_worker` 로 try_recv, generation 일치 시 hits 적용 / 불일치 시 silently 폐기 (사용자가 더 빠르게 타이핑하면 stale 결과 자동 drop). debounce_due 가 `searching && last_query == 현 input` 케이스 추가 skip — in-flight worker 의 결과 기다리는 동안 동일 query 재 spawn 안 함. spec: `tasks/p9/p9-fb-08-search-debounce.md`. +- **2026-05-03 P9 도그푸딩 후속 (p9-fb-05)** — `workspace.root` path policy 명확화. `kebab_config::expand_path_with_base(raw, data_dir, base_dir) -> PathBuf` 신규 — 기존 `expand_path` (tilde + env 만) 위에 relative path resolution 추가, 절대/`~`/`${VAR}` 입력은 base_dir 무시. `Config.source_dir: Option` 필드 (`#[serde(skip)]`) 신규 — `from_file` / `load` 가 `path.parent()` 로 stamp. `Config::resolve_workspace_root()` helper 가 `expand_path_with_base(&workspace.root, "", source_dir.unwrap_or(cwd))` 호출. kebab-app + kebab-source-fs 의 모든 `workspace.root` 사용 사이트가 `cfg.resolve_workspace_root()` 로 통일 — kebab-source-fs 의 fork 된 `expand_tilde` 헬퍼는 제거 (kebab-app 의 `storage.data_dir` 한 곳만 남음, P+ 통일 caveat). `kebab init` 가 생성하는 `config.toml` 위에 path policy 안내 헤더 코멘트 자동 prepend (절대/tilde/env/상대 + 상대 base = config dir). spec: `tasks/p9/p9-fb-05-config-path-policy.md`. ## 다음 task 후보 diff --git a/README.md b/README.md index 948f5ec..e718942 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ flowchart TB - `--config ` flag — 임시 워크스페이스 / 격리 테스트 시 사용. CLI / TUI 모두 honor. - `KEBAB_*` env — 일부 키 override (`KEBAB_RAG_SCORE_GATE`, `KEBAB_EVAL_GOLDEN`, `KEBAB_COMMIT_HASH` 등). - XDG layout: `~/.config/kebab/`, `~/.local/share/kebab/`, `~/.cache/kebab/`, `~/.local/state/kebab/`. +- `workspace.root` 경로 형식: 절대 (`/foo/bar`) / tilde (`~/KnowledgeBase`, default) / env (`${XDG_DATA_HOME}/kebab`) / 상대 (`./notes`, `notes`, `../shared/x`) 모두 가능. **상대 경로의 base 는 config.toml 자체가 위치한 디렉토리** — 사용자의 `cwd` 와 무관 (`--config /tmp/cfg.toml` + `root = "kb"` → `/tmp/kb`). p9-fb-05 정책. config 예시는 [docs/SMOKE.md](docs/SMOKE.md) 의 `/tmp/kebab-smoke/config.toml` 블록 참조. diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index bf29d1f..6ca52a3 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -123,13 +123,36 @@ pub fn init_workspace(force: bool) -> anyhow::Result<()> { } } - let workspace_root = expand_tilde(&kebab_config::Config::defaults().workspace.root); + let workspace_root = kebab_config::Config::defaults().resolve_workspace_root(); std::fs::create_dir_all(&workspace_root)?; if !cfg_path.exists() || force { let cfg = kebab_config::Config::defaults(); let toml_text = toml::to_string_pretty(&cfg)?; - std::fs::write(&cfg_path, toml_text)?; + // p9-fb-05: prepend a header comment documenting the path + // policy so a user editing this file knows what's allowed + // for `workspace.root` (and how relative paths resolve). + // The actual key lives inside `[workspace]` further down; + // we keep the explanation up top because users skim header + // comments first. + let header = "\ +# kebab config — `~/.config/kebab/config.toml`. +# +# `workspace.root` accepts: +# • absolute paths (`/home/me/KnowledgeBase`) +# • tilde (`~/KnowledgeBase`) ← default +# • env vars (`${XDG_DATA_HOME}/kebab`) +# • relative paths (`./notes`, `notes`, `../shared/x`) +# — relative paths resolve against the directory of THIS +# config file, NOT the user's `cwd` at invocation time. +# +# Override individual keys at runtime with `KEBAB_*` env vars +# (e.g. `KEBAB_WORKSPACE_ROOT=/tmp/test kebab ingest`). +\n"; + let mut combined = String::with_capacity(header.len() + toml_text.len()); + combined.push_str(header); + combined.push_str(&toml_text); + std::fs::write(&cfg_path, combined)?; } Ok(()) @@ -881,7 +904,9 @@ fn ingest_one_image_asset( // `~` / `${XDG_…}` expansion via the same helper the markdown // path uses, so a `~/KnowledgeBase` workspace.root resolves // identically across all media (HOTFIXES 2026-05-02 P9-4 follow-up). - let workspace_root = expand_tilde(&app.config.workspace.root); + // p9-fb-05: relative `workspace.root` resolves against the config + // file's directory (Config.source_dir), not the user's cwd. + let workspace_root = app.config.resolve_workspace_root(); let ctx = ExtractContext { asset, workspace_root: &workspace_root, @@ -1185,7 +1210,9 @@ fn ingest_one_pdf_asset( let extract_config = kebab_core::ExtractConfig::default(); // `~` / `${XDG_…}` expansion (HOTFIXES 2026-05-02 P9-4 follow-up). - let workspace_root = expand_tilde(&app.config.workspace.root); + // p9-fb-05: relative `workspace.root` resolves against the config + // file's directory (Config.source_dir), not the user's cwd. + let workspace_root = app.config.resolve_workspace_root(); let ctx = ExtractContext { asset, workspace_root: &workspace_root, diff --git a/crates/kebab-config/src/lib.rs b/crates/kebab-config/src/lib.rs index 56121a6..264aa13 100644 --- a/crates/kebab-config/src/lib.rs +++ b/crates/kebab-config/src/lib.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; mod paths; -pub use paths::expand_path; +pub use paths::{expand_path, expand_path_with_base}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Config { @@ -32,6 +32,15 @@ pub struct Config { /// `dark`). #[serde(default = "UiCfg::defaults")] pub ui: UiCfg, + /// p9-fb-05: directory of the on-disk config file this `Config` + /// was loaded from, if any. Populated by `Config::from_file` / + /// `Config::load` — never serialized (`#[serde(skip)]`). Used by + /// `expand_path_with_base` to resolve relative `workspace.root` + /// against the config file's location instead of the user's + /// `cwd` (so `--config /tmp/cfg.toml` + `root = "kb"` reads + /// `/tmp/kb` no matter where the user invoked from). + #[serde(skip)] + pub source_dir: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -290,9 +299,35 @@ impl Config { }, image: ImageCfg::defaults(), ui: UiCfg::defaults(), + // p9-fb-05: defaults are not loaded from disk, so no + // source_dir. Relative `workspace.root` (rare with + // defaults) falls back to caller `cwd` via the + // `unwrap_or_else(...)` in `expand_path_with_base` + // sites — see kebab-app's resolve_workspace_root. + source_dir: None, } } + /// p9-fb-05: resolve `workspace.root` to an absolute `PathBuf`. + /// Order: + /// 1. tilde / env / `${VAR}` substitutions per [`expand_path`]. + /// 2. if still relative, join onto `source_dir` (config file's + /// directory) when known, else `cwd`. + /// + /// Tilde / absolute / `${VAR}`-rooted inputs ignore `source_dir`. + /// `Config::defaults()` (which has no `source_dir`) effectively + /// uses `cwd` for relative inputs — which is the surprising + /// case spec p9-fb-05 calls out as a foot-gun, but it can only + /// arise when the user is using defaults AND has a relative + /// root, which is rare (defaults ship `~/KnowledgeBase`). + pub fn resolve_workspace_root(&self) -> PathBuf { + let base = self + .source_dir + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + paths::expand_path_with_base(&self.workspace.root, "", &base) + } + /// Read config from disk and merge env overrides on top of it. If the /// file is missing, defaults are used (so `kb doctor` runs with no /// prior `kb init`). @@ -313,9 +348,14 @@ impl Config { Ok(from_disk.apply_env(&env)) } + /// Parse a config from `path`. p9-fb-05: also stamps + /// `source_dir = path.parent()` so relative `workspace.root` + /// values resolve against the config file's directory rather + /// than the user's `cwd`. pub fn from_file(path: &Path) -> anyhow::Result { let text = std::fs::read_to_string(path)?; - let cfg: Self = toml::from_str(&text)?; + let mut cfg: Self = toml::from_str(&text)?; + cfg.source_dir = path.parent().map(Path::to_path_buf); Ok(cfg) } diff --git a/crates/kebab-config/src/paths.rs b/crates/kebab-config/src/paths.rs index bc39ca6..94db631 100644 --- a/crates/kebab-config/src/paths.rs +++ b/crates/kebab-config/src/paths.rs @@ -24,7 +24,7 @@ //! were applicable; relative paths are kept relative to the caller's //! CWD (not resolved here). -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Expand storage-path templates. See module docs for the substitution /// rules. @@ -33,6 +33,12 @@ use std::path::PathBuf; /// `{data_dir}` substitution becomes a no-op in that case so the /// recursive shape (`data_dir = "${XDG_DATA_HOME:-…}/kb"`) resolves /// without producing a literal `{data_dir}` token in the output. +/// +/// Relative paths (no leading `/`, `~`, `${VAR}`) are returned as-is — +/// the caller's CWD is the implicit base. Use [`expand_path_with_base`] +/// when relative paths must resolve against a specific directory (e.g. +/// `workspace.root` in p9-fb-05 resolves against the config file's +/// directory). pub fn expand_path(raw: &str, data_dir: &str) -> PathBuf { let mut s = raw.to_string(); @@ -68,6 +74,29 @@ pub fn expand_path(raw: &str, data_dir: &str) -> PathBuf { PathBuf::from(s) } +/// p9-fb-05: same substitutions as [`expand_path`], plus relative-path +/// resolution against a caller-supplied `base_dir`. After the absolute +/// / `~` / `${VAR}` substitutions are done, if the result is still a +/// relative path, it's joined onto `base_dir`. +/// +/// Used by `workspace.root` which must resolve against the config +/// file's directory (so `--config /tmp/cfg.toml` + `root = "kb"` +/// reads `/tmp/kb`, regardless of the user's `cwd`). Without this, +/// the user's `cwd` at invocation time would silently change which +/// workspace got indexed — invisible foot-gun. +/// +/// `base_dir` is consulted only for paths that come out relative +/// after substitutions; absolute / tilde / `${VAR}`-rooted inputs +/// ignore it. +pub fn expand_path_with_base(raw: &str, data_dir: &str, base_dir: &Path) -> PathBuf { + let expanded = expand_path(raw, data_dir); + if expanded.is_absolute() { + expanded + } else { + base_dir.join(expanded) + } +} + #[cfg(test)] mod tests { use super::*; @@ -170,6 +199,57 @@ mod tests { assert_eq!(p, PathBuf::from(home).join("runs")); } + // ── p9-fb-05: expand_path_with_base ───────────────────────────── + + #[test] + fn relative_path_resolves_against_base_dir() { + let base = Path::new("/tmp/kebab-cfg"); + assert_eq!( + expand_path_with_base("notes", "", base), + PathBuf::from("/tmp/kebab-cfg/notes") + ); + assert_eq!( + expand_path_with_base("./notes", "", base), + PathBuf::from("/tmp/kebab-cfg/./notes") + ); + assert_eq!( + expand_path_with_base("../parent/x", "", base), + PathBuf::from("/tmp/kebab-cfg/../parent/x") + ); + } + + #[test] + fn absolute_path_ignores_base_dir() { + let base = Path::new("/tmp/ignored-cfg"); + assert_eq!( + expand_path_with_base("/abs/notes", "", base), + PathBuf::from("/abs/notes") + ); + } + + #[test] + fn tilde_path_ignores_base_dir() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let home = std::env::var("HOME").expect("HOME must be set in tests"); + let base = Path::new("/tmp/ignored-cfg"); + let p = expand_path_with_base("~/x", "", base); + assert_eq!(p, PathBuf::from(home).join("x")); + } + + #[test] + fn xdg_var_path_ignores_base_dir() { + let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let _guard = XdgGuard::capture(); + // SAFETY: lock held for the duration of this test. + unsafe { std::env::set_var("XDG_DATA_HOME", "/xdg/data") }; + + let base = Path::new("/tmp/ignored-cfg"); + assert_eq!( + expand_path_with_base("${XDG_DATA_HOME}/kb", "", base), + PathBuf::from("/xdg/data/kb") + ); + } + #[test] fn data_dir_then_xdg_then_tilde_compose() { // Order matters: substitute `{data_dir}` (which itself contains diff --git a/crates/kebab-source-fs/src/connector.rs b/crates/kebab-source-fs/src/connector.rs index 0cf5e3e..b01c14a 100644 --- a/crates/kebab-source-fs/src/connector.rs +++ b/crates/kebab-source-fs/src/connector.rs @@ -44,11 +44,11 @@ pub struct FsSourceConnector { impl FsSourceConnector { pub fn new(config: &Config) -> Result { - // `config.workspace.root` is a String that may contain `~` or env - // expansions. P0-* did not yet provide a path-expansion helper in - // kb-config; for P1-1 we expand `~` ourselves and leave `${VAR}` - // for a follow-up. The vast majority of users hit the `~` case. - let root = expand_tilde(&config.workspace.root); + // p9-fb-05: tilde / env / `${VAR}` substitutions plus + // relative-path resolution against the config file's + // directory (Config.source_dir) — so `--config /tmp/cfg.toml` + // + `root = "kb"` reads `/tmp/kb`, not the user's cwd. + let root = config.resolve_workspace_root(); let copy_threshold_bytes = config .storage @@ -166,30 +166,11 @@ impl SourceConnector for FsSourceConnector { } } -// TODO(kb-config): hoist tilde + ${VAR} expansion into a kb-config helper -// once that crate gains a path-expansion API. Today this duplicates logic -// that P1-6 (store-sqlite) and future crates will also need. -/// Expand a leading `~` to the current user's home directory. No-op for -/// any other shape (absolute, relative, `${VAR}`-style). -fn expand_tilde(s: &str) -> PathBuf { - if let Some(rest) = s.strip_prefix("~/") { - if let Some(home) = dirs_home() { - return home.join(rest); - } - } else if s == "~" { - if let Some(home) = dirs_home() { - return home; - } - } - PathBuf::from(s) -} - -/// Tiny `dirs::home_dir`-compat shim that does NOT add the `dirs` crate to -/// our dep set (we explicitly enumerate allowed deps in the task spec). -/// Reads `$HOME` directly. -fn dirs_home() -> Option { - std::env::var_os("HOME").map(PathBuf::from) -} +// p9-fb-05: removed local `expand_tilde` + `dirs_home` shim. The +// canonical helper now lives in `kebab-config::resolve_workspace_root` +// (calling `expand_path_with_base`), so this crate just delegates via +// `Config::resolve_workspace_root` above. Keeps tilde / `${VAR}` / +// relative path semantics consistent with kebab-app and kebab-cli. #[cfg(test)] mod tests { diff --git a/tasks/p9/p9-fb-05-config-path-policy.md b/tasks/p9/p9-fb-05-config-path-policy.md index 793d921..0cc70a9 100644 --- a/tasks/p9/p9-fb-05-config-path-policy.md +++ b/tasks/p9/p9-fb-05-config-path-policy.md @@ -3,7 +3,7 @@ phase: P9 component: kebab-config + kebab-cli (init) + README task_id: p9-fb-05 title: "workspace.root path policy (relative? + init placeholder + README)" -status: planned +status: in_progress depends_on: [] unblocks: [] contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md -- 2.49.1 From 702c7c89f7a91c494adb0e33f3096d5bc27b8a58 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 3 May 2026 04:23:16 +0000 Subject: [PATCH 2/2] =?UTF-8?q?review(p9-fb-05):=20=ED=9A=8C=EC=B0=A8=201?= =?UTF-8?q?=20nit=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `Config.source_dir` 를 `pub(crate)` 로 좁힘. invariant ("from_file / load 만이 정당한 setter") 가 외부 mutation 으로 깨지지 않도록. 대신 `pub fn source_dir(&self) -> Option<&Path>` (read-only) + `pub fn with_source_dir(self, dir) -> Self` (builder) 노출 — 테스트 / 프로그래마틱 사용은 builder 통과. - `resolve_workspace_root` 의 `current_dir()` 실패 fallback 에 `tracing::warn!` 추가. chroot / deleted-cwd / permission 문제로 cwd 가 안 잡힐 때 silently `./root` 로 떨어지지 않고 로그가 남음. `tracing` 을 kebab-config 의 deps 에 추가 (workspace dep). 테스트 27 통과 + 워크스페이스 clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + crates/kebab-config/Cargo.toml | 3 +++ crates/kebab-config/src/lib.rs | 41 +++++++++++++++++++++++++++++----- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index afd79f2..9045280 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3571,6 +3571,7 @@ dependencies = [ "serde", "serde_json", "toml", + "tracing", ] [[package]] diff --git a/crates/kebab-config/Cargo.toml b/crates/kebab-config/Cargo.toml index 4b40b01..9cf48ee 100644 --- a/crates/kebab-config/Cargo.toml +++ b/crates/kebab-config/Cargo.toml @@ -15,3 +15,6 @@ serde = { workspace = true } serde_json = { workspace = true } toml = "0.8" dirs = "5" +# p9-fb-05: warn-log when current_dir() fails (chroot, deleted cwd) +# during workspace.root resolution. +tracing = { workspace = true } diff --git a/crates/kebab-config/src/lib.rs b/crates/kebab-config/src/lib.rs index 264aa13..e0e11d4 100644 --- a/crates/kebab-config/src/lib.rs +++ b/crates/kebab-config/src/lib.rs @@ -39,8 +39,13 @@ pub struct Config { /// against the config file's location instead of the user's /// `cwd` (so `--config /tmp/cfg.toml` + `root = "kb"` reads /// `/tmp/kb` no matter where the user invoked from). + /// + /// `pub(crate)` so external callers can't break the + /// "stamped only by from_file/load" invariant by hand. Use + /// [`Config::with_source_dir`] for tests / programmatic + /// construction that need a specific `source_dir`. #[serde(skip)] - pub source_dir: Option, + pub(crate) source_dir: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -308,6 +313,21 @@ impl Config { } } + /// p9-fb-05: read-only accessor for the source-file directory + /// (where `from_file` / `load` stamped it). Returns `None` for + /// `Config::defaults()` and other in-memory constructions. + pub fn source_dir(&self) -> Option<&Path> { + self.source_dir.as_deref() + } + + /// p9-fb-05: builder for tests / programmatic callers that need + /// to pin `source_dir` without going through `from_file`. Returns + /// `self` so it chains: `Config::defaults().with_source_dir(p)`. + pub fn with_source_dir(mut self, dir: PathBuf) -> Self { + self.source_dir = Some(dir); + self + } + /// p9-fb-05: resolve `workspace.root` to an absolute `PathBuf`. /// Order: /// 1. tilde / env / `${VAR}` substitutions per [`expand_path`]. @@ -321,10 +341,21 @@ impl Config { /// arise when the user is using defaults AND has a relative /// root, which is rare (defaults ship `~/KnowledgeBase`). pub fn resolve_workspace_root(&self) -> PathBuf { - let base = self - .source_dir - .clone() - .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + let base = self.source_dir.clone().unwrap_or_else(|| { + std::env::current_dir().unwrap_or_else(|e| { + // chroot / deleted-cwd / permission failure: log so a + // user with an environment problem doesn't silently + // wonder why their workspace.root resolved to "./root" + // (which then fails at `create_dir_all` time with a + // less obvious error). + tracing::warn!( + target: "kebab-config", + error = %e, + "current_dir() failed; falling back to '.' for workspace.root resolution" + ); + PathBuf::from(".") + }) + }); paths::expand_path_with_base(&self.workspace.root, "", &base) } -- 2.49.1