Skip to main content

ai_memory/cli/commands/
find_paths.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 ARCH-3 / FX-12 — `ai-memory find-paths` CLI subcommand.
5//!
6//! Closes the three-surface-parity gap on `memory_find_paths`. The
7//! MCP tool ([`crate::mcp::handle_find_paths`]) and the HTTP route
8//! landed previously; this module wires the CLI surface so operators
9//! can enumerate KG paths between two memories from a terminal.
10//!
11//! ## DRY contract
12//!
13//! No business logic lives here — this module is a clap arg-parser
14//! plus an output formatter. The actual path-enumeration semantics
15//! (BFS with cycle detection, `max_depth<=7`, `max_results<=50`) live
16//! in [`crate::mcp::handle_find_paths`]. The MCP, HTTP, and CLI
17//! surfaces all share that one implementation.
18
19use anyhow::Result;
20use clap::Args;
21use serde_json::{Value, json};
22
23use crate::cli::CliOutput;
24use crate::storage as db;
25
26/// CLI args for `ai-memory find-paths`. Mirrors the MCP
27/// `memory_find_paths` `input_schema` shape.
28#[derive(Args, Debug, Clone)]
29pub struct FindPathsArgs {
30    /// Path origin.
31    #[arg(long = "source-id", value_name = "ID")]
32    pub source_id: String,
33
34    /// Path destination.
35    #[arg(long = "target-id", value_name = "ID")]
36    pub target_id: String,
37
38    /// Max hops, ceiling 7. Defaults to 4 server-side.
39    #[arg(long = "max-depth", value_name = "N")]
40    pub max_depth: Option<u32>,
41
42    /// Max paths, ceiling 50. Defaults to 10 server-side.
43    #[arg(long = "max-results", value_name = "N")]
44    pub max_results: Option<u32>,
45
46    /// When set, include historically-invalidated edges.
47    #[arg(long = "include-invalidated")]
48    pub include_invalidated: bool,
49
50    /// Emit the raw JSON envelope (the same shape MCP / HTTP return)
51    /// instead of a human-readable list.
52    #[arg(long)]
53    pub json: bool,
54}
55
56/// `ai-memory find-paths` dispatch entry. Opens the DB at `db_path`,
57/// builds the MCP-shaped JSON params bag, and routes through the
58/// shared substrate primitive — guaranteeing the wire envelope is
59/// byte-equal across MCP / HTTP / CLI.
60///
61/// # Errors
62///
63/// - The DB at `db_path` cannot be opened.
64/// - The substrate validation rejects the supplied params.
65/// - `serde_json::to_string` cannot serialise the envelope (in
66///   practice never happens with the shapes used here).
67pub fn cmd_find_paths(
68    db_path: &std::path::Path,
69    args: &FindPathsArgs,
70    out: &mut CliOutput<'_>,
71) -> Result<()> {
72    let conn = db::open(db_path)?;
73
74    let mut params = json!({
75        "source_id": args.source_id,
76        "target_id": args.target_id,
77    });
78    if let Some(d) = args.max_depth {
79        params["max_depth"] = json!(d);
80    }
81    if let Some(m) = args.max_results {
82        params["max_results"] = json!(m);
83    }
84    if args.include_invalidated {
85        params[crate::models::field_names::INCLUDE_INVALIDATED] = json!(true);
86    }
87
88    let envelope = crate::mcp::handle_find_paths(&conn, &params)
89        .map_err(|e| anyhow::anyhow!("find-paths: {e}"))?;
90
91    if args.json {
92        writeln!(out.stdout, "{}", serde_json::to_string(&envelope)?)?;
93        return Ok(());
94    }
95
96    let count = envelope.get("count").and_then(Value::as_u64).unwrap_or(0);
97    writeln!(out.stdout, "find-paths: {count} path(s)")?;
98    if let Some(arr) = envelope.get("paths").and_then(Value::as_array) {
99        for (idx, path) in arr.iter().enumerate() {
100            if let Some(ids) = path.as_array() {
101                let chain: Vec<&str> = ids.iter().filter_map(Value::as_str).collect();
102                writeln!(out.stdout, "  [{}] {}", idx + 1, chain.join(" -> "))?;
103            }
104        }
105    }
106    Ok(())
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::cli::test_utils::{TestEnv, seed_memory};
113
114    #[test]
115    fn find_paths_cli_empty_db_returns_zero() {
116        let mut env = TestEnv::fresh();
117        let db = env.db_path.clone();
118        let a = seed_memory(&db, "ns", "fp-a", "alpha");
119        let b = seed_memory(&db, "ns", "fp-b", "beta");
120        let args = FindPathsArgs {
121            source_id: a,
122            target_id: b,
123            max_depth: None,
124            max_results: None,
125            include_invalidated: false,
126            json: true,
127        };
128        {
129            let mut out = env.output();
130            cmd_find_paths(&db, &args, &mut out).expect("find-paths ok");
131        }
132        let stdout = env.stdout_str();
133        let envelope: Value = serde_json::from_str(stdout.trim()).expect("parse envelope");
134        assert_eq!(envelope["count"].as_u64(), Some(0));
135    }
136
137    #[test]
138    fn find_paths_cli_invalid_id_returns_err() {
139        let mut env = TestEnv::fresh();
140        let db = env.db_path.clone();
141        let args = FindPathsArgs {
142            source_id: "bogus id with spaces".to_string(),
143            target_id: "another bogus".to_string(),
144            max_depth: None,
145            max_results: None,
146            include_invalidated: false,
147            json: true,
148        };
149        let mut out = env.output();
150        let err = cmd_find_paths(&db, &args, &mut out).expect_err("must fail");
151        assert!(err.to_string().contains("find-paths"), "got: {err}");
152    }
153
154    #[test]
155    fn find_paths_cli_text_output_with_path_and_all_params() {
156        let mut env = TestEnv::fresh();
157        let db = env.db_path.clone();
158        let a = seed_memory(&db, "ns", "fp-src", "alpha");
159        let b = seed_memory(&db, "ns", "fp-tgt", "beta");
160        {
161            let conn = db::open(&db).unwrap();
162            let now = chrono::Utc::now().to_rfc3339();
163            conn.execute(
164                "INSERT INTO memory_links (source_id, target_id, relation, created_at, valid_from)
165                 VALUES (?1, ?2, 'related_to', ?3, ?3)",
166                rusqlite::params![a, b, now],
167            )
168            .expect("insert link");
169        }
170        let args = FindPathsArgs {
171            source_id: a.clone(),
172            target_id: b.clone(),
173            max_depth: Some(4),
174            max_results: Some(10),
175            include_invalidated: true,
176            json: false,
177        };
178        {
179            let mut out = env.output();
180            cmd_find_paths(&db, &args, &mut out).expect("find-paths ok");
181        }
182        let stdout = env.stdout_str();
183        assert!(stdout.contains("path(s)"), "got: {stdout}");
184        assert!(stdout.contains("->"), "got: {stdout}");
185    }
186}