1use 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 "search_all" => handle_search_all(args),
14 _ => handle_retrieve(args),
15 }
16}
17
18pub fn resolve_handle_ref(id: &str) -> Option<String> {
21 let clean = id.strip_prefix('@').unwrap_or(id);
22 if clean.len() < 2 {
23 return None;
24 }
25 let prefix = clean.chars().next()?;
26 if !matches!(prefix, 'F' | 'S' | 'K' | 'M' | 'P') {
27 return None;
28 }
29 if !clean[1..].chars().all(|c| c.is_ascii_digit()) {
30 return None;
31 }
32
33 let ledger = ContextLedger::load();
34 let mut registry = HandleRegistry::new();
35 for entry in &ledger.entries {
36 if let (Some(item_id), Some(kind)) = (&entry.id, &entry.kind) {
37 let phi = entry.phi.unwrap_or(0.5);
38 let view_costs = entry.view_costs.clone().unwrap_or_else(|| {
39 crate::core::context_field::ViewCosts::from_full_tokens(entry.original_tokens)
40 });
41 registry.register(
42 item_id.clone(),
43 *kind,
44 &entry.path,
45 &format!("{} {}L", entry.path, entry.original_tokens),
46 &view_costs,
47 phi,
48 entry
49 .state
50 .as_ref()
51 .is_some_and(|s| *s == crate::core::context_field::ContextState::Pinned),
52 );
53 }
54 }
55
56 registry.resolve(clean).map(|h| h.source_path.clone())
57}
58
59fn handle_retrieve(args: &serde_json::Value) -> String {
60 let Some(id) = args.get("id").and_then(|v| v.as_str()) else {
61 return "ERROR: 'id' parameter is required. Use ctx_expand(action=\"list\") to see available archives, or pass a handle ref like @F1.".to_string();
62 };
63
64 if let Some(path) = resolve_handle_ref(id) {
66 let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("full");
67 return format!(
68 "[handle:{id} -> {path}]\nUse ctx_read(path=\"{path}\", mode=\"{mode}\") to load content."
69 );
70 }
71
72 if let Some(path) = crate::proxy::ccr::resolve_tee(id) {
82 return expand_tee_file(&path, args);
83 }
84
85 if id.starts_with("ref_") {
91 let Some(content) = crate::server::reference_store::resolve(id) else {
92 return format!(
93 "Reference '{id}' not found or expired (5-min TTL). \
94 Use the HTTP proxy at /v1/references/{id} if available."
95 );
96 };
97 return dispatch_selectors(id, &content, "Reference", args);
98 }
99 let Some(content) = archive::retrieve(id) else {
100 return format!(
101 "Archive '{id}' not found or expired. Use ctx_expand(action=\"list\") to see available archives."
102 );
103 };
104 dispatch_selectors(id, &content, "Archive", args)
105}
106
107fn dispatch_selectors(id: &str, content: &str, noun: &str, args: &serde_json::Value) -> String {
114 let label = format!("{} {id}", noun.to_ascii_lowercase());
115
116 if let Some(n) = args.get("head").and_then(serde_json::Value::as_u64) {
117 let n = n as usize;
118 return format!(
119 "{noun} {id} head {n}:\n{}",
120 archive::format_range(content, 1, n)
121 );
122 }
123 if let Some(n) = args.get("tail").and_then(serde_json::Value::as_u64) {
124 let n = n as usize;
125 let total = content.lines().count();
126 let start = if total > n { total - n + 1 } else { 1 };
127 return format!(
128 "{noun} {id} tail {n}:\n{}",
129 archive::format_range(content, start, total)
130 );
131 }
132 if args.get("json_keys").and_then(serde_json::Value::as_bool) == Some(true)
133 || args.get("json_path").is_some()
134 {
135 let path = args.get("json_path").and_then(|v| v.as_str());
136 return match archive::format_json_keys(content, path, &label) {
137 Some(out) => out,
138 None => format!(
139 "{noun} '{id}' is not valid JSON. Use ctx_expand(id=\"{id}\") for raw content."
140 ),
141 };
142 }
143 if let Some(pattern) = args.get("search").and_then(|v| v.as_str()) {
144 return archive::format_search(content, pattern, &label);
145 }
146
147 let start = args
148 .get("start_line")
149 .and_then(serde_json::Value::as_u64)
150 .map(|v| v as usize);
151 let end = args
152 .get("end_line")
153 .and_then(serde_json::Value::as_u64)
154 .map(|v| v as usize);
155 if let (Some(s), Some(e)) = (start, end) {
156 return format!(
157 "{noun} {id} lines {s}-{e}:\n{}",
158 archive::format_range(content, s, e)
159 );
160 }
161
162 let lines = content.lines().count();
163 let chars = content.len();
164 format!("{noun} {id} ({chars} chars, {lines} lines):\n{content}")
165}
166
167fn expand_tee_file(path: &std::path::Path, args: &serde_json::Value) -> String {
172 let Ok(content) = std::fs::read_to_string(path) else {
173 return format!(
174 "ERROR: CCR tee file is no longer available: {}",
175 path.display()
176 );
177 };
178 let label = path.file_name().and_then(|n| n.to_str()).unwrap_or("ccr");
179
180 if let Some(n) = args.get("head").and_then(serde_json::Value::as_u64) {
181 return format!(
182 "[ccr {label}] head {n}:\n{}",
183 head_lines(&content, n as usize)
184 );
185 }
186 if let Some(n) = args.get("tail").and_then(serde_json::Value::as_u64) {
187 return format!(
188 "[ccr {label}] tail {n}:\n{}",
189 tail_lines(&content, n as usize)
190 );
191 }
192 if args.get("json_keys").and_then(serde_json::Value::as_bool) == Some(true)
193 || args.get("json_path").is_some()
194 {
195 let jp = args.get("json_path").and_then(|v| v.as_str());
196 return match json_view(&content, jp) {
197 Some(out) => format!("[ccr {label}] json {}:\n{out}", jp.unwrap_or("(keys)")),
198 None => format!(
199 "[ccr {label}] not valid JSON or path not found. Use ctx_expand(id=\"{label}\") for raw content."
200 ),
201 };
202 }
203 if let Some(pattern) = args.get("search").and_then(|v| v.as_str()) {
204 return format!(
205 "[ccr {label}] search \"{pattern}\":\n{}",
206 search_lines(&content, pattern)
207 );
208 }
209 let start = args
210 .get("start_line")
211 .and_then(serde_json::Value::as_u64)
212 .map(|v| v as usize);
213 let end = args
214 .get("end_line")
215 .and_then(serde_json::Value::as_u64)
216 .map(|v| v as usize);
217 if let (Some(s), Some(e)) = (start, end) {
218 return format!(
219 "[ccr {label}] lines {s}-{e}:\n{}",
220 range_lines(&content, s, e)
221 );
222 }
223
224 let lines = content.lines().count();
225 format!(
226 "[ccr {label}] ({} chars, {lines} lines):\n{content}",
227 content.len()
228 )
229}
230
231fn head_lines(s: &str, n: usize) -> String {
232 s.lines().take(n).collect::<Vec<_>>().join("\n")
233}
234
235fn tail_lines(s: &str, n: usize) -> String {
236 let v: Vec<&str> = s.lines().collect();
237 let start = v.len().saturating_sub(n);
238 v[start..].join("\n")
239}
240
241fn range_lines(s: &str, start: usize, end: usize) -> String {
243 let v: Vec<&str> = s.lines().collect();
244 let a = start.saturating_sub(1).min(v.len());
245 let b = end.min(v.len());
246 if a >= b {
247 return String::new();
248 }
249 v[a..b].join("\n")
250}
251
252fn search_lines(s: &str, pattern: &str) -> String {
253 let hits: Vec<String> = s
254 .lines()
255 .enumerate()
256 .filter(|(_, l)| l.contains(pattern))
257 .map(|(i, l)| format!("{}: {}", i + 1, l))
258 .collect();
259 if hits.is_empty() {
260 format!("(no lines match \"{pattern}\")")
261 } else {
262 hits.join("\n")
263 }
264}
265
266fn json_view(s: &str, path: Option<&str>) -> Option<String> {
270 let root: serde_json::Value = serde_json::from_str(s).ok()?;
271 let target = match path {
272 Some(p) if !p.is_empty() => navigate_json(&root, p)?,
273 _ => &root,
274 };
275 if let Some(obj) = target.as_object() {
276 Some(obj.keys().cloned().collect::<Vec<_>>().join("\n"))
277 } else {
278 serde_json::to_string_pretty(target).ok()
279 }
280}
281
282fn navigate_json<'a>(v: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
283 let mut cur = v;
284 for seg in path.split('.').filter(|s| !s.is_empty()) {
285 cur = match seg.parse::<usize>() {
286 Ok(idx) => cur.get(idx)?,
287 Err(_) => cur.get(seg)?,
288 };
289 }
290 Some(cur)
291}
292
293fn handle_search_all(args: &serde_json::Value) -> String {
294 let query = match args.get("query").and_then(|v| v.as_str()) {
295 Some(q) if !q.is_empty() => q,
296 _ => return "ERROR: 'query' parameter required for search_all.".to_string(),
297 };
298 let limit = args
299 .get("limit")
300 .and_then(serde_json::Value::as_u64)
301 .unwrap_or(10) as usize;
302
303 let results = crate::core::archive_fts::search(query, limit);
304 if results.is_empty() {
305 return format!(
306 "No archives match \"{query}\". Indexed: {} entries.",
307 crate::core::archive_fts::entry_count()
308 );
309 }
310
311 let mut out = format!("{} result(s) for \"{}\":\n", results.len(), query);
312 for r in &results {
313 out.push_str(&format!(
314 " {} | {} | {} | …{}…\n",
315 r.archive_id, r.tool, r.command, r.snippet
316 ));
317 }
318 out.push_str("\nRetrieve full: ctx_expand(id=\"<archive_id>\")");
319 out
320}
321
322fn handle_list(args: &serde_json::Value) -> String {
323 let session_id = args.get("session_id").and_then(|v| v.as_str());
324 let entries = archive::list_entries(session_id);
325
326 if entries.is_empty() {
327 return "No archives found.".to_string();
328 }
329
330 let mut out = format!("{} archive(s):\n", entries.len());
331 for e in &entries {
332 out.push_str(&format!(
333 " {} | {} | {} | {} chars ({} tok) | {}\n",
334 e.id,
335 e.tool,
336 e.command,
337 e.size_chars,
338 e.size_tokens,
339 e.created_at.format("%H:%M:%S")
340 ));
341 }
342 out.push_str("\nRetrieve: ctx_expand(id=\"<id>\")");
343 out.push_str("\nSearch: ctx_expand(id=\"<id>\", search=\"ERROR\")");
344 out.push_str("\nRange: ctx_expand(id=\"<id>\", start_line=10, end_line=50)");
345 out.push_str("\nHead/Tail: ctx_expand(id=\"<id>\", head=120) | tail=40");
346 out.push_str("\nJSON: ctx_expand(id=\"<id>\", json_keys=true) | json_path=\"data.items\"");
347 out
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use serde_json::json;
354
355 #[test]
356 fn handle_missing_id_returns_error() {
357 let result = handle(&json!({}));
358 assert!(result.contains("ERROR"));
359 assert!(result.contains("id"));
360 }
361
362 #[test]
363 fn handle_nonexistent_returns_not_found() {
364 let result = handle(&json!({"id": "nonexistent_xyz"}));
365 assert!(result.contains("not found"));
366 }
367
368 #[test]
369 fn handle_list_empty() {
370 let result = handle(&json!({"action": "list"}));
371 assert!(
372 result.contains("No archives") || result.contains("archive(s)"),
373 "unexpected: {result}"
374 );
375 }
376
377 #[test]
378 fn text_selectors_slice_correctly() {
379 let body = (1..=10)
380 .map(|i| format!("line {i}"))
381 .collect::<Vec<_>>()
382 .join("\n");
383 assert_eq!(head_lines(&body, 2), "line 1\nline 2");
384 assert_eq!(tail_lines(&body, 2), "line 9\nline 10");
385 assert_eq!(range_lines(&body, 3, 4), "line 3\nline 4");
386 assert!(search_lines(&body, "line 7").contains("7: line 7"));
387 assert!(search_lines(&body, "zzz").contains("no lines match"));
388 }
389
390 #[test]
391 fn json_view_lists_keys_and_navigates() {
392 let doc = r#"{"a":{"b":[10,20,30]},"c":1}"#;
393 assert_eq!(json_view(doc, None).unwrap(), "a\nc");
394 assert_eq!(json_view(doc, Some("a")).unwrap(), "b");
395 assert_eq!(json_view(doc, Some("a.b.1")).unwrap(), "20");
396 assert!(json_view(doc, Some("a.missing")).is_none());
397 assert!(json_view("not json", None).is_none());
398 }
399
400 #[test]
401 fn ctx_expand_retrieves_proxy_tee_handle_surgically() {
402 let _lock = crate::core::data_dir::test_env_lock();
403 let original = (1..=60)
406 .map(|i| format!("output row {i:03}"))
407 .collect::<Vec<_>>()
408 .join("\n");
409 assert!(original.len() >= crate::proxy::ccr::MIN_TEE_BYTES);
410 let tee_handle = crate::proxy::ccr::persist(&original).expect("tee handle");
411
412 let full = handle(&json!({"id": tee_handle}));
414 assert!(full.contains("output row 001") && full.contains("output row 060"));
415
416 let hash = crate::core::hasher::hash_short(&original);
418 let head = handle(&json!({"id": hash, "head": 2}));
419 assert!(head.contains("output row 001") && !head.contains("output row 010"));
420 let search = handle(&json!({"id": hash, "search": "row 042"}));
421 assert!(search.contains("output row 042") && !search.contains("output row 001"));
422 }
423
424 #[test]
425 fn ctx_expand_retrieves_shell_tee_output_surgically() {
426 let _lock = crate::core::data_dir::test_env_lock();
427 let original = (1..=60)
431 .map(|i| format!("api response row {i:03}"))
432 .collect::<Vec<_>>()
433 .join("\n");
434 let tee_path =
435 crate::shell::save_tee("gh api /repos/foo/bar", &original).expect("shell tee saved");
436 let name = std::path::Path::new(&tee_path)
437 .file_name()
438 .and_then(|n| n.to_str())
439 .unwrap()
440 .to_string();
441
442 let full = handle(&json!({ "id": name.clone() }));
444 assert!(full.contains("api response row 001") && full.contains("api response row 060"));
445
446 let head = handle(&json!({ "id": name.clone(), "head": 2 }));
448 assert!(head.contains("api response row 001") && !head.contains("api response row 010"));
449 let search = handle(&json!({ "id": name, "search": "row 042" }));
450 assert!(search.contains("api response row 042") && !search.contains("row 001"));
451 }
452
453 #[test]
454 fn ctx_expand_retrieves_json_crush_original() {
455 let _lock = crate::core::data_dir::test_env_lock();
456 let original = (1..=50)
460 .map(|i| format!(r#"{{"id":{i},"ts":"2026-06-22T10:00:{i:02}Z"}}"#))
461 .collect::<Vec<_>>()
462 .join("\n");
463 assert!(original.len() >= crate::proxy::ccr::MIN_TEE_BYTES);
464 let handle_path = crate::proxy::ccr::persist_json(&original).expect("json tee handle");
465 assert!(handle_path.contains("json_"), "json_ prefix: {handle_path}");
466
467 let out = handle(&json!({ "id": handle_path }));
468 assert!(
469 out.contains("2026-06-22T10:00:42Z"),
470 "dropped column recoverable: {out}"
471 );
472 }
473
474 #[test]
475 fn ctx_expand_resolves_reference_store_ids() {
476 let body = (1..=40)
480 .map(|i| format!("ref row {i}"))
481 .collect::<Vec<_>>()
482 .join("\n");
483 let id = crate::server::reference_store::store(body);
484 assert!(id.starts_with("ref_"), "store must mint a ref_ id: {id}");
485
486 let full = handle(&json!({"id": id}));
487 assert!(
488 full.contains("Reference") && full.contains("ref row 1") && full.contains("ref row 40"),
489 "full: {full}"
490 );
491
492 let head = handle(&json!({"id": id, "head": 3}));
493 assert!(head.contains("ref row 1") && head.contains("ref row 3"));
494 assert!(!head.contains("ref row 4"), "head leaked row 4: {head}");
495
496 let tail = handle(&json!({"id": id, "tail": 2}));
497 assert!(tail.contains("ref row 39") && tail.contains("ref row 40"));
498 assert!(!tail.contains("ref row 38"), "tail leaked row 38: {tail}");
499
500 let search = handle(&json!({"id": id, "search": "ref row 7"}));
501 assert!(search.contains("ref row 7") && !search.contains("ref row 1\n"));
502
503 let range = handle(&json!({"id": id, "start_line": 5, "end_line": 6}));
504 assert!(range.contains("ref row 5") && range.contains("ref row 6"));
505 assert!(!range.contains("ref row 4") && !range.contains("ref row 7"));
506 }
507
508 #[test]
509 fn ctx_expand_reference_json_keys_and_missing() {
510 let id = crate::server::reference_store::store(r#"{"a":1,"b":[1,2,3]}"#.to_string());
511 let keys = handle(&json!({"id": id, "json_keys": true}));
512 assert!(keys.contains("object (2 keys)"), "got: {keys}");
513 assert!(keys.contains("array(3)"), "got: {keys}");
514
515 let missing = handle(&json!({"id": "ref_deadbeefcafef00d"}));
518 assert!(
519 missing.contains("not found or expired") && missing.contains("5-min TTL"),
520 "got: {missing}"
521 );
522 }
523}