Skip to main content

algocline_app/service/
pkg.rs

1use 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    /// List installed packages with metadata, showing the full override chain.
14    ///
15    /// Scans all search paths in priority order. For each package:
16    /// - `source`: the path it was found in
17    /// - `active`: whether this is the effective version (highest priority wins)
18    /// - `overrides`: if active and a lower-priority copy exists, shows what it overrides
19    pub async fn pkg_list(&self) -> Result<String, String> {
20        // Collect packages from all search paths in priority order.
21        // Key: package name, Value: list of (search_path_index, source_display)
22        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; // first occurrence = highest priority
54
55                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        // Second pass: add `overrides` field to active packages that shadow lower-priority ones.
77        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                    // The overridden sources (all except the first/active one)
91                    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        // Merge manifest info (installed_at, updated_at, install_source) into each package.
102        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    /// Install a package from a Git URL or local path.
145    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        // Local path: copy directly (supports uncommitted/dirty working trees)
150        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        // Normalize URL: add https:// only for bare domain-style URLs
156        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        // Clone to temp directory first to detect single vs collection
167        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        // Remove .git dir from staging
187        let _ = std::fs::remove_dir_all(staging.path().join(".git"));
188
189        // Detect: single package (init.lua at root) vs collection (subdirs with init.lua)
190        if staging.path().join("init.lua").exists() {
191            // Single package mode
192            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            // Record in manifest (best-effort; install itself already succeeded)
213            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            // Collection mode: scan for subdirs containing init.lua
225            if name.is_some() {
226                // name parameter is only meaningful for single-package repos
227                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            // Install bundled scenarios only when an explicit `scenarios/` subdir exists.
261            // Unlike `scenario_install` (which falls back to root via `resolve_scenario_source`),
262            // bundled scenarios are optional — we don't scan the package root for .lua files.
263            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            // Record in manifest (best-effort)
297            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    /// Install from a local directory path (supports dirty/uncommitted files).
314    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            // Single package
322            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                // Overwrite for local installs (dev workflow)
332                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            // Remove .git if copied
337            let _ = std::fs::remove_dir_all(dest.as_ref().join(".git"));
338
339            // Record in manifest (best-effort)
340            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            // Collection mode
352            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            // Record in manifest (best-effort)
395            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    /// Remove an installed package.
412    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        // Remove from manifest (best-effort)
423        let _ = manifest::record_remove(name);
424
425        Ok(serde_json::json!({ "removed": name }).to_string())
426    }
427
428    /// Install all bundled sources (collections + single packages).
429    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        // Fail only if ALL sources failed
439        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}