Skip to main content

ai_memory/cli/
link.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_link` and `cmd_resolve` migrations. See `cli::store` for the
5//! design pattern.
6
7use crate::cli::CliOutput;
8use crate::{color, db, models, validate};
9use anyhow::Result;
10use clap::Args;
11use std::path::Path;
12
13#[derive(Args)]
14pub struct LinkArgs {
15    pub source_id: String,
16    pub target_id: String,
17    #[arg(long, short, default_value = "related_to")]
18    pub relation: String,
19}
20
21#[derive(Args)]
22pub struct ResolveArgs {
23    /// ID of the memory that wins (supersedes)
24    pub winner_id: String,
25    /// ID of the memory that loses (superseded)
26    pub loser_id: String,
27}
28
29/// `link` handler.
30pub fn cmd_link(
31    db_path: &Path,
32    args: &LinkArgs,
33    json_out: bool,
34    out: &mut CliOutput<'_>,
35) -> Result<()> {
36    validate::validate_link(&args.source_id, &args.target_id, &args.relation)?;
37    let conn = db::open(db_path)?;
38    db::create_link(&conn, &args.source_id, &args.target_id, &args.relation)?;
39    if json_out {
40        writeln!(out.stdout, "{}", serde_json::json!({"linked": true}))?;
41    } else {
42        writeln!(
43            out.stdout,
44            "linked: {} --[{}]--> {}",
45            args.source_id, args.relation, args.target_id
46        )?;
47    }
48    Ok(())
49}
50
51/// `resolve` handler — record `winner supersedes loser`, demote loser
52/// priority/confidence, and refresh winner's TTL.
53pub fn cmd_resolve(
54    db_path: &Path,
55    args: &ResolveArgs,
56    json_out: bool,
57    out: &mut CliOutput<'_>,
58) -> Result<()> {
59    let conn = db::open(db_path)?;
60    validate::validate_link(&args.winner_id, &args.loser_id, "supersedes")?;
61    db::create_link(&conn, &args.winner_id, &args.loser_id, "supersedes")?;
62    let _ = db::update(
63        &conn,
64        &args.loser_id,
65        None,
66        None,
67        None,
68        None,
69        None,
70        Some(1),
71        Some(0.1),
72        None,
73        None,
74    )?;
75    db::touch(
76        &conn,
77        &args.winner_id,
78        models::SHORT_TTL_EXTEND_SECS,
79        models::MID_TTL_EXTEND_SECS,
80    )?;
81    if json_out {
82        writeln!(
83            out.stdout,
84            "{}",
85            serde_json::json!({"resolved": true, "winner": args.winner_id, "loser": args.loser_id})
86        )?;
87    } else {
88        writeln!(
89            out.stdout,
90            "resolved: {} supersedes {}",
91            color::long(&args.winner_id),
92            color::dim(&args.loser_id)
93        )?;
94    }
95    Ok(())
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::cli::test_utils::{TestEnv, seed_memory};
102
103    #[test]
104    fn test_link_happy_path() {
105        let mut env = TestEnv::fresh();
106        let db = env.db_path.clone();
107        let id1 = seed_memory(&db, "ns", "a", "ca");
108        let id2 = seed_memory(&db, "ns", "b", "cb");
109        let args = LinkArgs {
110            source_id: id1.clone(),
111            target_id: id2.clone(),
112            relation: "related_to".to_string(),
113        };
114        {
115            let mut out = env.output();
116            cmd_link(&db, &args, false, &mut out).unwrap();
117        }
118        assert!(
119            env.stdout_str().contains("linked:"),
120            "got: {}",
121            env.stdout_str()
122        );
123        // Confirm row exists in DB.
124        let conn = db::open(&db).unwrap();
125        let links = db::get_links(&conn, &id1).unwrap();
126        assert!(links.iter().any(|l| l.target_id == id2));
127    }
128
129    #[test]
130    fn test_link_invalid_relation_validation_error() {
131        let mut env = TestEnv::fresh();
132        let db = env.db_path.clone();
133        let id1 = seed_memory(&db, "ns", "a", "ca");
134        let id2 = seed_memory(&db, "ns", "b", "cb");
135        let args = LinkArgs {
136            source_id: id1,
137            target_id: id2,
138            relation: "totally-bogus-relation".to_string(),
139        };
140        let mut out = env.output();
141        let res = cmd_link(&db, &args, false, &mut out);
142        assert!(res.is_err());
143        let msg = res.unwrap_err().to_string();
144        assert!(msg.contains("invalid relation"), "got: {msg}");
145    }
146
147    #[test]
148    fn test_link_self_link_validation_error() {
149        let mut env = TestEnv::fresh();
150        let db = env.db_path.clone();
151        let id = seed_memory(&db, "ns", "a", "ca");
152        let args = LinkArgs {
153            source_id: id.clone(),
154            target_id: id,
155            relation: "related_to".to_string(),
156        };
157        let mut out = env.output();
158        let res = cmd_link(&db, &args, false, &mut out);
159        assert!(res.is_err());
160        let msg = res.unwrap_err().to_string();
161        assert!(msg.contains("itself"), "got: {msg}");
162    }
163
164    #[test]
165    fn test_link_json_output() {
166        let mut env = TestEnv::fresh();
167        let db = env.db_path.clone();
168        let id1 = seed_memory(&db, "ns", "a", "ca");
169        let id2 = seed_memory(&db, "ns", "b", "cb");
170        let args = LinkArgs {
171            source_id: id1,
172            target_id: id2,
173            relation: "supersedes".to_string(),
174        };
175        {
176            let mut out = env.output();
177            cmd_link(&db, &args, true, &mut out).unwrap();
178        }
179        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
180        assert_eq!(v["linked"].as_bool().unwrap(), true);
181    }
182
183    #[test]
184    fn test_resolve_creates_supersedes_link() {
185        let mut env = TestEnv::fresh();
186        let db = env.db_path.clone();
187        let winner = seed_memory(&db, "ns", "winner", "wins");
188        let loser = seed_memory(&db, "ns", "loser", "loses");
189        let args = ResolveArgs {
190            winner_id: winner.clone(),
191            loser_id: loser.clone(),
192        };
193        {
194            let mut out = env.output();
195            cmd_resolve(&db, &args, false, &mut out).unwrap();
196        }
197        let conn = db::open(&db).unwrap();
198        let links = db::get_links(&conn, &winner).unwrap();
199        assert!(
200            links
201                .iter()
202                .any(|l| l.target_id == loser && l.relation == "supersedes"),
203            "expected supersedes link from winner to loser"
204        );
205    }
206
207    #[test]
208    fn test_resolve_demotes_loser_priority_and_confidence() {
209        let mut env = TestEnv::fresh();
210        let db = env.db_path.clone();
211        let winner = seed_memory(&db, "ns", "winner", "wins");
212        let loser = seed_memory(&db, "ns", "loser", "loses");
213        let args = ResolveArgs {
214            winner_id: winner,
215            loser_id: loser.clone(),
216        };
217        {
218            let mut out = env.output();
219            cmd_resolve(&db, &args, true, &mut out).unwrap();
220        }
221        let conn = db::open(&db).unwrap();
222        let mem = db::get(&conn, &loser).unwrap().unwrap();
223        assert_eq!(mem.priority, 1);
224        assert!((mem.confidence - 0.1).abs() < 1e-6);
225    }
226
227    #[test]
228    fn test_resolve_touches_winner() {
229        let mut env = TestEnv::fresh();
230        let db = env.db_path.clone();
231        let winner = seed_memory(&db, "ns", "winner", "wins");
232        let loser = seed_memory(&db, "ns", "loser", "loses");
233        // Capture access_count + updated_at before resolve.
234        let conn = db::open(&db).unwrap();
235        let pre = db::get(&conn, &winner).unwrap().unwrap();
236        let pre_access = pre.access_count;
237        drop(conn);
238        let args = ResolveArgs {
239            winner_id: winner.clone(),
240            loser_id: loser,
241        };
242        {
243            let mut out = env.output();
244            cmd_resolve(&db, &args, true, &mut out).unwrap();
245        }
246        let conn = db::open(&db).unwrap();
247        let post = db::get(&conn, &winner).unwrap().unwrap();
248        // touch() bumps access_count.
249        assert!(
250            post.access_count >= pre_access,
251            "access_count should not regress: pre={pre_access} post={}",
252            post.access_count
253        );
254    }
255}