1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4#[derive(Serialize, Deserialize, Clone)]
5struct SharedContext {
6 from_agent: String,
7 to_agent: Option<String>,
8 files: Vec<SharedFile>,
9 message: Option<String>,
10 timestamp: String,
11}
12
13#[derive(Serialize, Deserialize, Clone)]
14struct SharedFile {
15 path: String,
16 content: String,
17 mode: String,
18 tokens: usize,
19}
20
21fn shared_dir(project_root: &str) -> PathBuf {
22 let hash = crate::core::project_hash::hash_project_root(project_root);
23 crate::core::data_dir::lean_ctx_data_dir()
24 .unwrap_or_else(|_| PathBuf::from("."))
25 .join("agents")
26 .join("shared")
27 .join(hash)
28}
29
30pub fn handle(
31 action: &str,
32 from_agent: Option<&str>,
33 to_agent: Option<&str>,
34 paths: Option<&str>,
35 message: Option<&str>,
36 cache: &crate::core::cache::SessionCache,
37 project_root: &str,
38) -> String {
39 match action {
40 "push" => handle_push(from_agent, to_agent, paths, message, cache, project_root),
41 "pull" => handle_pull(from_agent, project_root),
42 "list" => handle_list(project_root),
43 "clear" => handle_clear(from_agent, project_root),
44 _ => format!("Unknown action: {action}. Use: push, pull, list, clear"),
45 }
46}
47
48fn handle_push(
49 from_agent: Option<&str>,
50 to_agent: Option<&str>,
51 paths: Option<&str>,
52 message: Option<&str>,
53 cache: &crate::core::cache::SessionCache,
54 project_root: &str,
55) -> String {
56 let Some(from) = from_agent else {
57 return "Error: from_agent is required (register first via ctx_agent)".to_string();
58 };
59
60 let path_list: Vec<&str> = match paths {
61 Some(p) => p.split(',').map(str::trim).collect(),
62 None => return "Error: paths is required (comma-separated file paths)".to_string(),
63 };
64
65 let mut shared_files = Vec::new();
66 let mut not_found = Vec::new();
67
68 for path in &path_list {
69 let Some((content, tokens)) = cache.current_full_content(path) else {
74 not_found.push(*path);
75 continue;
76 };
77 let canonical = cache
78 .get(path)
79 .map_or_else(|| (*path).to_string(), |entry| entry.path.clone());
80 shared_files.push(SharedFile {
81 path: canonical,
82 content,
83 mode: "full".to_string(),
84 tokens,
85 });
86 }
87
88 if shared_files.is_empty() {
89 return format!(
90 "No cached files found to share. Files must be read first via ctx_read.\nNot found: {}",
91 not_found.join(", ")
92 );
93 }
94
95 let context = SharedContext {
96 from_agent: from.to_string(),
97 to_agent: to_agent.map(String::from),
98 files: shared_files.clone(),
99 message: message.map(String::from),
100 timestamp: chrono::Utc::now().to_rfc3339(),
101 };
102
103 let dir = shared_dir(project_root);
104 let _ = std::fs::create_dir_all(&dir);
105
106 let filename = format!(
107 "{}_{}.json",
108 from,
109 chrono::Utc::now().format("%Y%m%d_%H%M%S")
110 );
111 let path = dir.join(&filename);
112
113 match serde_json::to_string_pretty(&context) {
114 Ok(json) => {
115 if let Err(e) = std::fs::write(&path, json) {
116 return format!("Error writing shared context: {e}");
117 }
118 }
119 Err(e) => return format!("Error serializing shared context: {e}"),
120 }
121
122 let total_tokens: usize = shared_files.iter().map(|f| f.tokens).sum();
123 let mut result = format!(
124 "Shared {} files ({} tokens) from {from}",
125 shared_files.len(),
126 total_tokens
127 );
128
129 if let Some(target) = to_agent {
130 result.push_str(&format!(" → {target}"));
131 } else {
132 result.push_str(" → all agents (broadcast)");
133 }
134
135 if !not_found.is_empty() {
136 result.push_str(&format!(
137 "\nNot in cache (skipped): {}",
138 not_found.join(", ")
139 ));
140 }
141
142 result
143}
144
145fn handle_pull(agent_id: Option<&str>, project_root: &str) -> String {
146 let dir = shared_dir(project_root);
147 if !dir.exists() {
148 return "No shared contexts available.".to_string();
149 }
150
151 let my_id = agent_id.unwrap_or("anonymous");
152 let mut entries: Vec<SharedContext> = Vec::new();
153
154 if let Ok(readdir) = std::fs::read_dir(&dir) {
155 for entry in readdir.flatten() {
156 if let Ok(content) = std::fs::read_to_string(entry.path())
157 && let Ok(ctx) = serde_json::from_str::<SharedContext>(&content)
158 {
159 let is_for_me = ctx.to_agent.is_none() || ctx.to_agent.as_deref() == Some(my_id);
160 let is_not_from_me = ctx.from_agent != my_id;
161
162 if is_for_me && is_not_from_me {
163 entries.push(ctx);
164 }
165 }
166 }
167 }
168
169 if entries.is_empty() {
170 return "No shared contexts for you.".to_string();
171 }
172
173 entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
174
175 let mut out = format!("Shared contexts available ({}):\n", entries.len());
176 for ctx in &entries {
177 let file_list: Vec<&str> = ctx.files.iter().map(|f| f.path.as_str()).collect();
178 let total_tokens: usize = ctx.files.iter().map(|f| f.tokens).sum();
179 out.push_str(&format!(
180 "\n From: {} ({})\n Files: {} ({} tokens)\n {}\n",
181 ctx.from_agent,
182 &ctx.timestamp[..19],
183 file_list.join(", "),
184 total_tokens,
185 ctx.message
186 .as_deref()
187 .map(|m| format!("Message: {m}"))
188 .unwrap_or_default(),
189 ));
190 }
191
192 let total_files: usize = entries.iter().map(|e| e.files.len()).sum();
193 out.push_str(&format!(
194 "\nTotal: {} contexts, {} files. Use ctx_read on pulled files to load them into your cache.",
195 entries.len(),
196 total_files
197 ));
198
199 out
200}
201
202fn handle_list(project_root: &str) -> String {
203 let dir = shared_dir(project_root);
204 if !dir.exists() {
205 return "No shared contexts.".to_string();
206 }
207
208 let mut count = 0;
209 let mut total_files = 0;
210 let mut out = String::from("Shared context store:\n");
211
212 if let Ok(readdir) = std::fs::read_dir(&dir) {
213 for entry in readdir.flatten() {
214 if let Ok(content) = std::fs::read_to_string(entry.path())
215 && let Ok(ctx) = serde_json::from_str::<SharedContext>(&content)
216 {
217 count += 1;
218 total_files += ctx.files.len();
219 let target = ctx.to_agent.as_deref().unwrap_or("broadcast");
220 out.push_str(&format!(
221 " {} → {} ({} files, {})\n",
222 ctx.from_agent,
223 target,
224 ctx.files.len(),
225 &ctx.timestamp[..19]
226 ));
227 }
228 }
229 }
230
231 if count == 0 {
232 return "No shared contexts.".to_string();
233 }
234
235 out.push_str(&format!("\nTotal: {count} shares, {total_files} files"));
236 out
237}
238
239fn handle_clear(agent_id: Option<&str>, project_root: &str) -> String {
240 let dir = shared_dir(project_root);
241 if !dir.exists() {
242 return "Nothing to clear.".to_string();
243 }
244
245 let my_id = agent_id.unwrap_or("anonymous");
246 let mut removed = 0;
247
248 if let Ok(readdir) = std::fs::read_dir(&dir) {
249 for entry in readdir.flatten() {
250 if let Ok(content) = std::fs::read_to_string(entry.path())
251 && let Ok(ctx) = serde_json::from_str::<SharedContext>(&content)
252 && ctx.from_agent == my_id
253 {
254 let _ = std::fs::remove_file(entry.path());
255 removed += 1;
256 }
257 }
258 }
259
260 format!("Cleared {removed} shared context(s) from {my_id}")
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::core::cache::SessionCache;
267
268 fn shared_json(project_root: &str) -> String {
271 let dir = shared_dir(project_root);
272 let mut all = String::new();
273 if let Ok(rd) = std::fs::read_dir(&dir) {
274 for e in rd.flatten() {
275 all.push_str(&std::fs::read_to_string(e.path()).unwrap_or_default());
276 }
277 }
278 all
279 }
280
281 #[test]
282 fn push_shares_fresh_content_and_pull_lists_it() {
283 let _lock = crate::core::data_dir::test_env_lock();
284 let data = tempfile::tempdir().unwrap();
285 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
286
287 let proj = tempfile::tempdir().unwrap();
288 let root = proj.path().to_str().unwrap();
289 let file = proj.path().join("handover.md");
290 std::fs::write(&file, "HANDOVER marker-AAA\n").unwrap();
291 let path = file.to_str().unwrap();
292
293 let mut cache = SessionCache::new();
294 cache.store(path, "HANDOVER marker-AAA\n");
295
296 let out = handle_push(
297 Some("agentA"),
298 Some("agentB"),
299 Some(path),
300 None,
301 &cache,
302 root,
303 );
304 assert!(out.contains("Shared 1 files"), "push result: {out}");
305 assert!(
306 shared_json(root).contains("marker-AAA"),
307 "content not captured"
308 );
309
310 let pulled = handle_pull(Some("agentB"), root);
312 assert!(
313 pulled.contains("handover.md"),
314 "pull missing file: {pulled}"
315 );
316 }
317
318 #[test]
319 fn push_shares_edited_content_not_stale_diff_mtime() {
320 let _lock = crate::core::data_dir::test_env_lock();
323 let data = tempfile::tempdir().unwrap();
324 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
325
326 let proj = tempfile::tempdir().unwrap();
327 let root = proj.path().to_str().unwrap();
328 let file = proj.path().join("handover.md");
329 std::fs::write(&file, "V1 marker-AAA\n").unwrap();
330 let path = file.to_str().unwrap();
331
332 let mut cache = SessionCache::new();
333 cache.store(path, "V1 marker-AAA\n");
334
335 std::thread::sleep(std::time::Duration::from_millis(10));
336 std::fs::write(&file, "V2 marker-BBB\n").unwrap();
337
338 let out = handle_push(Some("a"), Some("b"), Some(path), None, &cache, root);
339 assert!(out.contains("Shared 1 files"), "push result: {out}");
340 let json = shared_json(root);
341 assert!(
342 json.contains("marker-BBB"),
343 "fresh content not shared: {json}"
344 );
345 assert!(
346 !json.contains("marker-AAA"),
347 "stale content leaked into handover: {json}"
348 );
349 }
350
351 #[test]
352 fn push_shares_edited_content_same_mtime_same_size() {
353 let _lock = crate::core::data_dir::test_env_lock();
355 let data = tempfile::tempdir().unwrap();
356 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
357
358 let proj = tempfile::tempdir().unwrap();
359 let root = proj.path().to_str().unwrap();
360 let file = proj.path().join("h.md");
361 std::fs::write(&file, "AAA\n").unwrap();
362 let path = file.to_str().unwrap();
363 let mtime = std::fs::metadata(&file).unwrap().modified().unwrap();
364
365 let mut cache = SessionCache::new();
366 cache.store(path, "AAA\n");
367
368 std::fs::write(&file, "BBB\n").unwrap();
370 std::fs::OpenOptions::new()
371 .write(true)
372 .open(&file)
373 .unwrap()
374 .set_modified(mtime)
375 .unwrap();
376
377 let out = handle_push(Some("a"), Some("b"), Some(path), None, &cache, root);
378 assert!(out.contains("Shared 1 files"), "push result: {out}");
379 let json = shared_json(root);
380 assert!(
381 json.contains("BBB"),
382 "hash backstop failed, stale shared: {json}"
383 );
384 assert!(!json.contains("AAA"), "stale content leaked: {json}");
385 }
386
387 #[test]
388 fn push_skips_uncached_paths() {
389 let _lock = crate::core::data_dir::test_env_lock();
390 let data = tempfile::tempdir().unwrap();
391 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
392
393 let proj = tempfile::tempdir().unwrap();
394 let root = proj.path().to_str().unwrap();
395 let cache = SessionCache::new(); let out = handle_push(
398 Some("a"),
399 Some("b"),
400 Some("/no/such/file.md"),
401 None,
402 &cache,
403 root,
404 );
405 assert!(
406 out.contains("No cached files found to share"),
407 "expected skip message: {out}"
408 );
409 }
410
411 #[test]
412 fn push_falls_back_to_last_known_when_file_deleted() {
413 let _lock = crate::core::data_dir::test_env_lock();
416 let data = tempfile::tempdir().unwrap();
417 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
418
419 let proj = tempfile::tempdir().unwrap();
420 let canon = proj.path().canonicalize().unwrap();
422 let root = canon.to_str().unwrap();
423 let file = canon.join("gone.md");
424 std::fs::write(&file, "LASTKNOWN-AAA\n").unwrap();
425 let path = file.to_str().unwrap();
426
427 let mut cache = SessionCache::new();
428 cache.store(path, "LASTKNOWN-AAA\n");
429 std::fs::remove_file(&file).unwrap();
430
431 let out = handle_push(Some("a"), Some("b"), Some(path), None, &cache, root);
432 assert!(out.contains("Shared 1 files"), "push result: {out}");
433 assert!(
434 shared_json(root).contains("LASTKNOWN-AAA"),
435 "last-known content not shared"
436 );
437 }
438}