Skip to main content

ai_memory/cli/
search.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_search` migration. See `cli::store` for the design pattern.
5
6use crate::cli::CliOutput;
7use crate::cli::helpers::{human_age, id_short};
8use crate::models::Tier;
9use crate::{db, validate};
10use anyhow::Result;
11use clap::Args;
12use std::path::Path;
13
14/// Clap-derived arg shape for the `search` subcommand. Definition moved
15/// from `main.rs` verbatim in W5b — fields and attrs unchanged.
16#[derive(Args)]
17pub struct SearchArgs {
18    #[arg(allow_hyphen_values = true)]
19    pub query: String,
20    #[arg(long, short)]
21    pub namespace: Option<String>,
22    #[arg(long, short)]
23    pub tier: Option<String>,
24    #[arg(long, default_value_t = 20)]
25    pub limit: usize,
26    #[arg(long)]
27    pub since: Option<String>,
28    #[arg(long)]
29    pub until: Option<String>,
30    #[arg(long)]
31    pub tags: Option<String>,
32    /// Filter by `metadata.agent_id` (exact match)
33    #[arg(long)]
34    pub agent_id: Option<String>,
35    /// Task 1.5: querying agent's namespace position for scope-based
36    /// visibility filtering.
37    #[arg(long)]
38    pub as_agent: Option<String>,
39    /// v0.7.0 WT-1-E — when set, search returns archived sources
40    /// alongside their atoms. Default `false` excludes sources whose
41    /// atoms surface in their place (atom-preference search).
42    #[arg(long)]
43    pub include_archived: bool,
44}
45
46/// `search` handler. Mirrors `cmd_search` from `main.rs` verbatim except
47/// every emit routes through `out.stdout` / `out.stderr` instead of
48/// `println!` / `eprintln!`.
49pub fn run(
50    db_path: &Path,
51    args: &SearchArgs,
52    json_out: bool,
53    out: &mut CliOutput<'_>,
54) -> Result<()> {
55    // #197: validate agent_id filter values
56    if let Some(ref aid) = args.agent_id {
57        validate::validate_agent_id(aid)?;
58    }
59    // #151: validate --as-agent namespace
60    if let Some(ref a) = args.as_agent {
61        validate::validate_namespace(a)?;
62    }
63    let conn = db::open(db_path)?;
64    let tier = args.tier.as_deref().and_then(Tier::from_str);
65    let results = db::search(
66        &conn,
67        &args.query,
68        args.namespace.as_deref(),
69        tier.as_ref(),
70        args.limit,
71        None,
72        args.since.as_deref(),
73        args.until.as_deref(),
74        args.tags.as_deref(),
75        args.agent_id.as_deref(),
76        args.as_agent.as_deref(),
77        args.include_archived,
78    )?;
79    if json_out {
80        writeln!(
81            out.stdout,
82            "{}",
83            serde_json::to_string(
84                &serde_json::json!({"results": results, "count": results.len()})
85            )?
86        )?;
87        return Ok(());
88    }
89    if results.is_empty() {
90        writeln!(out.stderr, "no results for: {}", args.query)?;
91        return Ok(());
92    }
93    for mem in &results {
94        let age = human_age(&mem.updated_at);
95        writeln!(
96            out.stdout,
97            "[{}/{}] {} (p={}, ns={}, {})",
98            mem.tier,
99            id_short(&mem.id),
100            mem.title,
101            mem.priority,
102            mem.namespace,
103            age
104        )?;
105    }
106    writeln!(out.stdout, "\n{} result(s)", results.len())?;
107    Ok(())
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::cli::test_utils::{TestEnv, seed_memory};
114
115    fn default_args() -> SearchArgs {
116        SearchArgs {
117            query: "needle".to_string(),
118            namespace: None,
119            tier: None,
120            limit: 20,
121            since: None,
122            until: None,
123            tags: None,
124            agent_id: None,
125            as_agent: None,
126            include_archived: false,
127        }
128    }
129
130    #[test]
131    fn test_search_happy_path_text() {
132        let mut env = TestEnv::fresh();
133        let db = env.db_path.clone();
134        seed_memory(&db, "test", "needle title", "haystack content");
135        let args = default_args();
136        {
137            let mut out = env.output();
138            run(&db, &args, false, &mut out).unwrap();
139        }
140        let stdout = env.stdout_str();
141        assert!(stdout.contains("needle title"), "got: {stdout}");
142        assert!(stdout.contains("result(s)"));
143    }
144
145    #[test]
146    fn test_search_happy_path_json() {
147        let mut env = TestEnv::fresh();
148        let db = env.db_path.clone();
149        seed_memory(&db, "test", "needle title", "haystack content");
150        let args = default_args();
151        {
152            let mut out = env.output();
153            run(&db, &args, true, &mut out).unwrap();
154        }
155        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
156        assert!(v["count"].as_u64().unwrap() >= 1);
157        assert!(v["results"].is_array());
158    }
159
160    #[test]
161    fn test_search_no_results() {
162        let mut env = TestEnv::fresh();
163        let db = env.db_path.clone();
164        let args = default_args();
165        {
166            let mut out = env.output();
167            run(&db, &args, false, &mut out).unwrap();
168        }
169        // Text branch: nothing on stdout, stderr carries the "no results".
170        assert_eq!(env.stdout_str(), "");
171        assert!(
172            env.stderr_str().contains("no results for: needle"),
173            "got: {}",
174            env.stderr_str()
175        );
176    }
177
178    #[test]
179    fn test_search_with_namespace_filter() {
180        let mut env = TestEnv::fresh();
181        let db = env.db_path.clone();
182        seed_memory(&db, "ns-a", "needle in a", "content a");
183        seed_memory(&db, "ns-b", "needle in b", "content b");
184        let mut args = default_args();
185        args.namespace = Some("ns-a".to_string());
186        {
187            let mut out = env.output();
188            run(&db, &args, true, &mut out).unwrap();
189        }
190        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
191        let results = v["results"].as_array().unwrap();
192        for r in results {
193            assert_eq!(r["namespace"].as_str().unwrap(), "ns-a");
194        }
195    }
196
197    #[test]
198    fn test_search_with_tier_filter() {
199        // seed_memory uses tier=mid; the "long" filter excludes everything.
200        let mut env = TestEnv::fresh();
201        let db = env.db_path.clone();
202        seed_memory(&db, "test", "needle title", "content");
203        let mut args = default_args();
204        args.tier = Some(Tier::Long.as_str().to_string());
205        {
206            let mut out = env.output();
207            run(&db, &args, true, &mut out).unwrap();
208        }
209        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
210        assert_eq!(v["count"].as_u64().unwrap(), 0);
211    }
212
213    #[test]
214    fn test_search_with_agent_id_filter() {
215        // seed_memory writes agent_id="test-agent" into metadata; passing
216        // a different agent_id excludes the row.
217        let mut env = TestEnv::fresh();
218        let db = env.db_path.clone();
219        seed_memory(&db, "test", "needle title", "content");
220        let mut args = default_args();
221        args.agent_id = Some("other-agent".to_string());
222        {
223            let mut out = env.output();
224            run(&db, &args, true, &mut out).unwrap();
225        }
226        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
227        assert_eq!(v["count"].as_u64().unwrap(), 0);
228
229        // And the affirmative case: matching agent_id returns the row.
230        let mut env2 = TestEnv::fresh();
231        let db2 = env2.db_path.clone();
232        seed_memory(&db2, "test", "needle title", "content");
233        let mut args2 = default_args();
234        args2.agent_id = Some("test-agent".to_string());
235        {
236            let mut out = env2.output();
237            run(&db2, &args2, true, &mut out).unwrap();
238        }
239        let v2: serde_json::Value = serde_json::from_str(env2.stdout_str().trim()).unwrap();
240        assert!(v2["count"].as_u64().unwrap() >= 1);
241    }
242
243    #[test]
244    fn test_search_invalid_agent_id_validation_error() {
245        let mut env = TestEnv::fresh();
246        let db = env.db_path.clone();
247        let mut args = default_args();
248        // Empty agent_id is rejected by validate_agent_id.
249        args.agent_id = Some(String::new());
250        let mut out = env.output();
251        let res = run(&db, &args, false, &mut out);
252        assert!(res.is_err(), "expected validate_agent_id to reject empty");
253    }
254
255    #[test]
256    fn test_search_invalid_as_agent_namespace_validation_error() {
257        let mut env = TestEnv::fresh();
258        let db = env.db_path.clone();
259        let mut args = default_args();
260        args.as_agent = Some(String::new());
261        let mut out = env.output();
262        let res = run(&db, &args, false, &mut out);
263        assert!(res.is_err(), "expected validate_namespace to reject empty");
264    }
265}