lean_ctx/tools/
ctx_expand.rs1use crate::core::archive;
2use crate::core::context_handles::HandleRegistry;
3use crate::core::context_ledger::ContextLedger;
4
5pub fn handle(args: &serde_json::Value) -> String {
6 let action = args
7 .get("action")
8 .and_then(|v| v.as_str())
9 .unwrap_or("retrieve");
10
11 match action {
12 "list" => handle_list(args),
13 _ => handle_retrieve(args),
14 }
15}
16
17pub fn resolve_handle_ref(id: &str) -> Option<String> {
20 let clean = id.strip_prefix('@').unwrap_or(id);
21 if clean.len() < 2 {
22 return None;
23 }
24 let prefix = clean.chars().next()?;
25 if !matches!(prefix, 'F' | 'S' | 'K' | 'M' | 'P') {
26 return None;
27 }
28 if !clean[1..].chars().all(|c| c.is_ascii_digit()) {
29 return None;
30 }
31
32 let ledger = ContextLedger::load();
33 let mut registry = HandleRegistry::new();
34 for entry in &ledger.entries {
35 if let (Some(ref item_id), Some(ref kind)) = (&entry.id, &entry.kind) {
36 let phi = entry.phi.unwrap_or(0.5);
37 let view_costs = entry.view_costs.clone().unwrap_or_else(|| {
38 crate::core::context_field::ViewCosts::from_full_tokens(entry.original_tokens)
39 });
40 registry.register(
41 item_id.clone(),
42 *kind,
43 &entry.path,
44 &format!("{} {}L", entry.path, entry.original_tokens),
45 &view_costs,
46 phi,
47 entry
48 .state
49 .as_ref()
50 .is_some_and(|s| *s == crate::core::context_field::ContextState::Pinned),
51 );
52 }
53 }
54
55 registry.resolve(clean).map(|h| h.source_path.clone())
56}
57
58fn handle_retrieve(args: &serde_json::Value) -> String {
59 let Some(id) = args.get("id").and_then(|v| v.as_str()) else {
60 return "ERROR: 'id' parameter is required. Use ctx_expand(action=\"list\") to see available archives, or pass a handle ref like @F1.".to_string();
61 };
62
63 if let Some(path) = resolve_handle_ref(id) {
65 let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("full");
66 return format!(
67 "[handle:{id} -> {path}]\nUse ctx_read(path=\"{path}\", mode=\"{mode}\") to load content."
68 );
69 }
70
71 if let Some(pattern) = args.get("search").and_then(|v| v.as_str()) {
72 return match archive::retrieve_with_search(id, pattern) {
73 Some(result) => result,
74 None => format!("Archive '{id}' not found or expired. Use ctx_expand(action=\"list\") to see available archives."),
75 };
76 }
77
78 let start = args
79 .get("start_line")
80 .and_then(serde_json::Value::as_u64)
81 .map(|v| v as usize);
82 let end = args
83 .get("end_line")
84 .and_then(serde_json::Value::as_u64)
85 .map(|v| v as usize);
86
87 if let (Some(s), Some(e)) = (start, end) {
88 return match archive::retrieve_with_range(id, s, e) {
89 Some(result) => {
90 format!("Archive {id} lines {s}-{e}:\n{result}")
91 }
92 None => format!("Archive '{id}' not found or expired."),
93 };
94 }
95
96 match archive::retrieve(id) {
97 Some(content) => {
98 let lines = content.lines().count();
99 let chars = content.len();
100 format!("Archive {id} ({chars} chars, {lines} lines):\n{content}")
101 }
102 None => format!(
103 "Archive '{id}' not found or expired. Use ctx_expand(action=\"list\") to see available archives."
104 ),
105 }
106}
107
108fn handle_list(args: &serde_json::Value) -> String {
109 let session_id = args.get("session_id").and_then(|v| v.as_str());
110 let entries = archive::list_entries(session_id);
111
112 if entries.is_empty() {
113 return "No archives found.".to_string();
114 }
115
116 let mut out = format!("{} archive(s):\n", entries.len());
117 for e in &entries {
118 out.push_str(&format!(
119 " {} | {} | {} | {} chars ({} tok) | {}\n",
120 e.id,
121 e.tool,
122 e.command,
123 e.size_chars,
124 e.size_tokens,
125 e.created_at.format("%H:%M:%S")
126 ));
127 }
128 out.push_str("\nRetrieve: ctx_expand(id=\"<id>\")");
129 out.push_str("\nSearch: ctx_expand(id=\"<id>\", search=\"ERROR\")");
130 out.push_str("\nRange: ctx_expand(id=\"<id>\", start_line=10, end_line=50)");
131 out
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use serde_json::json;
138
139 #[test]
140 fn handle_missing_id_returns_error() {
141 let result = handle(&json!({}));
142 assert!(result.contains("ERROR"));
143 assert!(result.contains("id"));
144 }
145
146 #[test]
147 fn handle_nonexistent_returns_not_found() {
148 let result = handle(&json!({"id": "nonexistent_xyz"}));
149 assert!(result.contains("not found"));
150 }
151
152 #[test]
153 fn handle_list_empty() {
154 let result = handle(&json!({"action": "list"}));
155 assert!(
156 result.contains("No archives") || result.contains("archive(s)"),
157 "unexpected: {result}"
158 );
159 }
160}