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(
61        &args.winner_id,
62        &args.loser_id,
63        crate::models::MemoryLinkRelation::Supersedes.as_str(),
64    )?;
65    db::create_link(
66        &conn,
67        &args.winner_id,
68        &args.loser_id,
69        crate::models::MemoryLinkRelation::Supersedes.as_str(),
70    )?;
71    let _ = db::update(
72        &conn,
73        &args.loser_id,
74        None,
75        None,
76        None,
77        None,
78        None,
79        Some(1),
80        Some(0.1),
81        None,
82        None,
83    )?;
84    db::touch(
85        &conn,
86        &args.winner_id,
87        models::SHORT_TTL_EXTEND_SECS,
88        models::MID_TTL_EXTEND_SECS,
89    )?;
90    if json_out {
91        writeln!(
92            out.stdout,
93            "{}",
94            serde_json::json!({"resolved": true, "winner": args.winner_id, "loser": args.loser_id})
95        )?;
96    } else {
97        writeln!(
98            out.stdout,
99            "resolved: {} supersedes {}",
100            color::long(&args.winner_id),
101            color::dim(&args.loser_id)
102        )?;
103    }
104    Ok(())
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::cli::test_utils::{TestEnv, seed_memory};
111
112    #[test]
113    fn test_link_happy_path() {
114        let mut env = TestEnv::fresh();
115        let db = env.db_path.clone();
116        let id1 = seed_memory(&db, "ns", "a", "ca");
117        let id2 = seed_memory(&db, "ns", "b", "cb");
118        let args = LinkArgs {
119            source_id: id1.clone(),
120            target_id: id2.clone(),
121            relation: "related_to".to_string(),
122        };
123        {
124            let mut out = env.output();
125            cmd_link(&db, &args, false, &mut out).unwrap();
126        }
127        assert!(
128            env.stdout_str().contains("linked:"),
129            "got: {}",
130            env.stdout_str()
131        );
132        // Confirm row exists in DB.
133        let conn = db::open(&db).unwrap();
134        let links = db::get_links(&conn, &id1).unwrap();
135        assert!(links.iter().any(|l| l.target_id == id2));
136    }
137
138    #[test]
139    fn test_link_invalid_relation_validation_error() {
140        let mut env = TestEnv::fresh();
141        let db = env.db_path.clone();
142        let id1 = seed_memory(&db, "ns", "a", "ca");
143        let id2 = seed_memory(&db, "ns", "b", "cb");
144        let args = LinkArgs {
145            source_id: id1,
146            target_id: id2,
147            relation: "totally-bogus-relation".to_string(),
148        };
149        let mut out = env.output();
150        let res = cmd_link(&db, &args, false, &mut out);
151        assert!(res.is_err());
152        let msg = res.unwrap_err().to_string();
153        assert!(msg.contains("invalid relation"), "got: {msg}");
154    }
155
156    #[test]
157    fn test_link_self_link_validation_error() {
158        let mut env = TestEnv::fresh();
159        let db = env.db_path.clone();
160        let id = seed_memory(&db, "ns", "a", "ca");
161        let args = LinkArgs {
162            source_id: id.clone(),
163            target_id: id,
164            relation: "related_to".to_string(),
165        };
166        let mut out = env.output();
167        let res = cmd_link(&db, &args, false, &mut out);
168        assert!(res.is_err());
169        let msg = res.unwrap_err().to_string();
170        assert!(msg.contains("itself"), "got: {msg}");
171    }
172
173    #[test]
174    fn test_link_json_output() {
175        let mut env = TestEnv::fresh();
176        let db = env.db_path.clone();
177        let id1 = seed_memory(&db, "ns", "a", "ca");
178        let id2 = seed_memory(&db, "ns", "b", "cb");
179        let args = LinkArgs {
180            source_id: id1,
181            target_id: id2,
182            relation: "supersedes".to_string(),
183        };
184        {
185            let mut out = env.output();
186            cmd_link(&db, &args, true, &mut out).unwrap();
187        }
188        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
189        assert_eq!(v["linked"].as_bool().unwrap(), true);
190    }
191
192    #[test]
193    fn test_resolve_creates_supersedes_link() {
194        let mut env = TestEnv::fresh();
195        let db = env.db_path.clone();
196        let winner = seed_memory(&db, "ns", "winner", "wins");
197        let loser = seed_memory(&db, "ns", "loser", "loses");
198        let args = ResolveArgs {
199            winner_id: winner.clone(),
200            loser_id: loser.clone(),
201        };
202        {
203            let mut out = env.output();
204            cmd_resolve(&db, &args, false, &mut out).unwrap();
205        }
206        let conn = db::open(&db).unwrap();
207        let links = db::get_links(&conn, &winner).unwrap();
208        assert!(
209            links.iter().any(|l| l.target_id == loser
210                && l.relation == crate::models::MemoryLinkRelation::Supersedes),
211            "expected supersedes link from winner to loser"
212        );
213    }
214
215    #[test]
216    fn test_resolve_demotes_loser_priority_and_confidence() {
217        let mut env = TestEnv::fresh();
218        let db = env.db_path.clone();
219        let winner = seed_memory(&db, "ns", "winner", "wins");
220        let loser = seed_memory(&db, "ns", "loser", "loses");
221        let args = ResolveArgs {
222            winner_id: winner,
223            loser_id: loser.clone(),
224        };
225        {
226            let mut out = env.output();
227            cmd_resolve(&db, &args, true, &mut out).unwrap();
228        }
229        let conn = db::open(&db).unwrap();
230        let mem = db::get(&conn, &loser).unwrap().unwrap();
231        assert_eq!(mem.priority, 1);
232        assert!((mem.confidence - 0.1).abs() < 1e-6);
233    }
234
235    #[test]
236    fn test_resolve_touches_winner() {
237        let mut env = TestEnv::fresh();
238        let db = env.db_path.clone();
239        let winner = seed_memory(&db, "ns", "winner", "wins");
240        let loser = seed_memory(&db, "ns", "loser", "loses");
241        // Capture access_count + updated_at before resolve.
242        let conn = db::open(&db).unwrap();
243        let pre = db::get(&conn, &winner).unwrap().unwrap();
244        let pre_access = pre.access_count;
245        drop(conn);
246        let args = ResolveArgs {
247            winner_id: winner.clone(),
248            loser_id: loser,
249        };
250        {
251            let mut out = env.output();
252            cmd_resolve(&db, &args, true, &mut out).unwrap();
253        }
254        let conn = db::open(&db).unwrap();
255        let post = db::get(&conn, &winner).unwrap().unwrap();
256        // touch() bumps access_count.
257        assert!(
258            post.access_count >= pre_access,
259            "access_count should not regress: pre={pre_access} post={}",
260            post.access_count
261        );
262    }
263
264    /// Coverage restoration (post-#1558 floor dip): `cmd_resolve`'s
265    /// validate-error propagation path — `cmd_link`'s twin is pinned
266    /// by `test_link_self_link_validation_error`, but resolve's
267    /// validate call (winner == loser ⇒ self-supersede) was not.
268    #[test]
269    fn test_resolve_self_resolve_validation_error() {
270        let mut env = TestEnv::fresh();
271        let db = env.db_path.clone();
272        let only = seed_memory(&db, "ns", "only", "self");
273        let args = ResolveArgs {
274            winner_id: only.clone(),
275            loser_id: only,
276        };
277        let mut out = env.output();
278        let err = cmd_resolve(&db, &args, false, &mut out).unwrap_err();
279        assert!(
280            err.to_string().to_lowercase().contains("self"),
281            "self-resolve must be refused by validate_link: {err}"
282        );
283    }
284
285    // -----------------------------------------------------------------
286    // GA-drive 2026-06-09 (per-module floor 96%) — error-branch
287    // coverage for the remaining `?` propagation sites. Each fallible
288    // call's closing `)?;` line carries the error-branch region; these
289    // tests drive each Err path so the line counts as covered.
290    // -----------------------------------------------------------------
291
292    /// Writer that always fails — drives the `?` error branch on the
293    /// `writeln!` sites (the broken-pipe propagation contract that
294    /// `cli::io_writer` documents: handlers must return the I/O error,
295    /// not panic).
296    struct FailingWriter;
297    impl std::io::Write for FailingWriter {
298        fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
299            Err(std::io::Error::new(
300                std::io::ErrorKind::BrokenPipe,
301                "test writer: broken pipe",
302            ))
303        }
304        fn flush(&mut self) -> std::io::Result<()> {
305            Ok(())
306        }
307    }
308
309    #[test]
310    fn test_resolve_missing_ids_create_link_error() {
311        // Valid-format IDs that don't exist: validate passes, the
312        // create_link write refuses with the typed MemoryNotFound.
313        let mut env = TestEnv::fresh();
314        let db = env.db_path.clone();
315        // Initialize schema so the failure comes from the link write,
316        // not from a missing table.
317        drop(db::open(&db).unwrap());
318        let args = ResolveArgs {
319            winner_id: "nonexistent-winner-id".into(),
320            loser_id: "nonexistent-loser-id".into(),
321        };
322        let mut out = env.output();
323        let res = cmd_resolve(&db, &args, false, &mut out);
324        assert!(res.is_err());
325        let msg = res.unwrap_err().to_string();
326        assert!(
327            msg.contains(crate::errors::msg::MEMORY_NOT_FOUND),
328            "got: {msg}"
329        );
330    }
331
332    #[test]
333    fn test_resolve_update_failure_propagates() {
334        // Force db::update on the loser to fail AFTER the supersedes
335        // link landed, via an abort trigger keyed on the loser row.
336        let mut env = TestEnv::fresh();
337        let db = env.db_path.clone();
338        let winner = seed_memory(&db, "ns", "winner", "wins");
339        let loser = seed_memory(&db, "ns", "loser", "loses");
340        let conn = db::open(&db).unwrap();
341        conn.execute_batch(&format!(
342            "CREATE TRIGGER test_fail_loser_update BEFORE UPDATE ON memories \
343             WHEN NEW.id = '{loser}' \
344             BEGIN SELECT RAISE(ABORT, 'test trigger: loser update refused'); END;"
345        ))
346        .unwrap();
347        drop(conn);
348        let args = ResolveArgs {
349            winner_id: winner,
350            loser_id: loser,
351        };
352        let mut out = env.output();
353        let res = cmd_resolve(&db, &args, false, &mut out);
354        assert!(res.is_err());
355        // storage::update maps the RAISE(ABORT) into its canonical
356        // constraint-violation wrap; the raw trigger prose may be
357        // swallowed by that mapping, so accept either spelling.
358        let msg = res.unwrap_err().to_string();
359        assert!(
360            msg.contains("update failed") || msg.contains("loser update refused"),
361            "got: {msg}"
362        );
363    }
364
365    #[test]
366    fn test_resolve_touch_failure_propagates() {
367        // db::update targets only the loser; an abort trigger keyed on
368        // the winner row fires first inside db::touch.
369        let mut env = TestEnv::fresh();
370        let db = env.db_path.clone();
371        let winner = seed_memory(&db, "ns", "winner", "wins");
372        let loser = seed_memory(&db, "ns", "loser", "loses");
373        let conn = db::open(&db).unwrap();
374        conn.execute_batch(&format!(
375            "CREATE TRIGGER test_fail_winner_touch BEFORE UPDATE ON memories \
376             WHEN NEW.id = '{winner}' \
377             BEGIN SELECT RAISE(ABORT, 'test trigger: winner touch refused'); END;"
378        ))
379        .unwrap();
380        drop(conn);
381        let args = ResolveArgs {
382            winner_id: winner,
383            loser_id: loser,
384        };
385        let mut out = env.output();
386        let res = cmd_resolve(&db, &args, true, &mut out);
387        assert!(res.is_err());
388        let msg = res.unwrap_err().to_string();
389        assert!(msg.contains("winner touch refused"), "got: {msg}");
390    }
391
392    #[test]
393    fn test_link_human_output_broken_pipe_propagates() {
394        let env = TestEnv::fresh();
395        let db = env.db_path.clone();
396        let id1 = seed_memory(&db, "ns", "a", "ca");
397        let id2 = seed_memory(&db, "ns", "b", "cb");
398        let args = LinkArgs {
399            source_id: id1,
400            target_id: id2,
401            relation: "related_to".to_string(),
402        };
403        let mut failing = FailingWriter;
404        let mut stderr: Vec<u8> = Vec::new();
405        let mut out = CliOutput {
406            stdout: &mut failing,
407            stderr: &mut stderr,
408        };
409        let res = cmd_link(&db, &args, false, &mut out);
410        assert!(res.is_err(), "broken pipe must propagate, not panic");
411    }
412
413    #[test]
414    fn test_link_json_output_broken_pipe_propagates() {
415        let env = TestEnv::fresh();
416        let db = env.db_path.clone();
417        let id1 = seed_memory(&db, "ns", "a", "ca");
418        let id2 = seed_memory(&db, "ns", "b", "cb");
419        let args = LinkArgs {
420            source_id: id1,
421            target_id: id2,
422            relation: "related_to".to_string(),
423        };
424        let mut failing = FailingWriter;
425        let mut stderr: Vec<u8> = Vec::new();
426        let mut out = CliOutput {
427            stdout: &mut failing,
428            stderr: &mut stderr,
429        };
430        let res = cmd_link(&db, &args, true, &mut out);
431        assert!(res.is_err(), "broken pipe must propagate, not panic");
432    }
433
434    #[test]
435    fn test_resolve_json_output_broken_pipe_propagates() {
436        let env = TestEnv::fresh();
437        let db = env.db_path.clone();
438        let winner = seed_memory(&db, "ns", "winner", "wins");
439        let loser = seed_memory(&db, "ns", "loser", "loses");
440        let args = ResolveArgs {
441            winner_id: winner,
442            loser_id: loser,
443        };
444        let mut failing = FailingWriter;
445        let mut stderr: Vec<u8> = Vec::new();
446        let mut out = CliOutput {
447            stdout: &mut failing,
448            stderr: &mut stderr,
449        };
450        let res = cmd_resolve(&db, &args, true, &mut out);
451        assert!(res.is_err(), "broken pipe must propagate, not panic");
452    }
453
454    #[test]
455    fn test_resolve_human_output_broken_pipe_propagates() {
456        let env = TestEnv::fresh();
457        let db = env.db_path.clone();
458        let winner = seed_memory(&db, "ns", "winner", "wins");
459        let loser = seed_memory(&db, "ns", "loser", "loses");
460        let args = ResolveArgs {
461            winner_id: winner,
462            loser_id: loser,
463        };
464        let mut failing = FailingWriter;
465        let mut stderr: Vec<u8> = Vec::new();
466        let mut out = CliOutput {
467            stdout: &mut failing,
468            stderr: &mut stderr,
469        };
470        let res = cmd_resolve(&db, &args, false, &mut out);
471        assert!(res.is_err(), "broken pipe must propagate, not panic");
472    }
473}