memstead_cli/commands/
search.rs1use 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#[derive(Parser, Debug)]
14#[command(after_long_help = super::FILTER_HELP)]
15pub struct Args {
16 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 #[arg(long = "field")]
30 pub field: Option<String>,
31
32 #[arg(long = "exclude", value_name = "TOKEN")]
42 pub exclude: Vec<String>,
43
44 #[arg(long = "phrase", value_name = "TEXT")]
52 pub phrase: Option<String>,
53
54 #[arg(long)]
56 pub edge_type: Option<String>,
57
58 #[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 #[arg(long = "filter", value_name = "KEY=VALUE")]
87 pub filter: Vec<String>,
88
89 #[arg(long, conflicts_with = "no_stub")]
91 pub stub: bool,
92
93 #[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 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 #[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 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 #[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 #[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}