Skip to main content

algocline_app/service/
pkg.rs

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