feat(config): from_file load 시 v2→v3 메모리 내 자동 변환(디스크 미변경)

schema_version < CURRENT 이면 migrate_document 경유로 메모리에서 변환 후 파싱.
디스크 파일은 불변(갱신은 kebab config migrate). 일회성 warn. 불변식 #3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:47:59 +00:00
parent 25e94feab8
commit 2686a4f27d

View File

@@ -986,7 +986,33 @@ impl Config {
}
}
let mut cfg: Self = toml::from_str(&text).map_err(|e| {
// v3: 파일의 schema_version 이 CURRENT 보다 낮으면 메모리에서 변환한다
// (디스크 미변경 — 파일 갱신은 `kebab config migrate`). 미변환 v2 파일도
// 설정 유실 없이 로드(불변식 #3). non-additive relocation(v2→v3) 은
// serde default forward-compat 로는 커버 안 되므로 반드시 거쳐야 한다.
let parse_text = {
let from = toml::from_str::<toml::Value>(&text)
.ok()
.and_then(|v| v.get("schema_version").and_then(toml::Value::as_integer))
.unwrap_or(1) as u32;
if from < crate::migrate::CURRENT_SCHEMA_VERSION {
static MIGRATE_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
MIGRATE_WARNED.get_or_init(|| {
tracing::warn!(
target: "kebab-config",
config = %path.display(),
from,
to = crate::migrate::CURRENT_SCHEMA_VERSION,
"config 가 옛 스키마입니다 — 이번 실행은 메모리에서 변환됨. 파일 갱신: `kebab config migrate`."
);
});
crate::migrate::migrate_document(&text).new_text
} else {
text.clone()
}
};
let mut cfg: Self = toml::from_str(&parse_text).map_err(|e| {
anyhow::Error::new(ConfigInvalid {
path: path.to_path_buf(),
cause: format!("parse_failed: {e}"),
@@ -1479,6 +1505,47 @@ theme = "dark"
assert_eq!(c, back);
}
/// 불변식 #3: `from_file` 이 v2 파일을 디스크 미변경으로 메모리에서 v3
/// 변환 — 미변환 v2 파일도 설정 유실 0.
#[test]
fn from_file_auto_migrates_v2_in_memory() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("config.toml");
std::fs::write(
&p,
"\
schema_version = 2
[workspace]
root = \"/my/notes\"
exclude = []
[chunking]
target_tokens = 777
[image.ocr]
enabled = true
engine = \"ollama-vision\"
model = \"gemma4:e4b\"
languages = [\"kor\"]
max_pixels = 1600
",
)
.unwrap();
let c = Config::from_file(&p).expect("v2 auto-migrate load");
// 사용자 v2 값이 새 경로로 살아있어야(기본값 유실 X).
assert_eq!(c.ingest.chunking.target_tokens, 777);
assert!(c.ingest.image.ocr.enabled);
assert_eq!(c.ingest.image.ocr.languages, vec!["kor"]);
// 디스크 파일은 안 바뀜(여전히 schema_version = 2 + [chunking]).
let on_disk = std::fs::read_to_string(&p).unwrap();
assert!(
on_disk.contains("schema_version = 2"),
"파일이 변경됨:\n{on_disk}"
);
assert!(on_disk.contains("[chunking]"), "파일이 변경됨:\n{on_disk}");
}
#[test]
fn v3_layout_nests_media_under_ingest() {
let c = Config::defaults();