리뷰 두 건에서 나온 지적을 반영한다.
1) sweep 이 끝나도 asset 진행바가 복구되지 않았다 (HIGH)
sweep 은 asset 진행바를 빌려 쓰면서 자기 라벨과 자기(더 작은) 총계를
씌운다. 그런데 `AssetStarted` 는 위치와 메시지만 세팅하고 길이·스타일은
건드리지 않는다. 그래서 sweep 이 한 번 돌면 그 뒤 색인 구간 전체가
`sweep [====] 4213/21` 로 그려졌다 — 라벨도 분모도 틀린다.
더 나쁜 건 스타일 교체가 v0.26.1 의 커스텀 키 `{asset_elapsed}` 를 같이
날린다는 점이다. 느린 asset 에서 `(Ns)` 가 도는 게 "멈춘 게 아님"의 유일한
신호인데 sweep 이 그걸 없앤다. 이 PR 이 sweep 구간에서 없앤 "hang 처럼
보임" 을 asset 구간에 새로 만드는 셈이었다.
바 세팅을 `dress_bar_for_assets` 로 빼고 `ScanCompleted` 와
`SweepCompleted` 양쪽에서 부른다. 스타일을 길이보다 먼저 세팅하는데,
indicatif 가 두 호출 사이에 다시 그릴 수 있어 과도기 프레임이 최소한
올바른 라벨을 달게 하기 위해서다.
TTY 전용이라 비-TTY 실측만 보고 있어서 놓쳤다. pty 로 재현해 고친 뒤
`ingest [====] 16/17 doc9.md` 로 나오는 것을 확인했다.
2) docs/DOGFOOD.md §1.8 이 갱신되지 않았다 (MEDIUM)
`--json` 이벤트 순서 목록에 sweep 3종이 빠져 있었다. verify 항목에
sweep 분모·연속성과 "sweep 뒤 진행바가 asset 분모로 돌아오는가" 를 넣었다
— 1번이 정확히 그 항목이 있었으면 잡혔을 결함이다.
3) ingest_progress.rs 의 ordering invariant 주석이 stale 했다 (MEDIUM)
§2.4a 순서 블록에 sweep 이 없었다. 설계 문서 자체는 frozen baseline 이고
HOTFIXES dated entry 가 있으니 규약상 문제없지만 코드 주석은 living 이다.
4) 잔가지 (LOW)
- purge 실패가 "디스크에 남아 있어 그냥 뒀다" 와 구분되지 않았다. 둘 다
`removed: false` 이고 ndjson 에는 아무것도 안 남아,
`sweep_summary` 의 `checked - purged` 차이로도 못 가른다. 사후 기록이
로그뿐이라는 게 이 PR 의 전제이므로 `purge_failed` 를 추가했다.
- `SweepProgress` 가 purge 할 때만 바 메시지를 세팅하고 비우지 않아,
마지막 purge 경로가 이후 후보를 훑는 내내 남았다. 안 지울 때는 비운다.
- 주석과 스키마가 신규 이벤트를 `v0.33.0` 이라고 적었는데, CLAUDE.md 의
bump 규칙상 이 변경은 patch 다 (additive-only wire + 관측성 개선, 새
명령·플래그·config 없음, 검색·색인 결과 불변 — 선례가 asset_phase).
`v0.32.1` 로 고쳤다.
미반영: `SweepCompleted` 가 TTY 에서 `bar.println` 대신 stderr 에 직접
쓰는 것 — 기존 `AssetTimings`/`PdfOcr*` 이 같은 패턴이라 신규 회귀가 아니고,
바꾸려면 그 셋을 같이 옮겨야 해서 별 건이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
259 lines
8.7 KiB
Rust
259 lines
8.7 KiB
Rust
// crates/kebab-app/tests/ingest_log_smoke.rs
|
|
//
|
|
// Integration tests for ingest_log feature (v0.20.x). Spec §5 AC-9 + AC-6.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use kebab_app::{IngestOpts, ingest_with_config};
|
|
use kebab_config::{Config, LoggingCfg};
|
|
use kebab_core::SourceScope;
|
|
use serde_json::Value;
|
|
use tempfile::TempDir;
|
|
|
|
fn minimal_config(workspace: &std::path::Path, log_dir: &std::path::Path) -> Config {
|
|
let data_dir = workspace.parent().unwrap().join("data");
|
|
std::fs::create_dir_all(&data_dir).unwrap();
|
|
let model_dir = workspace.parent().unwrap().join("models");
|
|
std::fs::create_dir_all(&model_dir).unwrap();
|
|
|
|
let mut cfg = Config::defaults();
|
|
cfg.workspace.root = Some(workspace.to_string_lossy().into_owned());
|
|
cfg.workspace.exclude.clear();
|
|
cfg.storage.data_dir = data_dir.to_string_lossy().into_owned();
|
|
cfg.storage.model_dir = model_dir.to_string_lossy().into_owned();
|
|
cfg.models.embedding.provider = "none".to_string();
|
|
cfg.models.embedding.dimensions = 0;
|
|
cfg.ingest.chunking.target_tokens = 80;
|
|
cfg.ingest.chunking.overlap_tokens = 20;
|
|
cfg.logging = LoggingCfg {
|
|
ingest_log_enabled: true,
|
|
ingest_log_dir: log_dir.to_path_buf(),
|
|
..Default::default()
|
|
};
|
|
cfg
|
|
}
|
|
|
|
/// AC-9: ingest → log file exists + each line valid JSON + last line kind=summary + scanned>0.
|
|
#[test]
|
|
fn ingest_log_smoke() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let workspace = tmp.path().join("kb");
|
|
std::fs::create_dir_all(&workspace).unwrap();
|
|
let log_dir = tmp.path().join("logs");
|
|
|
|
// 1. Minimal corpus: 1 markdown + 1 scanned PDF (OCR disabled — no Ollama needed).
|
|
std::fs::write(
|
|
workspace.join("hello.md"),
|
|
"# Hello\n\nThis is a smoke test.\n",
|
|
)
|
|
.unwrap();
|
|
let pdf_src = PathBuf::from("../kebab-parse-pdf/tests/fixtures/scanned_page1.pdf");
|
|
if pdf_src.exists() {
|
|
std::fs::copy(&pdf_src, workspace.join("scanned.pdf")).unwrap();
|
|
}
|
|
|
|
// 2. Config with logging enabled.
|
|
let cfg = minimal_config(&workspace, &log_dir);
|
|
let scope = SourceScope {
|
|
root: workspace.clone(),
|
|
exclude: vec![],
|
|
..Default::default()
|
|
};
|
|
|
|
// 3. Run ingest.
|
|
ingest_with_config(cfg, scope, IngestOpts::default())
|
|
.expect("ingest should succeed");
|
|
|
|
// 4. Assert log file exists in log_dir.
|
|
let log_files: Vec<_> = std::fs::read_dir(&log_dir)
|
|
.unwrap()
|
|
.filter_map(Result::ok)
|
|
.filter(|e| {
|
|
e.file_name().to_string_lossy().starts_with("ingest-")
|
|
&& e.file_name().to_string_lossy().ends_with(".ndjson")
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
log_files.len(),
|
|
1,
|
|
"expected exactly 1 ingest-*.ndjson file, found: {log_files:?}"
|
|
);
|
|
|
|
// 5. Parse each line as JSON — assert kind field present and valid.
|
|
let body = std::fs::read_to_string(log_files[0].path()).unwrap();
|
|
let lines: Vec<&str> = body.lines().collect();
|
|
assert!(!lines.is_empty(), "log file should not be empty");
|
|
|
|
let valid_kinds = [
|
|
"ocr",
|
|
"parse_error",
|
|
"skip",
|
|
"error",
|
|
"purge",
|
|
"purge_failed",
|
|
"sweep_summary",
|
|
"summary",
|
|
];
|
|
for line in &lines {
|
|
let v: Value = serde_json::from_str(line)
|
|
.unwrap_or_else(|e| panic!("line is not valid JSON: {e}\nline: {line}"));
|
|
let kind = v
|
|
.get("kind")
|
|
.and_then(|k| k.as_str())
|
|
.unwrap_or_else(|| panic!("line missing 'kind' field: {line}"));
|
|
assert!(
|
|
valid_kinds.contains(&kind),
|
|
"unexpected kind '{kind}' in line: {line}"
|
|
);
|
|
}
|
|
|
|
// 6. Last line must be kind=summary with scanned > 0.
|
|
let last = lines.last().unwrap();
|
|
let last_v: Value = serde_json::from_str(last).unwrap();
|
|
assert_eq!(
|
|
last_v.get("kind").and_then(|k| k.as_str()),
|
|
Some("summary"),
|
|
"last line must be kind=summary, got: {last}"
|
|
);
|
|
let scanned = last_v.get("scanned").and_then(Value::as_u64).unwrap_or(0);
|
|
assert!(scanned > 0, "summary.scanned should be > 0, got: {last}");
|
|
}
|
|
|
|
/// AC-6: ingest_log_enabled=false → no log file created.
|
|
#[test]
|
|
fn ingest_log_disabled_emits_no_file() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let workspace = tmp.path().join("kb");
|
|
std::fs::create_dir_all(&workspace).unwrap();
|
|
let log_dir = tmp.path().join("logs");
|
|
|
|
std::fs::write(
|
|
workspace.join("hello.md"),
|
|
"# Hello\n\nDisabled log test.\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let data_dir = tmp.path().join("data");
|
|
std::fs::create_dir_all(&data_dir).unwrap();
|
|
let model_dir = tmp.path().join("models");
|
|
std::fs::create_dir_all(&model_dir).unwrap();
|
|
|
|
let mut cfg = Config::defaults();
|
|
cfg.workspace.root = Some(workspace.to_string_lossy().into_owned());
|
|
cfg.workspace.exclude.clear();
|
|
cfg.storage.data_dir = data_dir.to_string_lossy().into_owned();
|
|
cfg.storage.model_dir = model_dir.to_string_lossy().into_owned();
|
|
cfg.models.embedding.provider = "none".to_string();
|
|
cfg.models.embedding.dimensions = 0;
|
|
cfg.logging = LoggingCfg {
|
|
ingest_log_enabled: false,
|
|
ingest_log_dir: log_dir.clone(),
|
|
..Default::default()
|
|
};
|
|
|
|
let scope = SourceScope {
|
|
root: workspace.clone(),
|
|
exclude: vec![],
|
|
..Default::default()
|
|
};
|
|
|
|
ingest_with_config(cfg, scope, IngestOpts::default())
|
|
.expect("ingest should succeed");
|
|
|
|
// log_dir should either not exist or contain 0 ingest-*.ndjson files.
|
|
let log_file_count = if log_dir.exists() {
|
|
std::fs::read_dir(&log_dir)
|
|
.unwrap()
|
|
.filter_map(Result::ok)
|
|
.filter(|e| {
|
|
e.file_name().to_string_lossy().starts_with("ingest-")
|
|
&& e.file_name().to_string_lossy().ends_with(".ndjson")
|
|
})
|
|
.count()
|
|
} else {
|
|
0
|
|
};
|
|
assert_eq!(
|
|
log_file_count, 0,
|
|
"no ingest-*.ndjson file should be created when disabled"
|
|
);
|
|
}
|
|
|
|
|
|
/// Issue #228: the deleted-file sweep wrote nothing to the ndjson log, so
|
|
/// a run whose whole wall-clock went into purging left a zero-byte file
|
|
/// and no way to reconstruct afterwards what had been deleted. The log is
|
|
/// the only post-hoc record — tracing goes to stderr and is gone.
|
|
#[test]
|
|
fn ingest_log_records_the_deleted_file_sweep() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let workspace = tmp.path().join("kb");
|
|
std::fs::create_dir_all(&workspace).unwrap();
|
|
let log_dir = tmp.path().join("logs");
|
|
|
|
let doomed = workspace.join("doomed.md");
|
|
std::fs::write(&doomed, "# doomed\n\nthis file is about to vanish\n").unwrap();
|
|
std::fs::write(workspace.join("kept.md"), "# kept\n\nthis one stays\n").unwrap();
|
|
|
|
let scope = SourceScope {
|
|
root: workspace.clone(),
|
|
include: vec!["**/*.md".to_string()],
|
|
exclude: Vec::new(),
|
|
};
|
|
ingest_with_config(
|
|
minimal_config(&workspace, &log_dir),
|
|
scope.clone(),
|
|
IngestOpts::default(),
|
|
)
|
|
.expect("first ingest should succeed");
|
|
|
|
std::fs::remove_file(&doomed).unwrap();
|
|
let report = ingest_with_config(
|
|
minimal_config(&workspace, &log_dir),
|
|
scope,
|
|
IngestOpts::default(),
|
|
)
|
|
.expect("second ingest should succeed");
|
|
assert_eq!(report.purged_deleted_files, 1);
|
|
|
|
// The second run's log is the later one; both runs write into log_dir.
|
|
let mut logs: Vec<PathBuf> = std::fs::read_dir(&log_dir)
|
|
.unwrap()
|
|
.filter_map(Result::ok)
|
|
.map(|e| e.path())
|
|
.filter(|p| p.extension().is_some_and(|x| x == "ndjson"))
|
|
.collect();
|
|
logs.sort();
|
|
let body = std::fs::read_to_string(logs.last().expect("a log per run")).unwrap();
|
|
|
|
let events: Vec<Value> = body
|
|
.lines()
|
|
.map(|l| serde_json::from_str(l).expect("each line is JSON"))
|
|
.collect();
|
|
let kind = |v: &Value| v.get("kind").and_then(Value::as_str).unwrap_or("").to_string();
|
|
|
|
let purges: Vec<&Value> = events.iter().filter(|v| kind(v) == "purge").collect();
|
|
assert_eq!(purges.len(), 1, "one purge line for the deleted file: {body}");
|
|
assert_eq!(
|
|
purges[0].get("doc_path").and_then(Value::as_str),
|
|
Some("doomed.md"),
|
|
"and it names which document went: {body}"
|
|
);
|
|
|
|
let sweep = events
|
|
.iter()
|
|
.find(|v| kind(v) == "sweep_summary")
|
|
.unwrap_or_else(|| panic!("the phase totals must be recorded: {body}"));
|
|
assert_eq!(sweep.get("purged").and_then(Value::as_u64), Some(1));
|
|
assert_eq!(
|
|
sweep.get("checked").and_then(Value::as_u64),
|
|
Some(1),
|
|
"one candidate examined: {body}"
|
|
);
|
|
assert!(
|
|
sweep.get("ms").is_some(),
|
|
"with a duration, which is what tells a user whether the phase was \
|
|
the run's bottleneck: {body}"
|
|
);
|
|
}
|