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
30fn sanitize_for_filename(s: &str) -> String {
38 s.chars()
39 .map(|c| {
40 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
41 c
42 } else {
43 '-'
44 }
45 })
46 .collect()
47}
48
49fn read_within_root(path: &str, project_root: &str) -> Option<(String, String, usize)> {
53 let root =
54 crate::core::pathutil::canonicalize_secure(std::path::Path::new(project_root)).ok()?;
55 let candidate = std::path::Path::new(path);
56 let absolute = if candidate.is_absolute() {
57 candidate.to_path_buf()
58 } else {
59 root.join(candidate)
60 };
61 let resolved = crate::core::pathutil::canonicalize_secure(&absolute).ok()?;
62 if !resolved.starts_with(&root) {
63 return None;
64 }
65 let resolved_str = resolved.to_string_lossy().to_string();
66 let content = crate::core::io_boundary::read_file_lossy(&resolved_str).ok()?;
67 let tokens = crate::core::tokens::count_tokens(&content);
68 Some((resolved_str, content, tokens))
69}
70
71pub fn handle(
72 action: &str,
73 from_agent: Option<&str>,
74 to_agent: Option<&str>,
75 paths: Option<&str>,
76 message: Option<&str>,
77 cache: &crate::core::cache::SessionCache,
78 project_root: &str,
79) -> String {
80 match action {
81 "push" => handle_push(from_agent, to_agent, paths, message, cache, project_root),
82 "pull" => handle_pull(from_agent, project_root),
83 "list" => handle_list(project_root),
84 "clear" => handle_clear(from_agent, project_root),
85 _ => format!("Unknown action: {action}. Use: push, pull, list, clear"),
86 }
87}
88
89fn handle_push(
90 from_agent: Option<&str>,
91 to_agent: Option<&str>,
92 paths: Option<&str>,
93 message: Option<&str>,
94 cache: &crate::core::cache::SessionCache,
95 project_root: &str,
96) -> String {
97 let Some(from) = from_agent else {
98 return "Error: from_agent is required (register first via ctx_agent)".to_string();
99 };
100
101 let path_list: Vec<&str> = match paths {
102 Some(p) => p.split(',').map(str::trim).collect(),
103 None => return "Error: paths is required (comma-separated file paths)".to_string(),
104 };
105
106 let mut shared_files = Vec::new();
107 let mut not_found = Vec::new();
108
109 for path in &path_list {
110 if let Some((content, tokens)) = cache.current_full_content(path) {
115 let canonical = cache
116 .get(path)
117 .map_or_else(|| (*path).to_string(), |entry| entry.path.clone());
118 shared_files.push(SharedFile {
119 path: canonical,
120 content,
121 mode: "full".to_string(),
122 tokens,
123 });
124 continue;
125 }
126 if let Some((canonical, content, tokens)) = read_within_root(path, project_root) {
131 shared_files.push(SharedFile {
132 path: canonical,
133 content,
134 mode: "full".to_string(),
135 tokens,
136 });
137 } else {
138 not_found.push(*path);
139 }
140 }
141
142 if shared_files.is_empty() {
143 return format!(
144 "No shareable files found (not cached, and not readable inside the project root).\nNot found: {}",
145 not_found.join(", ")
146 );
147 }
148
149 let context = SharedContext {
150 from_agent: from.to_string(),
151 to_agent: to_agent.map(String::from),
152 files: shared_files.clone(),
153 message: message.map(String::from),
154 timestamp: chrono::Utc::now().to_rfc3339(),
155 };
156
157 let dir = shared_dir(project_root);
158 let _ = std::fs::create_dir_all(&dir);
159
160 let filename = format!(
161 "{}_{}.json",
162 sanitize_for_filename(from),
163 chrono::Utc::now().format("%Y%m%d_%H%M%S")
164 );
165 let path = dir.join(&filename);
166
167 match serde_json::to_string_pretty(&context) {
168 Ok(json) => {
169 if let Err(e) = std::fs::write(&path, json) {
170 return format!("Error writing shared context: {e}");
171 }
172 }
173 Err(e) => return format!("Error serializing shared context: {e}"),
174 }
175
176 let total_tokens: usize = shared_files.iter().map(|f| f.tokens).sum();
177 let mut result = format!(
178 "Shared {} files ({} tokens) from {from}",
179 shared_files.len(),
180 total_tokens
181 );
182
183 if let Some(target) = to_agent {
184 result.push_str(&format!(" → {target}"));
185 } else {
186 result.push_str(" → all agents (broadcast)");
187 }
188
189 if !not_found.is_empty() {
190 result.push_str(&format!(
191 "\nNot in cache (skipped): {}",
192 not_found.join(", ")
193 ));
194 }
195
196 result
197}
198
199fn handle_pull(agent_id: Option<&str>, project_root: &str) -> String {
200 let dir = shared_dir(project_root);
201 if !dir.exists() {
202 return "No shared contexts available.".to_string();
203 }
204
205 let my_id = agent_id.unwrap_or("anonymous");
206 let mut entries: Vec<SharedContext> = Vec::new();
207
208 if let Ok(readdir) = std::fs::read_dir(&dir) {
209 for entry in readdir.flatten() {
210 if let Ok(content) = std::fs::read_to_string(entry.path())
211 && let Ok(ctx) = serde_json::from_str::<SharedContext>(&content)
212 {
213 let is_for_me = ctx.to_agent.is_none() || ctx.to_agent.as_deref() == Some(my_id);
214 let is_not_from_me = ctx.from_agent != my_id;
215
216 if is_for_me && is_not_from_me {
217 entries.push(ctx);
218 }
219 }
220 }
221 }
222
223 if entries.is_empty() {
224 return "No shared contexts for you.".to_string();
225 }
226
227 entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
228
229 let mut out = format!("Shared contexts available ({}):\n", entries.len());
230 for ctx in &entries {
231 let file_list: Vec<&str> = ctx.files.iter().map(|f| f.path.as_str()).collect();
232 let total_tokens: usize = ctx.files.iter().map(|f| f.tokens).sum();
233 out.push_str(&format!(
234 "\n From: {} ({})\n Files: {} ({} tokens)\n {}\n",
235 ctx.from_agent,
236 &ctx.timestamp[..19],
237 file_list.join(", "),
238 total_tokens,
239 ctx.message
240 .as_deref()
241 .map(|m| format!("Message: {m}"))
242 .unwrap_or_default(),
243 ));
244 }
245
246 let total_files: usize = entries.iter().map(|e| e.files.len()).sum();
247 out.push_str(&format!(
248 "\nTotal: {} contexts, {} files. Use ctx_read on pulled files to load them into your cache.",
249 entries.len(),
250 total_files
251 ));
252
253 out
254}
255
256fn handle_list(project_root: &str) -> String {
257 let dir = shared_dir(project_root);
258 if !dir.exists() {
259 return "No shared contexts.".to_string();
260 }
261
262 let mut count = 0;
263 let mut total_files = 0;
264 let mut out = String::from("Shared context store:\n");
265
266 if let Ok(readdir) = std::fs::read_dir(&dir) {
267 for entry in readdir.flatten() {
268 if let Ok(content) = std::fs::read_to_string(entry.path())
269 && let Ok(ctx) = serde_json::from_str::<SharedContext>(&content)
270 {
271 count += 1;
272 total_files += ctx.files.len();
273 let target = ctx.to_agent.as_deref().unwrap_or("broadcast");
274 out.push_str(&format!(
275 " {} → {} ({} files, {})\n",
276 ctx.from_agent,
277 target,
278 ctx.files.len(),
279 &ctx.timestamp[..19]
280 ));
281 }
282 }
283 }
284
285 if count == 0 {
286 return "No shared contexts.".to_string();
287 }
288
289 out.push_str(&format!("\nTotal: {count} shares, {total_files} files"));
290 out
291}
292
293fn handle_clear(agent_id: Option<&str>, project_root: &str) -> String {
294 let dir = shared_dir(project_root);
295 if !dir.exists() {
296 return "Nothing to clear.".to_string();
297 }
298
299 let my_id = agent_id.unwrap_or("anonymous");
300 let mut removed = 0;
301
302 if let Ok(readdir) = std::fs::read_dir(&dir) {
303 for entry in readdir.flatten() {
304 if let Ok(content) = std::fs::read_to_string(entry.path())
305 && let Ok(ctx) = serde_json::from_str::<SharedContext>(&content)
306 && ctx.from_agent == my_id
307 {
308 let _ = std::fs::remove_file(entry.path());
309 removed += 1;
310 }
311 }
312 }
313
314 format!("Cleared {removed} shared context(s) from {my_id}")
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::core::cache::SessionCache;
321
322 fn shared_json(project_root: &str) -> String {
325 let dir = shared_dir(project_root);
326 let mut all = String::new();
327 if let Ok(rd) = std::fs::read_dir(&dir) {
328 for e in rd.flatten() {
329 all.push_str(&std::fs::read_to_string(e.path()).unwrap_or_default());
330 }
331 }
332 all
333 }
334
335 #[test]
336 fn push_shares_fresh_content_and_pull_lists_it() {
337 let _lock = crate::core::data_dir::test_env_lock();
338 let data = tempfile::tempdir().unwrap();
339 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
340
341 let proj = tempfile::tempdir().unwrap();
342 let root = proj.path().to_str().unwrap();
343 let file = proj.path().join("handover.md");
344 std::fs::write(&file, "HANDOVER marker-AAA\n").unwrap();
345 let path = file.to_str().unwrap();
346
347 let mut cache = SessionCache::new();
348 cache.store(path, "HANDOVER marker-AAA\n");
349
350 let out = handle_push(
351 Some("agentA"),
352 Some("agentB"),
353 Some(path),
354 None,
355 &cache,
356 root,
357 );
358 assert!(out.contains("Shared 1 files"), "push result: {out}");
359 assert!(
360 shared_json(root).contains("marker-AAA"),
361 "content not captured"
362 );
363
364 let pulled = handle_pull(Some("agentB"), root);
366 assert!(
367 pulled.contains("handover.md"),
368 "pull missing file: {pulled}"
369 );
370 }
371
372 #[test]
373 fn push_shares_edited_content_not_stale_diff_mtime() {
374 let _lock = crate::core::data_dir::test_env_lock();
377 let data = tempfile::tempdir().unwrap();
378 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
379
380 let proj = tempfile::tempdir().unwrap();
381 let root = proj.path().to_str().unwrap();
382 let file = proj.path().join("handover.md");
383 std::fs::write(&file, "V1 marker-AAA\n").unwrap();
384 let path = file.to_str().unwrap();
385
386 let mut cache = SessionCache::new();
387 cache.store(path, "V1 marker-AAA\n");
388
389 std::thread::sleep(std::time::Duration::from_millis(10));
390 std::fs::write(&file, "V2 marker-BBB\n").unwrap();
391
392 let out = handle_push(Some("a"), Some("b"), Some(path), None, &cache, root);
393 assert!(out.contains("Shared 1 files"), "push result: {out}");
394 let json = shared_json(root);
395 assert!(
396 json.contains("marker-BBB"),
397 "fresh content not shared: {json}"
398 );
399 assert!(
400 !json.contains("marker-AAA"),
401 "stale content leaked into handover: {json}"
402 );
403 }
404
405 #[test]
406 fn push_shares_edited_content_same_mtime_same_size() {
407 let _lock = crate::core::data_dir::test_env_lock();
409 let data = tempfile::tempdir().unwrap();
410 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
411
412 let proj = tempfile::tempdir().unwrap();
413 let root = proj.path().to_str().unwrap();
414 let file = proj.path().join("h.md");
415 std::fs::write(&file, "AAA\n").unwrap();
416 let path = file.to_str().unwrap();
417 let mtime = std::fs::metadata(&file).unwrap().modified().unwrap();
418
419 let mut cache = SessionCache::new();
420 cache.store(path, "AAA\n");
421
422 std::fs::write(&file, "BBB\n").unwrap();
424 std::fs::OpenOptions::new()
425 .write(true)
426 .open(&file)
427 .unwrap()
428 .set_modified(mtime)
429 .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 let json = shared_json(root);
434 assert!(
435 json.contains("BBB"),
436 "hash backstop failed, stale shared: {json}"
437 );
438 assert!(!json.contains("AAA"), "stale content leaked: {json}");
439 }
440
441 #[test]
442 fn push_skips_uncached_paths() {
443 let _lock = crate::core::data_dir::test_env_lock();
444 let data = tempfile::tempdir().unwrap();
445 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
446
447 let proj = tempfile::tempdir().unwrap();
448 let root = proj.path().to_str().unwrap();
449 let cache = SessionCache::new(); let out = handle_push(
452 Some("a"),
453 Some("b"),
454 Some("/no/such/file.md"),
455 None,
456 &cache,
457 root,
458 );
459 assert!(
460 out.contains("No shareable files found"),
461 "expected skip message: {out}"
462 );
463 }
464
465 #[test]
466 fn push_falls_back_to_disk_inside_root_without_cache() {
467 let _lock = crate::core::data_dir::test_env_lock();
470 let data = tempfile::tempdir().unwrap();
471 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
472
473 let proj = tempfile::tempdir().unwrap();
474 let root = proj.path().to_str().unwrap();
475 std::fs::write(proj.path().join("notes.md"), "ORG-SHARE marker-CCC\n").unwrap();
476
477 let cache = SessionCache::new(); let out = handle_push(
479 Some("team:alice"),
480 None,
481 Some("notes.md"),
482 Some("handover"),
483 &cache,
484 root,
485 );
486 assert!(out.contains("Shared 1 files"), "push result: {out}");
487 assert!(
488 shared_json(root).contains("marker-CCC"),
489 "disk fallback content not captured"
490 );
491
492 let pulled = handle_pull(Some("team:bob"), root);
494 assert!(pulled.contains("notes.md"), "receiver pull: {pulled}");
495 let own = handle_pull(Some("team:alice"), root);
497 assert!(own.contains("No shared contexts for you"), "own: {own}");
498 }
499
500 #[test]
501 fn push_disk_fallback_is_jailed_to_project_root() {
502 let _lock = crate::core::data_dir::test_env_lock();
504 let data = tempfile::tempdir().unwrap();
505 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
506
507 let outside = tempfile::tempdir().unwrap();
508 let secret = outside.path().join("secret.txt");
509 std::fs::write(&secret, "TOP-SECRET\n").unwrap();
510
511 let proj = tempfile::tempdir().unwrap();
512 let root = proj.path().to_str().unwrap();
513 let cache = SessionCache::new();
514
515 for evil in [
516 secret.to_str().unwrap().to_string(),
517 format!("../{}", secret.display()),
518 "../../etc/hosts".to_string(),
519 ] {
520 let out = handle_push(Some("a"), None, Some(&evil), None, &cache, root);
521 assert!(
522 out.contains("No shareable files found"),
523 "jail escape via {evil}: {out}"
524 );
525 }
526 assert!(
527 !shared_json(root).contains("TOP-SECRET"),
528 "outside content leaked into share store"
529 );
530 }
531
532 #[test]
533 fn share_store_is_isolated_per_workspace_root() {
534 let _lock = crate::core::data_dir::test_env_lock();
537 let data = tempfile::tempdir().unwrap();
538 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
539
540 let ws1 = tempfile::tempdir().unwrap();
541 let ws2 = tempfile::tempdir().unwrap();
542 let root1 = ws1.path().to_str().unwrap();
543 let root2 = ws2.path().to_str().unwrap();
544 std::fs::write(ws1.path().join("a.md"), "WS1-ONLY\n").unwrap();
545
546 let cache = SessionCache::new();
547 let out = handle_push(Some("team:t1"), None, Some("a.md"), None, &cache, root1);
548 assert!(out.contains("Shared 1 files"), "push: {out}");
549
550 let other = handle_pull(Some("team:t2"), root2);
552 assert!(
553 other.contains("No shared contexts"),
554 "workspace isolation broken: {other}"
555 );
556 let same = handle_pull(Some("team:t2"), root1);
558 assert!(same.contains("a.md"), "same-workspace pull: {same}");
559 }
560
561 #[test]
562 fn share_filename_is_ntfs_safe_for_org_agent_ids() {
563 assert_eq!(sanitize_for_filename("team:alice"), "team-alice");
568 assert_eq!(
569 sanitize_for_filename("a/b\\c*d?e\"f<g>h|i"),
570 "a-b-c-d-e-f-g-h-i"
571 );
572 assert_eq!(sanitize_for_filename("agent_A.1-x"), "agent_A.1-x");
573 }
574
575 #[test]
576 fn push_falls_back_to_last_known_when_file_deleted() {
577 let _lock = crate::core::data_dir::test_env_lock();
580 let data = tempfile::tempdir().unwrap();
581 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
582
583 let proj = tempfile::tempdir().unwrap();
584 let canon = proj.path().canonicalize().unwrap();
586 let root = canon.to_str().unwrap();
587 let file = canon.join("gone.md");
588 std::fs::write(&file, "LASTKNOWN-AAA\n").unwrap();
589 let path = file.to_str().unwrap();
590
591 let mut cache = SessionCache::new();
592 cache.store(path, "LASTKNOWN-AAA\n");
593 std::fs::remove_file(&file).unwrap();
594
595 let out = handle_push(Some("a"), Some("b"), Some(path), None, &cache, root);
596 assert!(out.contains("Shared 1 files"), "push result: {out}");
597 assert!(
598 shared_json(root).contains("LASTKNOWN-AAA"),
599 "last-known content not shared"
600 );
601 }
602}