Files
kebab/crates/kebab-cli/tests/ingest_progress_cli.rs
altair823 e613236d60 feat(cli): kebab ingest progress display (p9-fb-02) + p9-fb-01 status flip
`kebab ingest` 가 진행 상황을 사용자에게 보여주는 두 surface 추가:

- **사람 모드 (TTY)**: indicatif `ProgressBar` on stderr — scan 중에는
  spinner, ScanCompleted 후 bar 로 전환, 매 asset 마다 message 갱신.
- **사람 모드 (non-TTY, CI/pipe)**: indicatif draw target 을 hidden
  으로 두고 stderr 에 한 줄씩 (`ingest: scanning`, `ingest: 1/N path`,
  `ingest: complete (...)`).
- **`--json` 모드**: stderr 비우고 stdout 에 line-delimited
  `ingest_progress.v1` JSON 을 emit. 마지막 줄은 기존
  `ingest_report.v1` 그대로 (외부 wrapper backward-compat).

구현:

- 신규 `crates/kebab-cli/src/progress.rs` — `ProgressMode::{Json,
  Human { tty }}`, `ProgressDisplay` (background thread 가 channel
  drain + 모드별 render), `now_rfc3339` helper. mode 가 무엇이든 ts
  는 wire emit 시점에 stamp.
- `crates/kebab-cli/src/wire.rs` 에 `wire_ingest_progress` 추가.
  serde tag (`kind`) 위에 `schema_version` + `ts` 두 필드 더해 spec
  §2.4a wire shape 완성.
- `Cmd::Ingest` 핸들러: mpsc channel 만들고 background thread 가
  display 돌리는 동안 main 이 `ingest_with_config_progress` 호출.
  ingest 반환 시 Sender drop → display thread 정상 종료. join 후
  최종 ingest_report 출력.
- 새 dep: `indicatif` 0.17 (TTY 전용 진행 바, non-TTY/--json 에서는
  hidden draw target).

Test:

- 3 lib unit (mode resolution + RFC 3339 round-trip).
- 3 integration (--json line-delimited / non-TTY stderr text /
  ts+kind 검증). 16 PASS 전체 회귀 0.

Plan 갱신:

- p9-fb-01: status `in_progress` → `completed` (PR #52 머지 후속).
- p9-fb-02: status `planned` → `in_progress`. 머지 후 별도 한 줄
  commit 으로 `completed` flip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:57:02 +00:00

165 lines
5.6 KiB
Rust

//! Integration coverage for `kebab ingest` 의 progress display
//! (p9-fb-02). Each test runs the built `kebab` bin in a fresh
//! subprocess against a tempdir-rooted XDG layout + tempdir
//! workspace so the assertions don't depend on the host config.
use std::io::Write;
use std::process::Command;
fn kebab_bin() -> std::path::PathBuf {
let manifest = env!("CARGO_MANIFEST_DIR");
std::path::PathBuf::from(manifest)
.parent()
.unwrap()
.parent()
.unwrap()
.join("target/debug/kebab")
}
/// Build a tempdir-rooted XDG layout with a workspace containing two
/// markdown files. Returns the tmp guard (to keep the dir alive) and
/// the workspace path the caller should pass to `--root`.
fn fixture_workspace() -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("workspace");
std::fs::create_dir_all(&ws).unwrap();
let mut a = std::fs::File::create(ws.join("a.md")).unwrap();
writeln!(a, "# Alpha\n\nfirst doc").unwrap();
let mut b = std::fs::File::create(ws.join("b.md")).unwrap();
writeln!(b, "# Beta\n\nsecond doc").unwrap();
(tmp, ws)
}
fn xdg_envs(tmp_path: &std::path::Path) -> [(&'static str, std::path::PathBuf); 4] {
[
("XDG_CONFIG_HOME", tmp_path.join("cfg")),
("XDG_DATA_HOME", tmp_path.join("data")),
("XDG_CACHE_HOME", tmp_path.join("cache")),
("XDG_STATE_HOME", tmp_path.join("state")),
]
}
#[test]
fn ingest_json_emits_line_delimited_progress_then_report() {
let (tmp, ws) = fixture_workspace();
let mut cmd = Command::new(kebab_bin());
cmd.args([
"--json",
"ingest",
"--root",
ws.to_str().unwrap(),
"--summary-only",
]);
for (k, v) in xdg_envs(tmp.path()) {
cmd.env(k, v);
}
let out = cmd.output().unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
// Every stdout line must be a JSON object. The last line is the
// existing ingest_report.v1; everything above is ingest_progress.v1.
let stdout = String::from_utf8(out.stdout).unwrap();
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert!(lines.len() >= 2, "expected ≥2 stdout lines, got: {stdout}");
let mut progress_seen = 0usize;
let mut last_schema = None;
for line in &lines {
let v: serde_json::Value =
serde_json::from_str(line).unwrap_or_else(|e| panic!("bad json line: {line:?} ({e})"));
let schema = v
.get("schema_version")
.and_then(|s| s.as_str())
.unwrap_or_else(|| panic!("missing schema_version: {line}"));
if schema == "ingest_progress.v1" {
progress_seen += 1;
}
last_schema = Some(schema.to_string());
}
assert!(progress_seen >= 4, "progress events: {progress_seen}");
assert_eq!(last_schema.as_deref(), Some("ingest_report.v1"));
}
#[test]
fn ingest_human_non_tty_emits_progress_lines_to_stderr() {
// Command::output gives no controlling tty, so the indicatif draw
// target is `hidden` and progress lines go to stderr instead.
let (tmp, ws) = fixture_workspace();
let mut cmd = Command::new(kebab_bin());
cmd.args([
"ingest",
"--root",
ws.to_str().unwrap(),
"--summary-only",
]);
for (k, v) in xdg_envs(tmp.path()) {
cmd.env(k, v);
}
let out = cmd.output().unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("ingest: scanning") || stderr.contains("ingest:"),
"expected progress text in stderr, got: {stderr}"
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("scanned ") && stdout.contains("new "),
"expected the human-mode summary line on stdout, got: {stdout}"
);
}
#[test]
fn ingest_json_progress_lines_carry_kind_and_ts() {
let (tmp, ws) = fixture_workspace();
let mut cmd = Command::new(kebab_bin());
cmd.args([
"--json",
"ingest",
"--root",
ws.to_str().unwrap(),
"--summary-only",
]);
for (k, v) in xdg_envs(tmp.path()) {
cmd.env(k, v);
}
let out = cmd.output().unwrap();
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
let mut saw_scan_started = false;
let mut saw_completed = false;
for line in stdout.lines().filter(|l| !l.is_empty()) {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
let schema = v.get("schema_version").and_then(|s| s.as_str()).unwrap();
if schema != "ingest_progress.v1" {
continue;
}
let kind = v.get("kind").and_then(|s| s.as_str()).unwrap();
// ts is a non-empty string and must round-trip as RFC 3339.
let ts = v.get("ts").and_then(|s| s.as_str()).unwrap();
assert!(!ts.is_empty(), "ts empty for {kind}");
if kind == "scan_started" {
saw_scan_started = true;
}
if kind == "completed" {
saw_completed = true;
// Counts mirror the report.
let counts = v.get("counts").unwrap();
assert_eq!(counts.get("scanned").and_then(|n| n.as_u64()), Some(2));
assert_eq!(counts.get("new").and_then(|n| n.as_u64()), Some(2));
}
}
assert!(saw_scan_started, "missing scan_started event");
assert!(saw_completed, "missing completed event");
}