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:
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user