3회차 리뷰가 2회차 지적 8건이 동작 수준에서 해결됐음을 확인하고 머지 가능으로 결론냈다. 남은 LOW 중 값이 있는 셋을 반영한다. 1) 마이그레이션 이력을 못 읽었을 때 파괴적 조언으로 떨어졌다 `Some(Some((None, Ok(어긋남))))` — 버전은 모르는데 표본은 읽힌 경우 — 가 `ok: false` + "`kebab reset`" 분기로 갔다. 버전을 모르는 것은 KB 를 날리라고 말할 근거가 못 된다. 판정을 "V016 이상임이 확인된 스토어"로 한정하고, 이력을 못 읽으면 `ok: true` + "판정 보류" 로 둔다. 실제 도달 경로는 사실상 없지만(chunks_fts 가 있으면 이력도 있다) 분기의 기본값이 파괴적인 쪽인 게 문제였다. 2) "점검하지 못했다" hint 가 가장 흔한 원인을 안 적었다 2회차에서 고친 HIGH — 이전 버전 doctor 가 남긴 0바이트 kebab.sqlite — 가 정확히 이 분기로 온다. 테이블이 없어 읽기에 실패한다. "아직 색인 전이거나" 를 앞에 넣었다. 3) vector_store 블록이 아직 env 를 안 얹고 있었다 2회차에서 fts_shadow 에 대해 고친 것과 같은 결함이 #234 에서 들어온 바로 아래 블록에 그대로 남아 있었다. 정보성 체크라 종료 코드에는 영향이 없지만 detail 이 엉뚱한 디렉토리를 가리킨다. 같이 맞췄다. 미반영: shadow 의 chunk_id 가 NULL 이면 분자에서 빠지는 과소 계수 — 트리거가 NOT NULL 컬럼을 미러링하므로 도달 불가이고 방향도 오탐이 아닌 과소 쪽이다. 새 테스트가 `KEBAB_STORAGE_DATA_DIR` 이 export 된 셸에서 실패하는 것은 점검이 제대로 동작하는 결과라, 원인을 빨리 찾도록 테스트 주석에 적어 두는 선에서 끝냈다. 실측 확인: KB 없음 / V015 / V016 / env override 네 경로 모두 의도한 문구가 나오고, KB 없는 경로는 파일을 남기지 않는다. env override 를 주면 fts_shadow 와 vector_store 가 같은 디렉토리를 본다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
184 lines
6.4 KiB
Rust
184 lines
6.4 KiB
Rust
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.code]"));
|
|
|
|
// 멱등: 재실행 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("code ingest skip 정책"),
|
|
"section comment missing:\n{text}"
|
|
);
|
|
assert!(text.contains("[ingest.code]"));
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
|
|
/// `doctor` is a diagnostic and must not manufacture the store it is
|
|
/// asked about. The `fts_shadow` check (issue #229 / V016) reads SQLite,
|
|
/// and `SqliteStore::open` creates the file — so on a machine with no KB
|
|
/// yet, running doctor once used to leave an empty `kebab.sqlite` behind.
|
|
#[test]
|
|
fn doctor_does_not_create_a_store_where_none_exists() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let data = dir.path().join("data");
|
|
let cfg = dir.path().join("config.toml");
|
|
fs::write(
|
|
&cfg,
|
|
format!(
|
|
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude=[\"*.md\"]\n\n\
|
|
[storage]\ndata_dir = \"{}\"\n",
|
|
data.display()
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
|
|
let check = report
|
|
.checks
|
|
.iter()
|
|
.find(|c| c.name == "fts_shadow")
|
|
.expect("doctor must report fts_shadow even with no store");
|
|
assert!(check.ok, "a missing store is not a drifted one");
|
|
|
|
assert!(
|
|
!data.join(kebab_store_sqlite::SQLITE_FILE).exists(),
|
|
"doctor must not create {} — it only reports",
|
|
kebab_store_sqlite::SQLITE_FILE
|
|
);
|
|
}
|
|
|
|
/// A store that predates V016 can be misaligned already — V009's
|
|
/// backfill inserted without an explicit rowid — but there the delete
|
|
/// trigger still addresses rows by chunk_id, so the drift is harmless.
|
|
/// Telling that user to `kebab reset` would destroy a healthy KB over a
|
|
/// condition that applying the migration fixes by itself.
|
|
///
|
|
/// Note: doctor applies env overrides with the same precedence as
|
|
/// `Config::load`, so `KEBAB_STORAGE_DATA_DIR` exported in the shell
|
|
/// redirects this check away from the temp store and fails the test.
|
|
/// That is the check behaving correctly, not a broken test — unset it.
|
|
#[test]
|
|
fn doctor_does_not_call_a_pre_v016_store_broken() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let data = dir.path().join("data");
|
|
std::fs::create_dir_all(&data).unwrap();
|
|
let cfg = dir.path().join("config.toml");
|
|
fs::write(
|
|
&cfg,
|
|
format!(
|
|
"schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude=[\"*.md\"]\n\n\
|
|
[storage]\ndata_dir = \"{}\"\n",
|
|
data.display()
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
// A store stamped at V015 with a shadow deliberately misaligned:
|
|
// exactly what a pre-V016 upgrade can look like.
|
|
let db = data.join(kebab_store_sqlite::SQLITE_FILE);
|
|
let conn = rusqlite::Connection::open(&db).unwrap();
|
|
conn.execute_batch(
|
|
"CREATE TABLE refinery_schema_history (version INTEGER);
|
|
INSERT INTO refinery_schema_history VALUES (15);
|
|
CREATE TABLE chunks (chunk_id TEXT PRIMARY KEY, text TEXT);
|
|
CREATE VIRTUAL TABLE chunks_fts USING fts5(chunk_id UNINDEXED, text);
|
|
INSERT INTO chunks VALUES ('a', 'x'), ('b', 'y');
|
|
INSERT INTO chunks_fts(rowid, chunk_id, text) VALUES (77, 'a', 'x');",
|
|
)
|
|
.unwrap();
|
|
drop(conn);
|
|
|
|
let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap();
|
|
let check = report
|
|
.checks
|
|
.iter()
|
|
.find(|c| c.name == "fts_shadow")
|
|
.unwrap();
|
|
assert!(
|
|
check.ok,
|
|
"a pre-V016 store must not fail the check: {}",
|
|
check.detail
|
|
);
|
|
assert!(
|
|
check.detail.contains("V016"),
|
|
"the detail should say the migration has not been applied, got {:?}",
|
|
check.detail
|
|
);
|
|
assert!(
|
|
check.hint.as_deref().unwrap_or_default().is_empty(),
|
|
"and must not tell the user to reset: {:?}",
|
|
check.hint
|
|
);
|
|
}
|