Skip to main content

lean_ctx_server/tools/
ctx_artifacts.rs

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