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}
40
41/// `search` handler. Mirrors `cmd_search` from `main.rs` verbatim except
42/// every emit routes through `out.stdout` / `out.stderr` instead of
43/// `println!` / `eprintln!`.
44pub fn run(
45    db_path: &Path,
46    args: &SearchArgs,
47    json_out: bool,
48    out: &mut CliOutput<'_>,
49) -> Result<()> {
50    // #197: validate agent_id filter values
51    if let Some(ref aid) = args.agent_id {
52        validate::validate_agent_id(aid)?;
53    }
54    // #151: validate --as-agent namespace
55    if let Some(ref a) = args.as_agent {
56        validate::validate_namespace(a)?;
57    }
58    let conn = db::open(db_path)?;
59    let tier = args.tier.as_deref().and_then(Tier::from_str);
60    let results = db::search(
61        &conn,
62        &args.query,
63        args.namespace.as_deref(),
64        tier.as_ref(),
65        args.limit,
66        None,
67        args.since.as_deref(),
68        args.until.as_deref(),
69        args.tags.as_deref(),
70        args.agent_id.as_deref(),
71        args.as_agent.as_deref(),
72    )?;
73    if json_out {
74        writeln!(
75            out.stdout,
76            "{}",
77            serde_json::to_string(
78                &serde_json::json!({"results": results, "count": results.len()})
79            )?
80        )?;
81        return Ok(());
82    }
83    if results.is_empty() {
84        writeln!(out.stderr, "no results for: {}", args.query)?;
85        return Ok(());
86    }
87    for mem in &results {
88        let age = human_age(&mem.updated_at);
89        writeln!(
90            out.stdout,
91            "[{}/{}] {} (p={}, ns={}, {})",
92            mem.tier,
93            id_short(&mem.id),
94            mem.title,
95            mem.priority,
96            mem.namespace,
97            age
98        )?;
99    }
100    writeln!(out.stdout, "\n{} result(s)", results.len())?;
101    Ok(())
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::cli::test_utils::{TestEnv, seed_memory};
108
109    fn default_args() -> SearchArgs {
110        SearchArgs {
111            query: "needle".to_string(),
112            namespace: None,
113            tier: None,
114            limit: 20,
115            since: None,
116            until: None,
117            tags: None,
118            agent_id: None,
119            as_agent: None,
120        }
121    }
122
123    #[test]
124    fn test_search_happy_path_text() {
125        let mut env = TestEnv::fresh();
126        let db = env.db_path.clone();
127        seed_memory(&db, "test", "needle title", "haystack content");
128        let args = default_args();
129        {
130            let mut out = env.output();
131            run(&db, &args, false, &mut out).unwrap();
132        }
133        let stdout = env.stdout_str();
134        assert!(stdout.contains("needle title"), "got: {stdout}");
135        assert!(stdout.contains("result(s)"));
136    }
137
138    #[test]
139    fn test_search_happy_path_json() {
140        let mut env = TestEnv::fresh();
141        let db = env.db_path.clone();
142        seed_memory(&db, "test", "needle title", "haystack content");
143        let args = default_args();
144        {
145            let mut out = env.output();
146            run(&db, &args, true, &mut out).unwrap();
147        }
148        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
149        assert!(v["count"].as_u64().unwrap() >= 1);
150        assert!(v["results"].is_array());
151    }
152
153    #[test]
154    fn test_search_no_results() {
155        let mut env = TestEnv::fresh();
156        let db = env.db_path.clone();
157        let args = default_args();
158        {
159            let mut out = env.output();
160            run(&db, &args, false, &mut out).unwrap();
161        }
162        // Text branch: nothing on stdout, stderr carries the "no results".
163        assert_eq!(env.stdout_str(), "");
164        assert!(
165            env.stderr_str().contains("no results for: needle"),
166            "got: {}",
167            env.stderr_str()
168        );
169    }
170
171    #[test]
172    fn test_search_with_namespace_filter() {
173        let mut env = TestEnv::fresh();
174        let db = env.db_path.clone();
175        seed_memory(&db, "ns-a", "needle in a", "content a");
176        seed_memory(&db, "ns-b", "needle in b", "content b");
177        let mut args = default_args();
178        args.namespace = Some("ns-a".to_string());
179        {
180            let mut out = env.output();
181            run(&db, &args, true, &mut out).unwrap();
182        }
183        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
184        let results = v["results"].as_array().unwrap();
185        for r in results {
186            assert_eq!(r["namespace"].as_str().unwrap(), "ns-a");
187        }
188    }
189
190    #[test]
191    fn test_search_with_tier_filter() {
192        // seed_memory uses tier=mid; the "long" filter excludes everything.
193        let mut env = TestEnv::fresh();
194        let db = env.db_path.clone();
195        seed_memory(&db, "test", "needle title", "content");
196        let mut args = default_args();
197        args.tier = Some("long".to_string());
198        {
199            let mut out = env.output();
200            run(&db, &args, true, &mut out).unwrap();
201        }
202        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
203        assert_eq!(v["count"].as_u64().unwrap(), 0);
204    }
205
206    #[test]
207    fn test_search_with_agent_id_filter() {
208        // seed_memory writes agent_id="test-agent" into metadata; passing
209        // a different agent_id excludes the row.
210        let mut env = TestEnv::fresh();
211        let db = env.db_path.clone();
212        seed_memory(&db, "test", "needle title", "content");
213        let mut args = default_args();
214        args.agent_id = Some("other-agent".to_string());
215        {
216            let mut out = env.output();
217            run(&db, &args, true, &mut out).unwrap();
218        }
219        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
220        assert_eq!(v["count"].as_u64().unwrap(), 0);
221
222        // And the affirmative case: matching agent_id returns the row.
223        let mut env2 = TestEnv::fresh();
224        let db2 = env2.db_path.clone();
225        seed_memory(&db2, "test", "needle title", "content");
226        let mut args2 = default_args();
227        args2.agent_id = Some("test-agent".to_string());
228        {
229            let mut out = env2.output();
230            run(&db2, &args2, true, &mut out).unwrap();
231        }
232        let v2: serde_json::Value = serde_json::from_str(env2.stdout_str().trim()).unwrap();
233        assert!(v2["count"].as_u64().unwrap() >= 1);
234    }
235
236    #[test]
237    fn test_search_invalid_agent_id_validation_error() {
238        let mut env = TestEnv::fresh();
239        let db = env.db_path.clone();
240        let mut args = default_args();
241        // Empty agent_id is rejected by validate_agent_id.
242        args.agent_id = Some(String::new());
243        let mut out = env.output();
244        let res = run(&db, &args, false, &mut out);
245        assert!(res.is_err(), "expected validate_agent_id to reject empty");
246    }
247
248    #[test]
249    fn test_search_invalid_as_agent_namespace_validation_error() {
250        let mut env = TestEnv::fresh();
251        let db = env.db_path.clone();
252        let mut args = default_args();
253        args.as_agent = Some(String::new());
254        let mut out = env.output();
255        let res = run(&db, &args, false, &mut out);
256        assert!(res.is_err(), "expected validate_namespace to reject empty");
257    }
258}