Skip to main content

algocline_app/service/pkg/
install.rs

1//! `pkg_install` — install a package from a Git URL or local path.
2
3use std::path::Path;
4
5use super::super::alc_toml::{
6    add_package_entry, load_alc_toml_document, save_alc_toml, PackageDep,
7};
8use super::super::lockfile::{load_lockfile, save_lockfile, LockFile, LockPackage};
9use super::super::manifest;
10use super::super::path::{copy_dir, ContainedPath};
11use super::super::project::resolve_project_root;
12use super::super::resolve::{
13    install_scenarios_from_dir, packages_dir, scenarios_dir, DirEntryFailures, AUTO_INSTALL_SOURCES,
14};
15use super::super::source::PackageSource;
16use super::super::AppService;
17
18impl AppService {
19    /// Install a package from a Git URL or local path.
20    pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
21        let pkg_dir = packages_dir()?;
22        let _ = std::fs::create_dir_all(&pkg_dir);
23
24        // Local path: copy directly (supports uncommitted/dirty working trees)
25        let local_path = Path::new(&url);
26        if local_path.is_absolute() && local_path.is_dir() {
27            return self
28                .install_from_local_path(local_path, &pkg_dir, name)
29                .await;
30        }
31
32        // Normalize URL: add https:// only for bare domain-style URLs
33        let git_url = if url.starts_with("http://")
34            || url.starts_with("https://")
35            || url.starts_with("file://")
36            || url.starts_with("git@")
37        {
38            url.clone()
39        } else {
40            format!("https://{url}")
41        };
42
43        // Clone to temp directory first to detect single vs collection
44        let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
45
46        let output = tokio::process::Command::new("git")
47            .args([
48                "clone",
49                "--depth",
50                "1",
51                &git_url,
52                &staging.path().to_string_lossy(),
53            ])
54            .output()
55            .await
56            .map_err(|e| format!("Failed to run git: {e}"))?;
57
58        if !output.status.success() {
59            let stderr = String::from_utf8_lossy(&output.stderr);
60            return Err(format!("git clone failed: {stderr}"));
61        }
62
63        // Remove .git dir from staging
64        let _ = std::fs::remove_dir_all(staging.path().join(".git"));
65
66        // Detect: single package (init.lua at root) vs collection (subdirs with init.lua)
67        if staging.path().join("init.lua").exists() {
68            // Single package mode
69            let name = name.unwrap_or_else(|| {
70                url.trim_end_matches('/')
71                    .rsplit('/')
72                    .next()
73                    .unwrap_or("unknown")
74                    .trim_end_matches(".git")
75                    .to_string()
76            });
77
78            let dest = ContainedPath::child(&pkg_dir, &name)?;
79            if dest.as_ref().exists() {
80                return Err(format!(
81                    "Package '{name}' already exists at {}. Remove it first.",
82                    dest.as_ref().display()
83                ));
84            }
85
86            copy_dir(staging.path(), dest.as_ref())
87                .map_err(|e| format!("Failed to copy package: {e}"))?;
88
89            // Record in manifest (best-effort; install itself already succeeded)
90            let _ = manifest::record_install(&name, None, &url);
91
92            // Update alc.toml + alc.lock if project root is found
93            self.update_project_files_for_install(std::slice::from_ref(&name))
94                .await;
95
96            let mut response = serde_json::json!({
97                "installed": [name],
98                "mode": "single",
99            });
100            if let Some(tp) = super::super::resolve::types_stub_path() {
101                response["types_path"] = serde_json::Value::String(tp);
102            }
103            Ok(response.to_string())
104        } else {
105            // Collection mode: scan for subdirs containing init.lua
106            if name.is_some() {
107                // name parameter is only meaningful for single-package repos
108                return Err(
109                    "The 'name' parameter is only supported for single-package repos (init.lua at root). \
110                     This repository is a collection (subdirs with init.lua)."
111                        .to_string(),
112                );
113            }
114
115            let mut installed = Vec::new();
116            let mut skipped = Vec::new();
117
118            let entries = std::fs::read_dir(staging.path())
119                .map_err(|e| format!("Failed to read staging dir: {e}"))?;
120
121            for entry in entries {
122                let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
123                let path = entry.path();
124                if !path.is_dir() {
125                    continue;
126                }
127                if !path.join("init.lua").exists() {
128                    continue;
129                }
130                let pkg_name = entry.file_name().to_string_lossy().to_string();
131                let dest = pkg_dir.join(&pkg_name);
132                if dest.exists() {
133                    skipped.push(pkg_name);
134                    continue;
135                }
136                copy_dir(&path, &dest)
137                    .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
138                installed.push(pkg_name);
139            }
140
141            // Install bundled scenarios only when an explicit `scenarios/` subdir exists.
142            let scenarios_subdir = staging.path().join("scenarios");
143            let mut scenarios_installed: Vec<String> = Vec::new();
144            let mut scenarios_failures: DirEntryFailures = Vec::new();
145            if scenarios_subdir.is_dir() {
146                if let Ok(sc_dir) = scenarios_dir() {
147                    std::fs::create_dir_all(&sc_dir)
148                        .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
149                    if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
150                        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
151                            if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
152                                scenarios_installed = arr
153                                    .iter()
154                                    .filter_map(|v| v.as_str().map(String::from))
155                                    .collect();
156                            }
157                            if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
158                                scenarios_failures = arr
159                                    .iter()
160                                    .filter_map(|v| v.as_str().map(String::from))
161                                    .collect();
162                            }
163                        }
164                    }
165                }
166            }
167
168            if installed.is_empty() && skipped.is_empty() {
169                return Err(
170                    "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
171                        .to_string(),
172                );
173            }
174
175            // Record in manifest (best-effort)
176            let _ = manifest::record_install_batch(&installed, &url);
177
178            // Update alc.toml + alc.lock if project root is found
179            self.update_project_files_for_install(&installed).await;
180
181            let mut response = serde_json::json!({
182                "installed": installed,
183                "skipped": skipped,
184                "scenarios_installed": scenarios_installed,
185                "scenarios_failures": scenarios_failures,
186                "mode": "collection",
187            });
188            if let Some(tp) = super::super::resolve::types_stub_path() {
189                response["types_path"] = serde_json::Value::String(tp);
190            }
191            Ok(response.to_string())
192        }
193    }
194
195    /// Install from a local directory path (supports dirty/uncommitted files).
196    async fn install_from_local_path(
197        &self,
198        source: &Path,
199        pkg_dir: &Path,
200        name: Option<String>,
201    ) -> Result<String, String> {
202        if source.join("init.lua").exists() {
203            // Single package
204            let name = name.unwrap_or_else(|| {
205                source
206                    .file_name()
207                    .map(|n| n.to_string_lossy().to_string())
208                    .unwrap_or_else(|| "unknown".to_string())
209            });
210
211            let dest = ContainedPath::child(pkg_dir, &name)?;
212            if dest.as_ref().exists() {
213                // Overwrite for local installs (dev workflow)
214                let _ = std::fs::remove_dir_all(&dest);
215            }
216
217            copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
218            // Remove .git if copied
219            let _ = std::fs::remove_dir_all(dest.as_ref().join(".git"));
220
221            // Record in manifest (best-effort)
222            let _ = manifest::record_install(&name, None, &source.display().to_string());
223
224            // Update alc.toml + alc.lock if project root is found
225            self.update_project_files_for_install(std::slice::from_ref(&name))
226                .await;
227
228            let mut response = serde_json::json!({
229                "installed": [name],
230                "mode": "local_single",
231            });
232            if let Some(tp) = super::super::resolve::types_stub_path() {
233                response["types_path"] = serde_json::Value::String(tp);
234            }
235            Ok(response.to_string())
236        } else {
237            // Collection mode
238            if name.is_some() {
239                return Err(
240                    "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
241                        .to_string(),
242                );
243            }
244
245            let mut installed = Vec::new();
246            let mut updated = Vec::new();
247
248            let entries =
249                std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
250
251            for entry in entries {
252                let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
253                let path = entry.path();
254                if !path.is_dir() || !path.join("init.lua").exists() {
255                    continue;
256                }
257                let pkg_name = entry.file_name().to_string_lossy().to_string();
258                let dest = pkg_dir.join(&pkg_name);
259                let existed = dest.exists();
260                if existed {
261                    let _ = std::fs::remove_dir_all(&dest);
262                }
263                copy_dir(&path, &dest)
264                    .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
265                let _ = std::fs::remove_dir_all(dest.join(".git"));
266                if existed {
267                    updated.push(pkg_name);
268                } else {
269                    installed.push(pkg_name);
270                }
271            }
272
273            if installed.is_empty() && updated.is_empty() {
274                return Err(
275                    "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
276                        .to_string(),
277                );
278            }
279
280            // Record in manifest (best-effort)
281            let source_str = source.display().to_string();
282            let all_names: Vec<String> = installed.iter().chain(updated.iter()).cloned().collect();
283            let _ = manifest::record_install_batch(&all_names, &source_str);
284
285            // Update alc.toml + alc.lock for newly installed packages
286            self.update_project_files_for_install(&installed).await;
287
288            let mut response = serde_json::json!({
289                "installed": installed,
290                "updated": updated,
291                "mode": "local_collection",
292            });
293            if let Some(tp) = super::super::resolve::types_stub_path() {
294                response["types_path"] = serde_json::Value::String(tp);
295            }
296            Ok(response.to_string())
297        }
298    }
299
300    /// After a successful cache install, update `alc.toml` and `alc.lock` if a project
301    /// root (containing `alc.toml`) is found.  Failures are logged but not propagated —
302    /// the install itself already succeeded.
303    async fn update_project_files_for_install(&self, names: &[String]) {
304        let root = match resolve_project_root(None) {
305            Some(r) => r,
306            None => return, // No project root → skip (current-compat)
307        };
308
309        // Load alc.toml document (preserving comments/formatting).
310        let mut doc = match load_alc_toml_document(&root) {
311            Ok(Some(d)) => d,
312            Ok(None) => return, // alc.toml not found → skip
313            Err(e) => {
314                tracing::warn!("pkg_install: failed to load alc.toml: {e}");
315                return;
316            }
317        };
318
319        // Load or create alc.lock.
320        let mut lock = match load_lockfile(&root) {
321            Ok(Some(l)) => l,
322            Ok(None) => LockFile {
323                version: 1,
324                packages: Vec::new(),
325            },
326            Err(e) => {
327                tracing::warn!("pkg_install: failed to load alc.lock: {e}");
328                return;
329            }
330        };
331
332        for name in names {
333            // Add to alc.toml (no-op if already present).
334            add_package_entry(&mut doc, name, &PackageDep::Version("*".to_string()));
335
336            // Resolve version via eval_simple (best-effort).
337            let version = self.fetch_pkg_version(name).await;
338
339            // Upsert into alc.lock.
340            upsert_lock_entry(&mut lock, name.clone(), version, PackageSource::Installed);
341        }
342
343        if let Err(e) = save_alc_toml(&root, &doc) {
344            tracing::warn!("pkg_install: failed to save alc.toml: {e}");
345        }
346        if let Err(e) = save_lockfile(&root, &lock) {
347            tracing::warn!("pkg_install: failed to save alc.lock: {e}");
348        }
349    }
350
351    /// Fetch package version via `eval_simple` (best-effort; returns `None` on failure).
352    async fn fetch_pkg_version(&self, name: &str) -> Option<String> {
353        if !is_safe_pkg_name(name) {
354            return None;
355        }
356        let code = format!(
357            r#"package.loaded["{name}"] = nil
358local pkg = require("{name}")
359return (pkg.meta or {{}}).version"#
360        );
361        match self.executor.eval_simple(code).await {
362            Ok(serde_json::Value::String(v)) if !v.is_empty() => Some(v),
363            _ => None,
364        }
365    }
366
367    /// Install all bundled sources (collections + single packages).
368    pub(in crate::service) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
369        let mut errors: Vec<String> = Vec::new();
370        for url in AUTO_INSTALL_SOURCES {
371            tracing::info!("auto-installing from {url}");
372            if let Err(e) = self.pkg_install(url.to_string(), None).await {
373                tracing::warn!("failed to auto-install from {url}: {e}");
374                errors.push(format!("{url}: {e}"));
375            }
376        }
377        // Fail only if ALL sources failed
378        if errors.len() == AUTO_INSTALL_SOURCES.len() {
379            return Err(format!(
380                "Failed to auto-install bundled packages: {}",
381                errors.join("; ")
382            ));
383        }
384        Ok(())
385    }
386}
387
388// ─── Helpers ────────────────────────────────────────────────────────────────
389
390/// Returns `true` iff `name` is safe to interpolate into a Lua source string.
391fn is_safe_pkg_name(name: &str) -> bool {
392    !name.is_empty()
393        && name
394            .bytes()
395            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
396}
397
398/// Insert or update a `LockPackage` entry in the lockfile.
399fn upsert_lock_entry(
400    lock: &mut LockFile,
401    name: String,
402    version: Option<String>,
403    source: PackageSource,
404) {
405    if let Some(existing) = lock.packages.iter_mut().find(|p| p.name == name) {
406        existing.version = version;
407        existing.source = source;
408    } else {
409        lock.packages.push(LockPackage {
410            name,
411            version,
412            source,
413        });
414    }
415}