Skip to main content

memstead_cli/commands/
search.rs

1use std::collections::HashMap;
2
3use clap::Parser;
4
5use memstead_base::EntityId;
6use memstead_base::ops::{Query, SearchScope};
7use memstead_base::render;
8
9use crate::output::{print_json, print_markdown};
10use crate::setup::{CliContext, CliEngine};
11
12/// Find entities by text or graph proximity.
13#[derive(Parser, Debug)]
14#[command(after_long_help = super::FILTER_HELP)]
15pub struct Args {
16    /// Free-text query. Omit for a pure structural filter.
17    pub text: Option<String>,
18
19    #[arg(long)]
20    pub mem: Option<String>,
21
22    #[arg(long = "type")]
23    pub entity_type: Option<String>,
24
25    /// Restrict text matching to a single field (title or section key).
26    /// Maps to `Query.field` — narrows `any`, `not`, and `phrase` for the
27    /// query. Replaces the former repeatable plural form, which was orphaned
28    /// at the engine level.
29    #[arg(long = "field")]
30    pub field: Option<String>,
31
32    /// Exclude entities whose text matches this token. Repeatable —
33    /// `--exclude OAuth --exclude SAML` drops every hit driven by
34    /// either. Maps to `Query.not`. When combined with `--field`, the
35    /// exclude scopes to that field via the engine's existing
36    /// `Query.field` semantics.
37    ///
38    /// Example: `memstead search auth --exclude OAuth` returns
39    /// "auth"-matching entities that are not driven by an `OAuth`
40    /// match.
41    #[arg(long = "exclude", value_name = "TOKEN")]
42    pub exclude: Vec<String>,
43
44    /// Restrict hits to entities containing this exact phrase
45    /// (adjacency-sensitive). Maps to `Query.phrase`. Composable with
46    /// `--field` (narrows the phrase match to one field) and
47    /// `--exclude` (drops phrase-matching hits that also match the
48    /// excluded token). Shell quoting is stripped before the binary
49    /// sees the positional text argument — use this flag rather than
50    /// quoting in the positional to express adjacency.
51    #[arg(long = "phrase", value_name = "TEXT")]
52    pub phrase: Option<String>,
53
54    /// Filter by edge type (e.g. USES, IMPLEMENTS).
55    #[arg(long)]
56    pub edge_type: Option<String>,
57
58    /// Only entities within `--depth` hops of this ID.
59    #[arg(long)]
60    pub related_to: Option<String>,
61
62    #[arg(long)]
63    pub depth: Option<usize>,
64
65    #[arg(long)]
66    pub limit: Option<usize>,
67
68    #[arg(long)]
69    pub offset: Option<usize>,
70
71    #[arg(long)]
72    pub level: Option<String>,
73
74    #[arg(long)]
75    pub status: Option<String>,
76
77    /// Equality filter on any schema-declared filterable field:
78    /// repeatable `--filter KEY=VALUE`. The four named-flag
79    /// shortcuts (`--type` / `--level` / `--status` / `--edge-type`)
80    /// handle their common cases; every other `filterable: equality`
81    /// field (e.g. `tags`, `scope`) is reachable via this generic
82    /// flag. Unknown keys are dropped and surface as engine
83    /// warnings. There is no `--confidence` shortcut: a field reached
84    /// only when a schema declares it goes through
85    /// `--filter <field>=<value>` rather than a dedicated flag.
86    #[arg(long = "filter", value_name = "KEY=VALUE")]
87    pub filter: Vec<String>,
88
89    /// Return only stub entities (conflicts with --no-stub).
90    #[arg(long, conflicts_with = "no_stub")]
91    pub stub: bool,
92
93    /// Return only real (non-stub) entities (conflicts with --stub).
94    #[arg(long, conflicts_with = "stub")]
95    pub no_stub: bool,
96}
97
98pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
99    let mut filters = HashMap::new();
100    if let Some(level) = args.level {
101        filters.insert("level".to_string(), level);
102    }
103    if let Some(status) = args.status {
104        filters.insert("status".to_string(), status);
105    }
106    for raw in &args.filter {
107        let (key, value) = super::parse_filter_arg(raw)?;
108        filters.insert(key, value);
109    }
110
111    // Wrap a positional CLI text argument into the flat Query shape. Each
112    // whitespace-separated token becomes an `any` term (OR semantics). Empty
113    // or missing `text` falls through to the metadata-only filter path.
114    // `--field` (when set) narrows the query to a single field via
115    // `Query.field`. `--exclude` (repeatable) routes each token into
116    // `Query.not` for the engine's exclude predicate. `--phrase` routes
117    // into `Query.phrase` for adjacency-sensitive matching. Either positional
118    // text or `--phrase` triggers Query construction; pure `--field` or
119    // `--exclude` without a text/phrase predicate fall through to the
120    // metadata-only filter path.
121    let any: Vec<String> = args
122        .text
123        .as_deref()
124        .map(|t| t.split_whitespace().map(|s| s.to_string()).collect())
125        .unwrap_or_default();
126    let query = if any.is_empty() && args.phrase.is_none() {
127        None
128    } else {
129        Some(Query {
130            any,
131            not: args.exclude.clone(),
132            field: args.field.clone(),
133            phrase: args.phrase.clone(),
134        })
135    };
136
137    let stub = match (args.stub, args.no_stub) {
138        (true, _) => Some(true),
139        (_, true) => Some(false),
140        _ => None,
141    };
142
143    let scope = SearchScope {
144        query,
145        mem: args.mem,
146        entity_type: args.entity_type,
147        limit: args.limit,
148        offset: args.offset,
149        filters,
150        range_filters: HashMap::new(),
151        edge_type: args.edge_type,
152        related_to: args.related_to.map(EntityId),
153        depth: args.depth,
154        expand_via: None,
155        expand_depth: None,
156        stub,
157        token_budget: None,
158    };
159
160    let result = match ctx.cli_engine()? {
161        #[cfg(feature = "mem-repo")]
162        CliEngine::MemRepo(engine) => {
163            if let Some(name) = scope.mem.as_deref()
164                && engine.mount(name).is_none()
165            {
166                return Err(super::list::unknown_mem_error(name, &engine).into());
167            }
168            engine.search(&scope)?
169        }
170        CliEngine::Filesystem(engine) => {
171            if let Some(name) = scope.mem.as_deref()
172                && engine.mount(name).is_none()
173            {
174                return Err(super::list::unknown_mem_error(name, &engine).into());
175            }
176            engine.search(&scope)?
177        }
178    };
179    let offset = scope.offset.unwrap_or(0);
180
181    if ctx.json {
182        let envelope = render::build_search_envelope(&result, offset);
183        print_json(&envelope)?;
184    } else {
185        print_markdown(&render::render_search_markdown(&result, offset));
186    }
187    Ok(())
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use clap::Parser;
194
195    /// There is no `--confidence` flag: the CLI parser doesn't
196    /// recognise it — agents pass `--filter confidence=<value>`
197    /// instead, which works for any schema that declares the field.
198    #[test]
199    fn search_rejects_removed_confidence_flag() {
200        let parsed = Args::try_parse_from(["search", "--confidence", "high"]);
201        assert!(
202            parsed.is_err(),
203            "--confidence must be removed from the CLI parser",
204        );
205        let err = parsed.unwrap_err();
206        // clap's "unknown argument" diagnostic shape — substrings
207        // present across clap minor versions.
208        let msg = err.to_string();
209        assert!(
210            msg.contains("--confidence") || msg.contains("unexpected"),
211            "expected clap unknown-argument diagnostic, got: {msg}",
212        );
213    }
214
215    /// The four remaining named-flag shortcuts still parse.
216    #[test]
217    fn search_accepts_remaining_named_shortcuts() {
218        let parsed = Args::try_parse_from([
219            "search",
220            "--type",
221            "spec",
222            "--level",
223            "M0",
224            "--status",
225            "active",
226            "--edge-type",
227            "USES",
228        ]);
229        assert!(
230            parsed.is_ok(),
231            "remaining shortcuts must still parse: {:?}",
232            parsed.err()
233        );
234    }
235
236    /// `--filter confidence=high` parses via the generic filter
237    /// path, which covers any schema that declares the field.
238    #[test]
239    fn search_filter_confidence_still_parses() {
240        let parsed = Args::try_parse_from(["search", "--filter", "confidence=high"]);
241        assert!(
242            parsed.is_ok(),
243            "--filter confidence=high must parse: {:?}",
244            parsed.err()
245        );
246        let args = parsed.unwrap();
247        assert!(
248            args.filter.iter().any(|f| f.contains("confidence")),
249            "filter list must carry the generic confidence pair: {:?}",
250            args.filter,
251        );
252    }
253}