style: cargo fmt --all (round 4 ingest log feature follow-up)

Phase C4 executor 의 마지막 `fix(test): clippy + fmt fixes` commit 이
test file 부분만 fmt 적용. workspace 전체 fmt 누락 발견 → cargo fmt --all
적용. 모든 import alphabetical reorder + line wrapping 정합.

추가 untracked artifact 동시 commit:
- docs/superpowers/specs/2026-05-28-v0.20-ingest-log-spec.md (491 line, ACCEPT)
- docs/superpowers/plans/2026-05-28-v0.20-ingest-log-plan.md (616 line, ACCEPT)

workspace test: 1370 passed / 0 failed / 50 ignored, ingest_log_smoke green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 04:18:40 +00:00
parent 445b096215
commit 685007789a
235 changed files with 6520 additions and 3955 deletions

View File

@@ -7,9 +7,8 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_config::Config;
use kebab_core::{
Answer, AnswerCitation, AnswerRetrievalSummary, Citation, ModelRef,
PromptTemplateVersion, RefusalReason, SearchMode, TokenUsage, TraceId, Turn,
WorkspacePath,
Answer, AnswerCitation, AnswerRetrievalSummary, Citation, ModelRef, PromptTemplateVersion,
RefusalReason, SearchMode, TokenUsage, TraceId, Turn, WorkspacePath,
};
use kebab_tui::{App, AskState, KeyOutcome, Pane, handle_key_ask, render_ask};
use ratatui::Terminal;
@@ -90,10 +89,7 @@ fn esc_returns_to_library_and_clears_streaming() {
s.streaming = true;
s.partial = "partial answer…".into();
}
let outcome = handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
let outcome = handle_key_ask(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
let s = app.ask.as_ref().unwrap();
assert!(!s.streaming);
@@ -155,12 +151,20 @@ fn jk_scroll_in_normal_mode_type_in_insert() {
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert_eq!(app.ask.as_ref().unwrap().scroll, 1, "j scrolls down in Normal");
assert_eq!(
app.ask.as_ref().unwrap().scroll,
1,
"j scrolls down in Normal"
);
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert_eq!(app.ask.as_ref().unwrap().scroll, 0, "k scrolls up in Normal");
assert_eq!(
app.ask.as_ref().unwrap().scroll,
0,
"k scrolls up in Normal"
);
// Now Insert — j/k type.
app.mode = kebab_tui::Mode::Insert;
handle_key_ask(
@@ -213,10 +217,7 @@ fn e_typed_into_input_when_input_nonempty() {
#[test]
fn enter_with_empty_input_is_continue() {
let mut app = fresh_app();
let outcome = handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
let outcome = handle_key_ask(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::Continue);
assert!(!app.ask.as_ref().unwrap().streaming);
}
@@ -229,10 +230,7 @@ fn enter_while_streaming_is_noop() {
s.input.push_str("anything");
s.streaming = true;
}
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// streaming flag remains true (no new worker spawned)
assert!(app.ask.as_ref().unwrap().streaming);
// No thread spawned because enter was a no-op.
@@ -335,7 +333,10 @@ fn render_grounded_answer_with_citation() {
})
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("test answer body"), "answer body rendered");
assert!(
rendered.contains("test answer body"),
"answer body rendered"
);
assert!(rendered.contains("grounded ✓"), "grounded status visible");
assert!(rendered.contains("notes/foo.md"), "citation path rendered");
assert!(rendered.contains("[1]"), "citation marker rendered");
@@ -346,7 +347,11 @@ fn render_refusal_score_gate_shows_status_without_citation_index_panic() {
let mut app = fresh_app();
{
let s = app.ask.as_mut().unwrap();
let mut ans = make_answer(false, Some(RefusalReason::ScoreGate), "insufficient grounding to answer.");
let mut ans = make_answer(
false,
Some(RefusalReason::ScoreGate),
"insufficient grounding to answer.",
);
ans.citations.clear(); // refusal often has no citations
s.turns.push(Turn {
question: "test refusal question".into(),
@@ -374,7 +379,10 @@ fn render_refusal_score_gate_shows_status_without_citation_index_panic() {
})
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("insufficient grounding"), "refusal body rendered");
assert!(
rendered.contains("insufficient grounding"),
"refusal body rendered"
);
assert!(rendered.contains("grounded ✗"), "ungrounded status visible");
assert!(rendered.contains("score_gate"), "refusal reason surfaced");
}
@@ -535,10 +543,7 @@ fn enter_with_detached_prior_thread_is_blocked() {
}
}));
}
let outcome = handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
let outcome = handle_key_ask(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// Enter is a no-op while a prior thread is attached.
assert_eq!(outcome, KeyOutcome::Continue);
let s = app.ask.as_ref().unwrap();
@@ -693,7 +698,10 @@ fn render_transcript_shows_completed_turns_in_order() {
assert!(q1_pos < q2_pos, "chronological order: Q1 before Q2");
assert!(rendered.contains("first question"), "first question text");
assert!(rendered.contains("second answer"), "second answer text");
assert!(rendered.contains("transcript (2 turns)"), "title shows count");
assert!(
rendered.contains("transcript (2 turns)"),
"title shows count"
);
}
#[test]
@@ -772,10 +780,16 @@ fn left_arrow_then_typing_inserts_at_cursor_in_ask() {
let mut app = fresh_app();
app.mode = kebab_tui::Mode::Insert;
for ch in "abc".chars() {
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE),
);
let s = app.ask.as_ref().unwrap();
assert_eq!(s.input.as_str(), "abXc", "X inserts before c, not at end");
assert_eq!(s.input.cursor_col(), 3, "cursor sits between X and c");
@@ -787,7 +801,10 @@ fn left_arrow_then_typing_inserts_at_cursor_in_ask() {
fn right_arrow_at_end_is_noop_in_ask() {
let mut app = fresh_app();
app.mode = kebab_tui::Mode::Insert;
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
);
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
let s = app.ask.as_ref().unwrap();
assert_eq!(s.input.cursor_col(), 1);
@@ -800,7 +817,10 @@ fn home_end_jump_cursor_in_ask() {
let mut app = fresh_app();
app.mode = kebab_tui::Mode::Insert;
for ch in "hello".chars() {
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 0);
@@ -815,7 +835,10 @@ fn delete_key_removes_char_at_cursor_in_ask() {
let mut app = fresh_app();
app.mode = kebab_tui::Mode::Insert;
for ch in "abc".chars() {
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
@@ -831,14 +854,20 @@ fn hangul_left_arrow_rewinds_by_two_cols_in_ask() {
let mut app = fresh_app();
app.mode = kebab_tui::Mode::Insert;
for ch in "한글".chars() {
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 4);
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(app.ask.as_ref().unwrap().input.cursor_col(), 2);
// Inserting at the new cursor position lands between the two
// syllables, proving cursor_col is not just a display annotation.
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE),
);
assert_eq!(app.ask.as_ref().unwrap().input.as_str(), "한X글");
}
@@ -859,7 +888,10 @@ fn ask_state_default_follow_tail_is_true() {
fn k_disengages_follow_tail_in_ask() {
let mut app = fresh_app();
app.mode = kebab_tui::Mode::Normal;
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert!(!app.ask.as_ref().unwrap().follow_tail);
}
@@ -875,7 +907,10 @@ fn shift_g_re_engages_follow_tail_in_ask() {
s.follow_tail = false;
s.scroll = 7;
}
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT));
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT),
);
let s = app.ask.as_ref().unwrap();
assert!(s.follow_tail, "Shift-G re-engages follow-tail");
assert_eq!(s.scroll, 0, "scroll cleared (renderer recomputes)");
@@ -888,7 +923,10 @@ fn ctrl_l_resets_follow_tail_in_ask() {
let mut app = fresh_app();
app.mode = kebab_tui::Mode::Normal;
app.ask.as_mut().unwrap().follow_tail = false;
handle_key_ask(&mut app, KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL),
);
assert!(app.ask.as_ref().unwrap().follow_tail);
}
@@ -917,18 +955,12 @@ fn page_up_rewinds_scroll_saturating_and_freezes_follow_tail_in_ask() {
app.mode = kebab_tui::Mode::Normal;
app.ask.as_mut().unwrap().scroll = 25;
app.ask.as_mut().unwrap().follow_tail = true;
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
);
handle_key_ask(&mut app, KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE));
let s = app.ask.as_ref().unwrap();
assert_eq!(s.scroll, 15);
assert!(!s.follow_tail);
app.ask.as_mut().unwrap().scroll = 3;
handle_key_ask(
&mut app,
KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
);
handle_key_ask(&mut app, KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE));
assert_eq!(app.ask.as_ref().unwrap().scroll, 0);
}
@@ -1173,7 +1205,6 @@ fn ask_state_multi_hop_field_default_false_and_round_trips() {
assert!(!s.multi_hop, "settable back to false");
}
/// Small render helper shared with the rest of the test module's
/// buffer-snapshot pattern. We define it locally here to avoid
/// reaching into private internals.

