Skip to main content

algocline_app/service/
pkg.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use super::path::{copy_dir, ContainedPath};
5use super::resolve::{
6    install_scenarios_from_dir, is_system_package, packages_dir, scenarios_dir, DirEntryFailures,
7    AUTO_INSTALL_SOURCES,
8};
9use super::AppService;
10
11impl AppService {
12    /// List installed packages with metadata, showing the full override chain.
13    ///
14    /// Scans all search paths in priority order. For each package:
15    /// - `source`: the path it was found in
16    /// - `active`: whether this is the effective version (highest priority wins)
17    /// - `overrides`: if active and a lower-priority copy exists, shows what it overrides
18    pub async fn pkg_list(&self) -> Result<String, String> {
19        // Collect packages from all search paths in priority order.
20        // Key: package name, Value: list of (search_path_index, source_display)
21        let mut seen: HashMap<String, Vec<(usize, String)>> = HashMap::new();
22        let mut all_packages: Vec<serde_json::Value> = Vec::new();
23
24        for (idx, sp) in self.search_paths.iter().enumerate() {
25            if !sp.path.is_dir() {
26                continue;
27            }
28            let entries = match std::fs::read_dir(&sp.path) {
29                Ok(e) => e,
30                Err(_) => continue,
31            };
32
33            for entry in entries.flatten() {
34                let path = entry.path();
35                if !path.is_dir() {
36                    continue;
37                }
38                if !path.join("init.lua").exists() {
39                    continue;
40                }
41                let name = entry.file_name().to_string_lossy().to_string();
42                if is_system_package(&name) {
43                    continue;
44                }
45
46                let source_display = sp.path.display().to_string();
47                seen.entry(name.clone())
48                    .or_default()
49                    .push((idx, source_display.clone()));
50
51                let occurrences = &seen[&name];
52                let active = occurrences.len() == 1; // first occurrence = highest priority
53
54                let code = format!(
55                    r#"local pkg = require("{name}")
56return pkg.meta or {{ name = "{name}" }}"#
57                );
58                let mut pkg_json = match self.executor.eval_simple(code).await {
59                    Ok(meta) => meta,
60                    Err(_) => serde_json::json!({ "name": name, "error": "failed to load meta" }),
61                };
62
63                if let Some(obj) = pkg_json.as_object_mut() {
64                    obj.insert(
65                        "source".to_string(),
66                        serde_json::Value::String(source_display),
67                    );
68                    obj.insert("active".to_string(), serde_json::Value::Bool(active));
69                }
70
71                all_packages.push(pkg_json);
72            }
73        }
74
75        // Second pass: add `overrides` field to active packages that shadow lower-priority ones.
76        for pkg in &mut all_packages {
77            let Some(obj) = pkg.as_object_mut() else {
78                continue;
79            };
80            let is_active = obj.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
81            if !is_active {
82                continue;
83            }
84            let Some(name) = obj.get("name").and_then(|v| v.as_str()) else {
85                continue;
86            };
87            if let Some(occurrences) = seen.get(name) {
88                if occurrences.len() > 1 {
89                    // The overridden sources (all except the first/active one)
90                    let overridden: Vec<&str> = occurrences
91                        .iter()
92                        .skip(1)
93                        .map(|(_, s)| s.as_str())
94                        .collect();
95                    obj.insert("overrides".to_string(), serde_json::json!(overridden));
96                }
97            }
98        }
99
100        let search_paths_json: Vec<serde_json::Value> = self
101            .search_paths
102            .iter()
103            .map(|sp| {
104                serde_json::json!({
105                    "path": sp.path.display().to_string(),
106                    "source": sp.source.to_string(),
107                })
108            })
109            .collect();
110
111        Ok(serde_json::json!({
112            "packages": all_packages,
113            "search_paths": search_paths_json,
114        })
115        .to_string())
116    }
117
118    /// Install a package from a Git URL or local path.
119    pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
120        let pkg_dir = packages_dir()?;
121        let _ = std::fs::create_dir_all(&pkg_dir);
122
123        // Local path: copy directly (supports uncommitted/dirty working trees)
124        let local_path = Path::new(&url);
125        if local_path.is_absolute() && local_path.is_dir() {
126            return self.install_from_local_path(local_path, &pkg_dir, name);
127        }
128
129        // Normalize URL: add https:// only for bare domain-style URLs
130        let git_url = if url.starts_with("http://")
131            || url.starts_with("https://")
132            || url.starts_with("file://")
133            || url.starts_with("git@")
134        {
135            url.clone()
136        } else {
137            format!("https://{url}")
138        };
139
140        // Clone to temp directory first to detect single vs collection
141        let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
142
143        let output = tokio::process::Command::new("git")
144            .args([
145                "clone",
146                "--depth",
147                "1",
148                &git_url,
149                &staging.path().to_string_lossy(),
150            ])
151            .output()
152            .await
153            .map_err(|e| format!("Failed to run git: {e}"))?;
154
155        if !output.status.success() {
156            let stderr = String::from_utf8_lossy(&output.stderr);
157            return Err(format!("git clone failed: {stderr}"));
158        }
159
160        // Remove .git dir from staging
161        let _ = std::fs::remove_dir_all(staging.path().join(".git"));
162
163        // Detect: single package (init.lua at root) vs collection (subdirs with init.lua)
164        if staging.path().join("init.lua").exists() {
165            // Single package mode
166            let name = name.unwrap_or_else(|| {
167                url.trim_end_matches('/')
168                    .rsplit('/')
169                    .next()
170                    .unwrap_or("unknown")
171                    .trim_end_matches(".git")
172                    .to_string()
173            });
174
175            let dest = ContainedPath::child(&pkg_dir, &name)?;
176            if dest.as_ref().exists() {
177                return Err(format!(
178                    "Package '{name}' already exists at {}. Remove it first.",
179                    dest.as_ref().display()
180                ));
181            }
182
183            copy_dir(staging.path(), dest.as_ref())
184                .map_err(|e| format!("Failed to copy package: {e}"))?;
185
186            let mut response = serde_json::json!({
187                "installed": [name],
188                "mode": "single",
189            });
190            if let Some(tp) = super::resolve::types_stub_path() {
191                response["types_path"] = serde_json::Value::String(tp);
192            }
193            Ok(response.to_string())
194        } else {
195            // Collection mode: scan for subdirs containing init.lua
196            if name.is_some() {
197                // name parameter is only meaningful for single-package repos
198                return Err(
199                    "The 'name' parameter is only supported for single-package repos (init.lua at root). \
200                     This repository is a collection (subdirs with init.lua)."
201                        .to_string(),
202                );
203            }
204
205            let mut installed = Vec::new();
206            let mut skipped = Vec::new();
207
208            let entries = std::fs::read_dir(staging.path())
209                .map_err(|e| format!("Failed to read staging dir: {e}"))?;
210
211            for entry in entries {
212                let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
213                let path = entry.path();
214                if !path.is_dir() {
215                    continue;
216                }
217                if !path.join("init.lua").exists() {
218                    continue;
219                }
220                let pkg_name = entry.file_name().to_string_lossy().to_string();
221                let dest = pkg_dir.join(&pkg_name);
222                if dest.exists() {
223                    skipped.push(pkg_name);
224                    continue;
225                }
226                copy_dir(&path, &dest)
227                    .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
228                installed.push(pkg_name);
229            }
230
231            // Install bundled scenarios only when an explicit `scenarios/` subdir exists.
232            // Unlike `scenario_install` (which falls back to root via `resolve_scenario_source`),
233            // bundled scenarios are optional — we don't scan the package root for .lua files.
234            let scenarios_subdir = staging.path().join("scenarios");
235            let mut scenarios_installed: Vec<String> = Vec::new();
236            let mut scenarios_failures: DirEntryFailures = Vec::new();
237            if scenarios_subdir.is_dir() {
238                if let Ok(sc_dir) = scenarios_dir() {
239                    std::fs::create_dir_all(&sc_dir)
240                        .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
241                    if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
242                        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
243                            if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
244                                scenarios_installed = arr
245                                    .iter()
246                                    .filter_map(|v| v.as_str().map(String::from))
247                                    .collect();
248                            }
249                            if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
250                                scenarios_failures = arr
251                                    .iter()
252                                    .filter_map(|v| v.as_str().map(String::from))
253                                    .collect();
254                            }
255                        }
256                    }
257                }
258            }
259
260            if installed.is_empty() && skipped.is_empty() {
261                return Err(
262                    "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
263                        .to_string(),
264                );
265            }
266
267            let mut response = serde_json::json!({
268                "installed": installed,
269                "skipped": skipped,
270                "scenarios_installed": scenarios_installed,
271                "scenarios_failures": scenarios_failures,
272                "mode": "collection",
273            });
274            if let Some(tp) = super::resolve::types_stub_path() {
275                response["types_path"] = serde_json::Value::String(tp);
276            }
277            Ok(response.to_string())
278        }
279    }
280
281    /// Install from a local directory path (supports dirty/uncommitted files).
282    fn install_from_local_path(
283        &self,
284        source: &Path,
285        pkg_dir: &Path,
286        name: Option<String>,
287    ) -> Result<String, String> {
288        if source.join("init.lua").exists() {
289            // Single package
290            let name = name.unwrap_or_else(|| {
291                source
292                    .file_name()
293                    .map(|n| n.to_string_lossy().to_string())
294                    .unwrap_or_else(|| "unknown".to_string())
295            });
296
297            let dest = ContainedPath::child(pkg_dir, &name)?;
298            if dest.as_ref().exists() {
299                // Overwrite for local installs (dev workflow)
300                let _ = std::fs::remove_dir_all(&dest);
301            }
302
303            copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
304            // Remove .git if copied
305            let _ = std::fs::remove_dir_all(dest.as_ref().join(".git"));
306
307            let mut response = serde_json::json!({
308                "installed": [name],
309                "mode": "local_single",
310            });
311            if let Some(tp) = super::resolve::types_stub_path() {
312                response["types_path"] = serde_json::Value::String(tp);
313            }
314            Ok(response.to_string())
315        } else {
316            // Collection mode
317            if name.is_some() {
318                return Err(
319                    "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
320                        .to_string(),
321                );
322            }
323
324            let mut installed = Vec::new();
325            let mut updated = Vec::new();
326
327            let entries =
328                std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
329
330            for entry in entries {
331                let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
332                let path = entry.path();
333                if !path.is_dir() || !path.join("init.lua").exists() {
334                    continue;
335                }
336                let pkg_name = entry.file_name().to_string_lossy().to_string();
337                let dest = pkg_dir.join(&pkg_name);
338                let existed = dest.exists();
339                if existed {
340                    let _ = std::fs::remove_dir_all(&dest);
341                }
342                copy_dir(&path, &dest)
343                    .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
344                let _ = std::fs::remove_dir_all(dest.join(".git"));
345                if existed {
346                    updated.push(pkg_name);
347                } else {
348                    installed.push(pkg_name);
349                }
350            }
351
352            if installed.is_empty() && updated.is_empty() {
353                return Err(
354                    "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
355                        .to_string(),
356                );
357            }
358
359            let mut response = serde_json::json!({
360                "installed": installed,
361                "updated": updated,
362                "mode": "local_collection",
363            });
364            if let Some(tp) = super::resolve::types_stub_path() {
365                response["types_path"] = serde_json::Value::String(tp);
366            }
367            Ok(response.to_string())
368        }
369    }
370
371    /// Remove an installed package.
372    pub async fn pkg_remove(&self, name: &str) -> Result<String, String> {
373        let pkg_dir = packages_dir()?;
374        let dest = ContainedPath::child(&pkg_dir, name)?;
375
376        if !dest.as_ref().exists() {
377            return Err(format!("Package '{name}' not found"));
378        }
379
380        std::fs::remove_dir_all(&dest).map_err(|e| format!("Failed to remove '{name}': {e}"))?;
381
382        Ok(serde_json::json!({ "removed": name }).to_string())
383    }
384
385    /// Install all bundled sources (collections + single packages).
386    pub(super) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
387        let mut errors: Vec<String> = Vec::new();
388        for url in AUTO_INSTALL_SOURCES {
389            tracing::info!("auto-installing from {url}");
390            if let Err(e) = self.pkg_install(url.to_string(), None).await {
391                tracing::warn!("failed to auto-install from {url}: {e}");
392                errors.push(format!("{url}: {e}"));
393            }
394        }
395        // Fail only if ALL sources failed
396        if errors.len() == AUTO_INSTALL_SOURCES.len() {
397            return Err(format!(
398                "Failed to auto-install bundled packages: {}",
399                errors.join("; ")
400            ));
401        }
402        Ok(())
403    }
404}