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(&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 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 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 assert!(
250 post.access_count >= pre_access,
251 "access_count should not regress: pre={pre_access} post={}",
252 post.access_count
253 );
254 }
255}