View File

@@ -23,16 +23,10 @@ fn fresh_app(focus: Pane) -> App {
fn f1_toggles_cheatsheet_visibility() {
let mut app = fresh_app(Pane::Library);
assert!(!app.cheatsheet_visible(), "starts hidden");
let consumed = cheatsheet_intercept(
&mut app,
KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE),
);
let consumed = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE));
assert!(consumed, "F1 must be consumed");
assert!(app.cheatsheet_visible(), "F1 opens");
let consumed = cheatsheet_intercept(
&mut app,
KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE),
);
let consumed = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE));
assert!(consumed, "second F1 also consumed");
assert!(!app.cheatsheet_visible(), "F1 closes when open");
}
@@ -44,22 +38,13 @@ fn f1_toggles_cheatsheet_visibility() {
fn esc_closes_cheatsheet_when_visible_otherwise_falls_through() {
let mut app = fresh_app(Pane::Library);
// Hidden → Esc falls through.
let consumed = cheatsheet_intercept(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
let consumed = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!consumed, "Esc with cheatsheet hidden must fall through");
// Visible → Esc closes + consumed.
let _ = cheatsheet_intercept(
&mut app,
KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE),
);
let _ = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE));
assert!(app.cheatsheet_visible());
let consumed = cheatsheet_intercept(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
let consumed = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(consumed, "Esc with cheatsheet visible must consume");
assert!(!app.cheatsheet_visible());
}
@@ -76,10 +61,7 @@ fn modifier_keys_do_not_toggle_cheatsheet() {
assert!(!consumed);
assert!(!app.cheatsheet_visible());
let consumed = cheatsheet_intercept(
&mut app,
KeyEvent::new(KeyCode::F(1), KeyModifiers::ALT),
);
let consumed = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::F(1), KeyModifiers::ALT));
assert!(!consumed);
assert!(!app.cheatsheet_visible());
}
@@ -90,10 +72,7 @@ fn modifier_keys_do_not_toggle_cheatsheet() {
#[test]
fn arbitrary_key_falls_through_when_cheatsheet_visible() {
let mut app = fresh_app(Pane::Library);
let _ = cheatsheet_intercept(
&mut app,
KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE),
);
let _ = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE));
assert!(app.cheatsheet_visible());
for key in [
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
@@ -116,10 +95,7 @@ fn cheatsheet_popup_contains_global_and_pane_sections() {
let mut app = fresh_app(Pane::Search);
app.focus = Pane::Search;
// Force visible — we're testing the renderer, not the toggle.
let _ = cheatsheet_intercept(
&mut app,
KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE),
);
let _ = cheatsheet_intercept(&mut app, KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE));
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
@@ -138,7 +114,10 @@ fn cheatsheet_popup_contains_global_and_pane_sections() {
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Global"), "Global section header present");
assert!(rendered.contains("Library"), "Library section header present");
assert!(
rendered.contains("Library"),
"Library section header present"
);
assert!(rendered.contains("Search"), "Search section header present");
assert!(rendered.contains("Ask"), "Ask section header present");
assert!(rendered.contains("F1"), "F1 binding listed");
@@ -149,7 +128,9 @@ fn cheatsheet_popup_contains_global_and_pane_sections() {
// the Inspect assertion when the body overflows; the rest of
// the section-header asserts still cover the primary contract.
if !rendered.contains("Inspect") {
eprintln!("[note] Inspect section overflowed popup body — known limitation per p9-fb-21 HOTFIXES");
eprintln!(
"[note] Inspect section overflowed popup body — known limitation per p9-fb-21 HOTFIXES"
);
}
// The "currently focused: <pane>" line lives at the bottom of
// the popup; it might get clipped if the popup's content
@@ -158,6 +139,8 @@ fn cheatsheet_popup_contains_global_and_pane_sections() {
// the primary contract.
let has_focused = rendered.contains("focused");
if !has_focused {
eprintln!("[note] 'focused' line absent — likely body overflowed popup height; sections still pinned");
eprintln!(
"[note] 'focused' line absent — likely body overflowed popup height; sections still pinned"
);
}
}

View File

@@ -8,9 +8,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_config::Config;
use kebab_core::{
AssetId, Block, BlockId, CanonicalDocument, Chunk, ChunkId, ChunkerVersion, CommonBlock,
DocumentId, HeadingBlock, Inline, Lang, Metadata, ParserVersion, Provenance,
ProvenanceEvent, ProvenanceKind, SourceSpan, SourceType, TextBlock, TrustLevel,
WorkspacePath,
DocumentId, HeadingBlock, Inline, Lang, Metadata, ParserVersion, Provenance, ProvenanceEvent,
ProvenanceKind, SourceSpan, SourceType, TextBlock, TrustLevel, WorkspacePath,
};
use kebab_tui::{
App, InspectState, InspectTarget, KeyOutcome, Pane, handle_key_inspect, render_inspect,
@@ -61,7 +60,10 @@ fn make_doc() -> CanonicalDocument {
}),
];
let mut user = serde_json::Map::new();
user.insert("custom_key".into(), serde_json::Value::String("custom_val".into()));
user.insert(
"custom_key".into(),
serde_json::Value::String("custom_val".into()),
);
CanonicalDocument {
doc_id,
@@ -141,10 +143,7 @@ fn esc_returns_to_recorded_pane() {
let s = app.inspect.as_mut().unwrap();
s.return_to = Pane::Search;
}
let outcome = handle_key_inspect(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
let outcome = handle_key_inspect(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Search));
}
@@ -200,16 +199,10 @@ fn page_down_scrolls_by_ten_in_inspect() {
fn page_up_rewinds_by_ten_saturating_in_inspect() {
let mut app = fresh_app();
app.inspect.as_mut().unwrap().scroll = 25;
handle_key_inspect(
&mut app,
KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
);
handle_key_inspect(&mut app, KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE));
assert_eq!(app.inspect.as_ref().unwrap().scroll, 15);
app.inspect.as_mut().unwrap().scroll = 3;
handle_key_inspect(
&mut app,
KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
);
handle_key_inspect(&mut app, KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE));
assert_eq!(app.inspect.as_ref().unwrap().scroll, 0);
}
@@ -273,7 +266,10 @@ fn doc_view_renders_header_and_metadata() {
rendered.contains("custom_key") || rendered.contains("custom_val"),
"user metadata pretty-printed"
);
assert!(rendered.contains("provenance"), "provenance section visible");
assert!(
rendered.contains("provenance"),
"provenance section visible"
);
assert!(rendered.contains("kb-source-fs"), "agent rendered");
assert!(rendered.contains("blocks"), "blocks section visible");
assert!(rendered.contains("Heading L1"), "block describe rendered");
@@ -319,10 +315,16 @@ fn chunk_view_renders_text_and_block_ids() {
s.chunk = Some(make_chunk());
}
let rendered = render_to_string(&app, 100, 40);
assert!(rendered.contains("md-heading-v1"), "chunker_version rendered");
assert!(
rendered.contains("md-heading-v1"),
"chunker_version rendered"
);
assert!(rendered.contains("Top / Sub"), "heading_path joined");
assert!(rendered.contains("Line 1-5"), "source span described");
assert!(rendered.contains("chunk body line one"), "text body rendered");
assert!(
rendered.contains("chunk body line one"),
"text body rendered"
);
assert!(
rendered.contains("embeddings (2)"),
"block_id count rendered inline on embeddings header"
@@ -343,8 +345,7 @@ fn inspect_doc_header_shows_stale_badge_when_threshold_exceeded() {
s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
let mut doc = make_doc();
// Backdate updated_at by 60 days so 60d > 30d threshold.
doc.metadata.updated_at =
OffsetDateTime::now_utc() - time::Duration::days(60);
doc.metadata.updated_at = OffsetDateTime::now_utc() - time::Duration::days(60);
s.doc = Some(doc);
}
let rendered = render_to_string(&app, 100, 40);
@@ -372,8 +373,7 @@ fn inspect_doc_header_omits_stale_badge_when_fresh() {
s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
let mut doc = make_doc();
// 1 day old — under the 30d threshold.
doc.metadata.updated_at =
OffsetDateTime::now_utc() - time::Duration::days(1);
doc.metadata.updated_at = OffsetDateTime::now_utc() - time::Duration::days(1);
s.doc = Some(doc);
}
let rendered = render_to_string(&app, 100, 40);
@@ -393,8 +393,7 @@ fn inspect_doc_header_omits_stale_badge_when_threshold_zero() {
s.target = Some(InspectTarget::Doc(DocumentId("d".repeat(32))));
let mut doc = make_doc();
// Even a year-old doc must not get [STALE] when threshold = 0.
doc.metadata.updated_at =
OffsetDateTime::now_utc() - time::Duration::days(365);
doc.metadata.updated_at = OffsetDateTime::now_utc() - time::Duration::days(365);
s.doc = Some(doc);
}
let rendered = render_to_string(&app, 100, 40);
@@ -410,10 +409,7 @@ fn no_inspect_state_returns_to_library() {
config.storage.data_dir = "/tmp/kebab-tui-inspect-tests-noop".into();
let mut app = App::new(config).unwrap();
app.focus = Pane::Inspect;
let outcome = handle_key_inspect(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
let outcome = handle_key_inspect(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
}

View File

@@ -18,7 +18,12 @@ use time::OffsetDateTime;
fn make_doc(path: &str, title: &str, tags: Vec<&str>) -> DocSummary {
DocSummary {
doc_id: DocumentId(format!("{:0<32}", path.chars().filter(|c| c.is_alphanumeric()).collect::<String>())),
doc_id: DocumentId(format!(
"{:0<32}",
path.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>()
)),
doc_path: WorkspacePath::new(path.into()).unwrap(),
title: title.into(),
lang: Lang("en".into()),
@@ -88,10 +93,8 @@ fn handle_key_library_q_quits() {
#[test]
fn handle_key_library_esc_quits_when_no_overlay() {
let mut app = app_with_docs(vec![]);
let outcome = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
let outcome =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::Quit);
}
@@ -118,10 +121,8 @@ fn handle_key_library_question_switches_to_ask() {
#[test]
fn handle_key_library_enter_does_not_switch_when_empty() {
let mut app = app_with_docs(vec![]);
let outcome = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
let outcome =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::Continue);
}
@@ -185,10 +186,8 @@ fn handle_key_library_arrow_down_moves_selection() {
#[test]
fn handle_key_library_enter_inspects_when_docs_present() {
let mut app = app_with_docs(vec![make_doc("a.md", "A", vec![])]);
let outcome = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
let outcome =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Inspect));
}
@@ -209,10 +208,8 @@ fn handle_key_library_f_opens_filter_overlay_then_enter_refreshes() {
);
}
// Enter commits + refreshes.
let o2 = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
let o2 =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(o2, KeyOutcome::Refresh);
}
@@ -235,10 +232,8 @@ fn filter_overlay_accepts_hangul_tags() {
);
}
// Enter commits.
let o2 = kebab_tui::handle_key_library(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
let o2 =
kebab_tui::handle_key_library(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(o2, KeyOutcome::Refresh);
// The library filter should now contain "한글" as a tag.
let filter = app.library_filter_for_testing();
@@ -272,9 +267,9 @@ fn filter_overlay_render_places_cursor_on_focused_field() {
// After draw, ratatui calls backend.set_cursor_position when the
// frame's cursor_position is Some. The terminal's
// get_cursor_position proxies to the backend.
let pos = terminal.get_cursor_position().expect(
"filter overlay must call set_cursor_position, so cursor pos must be readable",
);
let pos = terminal
.get_cursor_position()
.expect("filter overlay must call set_cursor_position, so cursor pos must be readable");
// The Tags label ("tags_any (csv): ") has display_width 16; inner.x
// is 1 (inside border). With empty input cursor_col=0, expected x=17.
// We assert x>0 to avoid hardcoding the exact layout geometry while
@@ -325,7 +320,10 @@ fn library_renders_column_header_row() {
.lines()
.position(|line| line.contains("TITLE"))
.expect("TITLE header should be present");
let lines_after = rendered.lines().skip(title_line_idx + 1).collect::<Vec<_>>();
let lines_after = rendered
.lines()
.skip(title_line_idx + 1)
.collect::<Vec<_>>();
assert!(
lines_after.iter().any(|line| line.contains("doc-")),
"no data rows after header:\n{rendered}"
@@ -338,7 +336,11 @@ fn library_renders_column_header_row() {
#[test]
fn library_renders_korean_titles_without_overflow() {
let docs = vec![
make_doc("ko/한글-노트.md", "러스트로 만드는 지식 베이스", vec!["rust", "한글"]),
make_doc(
"ko/한글-노트.md",
"러스트로 만드는 지식 베이스",
vec!["rust", "한글"],
),
make_doc("jp/漢字メモ.md", "日本語のテストドキュメント", vec!["jp"]),
make_doc("mix/hello-세계.md", "Hello, 세계 mixed title", vec!["mix"]),
];

View File

@@ -24,12 +24,13 @@ fn esc_in_insert_flips_to_normal_and_consumes() {
for &pane in &[Pane::Library, Pane::Search, Pane::Ask, Pane::Inspect] {
let mut app = fresh_app(pane);
app.mode = Mode::Insert;
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
let consumed = mode_intercept(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(consumed, "Esc in Insert must be consumed (pane: {pane:?})");
assert_eq!(app.mode, Mode::Normal, "mode flipped to Normal (pane: {pane:?})");
assert_eq!(
app.mode,
Mode::Normal,
"mode flipped to Normal (pane: {pane:?})"
);
}
}
@@ -40,10 +41,7 @@ fn esc_in_insert_flips_to_normal_and_consumes() {
fn esc_in_normal_mode_falls_through() {
let mut app = fresh_app(Pane::Library);
assert_eq!(app.mode, Mode::Normal);
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
let consumed = mode_intercept(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!consumed, "Esc in Normal must fall through to pane");
assert_eq!(app.mode, Mode::Normal, "mode unchanged");
}
@@ -55,13 +53,21 @@ fn esc_in_normal_mode_falls_through() {
fn i_in_normal_on_library_inspect_jobs_flips_to_insert() {
for &pane in &[Pane::Library, Pane::Inspect, Pane::Jobs] {
let mut app = fresh_app(pane);
assert_eq!(app.mode, Mode::Normal, "auto_for({pane:?}) should be Normal");
assert_eq!(
app.mode,
Mode::Normal,
"auto_for({pane:?}) should be Normal"
);
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
assert!(consumed, "i in Normal on {pane:?} must be consumed");
assert_eq!(app.mode, Mode::Insert, "mode flipped to Insert (pane: {pane:?})");
assert_eq!(
app.mode,
Mode::Insert,
"mode flipped to Insert (pane: {pane:?})"
);
}
}
@@ -72,7 +78,11 @@ fn i_in_normal_on_library_inspect_jobs_flips_to_insert() {
fn i_on_search_or_ask_in_insert_falls_through_to_pane() {
for &pane in &[Pane::Search, Pane::Ask] {
let mut app = fresh_app(pane);
assert_eq!(app.mode, Mode::Insert, "auto_for({pane:?}) should be Insert");
assert_eq!(
app.mode,
Mode::Insert,
"auto_for({pane:?}) should be Insert"
);
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
@@ -96,7 +106,11 @@ fn i_on_search_or_ask_in_normal_flips_to_insert() {
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
);
assert!(consumed, "i on {pane:?}/Normal must intercept (p9-fb-21)");
assert_eq!(app.mode, Mode::Insert, "mode flipped to Insert (pane: {pane:?})");
assert_eq!(
app.mode,
Mode::Insert,
"mode flipped to Insert (pane: {pane:?})"
);
}
}
@@ -107,10 +121,7 @@ fn i_on_search_or_ask_in_normal_flips_to_insert() {
fn modifier_keys_do_not_trigger_intercept() {
let mut app = fresh_app(Pane::Library);
app.mode = Mode::Insert;
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::CONTROL),
);
let consumed = mode_intercept(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::CONTROL));
assert!(!consumed, "Ctrl+Esc must fall through");
assert_eq!(app.mode, Mode::Insert, "mode unchanged");
@@ -136,16 +147,19 @@ fn shift_modifier_passes_modifier_filter() {
// 'i', so it falls through. Both are intentional.)
let mut app = fresh_app(Pane::Library);
app.mode = Mode::Insert;
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT),
let consumed = mode_intercept(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT));
assert!(
consumed,
"Shift+Esc still toggles (modifier filter allows SHIFT)"
);
assert!(consumed, "Shift+Esc still toggles (modifier filter allows SHIFT)");
let mut app = fresh_app(Pane::Library);
let consumed = mode_intercept(
&mut app,
KeyEvent::new(KeyCode::Char('I'), KeyModifiers::SHIFT),
);
assert!(!consumed, "Shift+I (capital) falls through — only lowercase 'i' toggles");
assert!(
!consumed,
"Shift+I (capital) falls through — only lowercase 'i' toggles"
);
}

View File

@@ -3,8 +3,8 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_config::Config;
use kebab_core::{
Citation, ChunkId, ChunkerVersion, DocumentId, EmbeddingModelId, IndexVersion,
RetrievalDetail, SearchHit, SearchMode, WorkspacePath,
ChunkId, ChunkerVersion, Citation, DocumentId, EmbeddingModelId, IndexVersion, RetrievalDetail,
SearchHit, SearchMode, WorkspacePath,
};
use kebab_tui::{
App, KeyOutcome, Mode, Pane, SearchState, SearchWorkerMessage, build_jump_command,
@@ -73,10 +73,7 @@ fn line_citation(path: &str, line: u32) -> Citation {
#[test]
fn esc_returns_to_library() {
let mut app = fresh_app();
let outcome = handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
let outcome = handle_key_search(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
}
@@ -134,20 +131,14 @@ fn enter_with_query_emits_refresh() {
let s = app.search.as_mut().unwrap();
s.input.push_str("rust");
}
let outcome = handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
let outcome = handle_key_search(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::Refresh);
}
#[test]
fn enter_with_empty_query_is_continue() {
let mut app = fresh_app();
let outcome = handle_key_search(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
let outcome = handle_key_search(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(outcome, KeyOutcome::Continue);
}
@@ -193,19 +184,23 @@ fn j_k_move_selection_within_bounds() {
#[test]
fn build_jump_command_line_uses_plus_n_for_vim() {
let citation = line_citation("notes/foo.md", 42);
let (program, args) =
build_jump_command(&citation, "vim", Path::new("/tmp/workspace"));
let (program, args) = build_jump_command(&citation, "vim", Path::new("/tmp/workspace"));
assert_eq!(program, "vim");
assert_eq!(args, vec!["+42".to_string(), "/tmp/workspace/notes/foo.md".into()]);
assert_eq!(
args,
vec!["+42".to_string(), "/tmp/workspace/notes/foo.md".into()]
);
}
#[test]
fn build_jump_command_line_uses_g_flag_for_code() {
let citation = line_citation("notes/foo.md", 42);
let (program, args) =
build_jump_command(&citation, "code", Path::new("/tmp/workspace"));
let (program, args) = build_jump_command(&citation, "code", Path::new("/tmp/workspace"));
assert_eq!(program, "code");
assert_eq!(args, vec!["-g".to_string(), "/tmp/workspace/notes/foo.md:42".into()]);
assert_eq!(
args,
vec!["-g".to_string(), "/tmp/workspace/notes/foo.md:42".into()]
);
}
#[test]
@@ -227,8 +222,18 @@ fn render_search_with_hits_shows_input_and_path() {
s.input.push_str("rust traits");
s.mode = SearchMode::Hybrid;
s.hits = vec![
make_hit(1, "notes/rust.md", "trait dispatch\nis dynamic", line_citation("notes/rust.md", 12)),
make_hit(2, "notes/dyn.md", "dynamic dispatch\nvtable", line_citation("notes/dyn.md", 3)),
make_hit(
1,
"notes/rust.md",
"trait dispatch\nis dynamic",
line_citation("notes/rust.md", 12),
),
make_hit(
2,
"notes/dyn.md",
"dynamic dispatch\nvtable",
line_citation("notes/dyn.md", 3),
),
];
s.selected_hit = 0;
}
@@ -249,10 +254,19 @@ fn render_search_with_hits_shows_input_and_path() {
})
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("hybrid"), "mode badge rendered: {rendered}");
assert!(
rendered.contains("hybrid"),
"mode badge rendered: {rendered}"
);
assert!(rendered.contains("rust traits"), "input text rendered");
assert!(rendered.contains("notes/rust.md"), "first hit path rendered");
assert!(rendered.contains("notes/dyn.md"), "second hit path rendered");
assert!(
rendered.contains("notes/rust.md"),
"first hit path rendered"
);
assert!(
rendered.contains("notes/dyn.md"),
"second hit path rendered"
);
}
/// p9-fb-32: Search pane prefixes the rank/score header line with a
@@ -454,7 +468,12 @@ fn g_key_enqueues_pending_editor_request() {
app.mode = kebab_tui::Mode::Normal;
{
let s = app.search.as_mut().unwrap();
s.hits = vec![make_hit(1, "notes/x.md", "snippet", line_citation("notes/x.md", 42))];
s.hits = vec![make_hit(
1,
"notes/x.md",
"snippet",
line_citation("notes/x.md", 42),
)];
s.selected_hit = 0;
}
assert!(app.pending_editor().is_none(), "queue starts empty");
@@ -570,15 +589,18 @@ fn poll_worker_noop_when_no_rx() {
/// Helper for the debounce_due tests — build a state with the four
/// fields the test cares about set, others default.
#[allow(clippy::field_reassign_with_default)]
fn search_state_with(input: &str, mode: SearchMode, searching: bool, last_query: Option<(String, SearchMode)>) -> SearchState {
fn search_state_with(
input: &str,
mode: SearchMode,
searching: bool,
last_query: Option<(String, SearchMode)>,
) -> SearchState {
let mut s = SearchState::default();
s.input.push_str(input);
s.mode = mode;
s.searching = searching;
s.last_query = last_query;
s.input_dirty_at = Some(
time::OffsetDateTime::now_utc() - time::Duration::seconds(1),
);
s.input_dirty_at = Some(time::OffsetDateTime::now_utc() - time::Duration::seconds(1));
s
}
@@ -683,12 +705,7 @@ fn o_in_normal_with_hits_enters_inspect() {
app.focus = Pane::Search;
app.mode = Mode::Normal;
let s = app.search.as_mut().unwrap();
s.hits = vec![make_hit(
1,
"a.md",
"snippet",
line_citation("a.md", 1),
)];
s.hits = vec![make_hit(1, "a.md", "snippet", line_citation("a.md", 1))];
s.selected_hit = 0;
let outcome = kebab_tui::handle_key_search(
&mut app,