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    /// Range filter on any `filterable: range` field: repeatable
90    /// `--range-filter KEY=VALUE` with the same key grammar as the MCP
91    /// `range_filters` map — `min_<field>` / `max_<field>` for numbers,
92    /// `<field>_after` / `<field>_before` for dates. The strings go to
93    /// the same engine path the MCP tool uses, so the same four outcome
94    /// codes apply with the same meaning: `RANGE_FILTER_KEY_MALFORMED`
95    /// (key ignored), `UNKNOWN_RANGE_FILTER_FIELD` (no type declares
96    /// the field — results stay unfiltered), `RANGE_FILTER_TYPE_SCOPED`
97    /// (other types declare it — applied with strict type-narrowing),
98    /// `FIELD_NOT_RANGE_FILTERABLE` (declared, but not `filterable:
99    /// range`). Composable with `--filter` and the named shortcuts.
100    #[arg(long = "range-filter", value_name = "KEY=VALUE")]
101    pub range_filter: Vec<String>,
102
103    /// Relationship types to follow from primary hits to pull in
104    /// graph-proximal neighbours: repeatable `--expand-via REL_TYPE`.
105    /// Mirrors the MCP `expand_via` parameter — expanded hits carry
106    /// `expansion: { of, via_edge, via_direction, depth }` and a
107    /// decayed score (0.5^depth).
108    #[arg(long = "expand-via", value_name = "REL_TYPE")]
109    pub expand_via: Vec<String>,
110
111    /// Max hops to traverse via `--expand-via` (default: 1). Mirrors
112    /// the MCP `expand_depth` parameter.
113    #[arg(long = "expand-depth", value_name = "N")]
114    pub expand_depth: Option<usize>,
115
116    /// Traversal direction for `--related-to` and `--expand-via`,
117    /// applied at EVERY hop: `out` follows edges pointing away from
118    /// the seed (what does this rest on), `in` follows edges pointing
119    /// at it (what rests on this), `both` (default) is the historical
120    /// undirected walk. Depth > 1 is a pure transitive closure in the
121    /// chosen direction — never a mixed walk.
122    #[arg(long, value_enum, default_value_t = DirectionArg::Both)]
123    pub direction: DirectionArg,
124
125    /// Return only stub entities (conflicts with --no-stub).
126    #[arg(long, conflicts_with = "no_stub")]
127    pub stub: bool,
128
129    /// Return only real (non-stub) entities (conflicts with --stub).
130    #[arg(long, conflicts_with = "stub")]
131    pub no_stub: bool,
132}
133
134/// CLI form of the traversal-direction selector. clap's `ValueEnum`
135/// refuses an unrecognised value with a typed error naming the
136/// accepted values — never a silent fallback to `both`.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
138pub enum DirectionArg {
139    Out,
140    In,
141    Both,
142}
143
144impl From<DirectionArg> for memstead_base::graph::query::TraversalDirection {
145    fn from(d: DirectionArg) -> Self {
146        match d {
147            DirectionArg::Out => Self::Out,
148            DirectionArg::In => Self::In,
149            DirectionArg::Both => Self::Both,
150        }
151    }
152}
153
154pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
155    let mut filters = HashMap::new();
156    if let Some(level) = args.level {
157        filters.insert("level".to_string(), level);
158    }
159    if let Some(status) = args.status {
160        filters.insert("status".to_string(), status);
161    }
162    for raw in &args.filter {
163        let (key, value) = super::parse_filter_arg(raw)?;
164        filters.insert(key, value);
165    }
166
167    // Range filters: the CLI only splits KEY=VALUE — the key grammar
168    // (`min_*` / `max_*` / `*_before` / `*_after`) is parsed by the
169    // same engine path the MCP `range_filters` map goes through, so
170    // the typed outcome codes are identical on both surfaces. A
171    // second grammar parser here is a defect.
172    let mut range_filters = HashMap::new();
173    for raw in &args.range_filter {
174        let (key, value) = super::parse_filter_arg(raw)?;
175        range_filters.insert(key, value);
176    }
177
178    // Wrap a positional CLI text argument into the flat Query shape. Each
179    // whitespace-separated token becomes an `any` term (OR semantics). Empty
180    // or missing `text` falls through to the metadata-only filter path.
181    // `--field` (when set) narrows the query to a single field via
182    // `Query.field`. `--exclude` (repeatable) routes each token into
183    // `Query.not` for the engine's exclude predicate. `--phrase` routes
184    // into `Query.phrase` for adjacency-sensitive matching. Either positional
185    // text or `--phrase` triggers Query construction; pure `--field` or
186    // `--exclude` without a text/phrase predicate fall through to the
187    // metadata-only filter path.
188    let any: Vec<String> = args
189        .text
190        .as_deref()
191        .map(|t| t.split_whitespace().map(|s| s.to_string()).collect())
192        .unwrap_or_default();
193    let query = if any.is_empty() && args.phrase.is_none() {
194        None
195    } else {
196        Some(Query {
197            any,
198            not: args.exclude.clone(),
199            field: args.field.clone(),
200            phrase: args.phrase.clone(),
201        })
202    };
203
204    let stub = match (args.stub, args.no_stub) {
205        (true, _) => Some(true),
206        (_, true) => Some(false),
207        _ => None,
208    };
209
210    let scope = SearchScope {
211        query,
212        mem: args.mem,
213        entity_type: args.entity_type,
214        limit: args.limit,
215        offset: args.offset,
216        filters,
217        range_filters,
218        edge_type: args.edge_type,
219        related_to: args.related_to.map(EntityId),
220        depth: args.depth,
221        expand_via: if args.expand_via.is_empty() {
222            None
223        } else {
224            Some(args.expand_via.clone())
225        },
226        expand_depth: args.expand_depth,
227        direction: args.direction.into(),
228        stub,
229        token_budget: None,
230    };
231
232    let result = match ctx.cli_engine()? {
233        #[cfg(feature = "mem-repo")]
234        CliEngine::MemRepo(engine) => {
235            if let Some(name) = scope.mem.as_deref()
236                && engine.mount(name).is_none()
237            {
238                return Err(super::list::unknown_mem_error(name, &engine).into());
239            }
240            engine.search(&scope)?
241        }
242        CliEngine::Filesystem(engine) => {
243            if let Some(name) = scope.mem.as_deref()
244                && engine.mount(name).is_none()
245            {
246                return Err(super::list::unknown_mem_error(name, &engine).into());
247            }
248            engine.search(&scope)?
249        }
250    };
251    let offset = scope.offset.unwrap_or(0);
252
253    if ctx.json {
254        let envelope = render::build_search_envelope(&result, offset);
255        print_json(&envelope)?;
256    } else {
257        print_markdown(&render::render_search_markdown(&result, offset));
258    }
259    Ok(())
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use clap::Parser;
266
267    /// There is no `--confidence` flag: the CLI parser doesn't
268    /// recognise it — agents pass `--filter confidence=<value>`
269    /// instead, which works for any schema that declares the field.
270    #[test]
271    fn search_rejects_removed_confidence_flag() {
272        let parsed = Args::try_parse_from(["search", "--confidence", "high"]);
273        assert!(
274            parsed.is_err(),
275            "--confidence must be removed from the CLI parser",
276        );
277        let err = parsed.unwrap_err();
278        // clap's "unknown argument" diagnostic shape — substrings
279        // present across clap minor versions.
280        let msg = err.to_string();
281        assert!(
282            msg.contains("--confidence") || msg.contains("unexpected"),
283            "expected clap unknown-argument diagnostic, got: {msg}",
284        );
285    }
286
287    /// The four remaining named-flag shortcuts still parse.
288    #[test]
289    fn search_accepts_remaining_named_shortcuts() {
290        let parsed = Args::try_parse_from([
291            "search",
292            "--type",
293            "spec",
294            "--level",
295            "M0",
296            "--status",
297            "active",
298            "--edge-type",
299            "USES",
300        ]);
301        assert!(
302            parsed.is_ok(),
303            "remaining shortcuts must still parse: {:?}",
304            parsed.err()
305        );
306    }
307
308    /// `--filter confidence=high` parses via the generic filter
309    /// path, which covers any schema that declares the field.
310    #[test]
311    fn search_filter_confidence_still_parses() {
312        let parsed = Args::try_parse_from(["search", "--filter", "confidence=high"]);
313        assert!(
314            parsed.is_ok(),
315            "--filter confidence=high must parse: {:?}",
316            parsed.err()
317        );
318        let args = parsed.unwrap();
319        assert!(
320            args.filter.iter().any(|f| f.contains("confidence")),
321            "filter list must carry the generic confidence pair: {:?}",
322            args.filter,
323        );
324    }
325}