//! `kebab` — command-line interface. Each subcommand maps 1:1 to a `kebab-app` //! function. Exit codes per design §10. use std::path::PathBuf; use std::process::ExitCode; use anyhow::Context; use clap::{Parser, Subcommand}; use kebab_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal}; mod cancel; mod progress; mod wire; #[derive(Parser, Debug)] #[command(name = "kebab", version, about = "personal local knowledge base")] struct Cli { /// Path to a non-default `config.toml`. #[arg(long, global = true)] config: Option, /// Show anyhow chain on errors. #[arg(long, global = true)] verbose: bool, /// Show tracing target/level on errors. #[arg(long, global = true)] debug: bool, /// Emit machine-readable wire JSON (`*.v1`). #[arg(long, global = true)] json: bool, /// Disable all write-path subcommands (also: KEBAB_READONLY=1 env var). #[arg(long, global = true, env = "KEBAB_READONLY", value_parser = parse_bool_env)] readonly: bool, /// Suppress all human-readable stderr output: progress lines, hints. /// Implied by `--json`. #[arg(long, global = true)] quiet: bool, #[command(subcommand)] command: Cmd, } // p10-1A-1: adding `repo` and `code_lang` Vec fields pushed `Cmd` // over clippy's large_enum_variant threshold. The enum is short-lived // (parsed once at startup, never cloned in a hot path) — boxing would add // noise with no real benefit. #[allow(clippy::large_enum_variant)] #[derive(Subcommand, Debug)] enum Cmd { /// Initialise XDG dirs + workspace + `config.toml`. Init { /// Overwrite an existing `config.toml`. #[arg(long)] force: bool, }, /// config.toml 관리 (스키마 마이그레이션 등). Config { #[command(subcommand)] what: ConfigWhat, }, /// Scan the workspace and ingest new/updated documents. Ingest { /// Workspace root override. #[arg(long)] root: Option, /// Suppress the per-file `items` list. #[arg(long)] summary_only: bool, /// p9-fb-23: bypass the per-asset early-skip path. Every asset is /// re-parsed, re-chunked, re-embedded, and re-upserted regardless /// of whether the DB already has a record with matching checksum /// and version stamps. Useful after manual schema bumps or when /// the user suspects the corpus is in a stale state. #[arg(long)] force_reingest: bool, }, /// Listing subcommands. List { #[command(subcommand)] what: ListWhat, }, /// Inspect documents or chunks by ID. Inspect { #[command(subcommand)] what: InspectWhat, }, /// p9-fb-35: verbatim chunk / doc / span fetch. Fetch { #[command(subcommand)] what: FetchWhat, }, /// Lexical / vector / hybrid search over chunks. Search { /// Query text. Not required when `--bulk` is set (queries from stdin). query: Option, #[arg(long, default_value_t = 10)] k: usize, #[arg(long, value_enum, default_value_t = ModeFlag::Hybrid)] mode: ModeFlag, #[arg(long)] explain: bool, /// p9-fb-19: bypass the in-process LRU search cache for /// this invocation. Forces a fresh retriever run even when /// the same query was just served from cache. Useful when /// debugging retriever behavior — and a no-op for the CLI /// (each invocation is a new process anyway, so the cache /// starts empty), but the flag stays for parity with the /// future TUI cache-aware search and for explicit intent. #[arg(long)] no_cache: bool, /// p9-fb-34: cap result wire JSON size at approximately N tokens /// (chars/4 estimate). When set, smaller snippets and fewer hits /// may be returned; check `truncated` in the JSON wire. #[arg(long)] max_tokens: Option, /// p9-fb-34: per-hit snippet character cap, overrides /// `config.search.snippet_chars` for this call only. #[arg(long)] snippet_chars: Option, /// p9-fb-34: opaque cursor from a previous response's /// `next_cursor` to fetch the next page. Mismatched /// `corpus_revision` returns `error.v1.code = stale_cursor`. #[arg(long)] cursor: Option, /// p9-fb-36: filter by `metadata.tags`. Repeatable; OR-within (any tag). #[arg(long)] tag: Vec, /// p9-fb-36: filter by `documents.lang` (ISO code). #[arg(long)] lang: Option, /// p9-fb-36: filter by `documents.workspace_path` glob. #[arg(long)] path_glob: Option, /// p9-fb-36: filter by minimum `documents.trust_level`. #[arg(long, value_enum)] trust_min: Option, /// p9-fb-36: filter by `assets.media_type` kind. Comma-separated. /// Aliases: `md` → `markdown`. Other accepted: `markdown`, `pdf`, /// `image`, `audio`, `code`, `other`. Unknown values match nothing. #[arg(long, value_delimiter = ',')] media: Vec, /// p9-fb-36: filter to docs whose `updated_at` is >= this RFC3339 /// timestamp (UTC). Invalid format → exit 2 with error.v1 /// code = config_invalid. #[arg(long)] ingested_after: Option, /// p9-fb-36: filter to a single doc by id. #[arg(long)] doc_id: Option, /// p10-1A-1: filter by repo name (`metadata.repo`). Repeatable; /// multi-value = OR. Empty = no filter (all repos returned). #[arg(long = "repo", value_name = "NAME", num_args = 1)] repo: Vec, /// p10-1A-1: filter by code language identifier (lowercase /// canonical). Repeatable or comma-separated. /// Examples: `rust`, `python`, `typescript`. /// Unknown values produce empty hits. #[arg( long = "code-lang", value_name = "LANG", num_args = 1, value_delimiter = ',' )] code_lang: Vec, /// p9-fb-37: emit pre-fusion lexical / vector / RRF candidate /// lists + per-stage timing in the response. Bypasses cache /// (debug intent — fresh run guaranteed). Requires embeddings /// when `--mode hybrid` or `--mode vector`; lexical mode runs /// without embeddings via a no-op vector stub. #[arg(long)] trace: bool, /// p9-fb-42: bulk multi-query mode. Reads ndjson from stdin — /// one JSON object per line, each item shape mirrors the /// single-query input. Output is per-query ndjson on stdout /// (one `bulk_search_item.v1` per line) plus a summary line on /// stderr. Single-query flags (`--mode`, `--k`, `--tag`, etc.) /// are ignored when `--bulk` is set; pass them per-item in the /// stdin JSON instead. Caps at 100 queries per call. #[arg(long)] bulk: bool, }, /// Retrieval-augmented question answering. Ask { query: String, #[arg(long, default_value_t = 8)] k: usize, #[arg(long, value_enum, default_value_t = ModeFlag::Hybrid)] mode: ModeFlag, #[arg(long)] explain: bool, #[arg(long)] temperature: Option, #[arg(long)] seed: Option, /// p9-fb-20: print the `근거:` block (full path / line range /// / score, one per line) after the answer. Default on. /// `--json` mode is unaffected — citations are always /// included in the wire payload regardless of this flag. #[arg(long, action = clap::ArgAction::SetTrue, conflicts_with = "hide_citations", default_value_t = true)] show_citations: bool, /// p9-fb-20: opt out of the `근거:` block (sticky-overrides /// `--show-citations`). Useful when piping the answer body /// to another tool that doesn't want trailing metadata. #[arg(long)] hide_citations: bool, /// p9-fb-18: persistent multi-turn chat session id. First call /// auto-creates the session in SQLite (`chat_sessions`), each /// subsequent call with the same id loads prior turns as /// history and appends the new Q/A. Without this flag, ask /// is single-shot (no persistence). The session id is /// caller-supplied — pick anything stable per conversation /// (e.g. `kebab-rust-async-2026-05`). #[arg(long, value_name = "ID")] session: Option, /// p9-fb-33: emit ndjson `answer_event.v1` events on stderr /// while streaming. Final stdout line is the existing /// `answer.v1`. Off by default to preserve final-only behavior. #[arg(long)] stream: bool, /// p9-fb-41: route this ask through the multi-hop pipeline /// — the query is decomposed into sub-questions, each /// retrieved independently, then synthesized over the /// merged chunk pool. Cost trade-off: 2–5× LLM calls /// (decompose + 0..N decide + synthesize) vs. single-pass. /// Worth it for compound questions (X 와 Y 의 관계, prereq /// chain, cross-doc reasoning); single-pass is faster for /// simple fact lookups. The full per-hop trace is exposed /// on `Answer.hops` in `--json` mode. #[arg(long)] multi_hop: bool, }, /// Wipe XDG data dirs (and optionally the Lance vector store) so the /// workspace can be re-initialised. **Irreversible.** Without /// `--yes`, prompts on TTY; aborts in non-interactive contexts. Reset { /// Wipe config + data + cache + state. Implies losing /// `config.toml` — re-run `kebab init` afterwards. #[arg(long, group = "reset_scope")] all: bool, /// Default. Wipe data + cache + state. Config is preserved. #[arg(long, group = "reset_scope")] data_only: bool, /// Wipe only the Lance vector store + truncate /// `embedding_records`. SQLite documents / chunks survive so the /// next `kebab ingest` re-embeds without re-parsing. #[arg(long, group = "reset_scope")] vector_only: bool, /// Wipe only the config dir. #[arg(long, group = "reset_scope")] config_only: bool, /// Purge stored docs that are outside the current walker scope /// (config narrowing / removed sub-directory). No filesystem paths /// are removed — this is purely a store-level reconciliation. /// Filesystem existence is NOT checked; anything the current walker /// would not visit is considered an orphan and removed from the store. #[arg(long, group = "reset_scope")] orphans_only: bool, /// Skip the interactive confirm. Required in non-interactive /// contexts (CI, pipes). #[arg(long)] yes: bool, }, /// Health check. Doctor, /// Print introspection report (wire schemas, capabilities, model versions, stats). Schema, /// Launch the Ratatui shell (P9-1 — Library pane only; search / /// ask / inspect panes land with p9-2 / p9-3 / p9-4). Tui, /// Eval suite (placeholder; lands in P9). Eval { #[command(subcommand)] what: EvalWhat, }, /// Run the MCP (Model Context Protocol) stdio server. Used by /// agent hosts (Claude Code / Cursor / OpenAI Agents) to call kebab /// tools (search / ask / schema / doctor). Mcp, /// Ingest a single file (workspace external paths allowed). /// Bytes are copied into `/_external/.`. IngestFile { /// File path to ingest. path: std::path::PathBuf, }, /// Ingest markdown content from stdin. v1 markdown only. /// Frontmatter (title + source_uri) is auto-injected. IngestStdin { /// Title — required, written to frontmatter. #[arg(long)] title: String, /// Source URI — optional, written to frontmatter when present. #[arg(long)] source_uri: Option, }, } #[derive(Subcommand, Debug)] enum ConfigWhat { /// 기존 config.toml 을 새 스키마로 마이그레이션(빠진 섹션 추가 + 멱등 + .bak 백업). Migrate { /// 변경만 출력하고 파일은 수정하지 않는다. #[arg(long)] dry_run: bool, }, } #[derive(Subcommand, Debug)] enum ListWhat { /// List documents currently indexed. Docs, } #[derive(Subcommand, Debug)] enum InspectWhat { /// Inspect a single document by ID. Doc { id: String }, /// Inspect a single chunk by ID. Chunk { id: String }, /// Corpus-wide OCR statistics (total events, latency percentiles, engine breakdown). OcrStats, /// Recent OCR failures, optionally filtered by document ID. OcrFailures { /// Filter failures to a single document UUID. #[arg(long)] doc_id: Option, /// Maximum number of failure rows to return. #[arg(long, default_value_t = 10)] limit: usize, }, } #[derive(Subcommand, Debug)] enum FetchWhat { /// Fetch a single chunk verbatim, optionally with surrounding context. Chunk { id: String, /// p9-fb-35: include ±N chunks before and after the target. #[arg(long)] context: Option, }, /// Fetch the entire normalized markdown text of a document. Doc { id: String, /// p9-fb-35: chars/4 budget cap. #[arg(long)] max_tokens: Option, }, /// Fetch a 1-based line range of a document. PDF / audio rejected. Span { doc_id: String, line_start: u32, line_end: u32, /// p9-fb-35: chars/4 budget cap. #[arg(long)] max_tokens: Option, }, } #[derive(Subcommand, Debug)] enum EvalWhat { /// Run the golden suite end-to-end and persist `eval_runs` + /// `eval_query_results` + `runs_dir//per_query.jsonl` /// (P5-1). Run { #[arg(long, default_value = "golden")] suite: String, #[arg(long, value_enum, default_value_t = ModeFlag::Lexical)] mode: ModeFlag, #[arg(long, default_value_t = 10)] k: usize, #[arg(long)] with_rag: bool, #[arg(long)] temperature: Option, #[arg(long)] seed: Option, }, /// Compute aggregate metrics for a stored run and write them back /// into `eval_runs.aggregate_json` (P5-2). Aggregate { run_id: String }, /// Compute variant-consistency metrics for a stored run and print /// a Markdown report (or JSON with `--json`). Variants { run_id: String, #[arg(long)] json: bool, }, /// Diff two stored runs (P5-2). Default output is a Markdown /// summary; use `--json` (top-level flag) for the raw report. Compare { run_a: String, run_b: String, /// Refuse to compare when the two runs' `chunker_version` /// differ (default is graceful doc-id fallback). #[arg(long)] strict_chunker_version: bool, /// Also write the Markdown report to /// `runs_dir//report.md`. #[arg(long)] write_report: bool, }, } #[derive(Clone, Copy, Debug, clap::ValueEnum)] enum ModeFlag { Lexical, Vector, Hybrid, } impl From for kebab_core::SearchMode { fn from(m: ModeFlag) -> Self { match m { ModeFlag::Lexical => kebab_core::SearchMode::Lexical, ModeFlag::Vector => kebab_core::SearchMode::Vector, ModeFlag::Hybrid => kebab_core::SearchMode::Hybrid, } } } /// p9-fb-36: clap value enum for `--trust-min`. Maps to /// `kebab_core::TrustLevel` via `From`. #[derive(clap::ValueEnum, Clone, Debug)] enum TrustLevelFlag { Primary, Secondary, Generated, } impl From for kebab_core::TrustLevel { fn from(f: TrustLevelFlag) -> Self { match f { TrustLevelFlag::Primary => kebab_core::TrustLevel::Primary, TrustLevelFlag::Secondary => kebab_core::TrustLevel::Secondary, TrustLevelFlag::Generated => kebab_core::TrustLevel::Generated, } } } /// Parse boolean env var accepting "1", "true", "yes", "on" (case-insensitive) /// as truthy; "0", "false", "no", "off" as falsy. Used for `KEBAB_READONLY`. fn parse_bool_env(s: &str) -> Result { match s.to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Ok(true), "0" | "false" | "no" | "off" => Ok(false), other => Err(format!( "expected 1/0/true/false/yes/no/on/off, got {other:?}" )), } } fn main() -> ExitCode { let cli = Cli::parse(); let level = if cli.debug { kebab_app::logging::LogLevel::Debug } else if cli.verbose { kebab_app::logging::LogLevel::Verbose } else { kebab_app::logging::LogLevel::Default }; // Fail-soft: if logging init errors (e.g. XDG state dir is read-only), // proceed without a guard rather than crashing — `kebab` is still usable. let _log_guard = kebab_app::logging::init(level).ok(); if cli.readonly && is_mutating(&cli.command) { let msg = "kebab: readonly mode — mutating commands are disabled"; if cli.json { let v1 = kebab_app::ErrorV1 { schema_version: kebab_app::ERROR_V1_ID.to_string(), code: "readonly_mode".to_string(), message: msg.to_string(), details: serde_json::json!({}), hint: Some( "remove --readonly (or unset KEBAB_READONLY) to allow writes".to_string(), ), }; let v = wire::wire_error_v1(&v1); eprintln!( "{}", serde_json::to_string(&v).unwrap_or_else(|_| msg.to_string()) ); } else { eprintln!("{msg}"); } return ExitCode::from(1); } match run(&cli) { Ok(()) => ExitCode::from(0), Err(e) => { let code = exit_code(&e); // Refusals at exit code 1 print to stdout (already done by the // caller); errors go to stderr. if code != 1 { if cli.json { let v1 = kebab_app::classify(&e, cli.verbose); let v = wire::wire_error_v1(&v1); eprintln!("{}", serde_json::to_string(&v).unwrap_or_else(|_| { "{\"schema_version\":\"error.v1\",\"code\":\"generic\",\"message\":\"serialize failed\"}".to_string() })); } else { eprintln!("error: {e}"); if cli.verbose { for cause in e.chain().skip(1) { eprintln!(" caused by: {cause}"); } } } } ExitCode::from(code) } } } fn exit_code(err: &anyhow::Error) -> u8 { if err.downcast_ref::().is_some() { return 1; } if err.downcast_ref::().is_some() { return 1; } if err.downcast_ref::().is_some() { return 3; } 2 } fn run(cli: &Cli) -> anyhow::Result<()> { match &cli.command { Cmd::Init { force } => { kebab_app::init_workspace(*force)?; if !cli.json { println!( "created {}", kebab_config::Config::xdg_config_path().display() ); println!( "created {}", kebab_config::Config::xdg_data_dir().display() ); println!( "created {}", kebab_config::Config::xdg_state_dir().display() ); println!("hint edit the config above, then `kebab ingest`"); println!( "hint remote Ollama 사용 시 config 의 `[models.llm] endpoint` 를 갱신 (기본 http://127.0.0.1:11434)" ); } Ok(()) } Cmd::Ingest { root, summary_only, force_reingest, } => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; let scope = kebab_core::SourceScope { root: root .clone() .unwrap_or_else(|| PathBuf::from(&cfg.workspace.root)), exclude: cfg.workspace.exclude.clone(), ..Default::default() }; // 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 plain_env = std::env::var("KEBAB_PROGRESS") .map(|v| v.eq_ignore_ascii_case("plain")) .unwrap_or(false); let mode = progress::ProgressMode::from_flags(cli.json, cli.quiet, plain_env); // Surface the active embedding backend/device on the terminal so the // user sees it without grepping kb.log (the per-device tracing line // only lands in the log file at --verbose). Suppressed under // --json/--quiet. The Metal note reflects the build (`embed_metal`); // the confirmed runtime device is in kb.log (`candle device = ...`). if !cli.json && !cli.quiet { let backend = match cfg.models.embedding.provider.as_str() { "candle" if cfg!(feature = "embed_metal") => "candle (Metal/GPU 빌드)", "candle" => "candle (CPU, 순수 Rust)", "fastembed" | "onnx" | "" => "fastembed (onnxruntime)", "none" => "비활성 (lexical-only)", other => other, }; eprintln!("임베딩 백엔드: {backend} · 모델 {} ({}-dim)", cfg.models.embedding.model, cfg.models.embedding.dimensions); } let (tx, rx) = std::sync::mpsc::channel::(); let display_handle = std::thread::spawn(move || progress::ProgressDisplay::new(mode).run(rx)); // p9-fb-04: register a Ctrl-C handler that flips the same // AtomicBool the facade polls at each step boundary. The // *second* Ctrl-C is a hard exit (handled inside `cancel`). let cancel_token = cancel::install_sigint_cancel()?; // p9-fb-23: use IngestOpts so force_reingest threads through // without churning the positional-arg list. let ingest_result = kebab_app::ingest_with_config_opts( cfg, scope, *summary_only, kebab_app::IngestOpts { progress: Some(tx), cancel: Some(cancel_token), force_reingest: *force_reingest, }, ); // Join the display thread *before* surfacing the ingest // outcome so the spinner / final newline is flushed // regardless of whether ingest returned Ok or Err. // join() returns Result, Box>; // we discard both — display thread errors / panics are // best-effort and must not change ingest's exit code. let _ = display_handle.join(); let report = ingest_result?; if cli.json { println!("{}", serde_json::to_string(&wire::wire_ingest(&report))?); } else { let skipped_breakdown = kebab_app::render_skipped_breakdown(&report.skipped_by_extension); let purged_suffix = if report.purged_deleted_files > 0 { format!(" purged {}", report.purged_deleted_files) } else { String::new() }; println!( "scanned {} new {} updated {} skipped {}{} errors {}{} ({} ms)", report.scanned, report.new, report.updated, report.skipped, skipped_breakdown, report.errors, purged_suffix, report.duration_ms ); } Ok(()) } Cmd::List { what } => match what { ListWhat::Docs => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; let docs = kebab_app::list_docs_with_config(cfg, kebab_core::DocFilter::default())?; if cli.json { println!( "{}", serde_json::to_string(&wire::wire_doc_summaries(&docs))? ); } else { for d in &docs { println!("{}", wire::format_doc_row(d)); } } Ok(()) } }, Cmd::Inspect { what } => match what { InspectWhat::Doc { id } => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; let doc_id: kebab_core::DocumentId = id.parse()?; let doc = kebab_app::inspect_doc_with_config(cfg, &doc_id)?; // Inspect doc emits a `CanonicalDocument` — there's no §2 // wire schema for it (P1-5 will decide whether this also // becomes a tagged wrapper or stays as the raw domain // object). Until then keep raw JSON, matching pre-P0-1 // behaviour. println!("{}", serde_json::to_string(&doc)?); Ok(()) } InspectWhat::Chunk { id } => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; let chunk_id: kebab_core::ChunkId = id.parse()?; let chunk = kebab_app::inspect_chunk_with_config(cfg, &chunk_id)?; println!( "{}", serde_json::to_string(&wire::wire_chunk_inspection(&chunk))? ); Ok(()) } InspectWhat::OcrStats => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; let app = kebab_app::App::open_with_config(cfg.clone())?; let stats = app.inspect_ocr_stats_with_config(&cfg)?; println!("{}", serde_json::to_string(&stats)?); Ok(()) } InspectWhat::OcrFailures { doc_id, limit } => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; let app = kebab_app::App::open_with_config(cfg.clone())?; let failures = app.inspect_ocr_failures_with_config(&cfg, doc_id.as_deref(), *limit)?; println!("{}", serde_json::to_string(&failures)?); Ok(()) } }, Cmd::Fetch { what } => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; let (query, opts) = match what { FetchWhat::Chunk { id, context } => ( kebab_core::FetchQuery::Chunk(kebab_core::ChunkId(id.clone())), kebab_core::FetchOpts { context: *context, max_tokens: None, }, ), FetchWhat::Doc { id, max_tokens } => ( kebab_core::FetchQuery::Doc(kebab_core::DocumentId(id.clone())), kebab_core::FetchOpts { context: None, max_tokens: *max_tokens, }, ), FetchWhat::Span { doc_id, line_start, line_end, max_tokens, } => ( kebab_core::FetchQuery::Span { doc_id: kebab_core::DocumentId(doc_id.clone()), line_start: *line_start, line_end: *line_end, }, kebab_core::FetchOpts { context: None, max_tokens: *max_tokens, }, ), }; let result = kebab_app::fetch_with_config(cfg, query, opts)?; if cli.json { println!( "{}", serde_json::to_string(&wire::wire_fetch_result(&result))? ); } else { render_fetch_plain(&result); } Ok(()) } Cmd::Search { query, k, mode, explain: _, no_cache, max_tokens, snippet_chars, cursor, tag, lang, path_glob, trust_min, media, ingested_after, doc_id, repo, code_lang, trace, bulk, } => { // p9-fb-42: bulk mode — stdin ndjson → bulk_search_with_config // → stdout ndjson per query + stderr summary. Single-query // flags are ignored (each item supplies its own). if *bulk { use std::io::{BufRead, Write}; let cfg = kebab_config::Config::load(cli.config.as_deref())?; let stdin = std::io::stdin(); let stdin_locked = stdin.lock(); let mut raw_items: Vec = Vec::new(); for (lineno, line) in stdin_locked.lines().enumerate() { let line = line?; if line.trim().is_empty() { continue; } let v: serde_json::Value = serde_json::from_str(&line).map_err(|e| { anyhow::Error::new(kebab_app::StructuredError(kebab_app::ErrorV1 { schema_version: kebab_app::ERROR_V1_ID.to_string(), code: "config_invalid".to_string(), message: format!("stdin ndjson line {} parse error: {e}", lineno + 1), details: serde_json::Value::Null, hint: Some( "each line must be a JSON object with at least `query`".to_string(), ), })) })?; raw_items.push(v); } let (items, summary) = kebab_app::bulk_search_with_config(cfg, raw_items)?; if cli.json { let mut stdout = std::io::stdout().lock(); for item in &items { let v = wire::wire_bulk_search_item(item); writeln!(stdout, "{}", serde_json::to_string(&v)?)?; } eprintln!( "bulk_summary: total={} succeeded={} failed={}", summary.total, summary.succeeded, summary.failed, ); } else { let mut stdout = std::io::stdout().lock(); for (idx, item) in items.iter().enumerate() { writeln!( stdout, "# Query {}: {}", idx + 1, serde_json::to_string(&item.query)?, )?; if let Some(err) = &item.error { writeln!(stdout, "error: {err}")?; } else if let Some(resp) = &item.response { writeln!(stdout, "{}", serde_json::to_string_pretty(resp)?)?; } writeln!(stdout)?; } eprintln!( "bulk_summary: total={} succeeded={} failed={}", summary.total, summary.succeeded, summary.failed, ); } return Ok(()); } let cfg = kebab_config::Config::load(cli.config.as_deref())?; // p9-fb-42: bulk mode requires no query; single-query mode requires query. let query_text = match query.as_ref() { Some(q) if q.trim().is_empty() => { return Err(anyhow::Error::new(kebab_app::StructuredError( kebab_app::ErrorV1 { schema_version: kebab_app::ERROR_V1_ID.to_string(), code: "invalid_input".to_string(), message: "query is empty; provide a non-empty search term or use --bulk".into(), details: serde_json::Value::Null, hint: Some("e.g. `kebab search 'rust async'` or `kebab search --bulk < queries.ndjson`".into()), }, ))); } Some(q) => q.clone(), None => { return Err(anyhow::anyhow!("query is required unless --bulk is set")); } }; // p9-fb-36: normalize --media aliases (md → markdown). fn normalize_media_alias(s: &str) -> String { match s.to_ascii_lowercase().as_str() { "md" => "markdown".to_string(), other => other.to_string(), } } let media_norm: Vec = media.iter().map(|s| normalize_media_alias(s)).collect(); // p9-fb-36: parse --ingested-after as RFC3339; structured error on failure. let ingested_after_parsed: Option = match ingested_after.as_deref() { Some(s) => { match time::OffsetDateTime::parse( s, &time::format_description::well_known::Rfc3339, ) { Ok(ts) => Some(ts), Err(e) => { return Err(anyhow::Error::new(kebab_app::StructuredError( kebab_app::ErrorV1 { schema_version: kebab_app::ERROR_V1_ID.to_string(), code: "config_invalid".to_string(), message: format!( "--ingested-after: invalid RFC3339 timestamp '{s}': {e}" ), details: serde_json::Value::Null, hint: Some( "expected format like 2026-04-01T00:00:00Z".to_string(), ), }, ))); } } } None => None, }; // p9-fb-36 + p10-1A-1: build SearchFilters from CLI flags. let filters = kebab_core::SearchFilters { tags_any: tag.clone(), lang: lang.as_ref().map(|s| kebab_core::Lang(s.clone())), path_glob: path_glob.clone(), trust_min: trust_min.clone().map(Into::into), media: media_norm, ingested_after: ingested_after_parsed, doc_id: doc_id.as_ref().map(|s| kebab_core::DocumentId(s.clone())), repo: repo.clone(), code_lang: code_lang.clone(), }; let q = kebab_core::SearchQuery { text: query_text, mode: (*mode).into(), k: *k, filters, }; let opts = kebab_core::SearchOpts { max_tokens: *max_tokens, snippet_chars: *snippet_chars, cursor: cursor.clone(), trace: *trace, }; // p9-fb-34: budget-aware path. --no-cache still bypasses the // App-level LRU; wire wrapper applies regardless. let app = kebab_app::App::open_with_config(cfg)?; if *no_cache { app.clear_search_cache(); } let resp = app.search_with_opts(q, opts)?; if cli.json { println!( "{}", serde_json::to_string(&wire::wire_search_response(&resp))? ); } else { // p9-fb-32: prefix `[stale]` on the doc_path for hits // whose `stale: true`. Yellow on TTY, plain otherwise — // mirrors the warning convention used by the progress // renderer (`progress.rs`). Detection uses stdlib // `IsTerminal` against stdout (the surface this print // lands on); no new dep. use std::io::IsTerminal; let color = std::io::stdout().is_terminal(); for h in &resp.hits { // Show 4-digit score so RRF fused scores (bounded // ~0–0.033 for k_rrf=60) don't all collapse to "0.02". // Append heading_path so multiple chunks from the same // document are distinguishable on a single line. let heading = if h.heading_path.is_empty() { String::new() } else { format!(" > {}", h.heading_path.join(" / ")) }; let stale_tag = if h.stale { if color { "\x1b[33m[stale]\x1b[0m " } else { "[stale] " } } else { "" }; println!( "{:>2}. {:.4} {}{}{}", h.rank, h.retrieval.fusion_score, stale_tag, h.doc_path.0, heading, ); } // p9-fb-34: truncation hint goes to stderr so it // doesn't pollute the stdout hit list. if resp.truncated { let next = resp.next_cursor.as_deref().unwrap_or("(none)"); eprintln!("[truncated; use --cursor {next} for the next page]"); } // v0.17.0 A5 Step 4: short-query advisory. `resp.hint` // is `Some` only when the result list is empty and the // trimmed query is shorter than the trigram tokenizer // can resolve (raw FTS5 mode opts out). stderr so it // doesn't pollute the stdout hit list. `--json` skips // this branch entirely; the field rides the wire. if let Some(hint) = &resp.hint { eprintln!("[hint] {hint}"); } if *trace { if let Some(t) = &resp.trace { eprintln!(); eprintln!("Trace:"); eprintln!( " lexical ({} hits, {}ms):", t.lexical.len(), t.timing.lexical_ms ); for c in t.lexical.iter().take(3) { eprintln!( " rank={} score={:.4} chunk={}", c.rank, c.score, c.chunk_id.0 ); } eprintln!( " vector ({} hits, {}ms):", t.vector.len(), t.timing.vector_ms ); for c in t.vector.iter().take(3) { eprintln!( " rank={} score={:.4} chunk={}", c.rank, c.score, c.chunk_id.0 ); } eprintln!( " fusion ({} inputs, {}ms)", t.rrf_inputs.len(), t.timing.fusion_ms ); eprintln!(" total: {}ms", t.timing.total_ms); } } } Ok(()) } Cmd::Ask { query, k, mode, explain, temperature, seed, show_citations, hide_citations, session, stream, multi_hop, } => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; if query.trim().is_empty() { return Err(anyhow::Error::new(kebab_app::StructuredError( kebab_app::ErrorV1 { schema_version: kebab_app::ERROR_V1_ID.to_string(), code: "invalid_input".to_string(), message: "query is empty; provide a non-empty prompt".into(), details: serde_json::Value::Null, hint: Some("e.g. `kebab ask \"explain this code\"`".into()), }, ))); } if *stream { // p9-fb-33: streaming branch. Background thread runs // ask_with_config (which calls into the rag pipeline); // main thread drains the receiver and writes // `answer_event.v1` ndjson to stderr. On BrokenPipe // (downstream consumer closed), drop the receiver so // the worker's next `send` returns SendError → // pipeline cancels with LlmStreamAborted. Final stdout // line is the existing `answer.v1` (mirrors // ingest_progress.v1 + ingest_report.v1 split). use std::io::Write; use std::sync::mpsc; let (tx, rx) = mpsc::channel::(); let opts = kebab_app::AskOpts { k: *k, explain: *explain, mode: (*mode).into(), temperature: *temperature, seed: *seed, stream_sink: Some(tx), history: Vec::new(), conversation_id: None, turn_index: None, multi_hop: *multi_hop, }; let cfg2 = cfg.clone(); let q = query.clone(); let session2 = session.clone(); let handle = std::thread::spawn(move || -> anyhow::Result { match session2.as_deref() { Some(sid) => kebab_app::ask_with_session_with_config(cfg2, sid, &q, opts), None => kebab_app::ask_with_config(cfg2, &q, opts), } }); // Drain receiver, write ndjson to stderr until // completion or BrokenPipe. let mut cancelled_pipe = false; { let mut stderr = std::io::stderr().lock(); for ev in &rx { let now = time::OffsetDateTime::now_utc(); let v = wire::wire_answer_event(&ev, now); let line = serde_json::to_string(&v)?; if let Err(e) = writeln!(stderr, "{line}") { if e.kind() == std::io::ErrorKind::BrokenPipe { cancelled_pipe = true; break; } return Err(e.into()); } } } if cancelled_pipe { // Dropping the receiver signals to the worker — // the next `send` returns SendError, which the // pipeline interprets as a cancel. drop(rx); } let result = handle .join() .map_err(|_| anyhow::anyhow!("ask worker panicked"))?; let ans = result?; // Final stdout line — answer.v1 for backwards // compat. BrokenPipe on stdout is silent (caller // already gone). let final_json = serde_json::to_string(&wire::wire_answer(&ans))?; let _ = writeln!(std::io::stdout().lock(), "{final_json}"); if !ans.grounded { return Err(RefusalSignal.into()); } Ok(()) } else { let opts = kebab_app::AskOpts { k: *k, explain: *explain, mode: (*mode).into(), temperature: *temperature, seed: *seed, // CLI ask is non-streaming by default (the answer // prints all at once on completion). `--stream` // takes the branch above; the TUI ask pane (P9-3) // wires up its own `mpsc::Sender`. stream_sink: None, // p9-fb-18: when `--session` is set, the facade // (`ask_with_session_with_config`) loads prior turns // from SQLite and stuffs them into AskOpts.history // before calling `ask_with_history`. Single-shot path // (no `--session`) keeps the empty defaults. history: Vec::new(), conversation_id: None, turn_index: None, multi_hop: *multi_hop, }; let ans = match session.as_deref() { Some(sid) => kebab_app::ask_with_session_with_config(cfg, sid, query, opts)?, None => kebab_app::ask_with_config(cfg, query, opts)?, }; if cli.json { println!("{}", serde_json::to_string(&wire::wire_answer(&ans))?); } else { println!("{}", ans.answer); // p9-fb-20: print the citation block after the // answer body when --hide-citations is not set // (--show-citations is the default). Skipped on // refusal-with-zero-citations to avoid an empty // `근거:` header. let print_citations = *show_citations && !*hide_citations; if print_citations && !ans.citations.is_empty() { // p9-fb-32: yellow `[stale]` prefix on TTY (mirrors // the search renderer's pattern in `Cmd::Search`). use std::io::IsTerminal; let color = std::io::stdout().is_terminal(); let mut out = std::io::stdout().lock(); render_ask_plain_citations(&mut out, &ans, color)?; } } // Refusal → exit 1. if !ans.grounded { return Err(RefusalSignal.into()); } Ok(()) } } Cmd::Reset { all, data_only: _, vector_only, config_only, orphans_only, yes, } => { use kebab_app::ResetScope; // `--data-only` explicit OR no scope flag at all → DataOnly. // The `data_only: _` binding above is intentional — clap's // `group = "reset_scope"` already enforces mutual exclusion, // so the flag's presence does not change the resolved scope. let scope = if *all { ResetScope::All } else if *vector_only { ResetScope::VectorOnly } else if *config_only { ResetScope::ConfigOnly } else if *orphans_only { ResetScope::OrphansOnly } else { ResetScope::DataOnly }; let cfg = kebab_config::Config::load(cli.config.as_deref())?; if matches!(scope, ResetScope::OrphansOnly) { // OrphansOnly: confirm UI shows orphan count + sample paths // rather than on-disk directory sizes. let orphan_paths = kebab_app::enumerate_orphans(&cfg)?; if !*yes { use std::io::IsTerminal; if !std::io::stdin().is_terminal() { anyhow::bail!( "reset --orphans-only is destructive and stdin is non-interactive — pass --yes to proceed" ); } if !confirm_orphans_only(&orphan_paths)? { if !cli.quiet { eprintln!("aborted."); } return Ok(()); } } let report = kebab_app::reset::execute(scope, &cfg)?; if cli.json { println!("{}", serde_json::to_string(&wire::wire_reset(&report))?); } else if report.orphans_purged > 0 { println!("orphans purged: {}", report.orphans_purged); for p in &report.purged_paths { println!(" - {}", p.0); } } else { println!("no orphaned docs found — store is already in sync with walker scope"); } return Ok(()); } let paths = kebab_app::reset::enumerate_paths(scope, &cfg); let bytes = kebab_app::reset::estimate_size_bytes(&paths); if !*yes { use std::io::IsTerminal; if !std::io::stdin().is_terminal() { anyhow::bail!( "reset is destructive and stdin is non-interactive — pass --yes to proceed" ); } if !confirm_destructive(scope, &paths, bytes)? { if !cli.quiet { eprintln!("aborted."); } return Ok(()); } } let report = kebab_app::reset::execute(scope, &cfg)?; if cli.json { println!("{}", serde_json::to_string(&wire::wire_reset(&report))?); } else { println!( "removed {} path(s); embedding_rows_truncated={}", report.removed_paths.len(), report.embedding_rows_truncated ); for p in &report.removed_paths { println!(" - {}", p.display()); } if matches!(scope, ResetScope::All | ResetScope::ConfigOnly) { println!("hint: run `kebab init` to recreate config.toml"); } } Ok(()) } Cmd::Schema => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; let report = kebab_app::schema_with_config(&cfg)?; if cli.json { let v = wire::wire_schema(&report); println!("{}", serde_json::to_string(&v)?); } else { print_schema_text(&report); } Ok(()) } Cmd::Config { what } => match what { ConfigWhat::Migrate { dry_run } => { let report = kebab_app::config_migrate_with_config_path(cli.config.as_deref(), *dry_run)?; if cli.json { println!( "{}", serde_json::to_string(&wire::wire_config_migration(&report))? ); } else if !report.changed { println!( "config 이미 최신입니다 (schema v{}).", report.to_schema_version ); } else { let verb = if report.dry_run { "변경 예정" } else { "적용됨" }; println!( "config 마이그레이션 {verb}: v{} → v{} ({} changes)", report.from_schema_version, report.to_schema_version, report.changes.len() ); for c in &report.changes { println!(" - [{:?}] {} — {}", c.kind, c.path, c.detail); } if let Some(bak) = &report.backup_path { println!("백업: {bak}"); } if report.dry_run { println!("(--dry-run: 파일 미수정. 적용하려면 --dry-run 없이 재실행)"); } } Ok(()) } }, Cmd::Doctor => { let report = kebab_app::doctor_with_config_path(cli.config.as_deref())?; if cli.json { println!("{}", serde_json::to_string(&wire::wire_doctor(&report))?); } else { for c in &report.checks { let mark = if c.ok { "✓" } else { "✗" }; println!("{mark} {:<20} {}", c.name, c.detail); if let (false, Some(hint)) = (c.ok, c.hint.as_ref()) { println!(" hint: {hint}"); } } if !report.ok { println!(); let failed = report.checks.iter().filter(|c| !c.ok).count(); println!("{failed} check(s) failed."); } } if !report.ok { return Err(DoctorUnhealthy.into()); } Ok(()) } Cmd::Tui => { // P9-1: Ratatui shell with Library pane. Search / Ask / // Inspect panes land in p9-2 / p9-3 / p9-4. let config = match cli.config.as_deref() { Some(path) => kebab_config::Config::load(Some(path))?, None => kebab_config::Config::load(None)?, }; let mut app = kebab_tui::App::new(config)?; app.run() } Cmd::Eval { what } => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; match what { EvalWhat::Run { suite, mode, k, with_rag, temperature, seed, } => { let opts = kebab_eval::EvalRunOpts { suite: suite.clone(), mode: (*mode).into(), with_rag: *with_rag, k: *k, temperature: *temperature, seed: *seed, }; let run = kebab_eval::run_eval_with_config(&cfg, &opts)?; if cli.json { println!("{}", serde_json::to_string_pretty(&run)?); } else { println!("run_id: {}", run.run_id); println!("queries: {}", run.per_query.len()); let failed = run.per_query.iter().filter(|q| q.error.is_some()).count(); println!("failed: {failed}"); } Ok(()) } EvalWhat::Aggregate { run_id } => { let agg = kebab_eval::compute_aggregate_with_config(&cfg, run_id)?; kebab_eval::store_aggregate_with_config(&cfg, run_id, &agg)?; if cli.json { println!("{}", serde_json::to_string_pretty(&agg)?); } else { println!("run_id: {run_id}"); println!( "queries: {} ({} failed)", agg.total_queries, agg.failed_queries ); println!( "hit@1: {:.4}", agg.hit_at_k.get(&1).copied().unwrap_or(0.0) ); println!( "hit@5: {:.4}", agg.hit_at_k.get(&5).copied().unwrap_or(0.0) ); println!("MRR: {:.4}", agg.mrr); } Ok(()) } EvalWhat::Variants { run_id, json } => { let rep = kebab_eval::compute_variant_consistency_with_config(&cfg, run_id)?; if *json { println!("{}", serde_json::to_string_pretty(&rep)?); } else { print!("{}", kebab_eval::render_variants_md(&rep)); } Ok(()) } EvalWhat::Compare { run_a, run_b, strict_chunker_version, write_report, } => { let opts = kebab_eval::CompareOpts { strict_chunker_version: *strict_chunker_version, }; let report = kebab_eval::compare_runs_with_config(&cfg, run_a, run_b, &opts)?; let md = kebab_eval::render_report_md(&report); if cli.json { println!("{}", serde_json::to_string_pretty(&report)?); } else { print!("{md}"); } if *write_report { let resolved_data_dir = kebab_config::expand_path(&cfg.storage.data_dir, ""); let runs_dir = kebab_config::expand_path( &cfg.storage.runs_dir, &resolved_data_dir.to_string_lossy(), ); let dir = runs_dir.join(run_b); std::fs::create_dir_all(&dir)?; let path = dir.join("report.md"); std::fs::write(&path, &md)?; if !cli.json { eprintln!("wrote {}", path.display()); } } Ok(()) } } } Cmd::IngestFile { path } => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; let report = kebab_app::ingest_file_with_config(cfg, path)?; if cli.json { let v = wire::wire_ingest(&report); println!("{}", serde_json::to_string(&v)?); } else { println!( "ingest-file: scanned={} new={} updated={} unchanged={} skipped={} errors={}", report.scanned, report.new, report.updated, report.unchanged, report.skipped, report.errors ); } Ok(()) } Cmd::IngestStdin { title, source_uri } => { use std::io::Read; let mut body = String::new(); std::io::stdin() .read_to_string(&mut body) .context("kebab ingest-stdin: read stdin")?; let cfg = kebab_config::Config::load(cli.config.as_deref())?; let report = kebab_app::ingest_stdin_with_config(cfg, &body, title, source_uri.as_deref())?; if cli.json { let v = wire::wire_ingest(&report); println!("{}", serde_json::to_string(&v)?); } else { println!( "ingest-stdin: scanned={} new={} updated={} unchanged={} skipped={} errors={}", report.scanned, report.new, report.updated, report.unchanged, report.skipped, report.errors ); } Ok(()) } Cmd::Mcp => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; kebab_mcp::serve_stdio(cfg, cli.config.clone()) } } } /// p9-fb-32: render the plain (non-JSON) citation block for `kebab ask`. /// Mirrors the `Cmd::Search` plain renderer's `[stale]` convention — /// yellow ANSI on TTY, plain text otherwise. Detection uses stdlib /// `IsTerminal` at the call site; this function takes the resolved /// `color` boolean so tests can pin both branches deterministically. /// /// Skipping the empty / no-citation path is the caller's responsibility /// (matches the original inline guard at the call site). fn render_ask_plain_citations( w: &mut impl std::io::Write, ans: &kebab_core::Answer, color: bool, ) -> std::io::Result<()> { writeln!(w)?; writeln!(w, "근거:")?; for (idx, c) in ans.citations.iter().enumerate() { let marker = c.marker.clone().unwrap_or_else(|| format!("{}", idx + 1)); // p9-fb-32: `[stale]` prefix on the URI for citations whose // `stale: true`. Yellow on TTY, plain otherwise — mirrors the // search-plain renderer in `Cmd::Search`. let stale_tag = if c.stale { if color { "\x1b[33m[stale]\x1b[0m " } else { "[stale] " } } else { "" }; writeln!(w, " [{}] {}{}", marker, stale_tag, c.citation.to_uri())?; } // p9-fb-20: retrieval 메타는 citation 별 점수가 AnswerCitation 에 // 없는 (`top_score` 만 retrieval-전체 max) 한계상 한 줄로 분리. // per-citation score 노출은 facade + AnswerCitation 의 미래 확장 후. writeln!( w, "(retrieval: top_score={:.2}, k={}, used={}/{})", ans.retrieval.top_score, ans.retrieval.k, ans.retrieval.chunks_used, ans.retrieval.chunks_returned, )?; Ok(()) } fn print_schema_text(s: &kebab_app::SchemaV1) { println!("kebab v{}", s.kebab_version); println!(); println!("wire schemas"); println!(" {}", s.wire.schemas.join(", ")); println!(); println!("capabilities"); let caps = [ ("json_mode", s.capabilities.json_mode), ("ingest_progress", s.capabilities.ingest_progress), ("ingest_cancellation", s.capabilities.ingest_cancellation), ("rag_multi_turn", s.capabilities.rag_multi_turn), ("search_cache", s.capabilities.search_cache), ("incremental_ingest", s.capabilities.incremental_ingest), ("streaming_ask", s.capabilities.streaming_ask), ("http_daemon", s.capabilities.http_daemon), ("mcp_server", s.capabilities.mcp_server), ("single_file_ingest", s.capabilities.single_file_ingest), ("bulk_search", s.capabilities.bulk_search), ]; for (name, on) in caps { let mark = if on { "✓" } else { "✗" }; println!(" {mark} {name}"); } println!(); println!("models"); println!(" parser_version {}", s.models.parser_version); println!(" chunker_version {}", s.models.chunker_version); println!(" embedding_version {}", s.models.embedding_version); println!( " prompt_template_version {}", s.models.prompt_template_version ); println!(" index_version {}", s.models.index_version); println!(" corpus_revision {}", s.models.corpus_revision); println!(); println!("stats"); println!(" doc_count {}", s.stats.doc_count); println!(" chunk_count {}", s.stats.chunk_count); println!(" asset_count {}", s.stats.asset_count); let last = s.stats.last_ingest_at.as_deref().unwrap_or("(never)"); println!(" last_ingest_at {last}"); } fn is_mutating(cmd: &Cmd) -> bool { matches!( cmd, Cmd::Ingest { .. } | Cmd::IngestFile { .. } | Cmd::IngestStdin { .. } | Cmd::Reset { .. } ) } /// Minimal stdin/stdout confirm prompt for destructive ops. No new dep — /// uses stdlib `IsTerminal` (the caller is expected to have already /// short-circuited the non-TTY case). Returns `Ok(true)` only when the /// user types `y` / `Y` / `yes`. Empty input or anything else → `false` /// (safe default). fn confirm_destructive( scope: kebab_app::ResetScope, paths: &[std::path::PathBuf], bytes: u64, ) -> anyhow::Result { use std::io::Write; let mut out = std::io::stderr().lock(); writeln!(out, "kebab reset ({scope:?}): about to remove")?; for p in paths { writeln!(out, " - {}", p.display())?; } writeln!(out, "estimated total: {bytes} bytes")?; write!(out, "Proceed? [y/N] ")?; out.flush()?; let mut line = String::new(); std::io::stdin().read_line(&mut line)?; let s = line.trim().to_ascii_lowercase(); Ok(matches!(s.as_str(), "y" | "yes")) } /// Confirm prompt for `--orphans-only`: shows the orphan count + a /// sample of up to 5 paths so the user knows what will be purged before /// committing. No filesystem paths are removed — only store records. fn confirm_orphans_only(orphan_paths: &[kebab_core::WorkspacePath]) -> anyhow::Result { use std::io::Write; let n = orphan_paths.len(); let mut out = std::io::stderr().lock(); if n == 0 { writeln!(out, "no orphaned docs found — nothing to purge.")?; out.flush()?; // Nothing to do; treat as confirmed so the caller can emit the // "no orphans" report without prompting. return Ok(true); } let sample: Vec<&str> = orphan_paths.iter().take(5).map(|p| p.0.as_str()).collect(); let sample_str = sample.join(", "); let ellipsis = if n > 5 { ", …" } else { "" }; writeln!( out, "Purge {n} stored doc(s) outside the current walker scope? (no filesystem paths removed)" )?; writeln!(out, " sample: {sample_str}{ellipsis}")?; write!(out, "[y/N] ")?; out.flush()?; let mut line = String::new(); std::io::stdin().read_line(&mut line)?; let s = line.trim().to_ascii_lowercase(); Ok(matches!(s.as_str(), "y" | "yes")) } /// p9-fb-35: human-friendly plain output for `kebab fetch`. fn render_fetch_plain(r: &kebab_core::FetchResult) { println!("# {} ({})", r.doc_path.0, format_kind(r.kind)); if r.stale { println!("[stale; indexed_at = {}]", r.indexed_at); } match r.kind { kebab_core::FetchKind::Chunk => { if !r.context_before.is_empty() { println!("\n=== before ==="); for c in &r.context_before { let heading = c .heading_path .last() .map_or("", std::string::String::as_str); println!("[{} § {}]\n{}\n", c.chunk_id.0, heading, c.text); } } if let Some(c) = &r.chunk { println!("\n=== target ==="); let heading = c .heading_path .last() .map_or("", std::string::String::as_str); println!("[{} § {}]\n{}\n", c.chunk_id.0, heading, c.text); } if !r.context_after.is_empty() { println!("\n=== after ==="); for c in &r.context_after { let heading = c .heading_path .last() .map_or("", std::string::String::as_str); println!("[{} § {}]\n{}\n", c.chunk_id.0, heading, c.text); } } } kebab_core::FetchKind::Doc | kebab_core::FetchKind::Span => { if let Some(text) = &r.text { println!("\n{text}"); } if r.truncated { eprintln!("[truncated; widen --max-tokens for fuller text]"); } } } } fn format_kind(k: kebab_core::FetchKind) -> &'static str { match k { kebab_core::FetchKind::Chunk => "chunk", kebab_core::FetchKind::Doc => "doc", kebab_core::FetchKind::Span => "span", } } #[cfg(test)] mod tests { //! p9-fb-32: unit tests for `render_ask_plain_citations`. The //! integration end-to-end (`tests/wire_ask_stale.rs`) is gated on //! a real Ollama, so we cover the renderer's `[stale]` logic here //! against a synthetic `Answer` instead. use super::*; use kebab_core::{ Answer, AnswerCitation, AnswerRetrievalSummary, Citation, ModelRef, PromptTemplateVersion, SearchMode, TokenUsage, TraceId, WorkspacePath, }; use time::OffsetDateTime; fn mk_answer(citations: Vec) -> Answer { Answer { answer: "ans".into(), citations, grounded: true, refusal_reason: None, model: ModelRef { id: "test".into(), provider: "test".into(), dimensions: None, }, embedding: None, prompt_template_version: PromptTemplateVersion("rag-v2".into()), retrieval: AnswerRetrievalSummary { trace_id: TraceId("ret_test".into()), mode: SearchMode::Lexical, k: 5, score_gate: 0.30, top_score: 0.80, chunks_returned: 1, chunks_used: 1, }, usage: TokenUsage { prompt_tokens: 0, completion_tokens: 0, latency_ms: 0, }, created_at: OffsetDateTime::now_utc(), conversation_id: None, turn_index: None, hops: None, verification: None, } } fn mk_citation(path: &str, stale: bool) -> AnswerCitation { AnswerCitation { marker: Some("1".into()), citation: Citation::Line { path: WorkspacePath::new(path.into()).unwrap(), start: 1, end: 1, section: None, }, indexed_at: OffsetDateTime::now_utc(), stale, } } #[test] fn plain_marks_stale_citation_no_color() { let ans = mk_answer(vec![mk_citation("a.md", true)]); let mut buf = Vec::new(); render_ask_plain_citations(&mut buf, &ans, false).unwrap(); let out = String::from_utf8(buf).unwrap(); assert!( out.contains("[stale]"), "expected `[stale]` marker in plain output, got:\n{out}" ); // No ANSI when color = false. assert!( !out.contains("\x1b["), "unexpected ANSI escape in non-color output:\n{out}" ); } #[test] fn plain_marks_stale_citation_color_uses_yellow_ansi() { let ans = mk_answer(vec![mk_citation("a.md", true)]); let mut buf = Vec::new(); render_ask_plain_citations(&mut buf, &ans, true).unwrap(); let out = String::from_utf8(buf).unwrap(); // Yellow ANSI + reset around the `[stale]` token, mirroring the // search-plain renderer in `Cmd::Search`. assert!( out.contains("\x1b[33m[stale]\x1b[0m"), "expected yellow [stale] ANSI sequence in color output, got:\n{out:?}" ); } #[test] fn plain_no_stale_tag_for_fresh_citation() { let ans = mk_answer(vec![mk_citation("a.md", false)]); let mut buf = Vec::new(); render_ask_plain_citations(&mut buf, &ans, true).unwrap(); let out = String::from_utf8(buf).unwrap(); assert!( !out.contains("[stale]"), "unexpected `[stale]` marker for fresh citation:\n{out}" ); } }