1use 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 pub winner_id: String,
25 pub loser_id: String,
27}
28
29pub 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
51pub 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 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 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 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 #[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 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 let mut env = TestEnv::fresh();
314 let db = env.db_path.clone();
315 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 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 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 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}