1use 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}