Skip to main content

lean_ctx/tools/
ctx_expand.rs

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
18/// Try to resolve a handle reference (@F1, @K1, etc.) to a file path.
19/// Returns None if the ID is not a handle reference.
20pub 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(ref item_id), Some(ref 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    // Handle reference resolution: @F1, @K1, @S1, etc.
65    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(pattern) = args.get("search").and_then(|v| v.as_str()) {
73        return match archive::retrieve_with_search(id, pattern) {
74            Some(result) => result,
75            None => format!("Archive '{id}' not found or expired. Use ctx_expand(action=\"list\") to see available archives."),
76        };
77    }
78
79    let start = args
80        .get("start_line")
81        .and_then(serde_json::Value::as_u64)
82        .map(|v| v as usize);
83    let end = args
84        .get("end_line")
85        .and_then(serde_json::Value::as_u64)
86        .map(|v| v as usize);
87
88    if let (Some(s), Some(e)) = (start, end) {
89        return match archive::retrieve_with_range(id, s, e) {
90            Some(result) => {
91                format!("Archive {id} lines {s}-{e}:\n{result}")
92            }
93            None => format!("Archive '{id}' not found or expired."),
94        };
95    }
96
97    match archive::retrieve(id) {
98        Some(content) => {
99            let lines = content.lines().count();
100            let chars = content.len();
101            format!("Archive {id} ({chars} chars, {lines} lines):\n{content}")
102        }
103        None => format!(
104            "Archive '{id}' not found or expired. Use ctx_expand(action=\"list\") to see available archives."
105        ),
106    }
107}
108
109fn handle_search_all(args: &serde_json::Value) -> String {
110    let query = match args.get("query").and_then(|v| v.as_str()) {
111        Some(q) if !q.is_empty() => q,
112        _ => return "ERROR: 'query' parameter required for search_all.".to_string(),
113    };
114    let limit = args
115        .get("limit")
116        .and_then(serde_json::Value::as_u64)
117        .unwrap_or(10) as usize;
118
119    let results = crate::core::archive_fts::search(query, limit);
120    if results.is_empty() {
121        return format!(
122            "No archives match \"{query}\". Indexed: {} entries.",
123            crate::core::archive_fts::entry_count()
124        );
125    }
126
127    let mut out = format!("{} result(s) for \"{}\":\n", results.len(), query);
128    for r in &results {
129        out.push_str(&format!(
130            "  {} | {} | {} | …{}…\n",
131            r.archive_id, r.tool, r.command, r.snippet
132        ));
133    }
134    out.push_str("\nRetrieve full: ctx_expand(id=\"<archive_id>\")");
135    out
136}
137
138fn handle_list(args: &serde_json::Value) -> String {
139    let session_id = args.get("session_id").and_then(|v| v.as_str());
140    let entries = archive::list_entries(session_id);
141
142    if entries.is_empty() {
143        return "No archives found.".to_string();
144    }
145
146    let mut out = format!("{} archive(s):\n", entries.len());
147    for e in &entries {
148        out.push_str(&format!(
149            "  {} | {} | {} | {} chars ({} tok) | {}\n",
150            e.id,
151            e.tool,
152            e.command,
153            e.size_chars,
154            e.size_tokens,
155            e.created_at.format("%H:%M:%S")
156        ));
157    }
158    out.push_str("\nRetrieve: ctx_expand(id=\"<id>\")");
159    out.push_str("\nSearch: ctx_expand(id=\"<id>\", search=\"ERROR\")");
160    out.push_str("\nRange: ctx_expand(id=\"<id>\", start_line=10, end_line=50)");
161    out
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use serde_json::json;
168
169    #[test]
170    fn handle_missing_id_returns_error() {
171        let result = handle(&json!({}));
172        assert!(result.contains("ERROR"));
173        assert!(result.contains("id"));
174    }
175
176    #[test]
177    fn handle_nonexistent_returns_not_found() {
178        let result = handle(&json!({"id": "nonexistent_xyz"}));
179        assert!(result.contains("not found"));
180    }
181
182    #[test]
183    fn handle_list_empty() {
184        let result = handle(&json!({"action": "list"}));
185        assert!(
186            result.contains("No archives") || result.contains("archive(s)"),
187            "unexpected: {result}"
188        );
189    }
190}