From 44dee2c30f58cf2a4fbddd366158297bbc80acdd Mon Sep 17 00:00:00 2001 From: altair823 Date: Tue, 5 May 2026 12:16:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(kebab-cli,=20kebab-tui):=20p9-fb-25=20task?= =?UTF-8?q?=206=20=E2=80=94=20render=20skipped-by-extension=20breakdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Append ": A docx, B txt, ..." after the N skipped count in both the CLI ingest summary and TUI status_line terminal events (completed + aborted). Breakdown is desc-sorted by count, ties broken by key alphabetic; empty map produces no extra text. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-cli/src/main.rs | 18 +++++++- crates/kebab-tui/src/ingest_progress.rs | 60 ++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs index 76a25c2..30a2c4a 100644 --- a/crates/kebab-cli/src/main.rs +++ b/crates/kebab-cli/src/main.rs @@ -4,6 +4,20 @@ use std::path::PathBuf; use std::process::ExitCode; +/// p9-fb-25: render `": A docx, B txt"` breakdown after the +/// `N skipped` count when the map is non-empty. Empty → empty +/// string (no extra punctuation). desc sort by count, ties broken +/// by key alphabetic. +fn render_skipped_breakdown(map: &std::collections::BTreeMap) -> String { + if map.is_empty() { + return String::new(); + } + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); + let parts: Vec = entries.iter().map(|(k, v)| format!("{v} {k}")).collect(); + format!(": {}", parts.join(", ")) +} + use clap::{Parser, Subcommand}; use kebab_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal}; @@ -371,12 +385,14 @@ fn run(cli: &Cli) -> anyhow::Result<()> { if cli.json { println!("{}", serde_json::to_string(&wire::wire_ingest(&report))?); } else { + let skipped_breakdown = render_skipped_breakdown(&report.skipped_by_extension); println!( - "scanned {} new {} updated {} skipped {} errors {} ({} ms)", + "scanned {} new {} updated {} skipped {}{} errors {} ({} ms)", report.scanned, report.new, report.updated, report.skipped, + skipped_breakdown, report.errors, report.duration_ms ); diff --git a/crates/kebab-tui/src/ingest_progress.rs b/crates/kebab-tui/src/ingest_progress.rs index d613ac2..e26a396 100644 --- a/crates/kebab-tui/src/ingest_progress.rs +++ b/crates/kebab-tui/src/ingest_progress.rs @@ -25,6 +25,18 @@ use kebab_core::SourceScope; use crate::app::{App, IngestState, TERMINAL_LINE_HOLD_SECS}; +/// p9-fb-25: render `": A docx, B txt"` breakdown after the `N skipped` +/// count when the map is non-empty. desc sort by count, ties by key. +fn render_skipped_breakdown(map: &std::collections::BTreeMap) -> String { + if map.is_empty() { + return String::new(); + } + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); + let parts: Vec = entries.iter().map(|(k, v)| format!("{v} {k}")).collect(); + format!(": {}", parts.join(", ")) +} + /// Already-running guard. Returns `Err` if `app.ingest_state` is /// already populated — pressing `r` twice in a row should not spawn /// two parallel workers (SQLite is mutexed but Lance writes can race @@ -175,8 +187,9 @@ pub fn status_line(state: &IngestState) -> String { let elapsed = state.started_at.elapsed(); let secs = elapsed.as_secs(); if state.aborted { + let skipped_breakdown = render_skipped_breakdown(&state.counts.skipped_by_extension); return format!( - "✗ ingest aborted at {}/{} after {}s (new={} updated={} unchanged={} skipped={} errors={})", + "✗ ingest aborted at {}/{} after {}s (new={} updated={} unchanged={} skipped={}{} errors={})", state.counts.scanned.saturating_sub(state.counts.errors), state.counts.scanned, secs, @@ -184,16 +197,19 @@ pub fn status_line(state: &IngestState) -> String { state.counts.updated, state.counts.unchanged, state.counts.skipped, + skipped_breakdown, state.counts.errors, ); } + let skipped_breakdown = render_skipped_breakdown(&state.counts.skipped_by_extension); return format!( - "✓ ingest: {} docs ({} new, {} updated, {} unchanged, {} skipped), {} chunks indexed in {}s", + "✓ ingest: {} docs ({} new, {} updated, {} unchanged, {} skipped{}), {} chunks indexed in {}s", state.counts.scanned, state.counts.new, state.counts.updated, state.counts.unchanged, state.counts.skipped, + skipped_breakdown, state.counts.chunks_indexed, secs, ); @@ -415,4 +431,44 @@ mod tests { // No worker to cancel — already terminated. assert!(!cancel_running_ingest(&app)); } + + #[test] + fn status_line_terminal_includes_skipped_breakdown() { + let mut s = fresh_state(); + let skipped_by_extension = std::collections::BTreeMap::from([ + ("docx".to_string(), 2u32), + ("txt".to_string(), 1u32), + ]); + let counts = AggregateCounts { + scanned: 10, + skipped: 3, + skipped_by_extension, + ..Default::default() + }; + apply_event(&mut s, IngestEvent::Completed { counts }); + let line = status_line(&s); + assert!( + line.contains("3 skipped: 2 docx, 1 txt"), + "breakdown must appear in: {line}" + ); + } + + #[test] + fn status_line_aborted_includes_skipped_breakdown() { + let mut s = fresh_state(); + let skipped_by_extension = + std::collections::BTreeMap::from([("pdf".to_string(), 2u32)]); + let counts = AggregateCounts { + scanned: 5, + skipped: 2, + skipped_by_extension, + ..Default::default() + }; + apply_event(&mut s, IngestEvent::Aborted { counts }); + let line = status_line(&s); + assert!( + line.contains("skipped=2: 2 pdf"), + "breakdown must appear in: {line}" + ); + } }