algocline_app/service/
pkg.rs1use std::collections::HashMap;
2use std::path::Path;
3
4use super::manifest;
5use super::path::{copy_dir, ContainedPath};
6use super::resolve::{
7 install_scenarios_from_dir, is_system_package, packages_dir, scenarios_dir, DirEntryFailures,
8 AUTO_INSTALL_SOURCES,
9};
10use super::AppService;
11
12impl AppService {
13 pub async fn pkg_list(&self) -> Result<String, String> {
20 let mut seen: HashMap<String, Vec<(usize, String)>> = HashMap::new();
23 let mut all_packages: Vec<serde_json::Value> = Vec::new();
24
25 for (idx, sp) in self.search_paths.iter().enumerate() {
26 if !sp.path.is_dir() {
27 continue;
28 }
29 let entries = match std::fs::read_dir(&sp.path) {
30 Ok(e) => e,
31 Err(_) => continue,
32 };
33
34 for entry in entries.flatten() {
35 let path = entry.path();
36 if !path.is_dir() {
37 continue;
38 }
39 if !path.join("init.lua").exists() {
40 continue;
41 }
42 let name = entry.file_name().to_string_lossy().to_string();
43 if is_system_package(&name) {
44 continue;
45 }
46
47 let source_display = sp.path.display().to_string();
48 seen.entry(name.clone())
49 .or_default()
50 .push((idx, source_display.clone()));
51
52 let occurrences = &seen[&name];
53 let active = occurrences.len() == 1; let code = format!(
56 r#"local pkg = require("{name}")
57return pkg.meta or {{ name = "{name}" }}"#
58 );
59 let mut pkg_json = match self.executor.eval_simple(code).await {
60 Ok(meta) => meta,
61 Err(_) => serde_json::json!({ "name": name, "error": "failed to load meta" }),
62 };
63
64 if let Some(obj) = pkg_json.as_object_mut() {
65 obj.insert(
66 "source".to_string(),
67 serde_json::Value::String(source_display),
68 );
69 obj.insert("active".to_string(), serde_json::Value::Bool(active));
70 }
71
72 all_packages.push(pkg_json);
73 }
74 }
75
76 for pkg in &mut all_packages {
78 let Some(obj) = pkg.as_object_mut() else {
79 continue;
80 };
81 let is_active = obj.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
82 if !is_active {
83 continue;
84 }
85 let Some(name) = obj.get("name").and_then(|v| v.as_str()) else {
86 continue;
87 };
88 if let Some(occurrences) = seen.get(name) {
89 if occurrences.len() > 1 {
90 let overridden: Vec<&str> = occurrences
92 .iter()
93 .skip(1)
94 .map(|(_, s)| s.as_str())
95 .collect();
96 obj.insert("overrides".to_string(), serde_json::json!(overridden));
97 }
98 }
99 }
100
101 let manifest_data = manifest::load_manifest().unwrap_or_default();
103 for pkg in &mut all_packages {
104 let Some(obj) = pkg.as_object_mut() else {
105 continue;
106 };
107 let Some(name) = obj.get("name").and_then(|v| v.as_str()).map(String::from) else {
108 continue;
109 };
110 if let Some(entry) = manifest_data.packages.get(&name) {
111 obj.insert(
112 "installed_at".to_string(),
113 serde_json::Value::String(entry.installed_at.clone()),
114 );
115 obj.insert(
116 "updated_at".to_string(),
117 serde_json::Value::String(entry.updated_at.clone()),
118 );
119 obj.insert(
120 "install_source".to_string(),
121 serde_json::Value::String(entry.source.clone()),
122 );
123 }
124 }
125
126 let search_paths_json: Vec<serde_json::Value> = self
127 .search_paths
128 .iter()
129 .map(|sp| {
130 serde_json::json!({
131 "path": sp.path.display().to_string(),
132 "source": sp.source.to_string(),
133 })
134 })
135 .collect();
136
137 Ok(serde_json::json!({
138 "packages": all_packages,
139 "search_paths": search_paths_json,
140 })
141 .to_string())
142 }
143
144 pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
146 let pkg_dir = packages_dir()?;
147 let _ = std::fs::create_dir_all(&pkg_dir);
148
149 let local_path = Path::new(&url);
151 if local_path.is_absolute() && local_path.is_dir() {
152 return self.install_from_local_path(local_path, &pkg_dir, name);
153 }
154
155 let git_url = if url.starts_with("http://")
157 || url.starts_with("https://")
158 || url.starts_with("file://")
159 || url.starts_with("git@")
160 {
161 url.clone()
162 } else {
163 format!("https://{url}")
164 };
165
166 let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
168
169 let output = tokio::process::Command::new("git")
170 .args([
171 "clone",
172 "--depth",
173 "1",
174 &git_url,
175 &staging.path().to_string_lossy(),
176 ])
177 .output()
178 .await
179 .map_err(|e| format!("Failed to run git: {e}"))?;
180
181 if !output.status.success() {
182 let stderr = String::from_utf8_lossy(&output.stderr);
183 return Err(format!("git clone failed: {stderr}"));
184 }
185
186 let _ = std::fs::remove_dir_all(staging.path().join(".git"));
188
189 if staging.path().join("init.lua").exists() {
191 let name = name.unwrap_or_else(|| {
193 url.trim_end_matches('/')
194 .rsplit('/')
195 .next()
196 .unwrap_or("unknown")
197 .trim_end_matches(".git")
198 .to_string()
199 });
200
201 let dest = ContainedPath::child(&pkg_dir, &name)?;
202 if dest.as_ref().exists() {
203 return Err(format!(
204 "Package '{name}' already exists at {}. Remove it first.",
205 dest.as_ref().display()
206 ));
207 }
208
209 copy_dir(staging.path(), dest.as_ref())
210 .map_err(|e| format!("Failed to copy package: {e}"))?;
211
212 let _ = manifest::record_install(&name, None, &url);
214
215 let mut response = serde_json::json!({
216 "installed": [name],
217 "mode": "single",
218 });
219 if let Some(tp) = super::resolve::types_stub_path() {
220 response["types_path"] = serde_json::Value::String(tp);
221 }
222 Ok(response.to_string())
223 } else {
224 if name.is_some() {
226 return Err(
228 "The 'name' parameter is only supported for single-package repos (init.lua at root). \
229 This repository is a collection (subdirs with init.lua)."
230 .to_string(),
231 );
232 }
233
234 let mut installed = Vec::new();
235 let mut skipped = Vec::new();
236
237 let entries = std::fs::read_dir(staging.path())
238 .map_err(|e| format!("Failed to read staging dir: {e}"))?;
239
240 for entry in entries {
241 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
242 let path = entry.path();
243 if !path.is_dir() {
244 continue;
245 }
246 if !path.join("init.lua").exists() {
247 continue;
248 }
249 let pkg_name = entry.file_name().to_string_lossy().to_string();
250 let dest = pkg_dir.join(&pkg_name);
251 if dest.exists() {
252 skipped.push(pkg_name);
253 continue;
254 }
255 copy_dir(&path, &dest)
256 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
257 installed.push(pkg_name);
258 }
259
260 let scenarios_subdir = staging.path().join("scenarios");
264 let mut scenarios_installed: Vec<String> = Vec::new();
265 let mut scenarios_failures: DirEntryFailures = Vec::new();
266 if scenarios_subdir.is_dir() {
267 if let Ok(sc_dir) = scenarios_dir() {
268 std::fs::create_dir_all(&sc_dir)
269 .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
270 if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
271 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
272 if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
273 scenarios_installed = arr
274 .iter()
275 .filter_map(|v| v.as_str().map(String::from))
276 .collect();
277 }
278 if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
279 scenarios_failures = arr
280 .iter()
281 .filter_map(|v| v.as_str().map(String::from))
282 .collect();
283 }
284 }
285 }
286 }
287 }
288
289 if installed.is_empty() && skipped.is_empty() {
290 return Err(
291 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
292 .to_string(),
293 );
294 }
295
296 let _ = manifest::record_install_batch(&installed, &url);
298
299 let mut response = serde_json::json!({
300 "installed": installed,
301 "skipped": skipped,
302 "scenarios_installed": scenarios_installed,
303 "scenarios_failures": scenarios_failures,
304 "mode": "collection",
305 });
306 if let Some(tp) = super::resolve::types_stub_path() {
307 response["types_path"] = serde_json::Value::String(tp);
308 }
309 Ok(response.to_string())
310 }
311 }
312
313 fn install_from_local_path(
315 &self,
316 source: &Path,
317 pkg_dir: &Path,
318 name: Option<String>,
319 ) -> Result<String, String> {
320 if source.join("init.lua").exists() {
321 let name = name.unwrap_or_else(|| {
323 source
324 .file_name()
325 .map(|n| n.to_string_lossy().to_string())
326 .unwrap_or_else(|| "unknown".to_string())
327 });
328
329 let dest = ContainedPath::child(pkg_dir, &name)?;
330 if dest.as_ref().exists() {
331 let _ = std::fs::remove_dir_all(&dest);
333 }
334
335 copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
336 let _ = std::fs::remove_dir_all(dest.as_ref().join(".git"));
338
339 let _ = manifest::record_install(&name, None, &source.display().to_string());
341
342 let mut response = serde_json::json!({
343 "installed": [name],
344 "mode": "local_single",
345 });
346 if let Some(tp) = super::resolve::types_stub_path() {
347 response["types_path"] = serde_json::Value::String(tp);
348 }
349 Ok(response.to_string())
350 } else {
351 if name.is_some() {
353 return Err(
354 "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
355 .to_string(),
356 );
357 }
358
359 let mut installed = Vec::new();
360 let mut updated = Vec::new();
361
362 let entries =
363 std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
364
365 for entry in entries {
366 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
367 let path = entry.path();
368 if !path.is_dir() || !path.join("init.lua").exists() {
369 continue;
370 }
371 let pkg_name = entry.file_name().to_string_lossy().to_string();
372 let dest = pkg_dir.join(&pkg_name);
373 let existed = dest.exists();
374 if existed {
375 let _ = std::fs::remove_dir_all(&dest);
376 }
377 copy_dir(&path, &dest)
378 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
379 let _ = std::fs::remove_dir_all(dest.join(".git"));
380 if existed {
381 updated.push(pkg_name);
382 } else {
383 installed.push(pkg_name);
384 }
385 }
386
387 if installed.is_empty() && updated.is_empty() {
388 return Err(
389 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
390 .to_string(),
391 );
392 }
393
394 let source_str = source.display().to_string();
396 let all_names: Vec<String> = installed.iter().chain(updated.iter()).cloned().collect();
397 let _ = manifest::record_install_batch(&all_names, &source_str);
398
399 let mut response = serde_json::json!({
400 "installed": installed,
401 "updated": updated,
402 "mode": "local_collection",
403 });
404 if let Some(tp) = super::resolve::types_stub_path() {
405 response["types_path"] = serde_json::Value::String(tp);
406 }
407 Ok(response.to_string())
408 }
409 }
410
411 pub async fn pkg_remove(&self, name: &str) -> Result<String, String> {
413 let pkg_dir = packages_dir()?;
414 let dest = ContainedPath::child(&pkg_dir, name)?;
415
416 if !dest.as_ref().exists() {
417 return Err(format!("Package '{name}' not found"));
418 }
419
420 std::fs::remove_dir_all(&dest).map_err(|e| format!("Failed to remove '{name}': {e}"))?;
421
422 let _ = manifest::record_remove(name);
424
425 Ok(serde_json::json!({ "removed": name }).to_string())
426 }
427
428 pub(super) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
430 let mut errors: Vec<String> = Vec::new();
431 for url in AUTO_INSTALL_SOURCES {
432 tracing::info!("auto-installing from {url}");
433 if let Err(e) = self.pkg_install(url.to_string(), None).await {
434 tracing::warn!("failed to auto-install from {url}: {e}");
435 errors.push(format!("{url}: {e}"));
436 }
437 }
438 if errors.len() == AUTO_INSTALL_SOURCES.len() {
440 return Err(format!(
441 "Failed to auto-install bundled packages: {}",
442 errors.join("; ")
443 ));
444 }
445 Ok(())
446 }
447}