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/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/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 56121a6..e0e11d4 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,20 @@ 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). + /// + /// `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(crate) source_dir: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -290,9 +304,61 @@ 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: 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`]. + /// 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(|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) + } + /// 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 +379,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