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}