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}