Files
kebab/crates/kebab-config/src/paths.rs
altair823 f1a448d6dc refactor(rename): kb → kebab — binary, env vars, XDG paths, file renames
두 번째 commit. 사용자 facing surface (CLI binary, env vars, XDG paths)
+ 코드 안 single-letter token (`KB_`, `kb.sqlite`, `/kb/`, tracing
target) 일괄 rename. 그리고 3 개 file rename:

- 디자인 doc `2026-04-27-kb-final-form-design.md` →
  `2026-04-27-kebab-final-form-design.md`
- 최초 보고서 `kb_local_rust_report.md` → `kebab_local_rust_report.md`
- workspace ignore `.kbignore` → `.kebabignore`

## 변경

- `crates/kebab-cli/Cargo.toml`: `[[bin]] name = "kb"` → `"kebab"`.
- `crates/kebab-cli/src/main.rs`: `#[command(name = "kb", …)]` →
  `name = "kebab"`.
- 모든 `KB_*` env var (코드 + doc + 테스트) → `KEBAB_*`. apply_env
  prefix 매칭 + 30+ 개 setting 키 모두.
- XDG paths: `~/.config/kb` / `~/.local/share/kb` / `~/.cache/kb` /
  `~/.local/state/kb` → `~/.config/kebab` 등. config defaults +
  expand_path tests + paths.rs 의 hardcode 모두.
- SQLite filename: `kb.sqlite` → `kebab.sqlite` (`SQLITE_FILE` const
  + 테스트 hardcode 모두).
- tracing target: `target: "kb-*"` → `"kebab-*"` (10+ 곳).
- snapshot fixture: `.kbignore` → `.kebabignore` (`fixtures/source-fs/
  tree-1.snapshot.json` 갱신).

## 검증

- `cargo test --workspace -j 1` clean (linker OOM 회피 위해 직렬).
- `cargo clippy --workspace --all-targets -- -D warnings` clean.

다음 commit 에서 docs sweep.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:01:35 +00:00

187 lines
7.0 KiB
Rust

