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>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -3517,6 +3517,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"indicatif",
|
||||
"kebab-app",
|
||||
"kebab-config",
|
||||
"kebab-core",
|
||||
@@ -3524,6 +3525,7 @@ dependencies = [
|
||||
"kebab-tui",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -41,6 +41,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
||||
- **P9-3 e/j/k 키 의 \"input empty\" 분기** — spec 의 `e=toggle explain` / `j=k=scroll` 이 typing 과 충돌 (\"explain\" / \"javascript\" 같은 단어 입력 깨짐). input 이 비어 있을 때만 command 키로 동작 — vim \"command vs insert\" 컨벤션 변형. 사용자가 텍스트 입력 시 모든 알파벳 정상 통과.
|
||||
- **P9-4 enter_inspect helper + Search `i` 키** — spec 의 진입 경로 (Library Enter → Doc inspect, Search `i` → Chunk inspect) 를 한 helper 로 묶음. `InspectTarget` enum (`Doc(DocumentId) | Chunk(ChunkId)`), `return_to: Pane` 가 Esc 시 원래 pane 으로 복귀. `c` 키가 모든 section (metadata / provenance / blocks / spans / text / embeddings) 일괄 collapse/expand — spec 의 \"focus 기반 selective collapse\" 는 v1 단순화.
|
||||
- **2026-05-02 P9 도그푸딩 후속 (p9-fb-06)** — `kebab reset --all|--data-only|--vector-only|--config-only [--yes]` 추가. TTY 가 아니면 `--yes` 필수 (silent destruction 금지). `--vector-only` 가 SQLite `embedding_records` 도 함께 truncate (off-disk Lance dir 만 wipe 시 orphan 방지). 도그푸딩 막힘 강도 1위 (수동 4 경로 `rm -rf` 부담) 해소. spec: `tasks/p9/p9-fb-06-data-reset-command.md`, plan: `docs/superpowers/plans/2026-05-02-p9-fb-06-reset-command.md`.
|
||||
- **2026-05-02 P9 도그푸딩 후속 (spec PR #51 + p9-fb-01 + p9-fb-02)** — `kebab ingest` 진행 표시 도입. frozen design §2.4a 신설 (wire schema `ingest_progress.v1` line-delimited streaming) + §10 의 long-running 작업 절 추가. `kebab-app::ingest_with_config_progress(.., progress: Option<Sender<IngestEvent>>)` facade 추가, 기존 `_with_config` 가 `progress=None` forwarding wrapper. CLI 가 indicatif TTY 진행 바 (stderr) / non-TTY 한 줄씩 / `--json` 모드는 line-delimited stdout. p9-fb-03 (TUI background worker) + p9-fb-04 (cancel) 가 같은 stream 위에 build.
|
||||
|
||||
## 다음 task 후보
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ kebab doctor
|
||||
| 명령 | 동작 |
|
||||
|------|------|
|
||||
| `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 |
|
||||
| `kebab ingest [<path>]` | Markdown / 이미지 / PDF 색인 (idempotent) |
|
||||
| `kebab ingest [<path>]` | Markdown / 이미지 / PDF 색인 (idempotent). TTY 에서는 stderr 진행 바, non-TTY (CI / pipe) 는 stderr 한 줄씩, `--json` 은 stdout 에 `ingest_progress.v1` 라인 streaming 후 마지막에 `ingest_report.v1` |
|
||||
| `kebab search --mode {lexical,vector,hybrid} "<query>"` | 검색. hybrid는 RRF fusion, citation 포함 |
|
||||
| `kebab list docs` | 색인된 문서 목록 |
|
||||
| `kebab inspect doc <id>` / `kebab inspect chunk <id>` | raw record 보기 |
|
||||
@@ -80,7 +80,7 @@ kebab doctor
|
||||
| `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) |
|
||||
| `kebab eval run / compare` | golden query 회귀 측정 |
|
||||
|
||||
모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`).
|
||||
모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`).
|
||||
|
||||
## 논리 아키텍처
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ kebab-tui = { path = "../kebab-tui" }
|
||||
anyhow = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
# p9-fb-02: ingest progress UI.
|
||||
# - TTY 사람 모드: indicatif spinner + bar (stderr).
|
||||
# - --json 모드 / non-TTY: indicatif 끄고 raw line emit.
|
||||
# - timestamp formatting (RFC 3339) 은 time crate.
|
||||
indicatif = "0.17"
|
||||
time = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -8,6 +8,7 @@ use clap::{Parser, Subcommand};
|
||||
|
||||
use kebab_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal};
|
||||
|
||||
mod progress;
|
||||
mod wire;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -283,7 +284,31 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
include: cfg.workspace.include.clone(),
|
||||
exclude: cfg.workspace.exclude.clone(),
|
||||
};
|
||||
let report = kebab_app::ingest_with_config(cfg, scope, *summary_only)?;
|
||||
|
||||
// p9-fb-02: spawn the progress display on a background
|
||||
// thread; the ingest call below holds the `Sender` end of
|
||||
// the channel and emits per-step events into it. When the
|
||||
// call returns, the `Sender` drops and the display thread
|
||||
// sees `recv()` return Err — exits cleanly.
|
||||
let mode = progress::ProgressMode::from_flags(cli.json);
|
||||
let (tx, rx) = std::sync::mpsc::channel::<kebab_app::IngestEvent>();
|
||||
let display_handle = std::thread::spawn(move || {
|
||||
progress::ProgressDisplay::new(mode).run(rx)
|
||||
});
|
||||
|
||||
let ingest_result = kebab_app::ingest_with_config_progress(
|
||||
cfg,
|
||||
scope,
|
||||
*summary_only,
|
||||
Some(tx),
|
||||
);
|
||||
|
||||
// Join the display thread *before* surfacing the ingest
|
||||
// outcome so the spinner / final newline is flushed
|
||||
// regardless of whether ingest returned Ok or Err.
|
||||
let _ = display_handle.join();
|
||||
|
||||
let report = ingest_result?;
|
||||
if cli.json {
|
||||
println!("{}", serde_json::to_string(&wire::wire_ingest(&report))?);
|
||||
} else {
|
||||
|
||||
231
crates/kebab-cli/src/progress.rs
Normal file
231
crates/kebab-cli/src/progress.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
//! `kebab ingest` progress display — consumes
|
||||
//! `kebab_app::IngestEvent` and renders to one of three surfaces:
|
||||
//!
|
||||
//! - **TTY 사람 모드**: indicatif `ProgressBar` on stderr (spinner
|
||||
//! while scanning, bar after `ScanCompleted`, message updates per
|
||||
//! asset). stdout is reserved for the final `ingest_report.v1`.
|
||||
//! - **non-TTY 사람 모드** (CI / pipe): indicatif uses `hidden`
|
||||
//! draw target (no terminal control codes), and we emit one
|
||||
//! `ingest: scanning…` / `ingest: N/M …` line per event to stderr
|
||||
//! instead. CLI consumers redirecting stderr can still parse it.
|
||||
//! - **`--json` 모드**: stderr stays silent; every event is dumped to
|
||||
//! stdout as `ingest_progress.v1` line-delimited JSON. The final
|
||||
//! `ingest_report.v1` line follows after the run completes (per
|
||||
//! §2.4a backwards-compat).
|
||||
//!
|
||||
//! Each subprocess of the binary creates one `ProgressDisplay` and
|
||||
//! drives it from a background thread that drains an
|
||||
//! `mpsc::Receiver<IngestEvent>`. The thread terminates when the
|
||||
//! `Sender` end is dropped (i.e. when `ingest_with_config_progress`
|
||||
//! returns).
|
||||
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
|
||||
use kebab_app::IngestEvent;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use crate::wire;
|
||||
|
||||
/// Rendering mode for `ProgressDisplay`. The mode is fixed at
|
||||
/// construction — each `kebab ingest` invocation is a single mode
|
||||
/// (chosen from `--json` plus `IsTerminal` detection).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ProgressMode {
|
||||
/// stdout = line-delimited `ingest_progress.v1`. stderr stays
|
||||
/// silent for events (errors / log frames still go to stderr).
|
||||
Json,
|
||||
/// stdout reserved for the final report; stderr gets an indicatif
|
||||
/// `ProgressBar` (TTY) or one short line per event (non-TTY).
|
||||
Human { tty: bool },
|
||||
}
|
||||
|
||||
impl ProgressMode {
|
||||
/// Pick the right mode from caller flags.
|
||||
pub fn from_flags(json: bool) -> Self {
|
||||
if json {
|
||||
Self::Json
|
||||
} else {
|
||||
Self::Human {
|
||||
tty: std::io::stderr().is_terminal(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains an `mpsc::Receiver<IngestEvent>` until the sender is dropped
|
||||
/// and renders each event according to `mode`. Construction only —
|
||||
/// kick off via [`ProgressDisplay::run`].
|
||||
pub struct ProgressDisplay {
|
||||
mode: ProgressMode,
|
||||
bar: Option<ProgressBar>,
|
||||
}
|
||||
|
||||
impl ProgressDisplay {
|
||||
pub fn new(mode: ProgressMode) -> Self {
|
||||
Self { mode, bar: None }
|
||||
}
|
||||
|
||||
/// Block until `rx` returns `Err` (sender dropped). Renders one
|
||||
/// frame per received event.
|
||||
pub fn run(mut self, rx: Receiver<IngestEvent>) -> anyhow::Result<()> {
|
||||
while let Ok(event) = rx.recv() {
|
||||
self.handle(&event)?;
|
||||
}
|
||||
if let Some(bar) = self.bar.take() {
|
||||
bar.finish_and_clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle(&mut self, event: &IngestEvent) -> anyhow::Result<()> {
|
||||
match self.mode {
|
||||
ProgressMode::Json => emit_json(event),
|
||||
ProgressMode::Human { tty } => self.handle_human(event, tty),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_human(&mut self, event: &IngestEvent, tty: bool) -> anyhow::Result<()> {
|
||||
match event {
|
||||
IngestEvent::ScanStarted { root } => {
|
||||
let bar = ProgressBar::new_spinner().with_message(format!("scanning {root}"));
|
||||
bar.set_draw_target(if tty {
|
||||
ProgressDrawTarget::stderr()
|
||||
} else {
|
||||
ProgressDrawTarget::hidden()
|
||||
});
|
||||
bar.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
self.bar = Some(bar);
|
||||
if !tty {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(err, "ingest: scanning {root}…");
|
||||
}
|
||||
}
|
||||
IngestEvent::ScanCompleted { total } => {
|
||||
if let Some(bar) = self.bar.as_mut() {
|
||||
bar.disable_steady_tick();
|
||||
bar.set_length(u64::from(*total));
|
||||
bar.set_position(0);
|
||||
bar.set_style(
|
||||
ProgressStyle::with_template(
|
||||
"ingest [{bar:30}] {pos}/{len} {wide_msg}",
|
||||
)
|
||||
.unwrap()
|
||||
.progress_chars("=> "),
|
||||
);
|
||||
bar.set_message("");
|
||||
}
|
||||
if !tty {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(err, "ingest: scan complete ({total} assets)");
|
||||
}
|
||||
}
|
||||
IngestEvent::AssetStarted {
|
||||
idx,
|
||||
total,
|
||||
path,
|
||||
media,
|
||||
} => {
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
bar.set_message(format!("{media} {path}"));
|
||||
}
|
||||
if !tty {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(err, "ingest: {idx}/{total} {media} {path}");
|
||||
}
|
||||
}
|
||||
IngestEvent::AssetFinished { idx, .. } => {
|
||||
if let Some(bar) = self.bar.as_ref() {
|
||||
bar.set_position(u64::from(*idx));
|
||||
}
|
||||
}
|
||||
IngestEvent::Completed { counts } => {
|
||||
if let Some(bar) = self.bar.take() {
|
||||
bar.finish_and_clear();
|
||||
}
|
||||
if !tty {
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(
|
||||
err,
|
||||
"ingest: complete (scanned={} new={} updated={} skipped={} errors={})",
|
||||
counts.scanned,
|
||||
counts.new,
|
||||
counts.updated,
|
||||
counts.skipped,
|
||||
counts.errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
IngestEvent::Aborted { counts } => {
|
||||
if let Some(bar) = self.bar.take() {
|
||||
bar.abandon_with_message(format!(
|
||||
"aborted at {}/{}",
|
||||
counts.scanned.saturating_sub(counts.errors),
|
||||
counts.scanned
|
||||
));
|
||||
}
|
||||
let mut err = std::io::stderr().lock();
|
||||
let _ = writeln!(
|
||||
err,
|
||||
"ingest: aborted (scanned={} new={} updated={} skipped={} errors={})",
|
||||
counts.scanned,
|
||||
counts.new,
|
||||
counts.updated,
|
||||
counts.skipped,
|
||||
counts.errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize an `IngestEvent` as the `ingest_progress.v1` wire shape
|
||||
/// (kind discriminator + RFC 3339 `ts`) and println to stdout. One
|
||||
/// event per line.
|
||||
fn emit_json(event: &IngestEvent) -> anyhow::Result<()> {
|
||||
let value = wire::wire_ingest_progress(event)?;
|
||||
let line = serde_json::to_string(&value)?;
|
||||
let mut out = std::io::stdout().lock();
|
||||
writeln!(out, "{line}")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Format the current wall-clock as RFC 3339 — used by `wire_ingest_progress`
|
||||
/// so every emitted event carries an `ts` field per §2.4a / the wire schema.
|
||||
pub(crate) fn now_rfc3339() -> anyhow::Result<String> {
|
||||
OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_flags_json_takes_priority_over_tty() {
|
||||
// --json forces Json regardless of TTY state.
|
||||
assert_eq!(ProgressMode::from_flags(true), ProgressMode::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_flags_human_reflects_stderr_tty() {
|
||||
// We can't synthesize a TTY in tests, but we can assert the
|
||||
// shape — mode is Human { tty: <something> } when --json=false.
|
||||
match ProgressMode::from_flags(false) {
|
||||
ProgressMode::Human { .. } => {}
|
||||
other => panic!("expected Human mode, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_rfc3339_parses_back() {
|
||||
let s = now_rfc3339().unwrap();
|
||||
// Round-trip via the parser to confirm the formatter emits a
|
||||
// well-formed RFC 3339 string.
|
||||
OffsetDateTime::parse(&s, &Rfc3339).expect("RFC 3339 round-trip");
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,25 @@ pub fn wire_reset(r: &kebab_app::ResetReport) -> Value {
|
||||
tag_object(v, "reset_report.v1")
|
||||
}
|
||||
|
||||
/// Wrap an [`kebab_app::IngestEvent`] as `ingest_progress.v1`. Adds
|
||||
/// the `schema_version` discriminator on top of serde's existing
|
||||
/// `kind` discriminator, plus an `ts` field with the current
|
||||
/// wall-clock — the emit site is the only place that knows the moment
|
||||
/// of emission, so the timestamp is stamped here rather than carried
|
||||
/// on the event itself.
|
||||
pub fn wire_ingest_progress(
|
||||
event: &kebab_app::IngestEvent,
|
||||
) -> anyhow::Result<Value> {
|
||||
let mut v = serde_json::to_value(event)?;
|
||||
if let Value::Object(ref mut map) = v {
|
||||
map.insert(
|
||||
"ts".to_string(),
|
||||
Value::String(crate::progress::now_rfc3339()?),
|
||||
);
|
||||
}
|
||||
Ok(tag_object(v, "ingest_progress.v1"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
164
crates/kebab-cli/tests/ingest_progress_cli.rs
Normal file
164
crates/kebab-cli/tests/ingest_progress_cli.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
//! 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");
|
||||
}
|
||||
@@ -3,7 +3,7 @@ phase: P9
|
||||
component: kebab-app + kebab-core
|
||||
task_id: p9-fb-01
|
||||
title: "Ingest progress callback / event channel"
|
||||
status: in_progress
|
||||
status: completed
|
||||
depends_on: []
|
||||
unblocks: [p9-fb-02, p9-fb-03]
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
|
||||
@@ -3,7 +3,7 @@ phase: P9
|
||||
component: kebab-cli
|
||||
task_id: p9-fb-02
|
||||
title: "CLI progress display (spinner + text + --json line events)"
|
||||
status: planned
|
||||
status: in_progress
|
||||
depends_on: [p9-fb-01]
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
|
||||
Reference in New Issue
Block a user