From 6a18847892c193c0122329d7d0ab8403c686c4de Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Sun, 10 May 2026 03:57:55 +0900 Subject: [PATCH] feat(cli): kebab search filter flags (fb-36) 7 new flags: --tag (repeatable), --lang, --path-glob, --trust-min (value_enum), --media (csv with `md` alias), --ingested-after (RFC3339; config_invalid on parse fail), --doc-id. Dispatch translates clap values into SearchFilters and propagates structured errors through the existing StructuredError wrapper from fb-34. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-cli/src/main.rs | 111 ++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs index c92ba27..7e41d8a 100644 --- a/crates/kebab-cli/src/main.rs +++ b/crates/kebab-cli/src/main.rs @@ -131,6 +131,38 @@ enum Cmd { /// `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`, `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, }, /// Retrieval-augmented question answering. @@ -351,6 +383,25 @@ impl From for kebab_core::SearchMode { } } +/// 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 { @@ -611,13 +662,71 @@ fn run(cli: &Cli) -> anyhow::Result<()> { max_tokens, snippet_chars, cursor, + tag, + lang, + path_glob, + trust_min, + media, + ingested_after, + doc_id, } => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; + + // 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: build SearchFilters from the 7 new 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())), + }; + let q = kebab_core::SearchQuery { text: query.clone(), mode: (*mode).into(), k: *k, - filters: kebab_core::SearchFilters::default(), + filters, }; let opts = kebab_core::SearchOpts { max_tokens: *max_tokens,