From bd7c4fd7ef3d6a6f4592452c5fd72eb98ab99af6 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 31 May 2026 11:44:39 +0000 Subject: [PATCH] =?UTF-8?q?feat(config):=20config=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=97=94=EC=A7=84=20(re?= =?UTF-8?q?concile=20+=20step=20=EC=B2=B4=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toml_edit 0.22 의존성 추가 - migrate.rs: CURRENT_SCHEMA_VERSION=2, annotated_default_document(주석 카탈로그 공유 원천), reconcile(빠진 섹션/키 주석과 함께 추가, 값 불가침), step_1_to_2(workspace.include 제거), migrate_document(step+reconcile+stamp) - schema_version default 1 → 2 - 56 tests green, clippy -D warnings clean Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-config/src/lib.rs | 2 +- crates/kebab-config/src/migrate.rs | 355 ++++++++++++++++++++++++++++- 2 files changed, 352 insertions(+), 5 deletions(-) diff --git a/crates/kebab-config/src/lib.rs b/crates/kebab-config/src/lib.rs index aa69ef2..8e66bca 100644 --- a/crates/kebab-config/src/lib.rs +++ b/crates/kebab-config/src/lib.rs @@ -670,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![ diff --git a/crates/kebab-config/src/migrate.rs b/crates/kebab-config/src/migrate.rs index 8985f06..2ab5697 100644 --- a/crates/kebab-config/src/migrate.rs +++ b/crates/kebab-config/src/migrate.rs @@ -5,14 +5,14 @@ //! non-additive 변환(deprecated 제거 등). 자세한 계약은 spec //! `docs/superpowers/specs/2026-05-31-config-migration-design.md`. -use serde::Serialize; +use toml_edit::DocumentMut; /// 현재 바이너리가 이해하는 config 스키마 버전. 마이그레이션 완료 시 /// 사용자 파일의 `schema_version` 을 이 값으로 stamp 한다. pub const CURRENT_SCHEMA_VERSION: u32 = 2; /// 한 번의 마이그레이션에서 발생한 개별 변경. -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, serde::Serialize)] pub struct MigrationChange { pub kind: ChangeKind, /// dotted path, 예: `ingest.expansion`, `workspace.include`. @@ -21,7 +21,7 @@ pub struct MigrationChange { pub detail: String, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] pub enum ChangeKind { AddedSection, @@ -31,7 +31,7 @@ pub enum ChangeKind { /// 마이그레이션 결과 요약(순수 변환 단계 산출). I/O 계층이 backup_path /// 등을 채워 wire 로 내보낸다. -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, serde::Serialize)] pub struct MigrationOutcome { pub from_schema_version: u32, pub to_schema_version: u32, @@ -45,3 +45,350 @@ impl MigrationOutcome { !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 자동 결정): Markdown(.md) / 이미지(.png .jpg) / 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 = 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, +) { + 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) { + 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) { + 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); + } +}