Compare commits
7 Commits
2a4df4d48d
...
4b4a4c0b32
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b4a4c0b32 | |||
| f2cc325cf3 | |||
| b7e022a5e3 | |||
| bd7c4fd7ef | |||
| 4dcb4a45d6 | |||
| 6d86214060 | |||
| 6bbb8f854b |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4371,6 +4371,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"toml_edit 0.22.27",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ base64 = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
kebab-config = { path = "../kebab-config" }
|
||||
# doc-side expansion (Phase 2) Task 4: ExpansionGenerator unit tests build
|
||||
# MockLanguageModel (gated behind kebab-llm's `mock` feature, default OFF in
|
||||
# [dependencies]). Enabling it here turns it on for the test build only.
|
||||
|
||||
@@ -143,40 +143,10 @@ pub fn init_workspace(force: bool) -> anyhow::Result<()> {
|
||||
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)?;
|
||||
// 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.
|
||||
#
|
||||
# 처리 가능한 형식 (extractor 가 자동 결정 — config 에 명시할 수 없음):
|
||||
# • Markdown: .md
|
||||
# • 이미지: .png .jpg .jpeg (OCR + caption)
|
||||
# • PDF: .pdf
|
||||
# 다른 확장자는 ingest 시 자동 skip + warning. 처리 대상 폴더의
|
||||
# 일부만 ingest 하고 싶으면 `kebab ingest <path>` 로 root 명시
|
||||
# 또는 `.kebabignore` 파일 / 본 `workspace.exclude` 로 denylist.
|
||||
#
|
||||
# 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)?;
|
||||
// init 과 migrate 가 동일한 "주석 달린 default" 문서를 공유한다
|
||||
// (주석 카탈로그·헤더의 단일 원천 = kebab_config::migrate).
|
||||
let doc = kebab_config::migrate::annotated_default_document();
|
||||
std::fs::write(&cfg_path, doc.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -3211,6 +3181,48 @@ pub fn doctor_with_config_path(
|
||||
hint: data_hint,
|
||||
});
|
||||
|
||||
// config_migration — 사용자 파일이 새 스키마와 동기인지(dry-run 마이그레이션).
|
||||
// 파일이 존재할 때만 점검(없으면 defaults 사용 중이라 마이그레이션 무의미).
|
||||
if cfg_path.exists() {
|
||||
if let Ok(text) = std::fs::read_to_string(&cfg_path) {
|
||||
let outcome = kebab_config::migrate::migrate_document(&text);
|
||||
let (mok, detail, hint) = if outcome.changed() {
|
||||
let added = outcome
|
||||
.changes
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
matches!(
|
||||
c.kind,
|
||||
kebab_config::migrate::ChangeKind::AddedSection
|
||||
| kebab_config::migrate::ChangeKind::AddedKey
|
||||
)
|
||||
})
|
||||
.count();
|
||||
let removed = outcome.changes.len() - added;
|
||||
(
|
||||
false,
|
||||
format!(
|
||||
"{} pending changes (added {added}, removed {removed} deprecated)",
|
||||
outcome.changes.len()
|
||||
),
|
||||
Some("run `kebab config migrate` to update your config.toml".to_string()),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
true,
|
||||
format!("config up to date (schema v{})", outcome.to_schema_version),
|
||||
None,
|
||||
)
|
||||
};
|
||||
checks.push(DoctorCheck {
|
||||
name: "config_migration".to_string(),
|
||||
ok: mok,
|
||||
detail,
|
||||
hint,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let ok = checks.iter().all(|c| c.ok);
|
||||
Ok(DoctorReport {
|
||||
schema_version: "doctor.v1".to_string(),
|
||||
@@ -3227,6 +3239,66 @@ pub fn doctor() -> anyhow::Result<DoctorReport> {
|
||||
doctor_with_config_path(None)
|
||||
}
|
||||
|
||||
/// `kebab config migrate` 의 결과(wire `config_migration.v1` 소스).
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
pub struct ConfigMigrationReport {
|
||||
/// 항상 `"config_migration.v1"`.
|
||||
pub schema_version: String,
|
||||
pub config_path: String,
|
||||
pub dry_run: bool,
|
||||
pub from_schema_version: u32,
|
||||
pub to_schema_version: u32,
|
||||
pub changed: bool,
|
||||
pub backup_path: Option<String>,
|
||||
pub changes: Vec<kebab_config::migrate::MigrationChange>,
|
||||
}
|
||||
|
||||
/// 사용자 config.toml 을 새 스키마로 마이그레이션한다(facade).
|
||||
/// `config_path` 미지정 시 XDG 기본. `dry_run=true` 면 파일·백업 미변경.
|
||||
/// 안전: 변경 시 `.bak` 백업 후 tmp 에 쓰고 round-trip 검증 → atomic rename.
|
||||
pub fn config_migrate_with_config_path(
|
||||
config_path: Option<&std::path::Path>,
|
||||
dry_run: bool,
|
||||
) -> anyhow::Result<ConfigMigrationReport> {
|
||||
let path: PathBuf = match config_path {
|
||||
Some(p) => p.to_path_buf(),
|
||||
None => kebab_config::Config::xdg_config_path(),
|
||||
};
|
||||
if !path.exists() {
|
||||
anyhow::bail!(
|
||||
"config 파일이 없습니다: {} — 먼저 `kebab init` 을 실행하세요.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
let text = std::fs::read_to_string(&path)?;
|
||||
let outcome = kebab_config::migrate::migrate_document(&text);
|
||||
|
||||
let mut backup_path = None;
|
||||
if !dry_run && outcome.changed() {
|
||||
let bak = path.with_extension("toml.bak");
|
||||
std::fs::copy(&path, &bak)?;
|
||||
backup_path = Some(bak.display().to_string());
|
||||
let tmp = path.with_extension("toml.tmp");
|
||||
std::fs::write(&tmp, &outcome.new_text)?;
|
||||
if kebab_config::Config::from_file(&tmp).is_err() {
|
||||
std::fs::remove_file(&tmp).ok();
|
||||
anyhow::bail!("마이그레이션 결과가 유효하지 않아 원본을 보존합니다.");
|
||||
}
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
}
|
||||
|
||||
Ok(ConfigMigrationReport {
|
||||
schema_version: "config_migration.v1".to_string(),
|
||||
config_path: path.display().to_string(),
|
||||
dry_run,
|
||||
from_schema_version: outcome.from_schema_version,
|
||||
to_schema_version: outcome.to_schema_version,
|
||||
changed: outcome.changed(),
|
||||
backup_path,
|
||||
changes: outcome.changes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Single-file ingest (p9-fb-31). Copies the file to
|
||||
/// `<workspace.root>/_external/<blake3-12>.<ext>` and runs the
|
||||
/// per-medium ingest pipeline on that single asset. Returns an
|
||||
|
||||
@@ -108,6 +108,7 @@ const WIRE_SCHEMAS: &[&str] = &[
|
||||
"doc_summary.v1",
|
||||
"chunk_inspection.v1",
|
||||
"doctor.v1",
|
||||
"config_migration.v1",
|
||||
"ingest_report.v1",
|
||||
"ingest_progress.v1",
|
||||
"reset_report.v1",
|
||||
|
||||
82
crates/kebab-app/tests/config_migrate.rs
Normal file
82
crates/kebab-app/tests/config_migrate.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn migrate_writes_backup_and_atomic_with_dry_run_noop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = dir.path().join("config.toml");
|
||||
fs::write(
|
||||
&cfg,
|
||||
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude = [\"*.md\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// dry-run: 파일·백업 미변경.
|
||||
let report = kebab_app::config_migrate_with_config_path(Some(&cfg), true).unwrap();
|
||||
assert!(report.changed);
|
||||
assert!(report.dry_run);
|
||||
assert!(report.backup_path.is_none());
|
||||
assert!(!dir.path().join("config.toml.bak").exists());
|
||||
assert!(
|
||||
fs::read_to_string(&cfg).unwrap().contains("include"),
|
||||
"dry-run modified file"
|
||||
);
|
||||
|
||||
// 실제 적용: 백업 생성 + 파일 갱신.
|
||||
let report = kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap();
|
||||
assert!(report.changed);
|
||||
assert!(!report.dry_run);
|
||||
assert!(report.backup_path.is_some());
|
||||
assert!(dir.path().join("config.toml.bak").exists());
|
||||
let new = fs::read_to_string(&cfg).unwrap();
|
||||
assert!(!new.contains("include"));
|
||||
assert!(new.contains("[ingest.expansion]"));
|
||||
|
||||
// 멱등: 재실행 changed=false.
|
||||
let report = kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap();
|
||||
assert!(!report.changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_missing_file_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = dir.path().join("nope.toml");
|
||||
assert!(kebab_app::config_migrate_with_config_path(Some(&cfg), false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotated_default_serialization_contains_section_comments() {
|
||||
let doc = kebab_config::migrate::annotated_default_document();
|
||||
let text = doc.to_string();
|
||||
assert!(text.contains("doc-side 별칭"), "section comment missing:\n{text}");
|
||||
assert!(text.contains("[ingest.expansion]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_flags_outdated_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cfg = dir.path().join("config.toml");
|
||||
fs::write(
|
||||
&cfg,
|
||||
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude=[\"*.md\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
|
||||
let check = report
|
||||
.checks
|
||||
.iter()
|
||||
.find(|c| c.name == "config_migration")
|
||||
.unwrap();
|
||||
assert!(!check.ok, "outdated config should fail check");
|
||||
assert!(check.hint.as_deref().unwrap().contains("config migrate"));
|
||||
assert!(!report.ok, "overall doctor should be false");
|
||||
|
||||
// migrate 후엔 통과.
|
||||
kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap();
|
||||
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
|
||||
let check = report
|
||||
.checks
|
||||
.iter()
|
||||
.find(|c| c.name == "config_migration")
|
||||
.unwrap();
|
||||
assert!(check.ok, "after migrate should pass");
|
||||
}
|
||||
@@ -60,6 +60,12 @@ enum Cmd {
|
||||
force: bool,
|
||||
},
|
||||
|
||||
/// config.toml 관리 (스키마 마이그레이션 등).
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
what: ConfigWhat,
|
||||
},
|
||||
|
||||
/// Scan the workspace and ingest new/updated documents.
|
||||
Ingest {
|
||||
/// Workspace root override.
|
||||
@@ -346,6 +352,16 @@ enum Cmd {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum ConfigWhat {
|
||||
/// 기존 config.toml 을 새 스키마로 마이그레이션(빠진 섹션 추가 + 멱등 + .bak 백업).
|
||||
Migrate {
|
||||
/// 변경만 출력하고 파일은 수정하지 않는다.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum ListWhat {
|
||||
/// List documents currently indexed.
|
||||
@@ -1310,6 +1326,42 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Cmd::Config { what } => match what {
|
||||
ConfigWhat::Migrate { dry_run } => {
|
||||
let report =
|
||||
kebab_app::config_migrate_with_config_path(cli.config.as_deref(), *dry_run)?;
|
||||
if cli.json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&wire::wire_config_migration(&report))?
|
||||
);
|
||||
} else if !report.changed {
|
||||
println!(
|
||||
"config 이미 최신입니다 (schema v{}).",
|
||||
report.to_schema_version
|
||||
);
|
||||
} else {
|
||||
let verb = if report.dry_run { "변경 예정" } else { "적용됨" };
|
||||
println!(
|
||||
"config 마이그레이션 {verb}: v{} → v{} ({} changes)",
|
||||
report.from_schema_version,
|
||||
report.to_schema_version,
|
||||
report.changes.len()
|
||||
);
|
||||
for c in &report.changes {
|
||||
println!(" - [{:?}] {} — {}", c.kind, c.path, c.detail);
|
||||
}
|
||||
if let Some(bak) = &report.backup_path {
|
||||
println!("백업: {bak}");
|
||||
}
|
||||
if report.dry_run {
|
||||
println!("(--dry-run: 파일 미수정. 적용하려면 --dry-run 없이 재실행)");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|
||||
Cmd::Doctor => {
|
||||
let report = kebab_app::doctor_with_config_path(cli.config.as_deref())?;
|
||||
if cli.json {
|
||||
|
||||
@@ -225,6 +225,12 @@ pub fn wire_bulk_search_item(item: &kebab_core::BulkSearchItem) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
/// `config_migration.v1` 직렬화. `ConfigMigrationReport` 가 `schema_version`
|
||||
/// 필드를 자체 보유하므로(doctor 와 동일) 그대로 직렬화한다.
|
||||
pub fn wire_config_migration(r: &kebab_app::ConfigMigrationReport) -> Value {
|
||||
serde_json::to_value(r).expect("ConfigMigrationReport serializes")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -15,6 +15,7 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
dirs = "5"
|
||||
# p9-fb-05: warn-log when current_dir() fails (chroot, deleted cwd)
|
||||
# during workspace.root resolution.
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod paths;
|
||||
pub mod migrate;
|
||||
pub use paths::{expand_path, expand_path_with_base};
|
||||
|
||||
/// Signal: `Config::from_file` / `Config::load` failed due to missing path,
|
||||
@@ -669,7 +670,7 @@ impl Config {
|
||||
/// Defaults per design §6.4.
|
||||
pub fn defaults() -> Self {
|
||||
Self {
|
||||
schema_version: 1,
|
||||
schema_version: crate::migrate::CURRENT_SCHEMA_VERSION,
|
||||
workspace: WorkspaceCfg {
|
||||
root: "~/KnowledgeBase".to_string(),
|
||||
exclude: vec![
|
||||
|
||||
399
crates/kebab-config/src/migrate.rs
Normal file
399
crates/kebab-config/src/migrate.rs
Normal file
@@ -0,0 +1,399 @@
|
||||
//! config.toml 마이그레이션 엔진 (순수 변환, I/O 없음).
|
||||
//!
|
||||
//! 두 메커니즘: (1) reconciliation — default 구조에 있고 사용자 파일에
|
||||
//! 없는 섹션/키를 주석과 함께 추가. (2) step 체인 — schema_version 기반
|
||||
//! non-additive 변환(deprecated 제거 등). 자세한 계약은 spec
|
||||
//! `docs/superpowers/specs/2026-05-31-config-migration-design.md`.
|
||||
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
/// 현재 바이너리가 이해하는 config 스키마 버전. 마이그레이션 완료 시
|
||||
/// 사용자 파일의 `schema_version` 을 이 값으로 stamp 한다.
|
||||
pub const CURRENT_SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
/// 한 번의 마이그레이션에서 발생한 개별 변경.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
pub struct MigrationChange {
|
||||
pub kind: ChangeKind,
|
||||
/// dotted path, 예: `ingest.expansion`, `workspace.include`.
|
||||
pub path: String,
|
||||
/// 사람·wire 용 한 줄 설명.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChangeKind {
|
||||
AddedSection,
|
||||
AddedKey,
|
||||
RemovedDeprecated,
|
||||
}
|
||||
|
||||
/// 마이그레이션 결과 요약(순수 변환 단계 산출). I/O 계층이 backup_path
|
||||
/// 등을 채워 wire 로 내보낸다.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
pub struct MigrationOutcome {
|
||||
pub from_schema_version: u32,
|
||||
pub to_schema_version: u32,
|
||||
pub changes: Vec<MigrationChange>,
|
||||
/// 변환 후 직렬화된 새 문서 텍스트(멱등 시 입력과 동일).
|
||||
pub new_text: String,
|
||||
}
|
||||
|
||||
impl MigrationOutcome {
|
||||
pub fn changed(&self) -> bool {
|
||||
!self.changes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 문서 최상단 헤더(경로 정책 등). 기존 init 헤더를 이전.
|
||||
const HEADER: &str = "\
|
||||
# kebab config — `~/.config/kebab/config.toml`.
|
||||
#
|
||||
# `workspace.root` accepts: 절대 / tilde(~) / env(${VAR}) / 상대 경로.
|
||||
# 상대 경로의 base 는 cwd 가 아니라 THIS config 파일의 디렉토리.
|
||||
#
|
||||
# 처리 가능한 형식 (extractor 가 자동 결정 — config 에 명시할 수 없음):
|
||||
# • Markdown: .md
|
||||
# • 이미지: .png .jpg .jpeg (OCR + caption)
|
||||
# • PDF: .pdf
|
||||
#
|
||||
# 런타임 override: `KEBAB_*` env (예: KEBAB_WORKSPACE_ROOT=/tmp kebab ingest).
|
||||
#
|
||||
# 이 파일은 `kebab config migrate` 로 새 스키마에 맞춰 갱신할 수 있다
|
||||
# (빠진 섹션 추가 + 손본 값·주석 보존).
|
||||
";
|
||||
|
||||
/// 테이블 헤더(`[section]`) 위에 붙일 주석. dotted path → 한 줄(들).
|
||||
fn section_comment(path: &str) -> Option<&'static str> {
|
||||
Some(match path {
|
||||
"workspace" => "# 색인 대상 워크스페이스.",
|
||||
"storage" => "# XDG 저장 경로(데이터/sqlite/벡터/에셋/모델).",
|
||||
"indexing" => "# 병렬도 + 파일시스템 watch.",
|
||||
"chunking" => "# 청크 크기·오버랩·heading 존중.",
|
||||
"models" => "# embedding / llm / nli 모델.",
|
||||
"models.embedding" => "# 다국어 sentence embedding. dim 불일치 시 검색 0건.",
|
||||
"models.llm" => "# Ollama host:port + 모델.",
|
||||
"models.nli" => "# NLI(groundedness) 모델.",
|
||||
"search" => "# 검색 기본 k·stale 기준·fusion.",
|
||||
"rag" => "# 답변 생성: prompt 템플릿·score gate·NLI.",
|
||||
"image" => "# 이미지 OCR + 캡션(기본 off, asset 당 모델 호출 비용).",
|
||||
"image.ocr" => "# 이미지 OCR(기본 off).",
|
||||
"image.caption" => "# 이미지 캡션(기본 off).",
|
||||
"ui" => "# TUI 팔레트·role 스타일.",
|
||||
"ingest" => "# ingest 정책(code skip 등).",
|
||||
"ingest.code" => "# code ingest skip 정책(.gitignore 자동 honor).",
|
||||
"ingest.expansion" => "# doc-side 별칭 확장(기본 off). 패러프레이즈 강건성↑, LLM 비용 큼.",
|
||||
"pdf" => "# PDF ingest. scanned PDF OCR 은 기본 off(page 당 cost).",
|
||||
"pdf.ocr" => "# scanned PDF page-단위 OCR(기본 off).",
|
||||
"logging" => "# ingest 로그(기본 on, ~/.local/state/kebab/logs).",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Config::defaults() 를 직렬화 + 주석 부착한 "완전체" 문서.
|
||||
/// init 과 migrate reconciliation 의 단일 참조 원천.
|
||||
pub fn annotated_default_document() -> DocumentMut {
|
||||
let defaults = crate::Config::defaults();
|
||||
let pretty = toml::to_string_pretty(&defaults).expect("defaults serialize");
|
||||
let mut doc: DocumentMut = pretty.parse().expect("defaults parse as toml_edit");
|
||||
|
||||
// 헤더: 첫 최상위 항목의 prefix 로.
|
||||
if let Some((mut first_key, _)) = doc.as_table_mut().iter_mut().next() {
|
||||
first_key.leaf_decor_mut().set_prefix(format!("{HEADER}\n"));
|
||||
}
|
||||
|
||||
annotate_table(doc.as_table_mut(), "");
|
||||
doc
|
||||
}
|
||||
|
||||
/// 재귀적으로 하위 테이블에 헤더 주석 부착. `prefix_path` 는 dotted 누적 경로.
|
||||
/// annotated_default_document 는 항상 주석 없는 defaults 에서 새로 만들므로
|
||||
/// 무조건 부착해도 중복되지 않는다.
|
||||
fn annotate_table(table: &mut toml_edit::Table, prefix_path: &str) {
|
||||
let keys: Vec<String> = table.iter().map(|(k, _)| k.to_string()).collect();
|
||||
for key in keys {
|
||||
let path = if prefix_path.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{prefix_path}.{key}")
|
||||
};
|
||||
if let Some(item) = table.get_mut(&key) {
|
||||
if let Some(sub) = item.as_table_mut() {
|
||||
if let Some(c) = section_comment(&path) {
|
||||
sub.decor_mut().set_prefix(format!("\n{c}\n"));
|
||||
}
|
||||
annotate_table(sub, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 참조(주석 달린 default) 테이블 `reference` 를 기준으로, 사용자 테이블
|
||||
/// `user` 에 없는 항목을 decor(주석) 포함 통째 복사한다. 이미 있는 키는
|
||||
/// 건드리지 않는다(값 불가침). 양쪽이 테이블이면 하위로 재귀.
|
||||
pub fn reconcile(
|
||||
reference: &toml_edit::Table,
|
||||
user: &mut toml_edit::Table,
|
||||
prefix_path: &str,
|
||||
changes: &mut Vec<MigrationChange>,
|
||||
) {
|
||||
for (key, ref_item) in reference.iter() {
|
||||
let path = if prefix_path.is_empty() {
|
||||
key.to_string()
|
||||
} else {
|
||||
format!("{prefix_path}.{key}")
|
||||
};
|
||||
match user.get_mut(key) {
|
||||
None => {
|
||||
// schema_version 키는 stamp 단계가 다룬다(change 기록 X).
|
||||
if path == "schema_version" {
|
||||
user.insert(key, ref_item.clone());
|
||||
continue;
|
||||
}
|
||||
let kind = if ref_item.is_table() {
|
||||
ChangeKind::AddedSection
|
||||
} else {
|
||||
ChangeKind::AddedKey
|
||||
};
|
||||
user.insert(key, ref_item.clone());
|
||||
changes.push(MigrationChange {
|
||||
kind,
|
||||
path: path.clone(),
|
||||
detail: section_comment(&path).map_or_else(
|
||||
|| format!("{key} 추가"),
|
||||
|c| c.trim_start_matches("# ").to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
Some(existing) => {
|
||||
if let (Some(ref_tbl), Some(user_tbl)) =
|
||||
(ref_item.as_table(), existing.as_table_mut())
|
||||
{
|
||||
reconcile(ref_tbl, user_tbl, &path, changes);
|
||||
}
|
||||
// 둘 다 테이블이 아니면(스칼라 등) 값 불가침 → 무시.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// v1 → v2: deprecated `workspace.include` 제거(p9-fb-25). 멱등.
|
||||
pub fn step_1_to_2(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>) {
|
||||
if let Some(ws) = doc.get_mut("workspace").and_then(|i| i.as_table_mut()) {
|
||||
if ws.remove("include").is_some() {
|
||||
changes.push(MigrationChange {
|
||||
kind: ChangeKind::RemovedDeprecated,
|
||||
path: "workspace.include".to_string(),
|
||||
detail: "p9-fb-25: 처리 형식은 extractor 가 자동 결정 — 더 이상 사용 안 함."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 파일의 schema_version(없으면 1) 부터 CURRENT 까지 step 적용.
|
||||
fn run_steps(doc: &mut DocumentMut, from: u32, changes: &mut Vec<MigrationChange>) {
|
||||
if from < 2 {
|
||||
step_1_to_2(doc, changes);
|
||||
}
|
||||
// 미래 step: if from < 3 { step_2_to_3(...) } ...
|
||||
}
|
||||
|
||||
/// 사용자 config.toml 텍스트를 받아 step 체인 + reconciliation + version
|
||||
/// stamp 를 적용하고 결과를 반환한다. 순수 함수(I/O 없음). 파싱 실패 시
|
||||
/// from=1, 변경 없음, new_text=입력 그대로(상위에서 파싱 에러를 따로 처리).
|
||||
pub fn migrate_document(text: &str) -> MigrationOutcome {
|
||||
let mut doc: DocumentMut = match text.parse() {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return MigrationOutcome {
|
||||
from_schema_version: 1,
|
||||
to_schema_version: CURRENT_SCHEMA_VERSION,
|
||||
changes: Vec::new(),
|
||||
new_text: text.to_string(),
|
||||
};
|
||||
}
|
||||
};
|
||||
let from = doc
|
||||
.get("schema_version")
|
||||
.and_then(toml_edit::Item::as_integer)
|
||||
.unwrap_or(1) as u32;
|
||||
|
||||
let mut changes = Vec::new();
|
||||
|
||||
// 1. non-additive step 체인.
|
||||
run_steps(&mut doc, from, &mut changes);
|
||||
|
||||
// 2. additive reconciliation(버전 무관).
|
||||
let reference = annotated_default_document();
|
||||
let ref_table = reference.as_table().clone();
|
||||
reconcile(&ref_table, doc.as_table_mut(), "", &mut changes);
|
||||
|
||||
// 3. schema_version stamp.
|
||||
let current_in_file = doc
|
||||
.get("schema_version")
|
||||
.and_then(toml_edit::Item::as_integer)
|
||||
.unwrap_or(0) as u32;
|
||||
if current_in_file != CURRENT_SCHEMA_VERSION {
|
||||
doc["schema_version"] = toml_edit::value(i64::from(CURRENT_SCHEMA_VERSION));
|
||||
}
|
||||
|
||||
MigrationOutcome {
|
||||
from_schema_version: from,
|
||||
to_schema_version: CURRENT_SCHEMA_VERSION,
|
||||
changes,
|
||||
new_text: doc.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn annotated_default_has_all_sections_and_parses_back_to_defaults() {
|
||||
let doc = annotated_default_document();
|
||||
let text = doc.to_string();
|
||||
// PdfCfg/ImageCfg/ModelsCfg/IngestCfg 는 스칼라 필드가 없어 bare
|
||||
// `[pdf]` 등은 안 나오고 `[pdf.ocr]` 같은 하위 테이블만 직렬화된다.
|
||||
for section in [
|
||||
"[workspace]",
|
||||
"[ingest.expansion]",
|
||||
"[pdf.ocr]",
|
||||
"[logging]",
|
||||
"[ui]",
|
||||
] {
|
||||
assert!(text.contains(section), "missing {section}:\n{text}");
|
||||
}
|
||||
assert!(text.contains("# "), "no comments attached");
|
||||
let back: crate::Config = toml::from_str(&text).expect("parse annotated default");
|
||||
assert_eq!(back, crate::Config::defaults());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_adds_missing_section_preserving_user_values_and_comments() {
|
||||
// ingest 는 code 만 있고 expansion 누락(v0.21.0 동기 시나리오),
|
||||
// logging 통째 누락, score 는 사용자가 바꿈, 주석 보유.
|
||||
let user_text = "\
|
||||
schema_version = 1
|
||||
|
||||
[workspace]
|
||||
root = \"/my/notes\" # 내 워크스페이스
|
||||
|
||||
[search]
|
||||
default_k = 25
|
||||
|
||||
[ingest.code]
|
||||
skip_generated_header = true
|
||||
";
|
||||
let mut user: DocumentMut = user_text.parse().unwrap();
|
||||
let reference = annotated_default_document();
|
||||
let ref_tbl = reference.as_table().clone();
|
||||
let mut changes = Vec::new();
|
||||
reconcile(&ref_tbl, user.as_table_mut(), "", &mut changes);
|
||||
let out = user.to_string();
|
||||
|
||||
// 부분 존재하는 [ingest] 에 expansion 만 주석과 함께 추가.
|
||||
assert!(out.contains("[ingest.expansion]"), "expansion not added:\n{out}");
|
||||
// 통째 누락된 logging 추가.
|
||||
assert!(out.contains("[logging]"), "logging not added");
|
||||
// 사용자 값/주석/기존 섹션 보존.
|
||||
assert!(out.contains("root = \"/my/notes\""));
|
||||
assert!(out.contains("# 내 워크스페이스"));
|
||||
assert!(out.contains("default_k = 25"));
|
||||
assert!(out.contains("skip_generated_header = true"));
|
||||
// 새 섹션 주석 부착.
|
||||
assert!(out.contains("doc-side 별칭"));
|
||||
// 부분 존재 부모로 재귀해 leaf 경로를 기록.
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|c| c.kind == ChangeKind::AddedSection && c.path == "ingest.expansion"),
|
||||
"changes: {changes:?}"
|
||||
);
|
||||
// 통째 누락 부모는 부모 경로로 한 번 기록.
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|c| c.kind == ChangeKind::AddedSection && c.path == "logging")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_does_not_overwrite_user_value_differing_from_default() {
|
||||
let user_text = "\
|
||||
schema_version = 2
|
||||
|
||||
[rag]
|
||||
score_gate = 0.8
|
||||
";
|
||||
let mut user: DocumentMut = user_text.parse().unwrap();
|
||||
let reference = annotated_default_document();
|
||||
let ref_tbl = reference.as_table().clone();
|
||||
let mut changes = Vec::new();
|
||||
reconcile(&ref_tbl, user.as_table_mut(), "", &mut changes);
|
||||
let out = user.to_string();
|
||||
assert!(out.contains("score_gate = 0.8"), "user value clobbered:\n{out}");
|
||||
assert!(!changes.iter().any(|c| c.path == "rag.score_gate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_1_to_2_removes_deprecated_workspace_include() {
|
||||
let user_text = "\
|
||||
[workspace]
|
||||
root = \"/n\"
|
||||
include = [\"*.md\"]
|
||||
";
|
||||
let mut user: DocumentMut = user_text.parse().unwrap();
|
||||
let mut changes = Vec::new();
|
||||
step_1_to_2(&mut user, &mut changes);
|
||||
let out = user.to_string();
|
||||
assert!(!out.contains("include"), "include not removed:\n{out}");
|
||||
assert!(
|
||||
changes
|
||||
.iter()
|
||||
.any(|c| c.kind == ChangeKind::RemovedDeprecated && c.path == "workspace.include")
|
||||
);
|
||||
let mut changes2 = Vec::new();
|
||||
step_1_to_2(&mut user, &mut changes2);
|
||||
assert!(changes2.is_empty());
|
||||
}
|
||||
|
||||
fn read_schema_version(text: &str) -> u32 {
|
||||
let doc: DocumentMut = text.parse().unwrap();
|
||||
doc.get("schema_version")
|
||||
.and_then(toml_edit::Item::as_integer)
|
||||
.unwrap_or(1) as u32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_document_stamps_version_and_is_idempotent() {
|
||||
let old = "\
|
||||
schema_version = 1
|
||||
|
||||
[workspace]
|
||||
root = \"/n\"
|
||||
include = [\"*.md\"]
|
||||
";
|
||||
let outcome = migrate_document(old);
|
||||
assert_eq!(outcome.from_schema_version, 1);
|
||||
assert_eq!(outcome.to_schema_version, CURRENT_SCHEMA_VERSION);
|
||||
assert!(outcome.changed());
|
||||
assert!(!outcome.new_text.contains("include"));
|
||||
assert!(outcome.new_text.contains("[ingest.expansion]"));
|
||||
assert_eq!(read_schema_version(&outcome.new_text), CURRENT_SCHEMA_VERSION);
|
||||
|
||||
let again = migrate_document(&outcome.new_text);
|
||||
assert!(!again.changed(), "not idempotent: {:?}", again.changes);
|
||||
assert_eq!(again.new_text, outcome.new_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_document_missing_schema_version_treated_as_v1() {
|
||||
let old = "[workspace]\nroot = \"/n\"\n";
|
||||
let outcome = migrate_document(old);
|
||||
assert_eq!(outcome.from_schema_version, 1);
|
||||
assert_eq!(read_schema_version(&outcome.new_text), CURRENT_SCHEMA_VERSION);
|
||||
}
|
||||
}
|
||||
1188
docs/superpowers/plans/2026-05-31-config-migration.md
Normal file
1188
docs/superpowers/plans/2026-05-31-config-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
268
docs/superpowers/specs/2026-05-31-config-migration-design.md
Normal file
268
docs/superpowers/specs/2026-05-31-config-migration-design.md
Normal file
@@ -0,0 +1,268 @@
|
||||
# config 마이그레이션 — 설계 (spec)
|
||||
|
||||
> 2026-05-31. config.toml **스키마 진화 시 기존 사용자 파일을 자동 갱신**하는 기능의
|
||||
> 설계 계약. kickoff 인계 문서
|
||||
> [`2026-05-31-config-migration-kickoff.md`](../handoffs/2026-05-31-config-migration-kickoff.md)
|
||||
> 의 brainstorm 결과를 확정한 spec 이다. 본 문서를 기준으로 plan → 구현.
|
||||
|
||||
## 0. 결정 요약 (brainstorm 게이트)
|
||||
|
||||
| 축 | 결정 | 근거 |
|
||||
|----|------|------|
|
||||
| **트리거** | 명시 명령 `kebab config migrate` + `kebab doctor` 안내 | 예측 가능성·안전. load 시 자동 쓰기는 쓰기 권한/동시 실행/손상 위험. |
|
||||
| **주석 보존** | `toml_edit` 부분 편집 | 사용자가 손본 값·주석·순서·정렬 100% 보존. 빠진 것만 추가. |
|
||||
| **버전 메커니즘** | reconciliation(additive) + step 체인(non-additive) 하이브리드 | kebab config 는 `schema_version` 이 줄곧 `1` 인 채로 섹션이 누적돼 버전 번호만으로 "무엇이 빠졌는지" 구분 불가 → 구조 비교가 본질. |
|
||||
|
||||
## 1. 동기 (kickoff §1 재확인)
|
||||
|
||||
v0.21.0 에서 `[ingest.expansion]` 등 섹션이 늘었지만, 기존 사용자 config.toml 은
|
||||
serde default 로 **동작은 호환**(off 로 로드)되나 그 섹션이 **파일에 써지지 않아**
|
||||
사용자가 파일을 열어도 새 기능의 존재·노브를 알 수 없다. DB 는 V00X refinery
|
||||
마이그레이션이 있는데 config 는 없다 — 이걸 만든다.
|
||||
|
||||
핵심: **데이터 무효화가 아니라 *파일 가시성* 문제**. 읽기 호환성은 이미 확보돼 있으므로
|
||||
(`#[serde(default)]`), 만들 것은 *사용자 파일을 새 스키마에 맞춰 갱신*하는 것이다.
|
||||
|
||||
## 2. 비목표 (YAGNI)
|
||||
|
||||
- config 값의 **의미적 검증**(예: score_gate 범위 체크) — 별개 기능. 본 작업 범위 아님.
|
||||
- **load 시 자동 마이그레이션** — 명시적으로 제외(트리거 결정). 추후 필요 시 별 작업.
|
||||
- **다운그레이드**(새 → 옛 스키마) — 단방향만.
|
||||
- 기존 사용자 **값의 재조정**(default 가 바뀌었다고 사용자 값 덮어쓰기) — 절대 안 함.
|
||||
마이그레이션은 *없는 것 추가* + *deprecated 정리*만. 사용자가 명시한 값은 불가침.
|
||||
|
||||
## 3. 아키텍처 — 두 메커니즘
|
||||
|
||||
마이그레이션은 사용자 파일(`toml_edit::DocumentMut`)에 다음 순서로 적용한다.
|
||||
|
||||
```
|
||||
원본 파일 → [1. step 체인(non-additive)] → [2. reconciliation(additive)] → [3. schema_version stamp] → 결과
|
||||
```
|
||||
|
||||
### 3.1 Reconciliation (additive — 핵심 메커니즘)
|
||||
|
||||
**정의**: "default Config 구조에는 있지만 사용자 파일에 없는 테이블/키를, 설명 주석과
|
||||
함께 사용자 파일에 추가한다." 버전과 무관하게 동작하며 멱등이다.
|
||||
|
||||
**참조 문서 = 주석 달린 default**: `annotated_default_document()` 가 단일 진실 원천이다.
|
||||
|
||||
```
|
||||
fn annotated_default_document() -> toml_edit::DocumentMut
|
||||
// Config::defaults() 를 toml_edit Document 로 직렬화한 뒤,
|
||||
// 주석 카탈로그(§3.3)의 설명을 각 테이블/키의 decor(prefix)에 부착.
|
||||
// → 이 문서가 "완전체 config.toml" 의 정의.
|
||||
```
|
||||
|
||||
`kebab init` 도 이 함수의 출력을 그대로 파일로 쓴다(§5.2). 즉 **init 과 migrate 가
|
||||
동일한 참조 문서를 공유** → 주석·구조의 단일 원천.
|
||||
|
||||
**reconcile 알고리즘** (참조 문서 `ref` → 사용자 문서 `user`, 재귀):
|
||||
|
||||
```
|
||||
for each (key, ref_item) in ref (문서 순서 유지):
|
||||
if key 가 user 에 없음:
|
||||
user 에 ref_item 을 통째 복사 (decor=주석 포함). → change: added_section / added_key
|
||||
else if ref_item 과 user[key] 가 둘 다 테이블:
|
||||
recurse(ref_item, user[key]) # 하위만 비교
|
||||
else:
|
||||
# 키가 이미 존재(값이 default 와 달라도) → 건드리지 않음. (값 불가침)
|
||||
```
|
||||
|
||||
- **삽입 위치**: 누락 키는 해당 테이블 **끝에 append**(결정적·단순). 사용자가 짜둔 기존
|
||||
순서는 보존되고 새 항목만 뒤에 붙는다.
|
||||
- **중첩 테이블**: `[ingest]` 는 있는데 `[ingest.expansion]` 이 없으면 `expansion`
|
||||
하위 테이블만 추가. `[ingest]` 자체가 없으면 `[ingest]` + 그 안의 모든 하위를 추가.
|
||||
- **값 불가침 예시**: 사용자가 `score_gate = 0.8` 로 바꿔뒀고 default 가 0.6 이어도,
|
||||
키가 존재하므로 **0.8 유지**. 마이그레이션은 0.6 으로 되돌리지 않는다.
|
||||
|
||||
### 3.2 Step 체인 (non-additive)
|
||||
|
||||
`schema_version` 기반 버전별 변환 함수. additive 가 아닌 변경(deprecated 제거, rename,
|
||||
형식 변환)을 담당한다. DB refinery 패턴 차용.
|
||||
|
||||
```
|
||||
const CURRENT_SCHEMA_VERSION: u32 = 2; // 이번 작업에서 1 → 2
|
||||
|
||||
fn step_1_to_2(doc: &mut DocumentMut, changes: &mut Vec<MigrationChange>)
|
||||
// v1 → v2 변환: 옛 `workspace.include` 키 제거 (p9-fb-25 deprecated).
|
||||
// - doc["workspace"]["include"] 존재 시 remove → change: removed_deprecated.
|
||||
// - 없으면 noop (멱등).
|
||||
```
|
||||
|
||||
- **실행 범위**: 파일의 `schema_version`(없으면 1 로 간주) 부터 `CURRENT` 까지 순차 적용.
|
||||
이미 `CURRENT` 이상이면 step 없음.
|
||||
- 각 step 은 **개별적으로 멱등**(이미 적용된 상태에서 재실행해도 noop).
|
||||
- 이번 작업의 유일한 step 은 `1→2`(workspace.include 제거). 누적된 섹션 추가
|
||||
(image/ui/ingest/pdf/logging/expansion)는 **전부 reconciliation 이 처리**하므로
|
||||
step 으로 만들지 않는다. step 체인은 "구조로 표현 못 하는 변환"만 담는다.
|
||||
|
||||
### 3.3 주석 카탈로그
|
||||
|
||||
"섹션/키 → 한국어 설명 주석" 매핑을 kebab-config 의 마이그레이션 모듈 한 곳에 정적
|
||||
정의한다. 단일 원천 — README/SMOKE 와 중복하지 않고 여기를 정본으로.
|
||||
|
||||
- 기존 `init_workspace` 의 헤더(경로 정책 설명, `kebab-app/src/lib.rs:147~`)는
|
||||
**문서 레벨 prefix** 로 이전한다(`annotated_default_document` 가 부착).
|
||||
- 섹션별 주석은 README Configuration §의 노브 설명을 차용해 **간결**하게(1~2줄).
|
||||
예: `[ingest.expansion]` → `# doc-side 별칭 확장 (기본 off). 검색 패러프레이즈 강건성↑.`
|
||||
- 주석 문구는 짧게, 과하지 않게. 전체 문서는 생성된 파일·README·SMOKE 참고로 유도.
|
||||
|
||||
### 3.4 멱등성 보장 (안전 1축)
|
||||
|
||||
- reconciliation: 이미 있는 키는 skip → 두 번째 실행 시 changes 비어 있음.
|
||||
- step: 각 step 이 noop-safe.
|
||||
- 결과: **마이그레이션 후 재실행하면 `changed=false`, 파일 미변경.** 이것이 doctor
|
||||
체크(§5.3)와 멱등 테스트의 핵심 단언.
|
||||
|
||||
## 4. 안전 3축 (kickoff §4.4)
|
||||
|
||||
1. **멱등** — §3.4.
|
||||
2. **백업** — 파일 수정 직전 `<config>.bak` 생성(원본 복사). 기존 `.bak` 있으면 덮어씀
|
||||
(단순화; 변경 내용은 dry-run 으로 사전 확인 가능). dry-run 시 백업도 안 만듦.
|
||||
3. **dry-run** — `--dry-run` 은 changes 만 계산·출력하고 **파일·백업 모두 미수정**.
|
||||
|
||||
**실패 시 원본 보존(atomic write)**: 편집 결과는 `<config>.tmp` 에 먼저 쓰고
|
||||
`rename(tmp, config)` 로 교체. rename 이전 어느 단계에서 실패해도 원본 불변. 순서:
|
||||
`백업 생성 → tmp 쓰기 → tmp 검증(재파싱 round-trip) → atomic rename`.
|
||||
|
||||
## 5. 표면 (surface)
|
||||
|
||||
### 5.1 CLI — `kebab config migrate`
|
||||
|
||||
신규 top-level `Config` 서브커맨드 그룹(clap nested, `Inspect`/`List` 패턴 차용):
|
||||
|
||||
```
|
||||
kebab config migrate [--dry-run] [--json]
|
||||
```
|
||||
|
||||
- 전역 `--config <path>` 존중 (facade rule). 미지정 시 XDG 기본 경로.
|
||||
- 대상 파일이 없으면 에러: `config 파일이 없습니다. 먼저 kebab init 을 실행하세요.`
|
||||
(`--json` 시 `error.v1`, code `config_not_found`).
|
||||
- 사람용 출력: 변경 목록(추가된 섹션/키, 제거된 deprecated) + 백업 경로 + "N changes
|
||||
applied" 또는 "already up to date".
|
||||
- `--json`: `config_migration.v1` (§5.4).
|
||||
|
||||
**facade**: kebab-cli 는 kebab-app 의
|
||||
`config_migrate_with_config_path(config_path: Option<&Path>, dry_run: bool)
|
||||
-> anyhow::Result<ConfigMigrationReport>` 를 호출(파일 read/백업/atomic write
|
||||
오케스트레이션은 app 계층, 순수 변환은 config 계층 — §6).
|
||||
|
||||
### 5.2 `kebab init` 영향 (user-visible)
|
||||
|
||||
`init_workspace` 가 `annotated_default_document()` 출력을 쓰도록 변경. 결과: init 이
|
||||
생성하는 config.toml 이 **섹션별 주석을 포함**(기존엔 헤더만). 이는 user-visible surface
|
||||
변경이므로 README Configuration §·docs/SMOKE.md 의 config 예시 블록 동기화 필요.
|
||||
|
||||
### 5.3 `kebab doctor` 체크 추가 (additive)
|
||||
|
||||
config load 체크 직후 `config_migration` 체크 1개 추가:
|
||||
|
||||
- 내부적으로 dry-run 마이그레이션 실행 → changes 비었으면 `ok=true`,
|
||||
detail `config up to date (schema v2)`, hint=None.
|
||||
- changes 있으면 `ok=false`, detail `N pending changes (added M sections, removed K
|
||||
deprecated)`, hint `run kebab config migrate to update your config.toml`.
|
||||
- **trade-off (확정)**: `DoctorCheck` 는 `ok: bool` 뿐이고 hint 는 `ok==false` 일 때
|
||||
표시되는 규약이므로, "마이그레이션 필요"는 `ok=false` 로 신호한다. 이는 전체
|
||||
`DoctorReport.ok`(모든 체크의 AND)를 false 로 만든다 — 즉 *완전히 동작하지만
|
||||
config 가 옛 스키마인* 환경에서 `kebab doctor` 가 "비정상"으로 보고된다. 이를
|
||||
의도된 동작으로 받아들인다(doctor = "정리할 것이 있는가"의 점검이고, hint 가 정확한
|
||||
교정 명령을 제시). 새 키만 추가하는 additive 변경을 "건강 실패"로 과하게 보는 면이
|
||||
있으나, 별도 warn 상태를 도입하는 것(스키마·표면 변경)보다 단순함을 택한다.
|
||||
- `doctor.v1` 스키마는 변경 없음(checks 배열에 행 1개 추가 — additive, backward-compat).
|
||||
|
||||
### 5.4 wire schema `config_migration.v1` (신규)
|
||||
|
||||
`docs/wire-schema/v1/config_migration.schema.json` 신설. `--json` 출력:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "config_migration.v1",
|
||||
"dry_run": true,
|
||||
"config_path": "/home/me/.config/kebab/config.toml",
|
||||
"from_schema_version": 1,
|
||||
"to_schema_version": 2,
|
||||
"changed": true,
|
||||
"backup_path": null,
|
||||
"changes": [
|
||||
{ "kind": "added_section", "path": "ingest.expansion", "detail": "doc-side 별칭 확장 (기본 off)" },
|
||||
{ "kind": "added_key", "path": "logging.enabled", "detail": "ingest 로그 활성화" },
|
||||
{ "kind": "removed_deprecated","path": "workspace.include","detail": "p9-fb-25: extractor 자동 결정" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `changed`: 실제(또는 dry-run 시 가정) 변경 발생 여부. false 면 changes=[].
|
||||
- `backup_path`: 실제 적용 시 `.bak` 경로, dry-run 시 `null`.
|
||||
- `kind` enum: `added_section | added_key | removed_deprecated`. (향후 `renamed`,
|
||||
`reformatted` 확장 여지 — 본 작업은 3종.)
|
||||
- additive 신규 스키마 → 기존 통합 영향 없음. wire major bump 아님(v1 추가).
|
||||
|
||||
## 6. 코드 배치 (crate 경계)
|
||||
|
||||
| 위치 | 책임 | 비고 |
|
||||
|------|------|------|
|
||||
| `crates/kebab-config/src/migrate.rs` (신규) | **순수 변환**: `annotated_default_document`, `reconcile`, step 체인, `CURRENT_SCHEMA_VERSION`, 주석 카탈로그, `MigrationChange`/`ConfigMigrationReport` 타입, `migrate_document(doc) -> Vec<MigrationChange>` | I/O 없음. 문자열 in → 문자열 out 로 테스트 가능. |
|
||||
| `crates/kebab-config/Cargo.toml` | `toml_edit = "0.22"` 의존성 추가 | 주석 보존 편집 핵심. |
|
||||
| `crates/kebab-app/src/lib.rs` | **I/O 오케스트레이션**: `config_migrate_with_config_path`(read → migrate_document → 백업 → tmp write → atomic rename), `init_workspace` 가 `annotated_default_document` 사용하도록 수정, doctor 에 체크 추가 | facade. fs 부작용은 app 계층. |
|
||||
| `crates/kebab-cli/src/main.rs` | `Config { Migrate { dry_run } }` 서브커맨드, 사람용 출력 | kebab-app facade 만 호출. |
|
||||
| `crates/kebab-cli/src/wire.rs` | `wire_config_migration(report) -> Value` | `config_migration.v1` 직렬화. |
|
||||
| `docs/wire-schema/v1/config_migration.schema.json` (신규) | wire 계약 | |
|
||||
|
||||
**경계 근거**: kebab-config 는 이미 파일 *읽기*(`from_file`)를 하지만, *쓰기*는
|
||||
`init_workspace`(app)에 있다. 일관성·테스트성 위해 순수 변환은 config, 부작용(백업·쓰기)
|
||||
은 app. doctor(app)·cli 모두 동일 순수 변환을 재사용.
|
||||
|
||||
## 7. schema_version 의 새 의미
|
||||
|
||||
- 기존: 항상 `1`, 검증·로직에 안 쓰이는 장식.
|
||||
- 신규: "이 파일이 sync 된 스키마 버전" 마커 + step 체인의 축.
|
||||
- `Config::defaults().schema_version` 및 `CURRENT_SCHEMA_VERSION` 을 **2** 로 bump.
|
||||
마이그레이션 완료 시 사용자 파일의 `schema_version` 을 `CURRENT` 로 stamp.
|
||||
- 읽기 경로(`from_file`)는 여전히 `schema_version` 으로 **거부하지 않음**(forward-compat
|
||||
유지). 즉 옛 바이너리로 새 파일을, 새 바이너리로 옛 파일을 읽어도 동작.
|
||||
|
||||
## 8. 문서 동기화 (user-facing surface)
|
||||
|
||||
- **README.md Configuration §**: `kebab config migrate` 한 줄 + init config 가 섹션
|
||||
주석을 갖는다는 설명. config 예시 블록을 `annotated_default_document` 산출과 일치.
|
||||
- **docs/SMOKE.md**: config 예시 블록 동기화. migrate dry-run smoke 단계 추가.
|
||||
- **docs/DOGFOOD.md**: config 관련 section 에 migrate 시나리오(옛 파일 → migrate →
|
||||
섹션 가시성 확인) 추가.
|
||||
- **tasks/HOTFIXES.md**: 머지 후 dated entry(`## YYYY-MM-DD — config 마이그레이션`),
|
||||
도그푸딩 evidence(옛 config 에 빠진 섹션 N개 추가 + workspace.include 제거 멱등 확인).
|
||||
- **HANDOFF.md**: 해당되면 한 줄.
|
||||
|
||||
## 9. 릴리스 트리거 판단
|
||||
|
||||
- 신규 CLI 서브커맨드(`config migrate`) + doctor 체크 + init 출력 변경 = **user-visible
|
||||
surface 변경** → 도그푸딩 필수, README 동기화 필수.
|
||||
- `schema_version` bump(1→2)는 **additive**(데이터 무효화 아님, 읽기 호환 유지) →
|
||||
CLAUDE.md §Versioning 의 DB/wire breaking 기준엔 해당 안 됨. 다만 surface 누적이
|
||||
있으므로 **minor bump** 대상일 수 있음. 실제 bump/release 컷 시점은 사용자 판단.
|
||||
|
||||
## 10. 테스트 전략 (plan 의 TDD 근거)
|
||||
|
||||
순수 변환(kebab-config)이 테스트의 중심 — 문자열 in/out, fs 불필요:
|
||||
|
||||
1. **reconciliation 추가**: 옛 config 문자열(섹션 누락) → migrate → 누락 섹션이 주석과
|
||||
함께 추가됐고, 기존 키·주석·순서는 보존.
|
||||
2. **값 불가침**: 사용자가 바꾼 값(예: `score_gate = 0.8`)이 migrate 후에도 유지.
|
||||
3. **멱등**: migrate 출력을 다시 migrate → `changed=false`, 동일 문자열.
|
||||
4. **step (workspace.include 제거)**: 옛 키 있는 문자열 → 제거됨 + change 기록. 없으면 noop.
|
||||
5. **schema_version stamp**: 결과의 `schema_version = 2`. 없던 파일엔 추가됨.
|
||||
6. **주석 보존**: 사용자가 임의 키에 단 주석이 migrate 후에도 그대로.
|
||||
7. (app) **백업·atomic·실패 보존**: 백업 파일 생성, tmp rename, 손상 입력 시 원본 불변.
|
||||
8. (app) **dry-run**: 파일·백업 미생성, report.changed 정확.
|
||||
9. (cli/wire) `config_migration.v1` 직렬화 형태.
|
||||
|
||||
## 11. Risks / notes
|
||||
|
||||
- `toml_edit` 신규 의존성 — kebab-config 에 추가. `toml`(0.8)과 공존(serde 경로는
|
||||
여전히 `toml`, 편집 경로만 `toml_edit`). 버전은 구현 시 최신 0.22.x 확인.
|
||||
- reconciliation 의 "끝에 append" 는 사용자가 짠 미적 순서를 흩뜨릴 수 있으나(새 섹션이
|
||||
뒤로 몰림), 값·주석·기존 순서 보존이 우선이며 단순·결정적이라 채택.
|
||||
- 첫 step(`1→2`)은 사실상 이미 무시되는 `workspace.include` 청소뿐 — step 체인은 주로
|
||||
*프레임워크*로서 미래 non-additive 변경을 위해 깔아둔다.
|
||||
- kickoff 인계 문서와의 차이: kickoff §4.2 는 "버전별 변환 함수 체인"만 제안했으나,
|
||||
kebab 의 serde-default 특성상 additive 변경은 step 으로 표현하기 부적절(버전 무관) →
|
||||
**reconciliation 을 1급 메커니즘으로 승격**하고 step 은 non-additive 전용으로 한정.
|
||||
38
docs/wire-schema/v1/config_migration.v1.schema.json
Normal file
38
docs/wire-schema/v1/config_migration.v1.schema.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "config_migration.v1",
|
||||
"description": "Result of `kebab config migrate` — schema reconciliation of a user's config.toml.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema_version",
|
||||
"config_path",
|
||||
"dry_run",
|
||||
"from_schema_version",
|
||||
"to_schema_version",
|
||||
"changed",
|
||||
"changes"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": { "const": "config_migration.v1" },
|
||||
"config_path": { "type": "string" },
|
||||
"dry_run": { "type": "boolean" },
|
||||
"from_schema_version": { "type": "integer" },
|
||||
"to_schema_version": { "type": "integer" },
|
||||
"changed": { "type": "boolean" },
|
||||
"backup_path": { "type": ["string", "null"] },
|
||||
"changes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["kind", "path", "detail"],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": ["added_section", "added_key", "removed_deprecated"]
|
||||
},
|
||||
"path": { "type": "string" },
|
||||
"detail": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user