Skip to main content

lean_ctx/tools/
ctx_artifacts.rs

1use std::path::Path;
2
3use serde::Serialize;
4
5#[derive(Debug, Serialize)]
6struct ArtifactsStatus {
7    project_root: String,
8    registry_count: usize,
9    resolved_count: usize,
10    missing_count: usize,
11    index_file: String,
12    index_exists: bool,
13    warnings: Vec<String>,
14}
15
16pub fn handle(
17    action: &str,
18    project_root: &Path,
19    query: Option<&str>,
20    top_k: Option<usize>,
21    format: Option<&str>,
22) -> String {
23    match action {
24        "list" => list(project_root, format),
25        "status" => status(project_root, format),
26        "index" | "reindex" => reindex(project_root, format),
27        "search" => search(project_root, query, top_k, format),
28        "remove" => remove(project_root, query, format),
29        _ => "Unknown action. Use: list, status, reindex, search".to_string(),
30    }
31}
32
33fn list(project_root: &Path, format: Option<&str>) -> String {
34    let resolved = crate::core::artifacts::load_resolved(project_root);
35    match format.unwrap_or("json") {
36        "markdown" | "md" => {
37            let mut out = String::new();
38            out.push_str("# Context artifacts\n\n");
39            out.push_str(&format!(
40                "- Project root: `{}`\n\n",
41                project_root.to_string_lossy()
42            ));
43            if !resolved.warnings.is_empty() {
44                out.push_str("## Warnings\n");
45                for w in &resolved.warnings {
46                    out.push_str(&format!("- {w}\n"));
47                }
48                out.push('\n');
49            }
50            if resolved.artifacts.is_empty() {
51                out.push_str("_No artifacts registered._\n");
52                return out;
53            }
54            out.push_str("## Artifacts\n");
55            for a in &resolved.artifacts {
56                let kind = if a.is_dir { "dir" } else { "file" };
57                let exists = if a.exists { "exists" } else { "missing" };
58                out.push_str(&format!(
59                    "- `{}` ({kind}, {exists}) — {}\n",
60                    a.path, a.description
61                ));
62            }
63            out
64        }
65        _ => serde_json::to_string_pretty(&resolved)
66            .unwrap_or_else(|e| format!("{{\"error\":\"serialization failed: {e}\"}}")),
67    }
68}
69
70fn status(project_root: &Path, format: Option<&str>) -> String {
71    let resolved = crate::core::artifacts::load_resolved(project_root);
72    let index_file = crate::core::artifact_index::index_file_path(project_root);
73    let index_exists = index_file.exists();
74
75    let missing = resolved.artifacts.iter().filter(|a| !a.exists).count();
76    let st = ArtifactsStatus {
77        project_root: project_root.to_string_lossy().to_string(),
78        registry_count: resolved.artifacts.len(),
79        resolved_count: resolved.artifacts.len(),
80        missing_count: missing,
81        index_file: index_file.to_string_lossy().to_string(),
82        index_exists,
83        warnings: resolved.warnings,
84    };
85
86    match format.unwrap_or("json") {
87        "markdown" | "md" => {
88            let mut out = String::new();
89            out.push_str("# Artifacts status\n\n");
90            out.push_str(&format!("- Project root: `{}`\n", st.project_root));
91            out.push_str(&format!("- Registry entries: `{}`\n", st.registry_count));
92            out.push_str(&format!("- Missing: `{}`\n", st.missing_count));
93            out.push_str(&format!("- Index file: `{}`\n", st.index_file));
94            out.push_str(&format!(
95                "- Index exists: `{}`\n\n",
96                if st.index_exists { "yes" } else { "no" }
97            ));
98            if !st.warnings.is_empty() {
99                out.push_str("## Warnings\n");
100                for w in &st.warnings {
101                    out.push_str(&format!("- {w}\n"));
102                }
103                out.push('\n');
104            }
105            out
106        }
107        _ => serde_json::to_string_pretty(&st)
108            .unwrap_or_else(|e| format!("{{\"error\":\"serialization failed: {e}\"}}")),
109    }
110}
111
112fn reindex(project_root: &Path, format: Option<&str>) -> String {
113    let (idx, warnings) = crate::core::artifact_index::rebuild_from_scratch(project_root);
114    let index_file = crate::core::artifact_index::index_file_path(project_root);
115    let res = serde_json::json!({
116        "project_root": project_root.to_string_lossy().to_string(),
117        "index_file": index_file.to_string_lossy().to_string(),
118        "files": idx.files.len(),
119        "chunks": idx.doc_count,
120        "warnings": warnings,
121    });
122    match format.unwrap_or("json") {
123        "markdown" | "md" => {
124            let mut out = String::new();
125            out.push_str("# Artifacts reindex\n\n");
126            out.push_str(&format!(
127                "- Project root: `{}`\n- Files: `{}`\n- Chunks: `{}`\n- Index file: `{}`\n",
128                res["project_root"].as_str().unwrap_or_default(),
129                res["files"].as_u64().unwrap_or(0),
130                res["chunks"].as_u64().unwrap_or(0),
131                res["index_file"].as_str().unwrap_or_default()
132            ));
133            if let Some(w) = res["warnings"].as_array() {
134                if !w.is_empty() {
135                    out.push_str("\n## Warnings\n");
136                    for ww in w {
137                        if let Some(s) = ww.as_str() {
138                            out.push_str(&format!("- {s}\n"));
139                        }
140                    }
141                }
142            }
143            out
144        }
145        _ => serde_json::to_string_pretty(&res)
146            .unwrap_or_else(|e| format!("{{\"error\":\"serialization failed: {e}\"}}")),
147    }
148}
149
150fn search(
151    project_root: &Path,
152    query: Option<&str>,
153    top_k: Option<usize>,
154    format: Option<&str>,
155) -> String {
156    let Some(q) = query.map(str::trim).filter(|s| !s.is_empty()) else {
157        return "query is required for action=search".to_string();
158    };
159    let k = top_k.unwrap_or(10).clamp(1, 50);
160    let (idx, mut warnings) = crate::core::artifact_index::load_or_build(project_root);
161    let results = idx.search(q, k);
162    if idx.doc_count == 0 {
163        warnings.push("artifact index is empty (no indexed chunks)".to_string());
164    }
165    let res = serde_json::json!({
166        "project_root": project_root.to_string_lossy().to_string(),
167        "query": q,
168        "top_k": k,
169        "results": results,
170        "warnings": warnings,
171    });
172    match format.unwrap_or("json") {
173        "markdown" | "md" => {
174            let mut out = String::new();
175            out.push_str("# Artifact search\n\n");
176            out.push_str(&format!(
177                "- Query: `{}`\n- Results: `{}`\n\n",
178                q,
179                res["results"].as_array().map_or(0, Vec::len)
180            ));
181            if let Some(w) = res["warnings"].as_array() {
182                if !w.is_empty() {
183                    out.push_str("## Warnings\n");
184                    for ww in w {
185                        if let Some(s) = ww.as_str() {
186                            out.push_str(&format!("- {s}\n"));
187                        }
188                    }
189                    out.push('\n');
190                }
191            }
192            out.push_str("## Results\n");
193            for r in results {
194                out.push_str(&format!(
195                    "- `{}` ({}–{}): {}\n",
196                    r.file_path,
197                    r.start_line,
198                    r.end_line,
199                    r.snippet.replace('\n', " ")
200                ));
201            }
202            out
203        }
204        _ => serde_json::to_string_pretty(&res)
205            .unwrap_or_else(|e| format!("{{\"error\":\"serialization failed: {e}\"}}")),
206    }
207}
208
209fn remove(project_root: &Path, name: Option<&str>, format: Option<&str>) -> String {
210    let Some(name) = name.map(str::trim).filter(|s| !s.is_empty()) else {
211        return "name is required for action=remove".to_string();
212    };
213
214    let lean_path = project_root.join(".leanctxcontextartifacts.json");
215    if !lean_path.exists() {
216        let socrati = project_root.join(".socraticodecontextartifacts.json");
217        if socrati.exists() {
218            return "registry is in .socraticodecontextartifacts.json; migrate to .leanctxcontextartifacts.json to edit"
219                .to_string();
220        }
221        return "no artifact registry file found".to_string();
222    }
223
224    let content = match std::fs::read_to_string(&lean_path) {
225        Ok(s) => s,
226        Err(e) => return format!("failed to read registry: {e}"),
227    };
228
229    let (as_array, mut specs): (bool, Vec<crate::core::artifacts::ArtifactSpec>) =
230        match serde_json::from_str::<serde_json::Value>(&content) {
231            Ok(v) => {
232                if let Some(arr) = v.as_array() {
233                    let mut out = Vec::new();
234                    for item in arr {
235                        if let Ok(s) = serde_json::from_value::<crate::core::artifacts::ArtifactSpec>(
236                            item.clone(),
237                        ) {
238                            out.push(s);
239                        }
240                    }
241                    (true, out)
242                } else if let Some(obj) = v.as_object() {
243                    if let Some(arts) = obj.get("artifacts").and_then(|a| a.as_array()) {
244                        let mut out = Vec::new();
245                        for item in arts {
246                            if let Ok(s) = serde_json::from_value::<
247                                crate::core::artifacts::ArtifactSpec,
248                            >(item.clone())
249                            {
250                                out.push(s);
251                            }
252                        }
253                        (false, out)
254                    } else {
255                        return "invalid registry schema (expected array or {artifacts:[...]})"
256                            .to_string();
257                    }
258                } else {
259                    return "invalid registry schema (expected array or object)".to_string();
260                }
261            }
262            Err(e) => return format!("invalid JSON: {e}"),
263        };
264
265    let before = specs.len();
266    specs.retain(|s| s.name.trim() != name);
267    let removed = before.saturating_sub(specs.len());
268
269    if removed == 0 {
270        return format!("artifact not found: {name}");
271    }
272
273    let new_json = if as_array {
274        serde_json::to_string_pretty(&specs)
275    } else {
276        serde_json::to_string_pretty(&serde_json::json!({ "artifacts": specs }))
277    }
278    .unwrap_or_else(|e| format!("{{\"error\":\"serialization failed: {e}\"}}"));
279
280    if let Err(e) = std::fs::write(&lean_path, new_json) {
281        return format!("failed to write registry: {e}");
282    }
283
284    let res = serde_json::json!({
285        "project_root": project_root.to_string_lossy().to_string(),
286        "registry_file": lean_path.to_string_lossy().to_string(),
287        "removed": removed,
288        "name": name
289    });
290    match format.unwrap_or("json") {
291        "markdown" | "md" => {
292            format!(
293                "# Artifact removed\n\n- Name: `{}`\n- Removed: `{}`\n- Registry: `{}`\n",
294                name,
295                removed,
296                res["registry_file"].as_str().unwrap_or_default(),
297            )
298        }
299        _ => serde_json::to_string_pretty(&res)
300            .unwrap_or_else(|e| format!("{{\"error\":\"serialization failed: {e}\"}}")),
301    }
302}