Skip to main content

lean_ctx/tools/
ctx_share.rs

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        // Revalidate against disk before handing the file to another agent: a
70        // stale cached copy would silently pass an outdated handover file to the
71        // receiving agent. `current_full_content` re-reads when the cache is
72        // behind disk, so the receiver always gets the current content.
73        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    /// Concatenated JSON of every shared-context file for `project_root`. Lets a
269    /// test assert on exactly what content was *captured into the handover*.
270    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        // The receiver sees the handover listed.
311        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        // Carlos handover: a file edited *after* it was cached must be shared as
321        // the NEW content — the receiving agent must never get the pre-edit copy.
322        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        // Hash backstop: identical mtime + identical size, changed content.
354        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        // Same length (4 bytes), restore the original mtime → only the content hash differs.
369        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(); // empty
396
397        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        // Stale + unreadable (deleted between cache and handover): the last-known
414        // cached copy is shared rather than dropping the file silently.
415        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        // Canonicalize so the cache key stays stable after the file is removed.
421        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}