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