//! Shared path expansion helper.
//!
//! `Config::storage.*` fields are stored as raw template strings (e.g.
//! `${XDG_DATA_HOME:-~/.local/share}/kebab`, `{data_dir}/runs`). Every
//! crate that turns one of those strings into a real filesystem path
//! needs to apply the same set of substitutions; this module is the
//! single source of truth so the behavior cannot drift.
//!
//! Substitutions, applied in order:
//!
//! 1. `{data_dir}` → caller-supplied `data_dir`.
//! - When the caller passes an empty `data_dir` (because they ARE
//! resolving `data_dir` itself), the substitution is a no-op so
//! a literal `{data_dir}` is left in place rather than producing
//! a `/{data_dir}/...` artifact.
//! 2. `${XDG_DATA_HOME:-<default>}` (or the bare `${XDG_DATA_HOME}`) →
//! the env var if set + non-empty, else the default after `:-`.
//! Mimics POSIX shell's `${VAR:-default}` semantics. Mid-string
//! occurrences are supported; only the first match is replaced.
//! 3. Leading `~` / `~/...` → `$HOME`. Any non-leading `~` is left
//! literal (matches shell behavior — only the first segment expands).
//!
//! The result is a `PathBuf` regardless of whether all substitutions
//! were applicable; relative paths are kept relative to the caller's
//! CWD (not resolved here).
use std::path::PathBuf;
/// Expand storage-path templates. See module docs for the substitution
/// rules.
///
/// Pass an empty `data_dir` when resolving `data_dir` itself; the
/// `{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.
pub fn expand_path(raw: &str, data_dir: &str) -> PathBuf {
let mut s = raw.to_string();
// 1. {data_dir} substitution (skipped when resolving data_dir
// itself; see module docs).
if !data_dir.is_empty() {
s = s.replace("{data_dir}", data_dir);
}
// 2. ${XDG_DATA_HOME:-<default>}: env override else default.
if let Some(start) = s.find("${XDG_DATA_HOME") {
if let Some(rel_end) = s[start..].find('}') {
let end = start + rel_end + 1; // include trailing '}'
let inner = &s[start + 2..end - 1]; // strip ${ and }
let replacement = match std::env::var("XDG_DATA_HOME") {
Ok(v) if !v.is_empty() => v,
_ => match inner.split_once(":-") {
Some((_, default)) => default.to_string(),
None => String::new(),
},
};
s.replace_range(start..end, &replacement);
}
}
// 3. Leading `~` → $HOME.
if let Some(rest) = s.strip_prefix('~') {
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
return home.join(rest.trim_start_matches('/'));
}
}
PathBuf::from(s)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex as StdMutex;
/// `XDG_DATA_HOME` / `HOME` env mutations must be serialized so
/// concurrent test runs (cargo's default parallel runner) don't
/// observe each other's transient values.
static ENV_LOCK: StdMutex<()> = StdMutex::new(());
/// RAII guard: snapshots `XDG_DATA_HOME` on construction, restores
/// it on drop.
struct XdgGuard {
prior: Option<String>,
}
impl XdgGuard {
fn capture() -> Self {
Self {
prior: std::env::var("XDG_DATA_HOME").ok(),
}
}
}
impl Drop for XdgGuard {
fn drop(&mut self) {
// SAFETY: edition 2024 marks set_var/remove_var unsafe
// because env mutation is not thread-safe. The ENV_LOCK
// guard at the call site prevents concurrent observation.
unsafe {
match &self.prior {
Some(v) => std::env::set_var("XDG_DATA_HOME", v),
None => std::env::remove_var("XDG_DATA_HOME"),
}
}
}
}
#[test]
fn substitutes_data_dir_template() {
let p = expand_path("{data_dir}/runs", "/tmp/kbtest");
assert_eq!(p, PathBuf::from("/tmp/kbtest/runs"));
}
#[test]
fn data_dir_substitution_skipped_when_empty() {
// Empty `data_dir` is the "resolving data_dir itself" signal;
// the literal `{data_dir}` token must survive.
let p = expand_path("{data_dir}/runs", "");
assert_eq!(p, PathBuf::from("{data_dir}/runs"));
}
#[test]
fn passthrough_absolute_path() {
let p = expand_path("/abs/runs", "/ignored");
assert_eq!(p, PathBuf::from("/abs/runs"));
}
#[test]
fn xdg_data_home_set_replaces_var() {
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", "/custom/path") };
let p = expand_path("${XDG_DATA_HOME:-~/.local/share}/kebab", "");
assert_eq!(p, PathBuf::from("/custom/path/kebab"));
}
#[test]
fn xdg_data_home_unset_uses_default() {
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::remove_var("XDG_DATA_HOME") };
let home = std::env::var("HOME").expect("HOME must be set in tests");
let expected = PathBuf::from(home).join(".local/share/kebab");
let p = expand_path("${XDG_DATA_HOME:-~/.local/share}/kebab", "");
assert_eq!(p, expected);
}
#[test]
fn xdg_with_no_default_resolves_to_empty_when_unset() {
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::remove_var("XDG_DATA_HOME") };
// No `:-default` clause, no env var → empty string substitution.
let p = expand_path("${XDG_DATA_HOME}/kb", "");
assert_eq!(p, PathBuf::from("/kb"));
}
#[test]
fn leading_tilde_expands_to_home() {
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 p = expand_path("~/runs", "");
assert_eq!(p, PathBuf::from(home).join("runs"));
}
#[test]
fn data_dir_then_xdg_then_tilde_compose() {
// Order matters: substitute `{data_dir}` (which itself contains
// an unexpanded `${XDG_DATA_HOME}` and `~`), then the other two
// resolve the result.
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 p = expand_path("{data_dir}/runs", "/xdg/data/kebab");
assert_eq!(p, PathBuf::from("/xdg/data/kebab/runs"));
}
}