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, PathBuf};
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, ProjectFilesError};
18
19/// Explicit install dispatch. Carries exactly the information `pkg_install`
20/// needs after classification so that downstream code does not re-classify
21/// a string (which is racy: the local directory may disappear between the
22/// caller's check and the installer's check).
23#[derive(Debug, Clone)]
24pub(crate) enum InstallSource {
25    /// Copy from a local directory (absolute path).
26    LocalPath(PathBuf),
27    /// Clone from a Git URL (already normalized with scheme or `git@`).
28    GitUrl(String),
29}
30
31/// Classify a caller-provided `url` string into an [`InstallSource`].
32///
33/// Must stay consistent with [`super::super::source::infer_from_legacy_source_string`]:
34/// an absolute-path-*shaped* string maps to [`InstallSource::LocalPath`]
35/// (matching `PackageSource::Installed`), everything else maps to a normalized
36/// Git URL. Classification is deliberately syntactic — no filesystem probes.
37/// Rationale: a dir that is_absolute but currently missing used to fall through
38/// to the Git arm and produce `https:///abs/path`, which git rejects with
39/// `unable to find remote helper for 'https'`. Keeping the classification
40/// syntactic gives `install_from_local_path` a chance to surface a diagnostic
41/// "Failed to read source dir" error instead.
42fn classify_install_url(url: &str) -> InstallSource {
43    let local_path = Path::new(url);
44    if local_path.is_absolute() {
45        return InstallSource::LocalPath(local_path.to_path_buf());
46    }
47
48    InstallSource::GitUrl(prefix_git_scheme_if_missing(url))
49}
50
51/// Prepend `https://` to a Git remote-style string that lacks a scheme.
52///
53/// Accepts `http://`, `https://`, `file://`, and `git@` prefixes as-is; any
54/// other input (e.g. bare `github.com/a/b`) is prefixed with `https://`.
55/// Shared between `classify_install_url` (install path) and
56/// `pkg::repair::normalize_git_url` (repair path) — both need the same
57/// normalization when routing an already-decided Git URL through `git clone`.
58pub(super) fn prefix_git_scheme_if_missing(url: &str) -> String {
59    if url.starts_with("http://")
60        || url.starts_with("https://")
61        || url.starts_with("file://")
62        || url.starts_with("git@")
63    {
64        url.to_string()
65    } else {
66        format!("https://{url}")
67    }
68}
69
70impl AppService {
71    /// Install a package from a Git URL or local path (string-typed, public MCP API).
72    ///
73    /// Classifies `url` via [`classify_install_url`] then delegates to
74    /// [`AppService::pkg_install_typed`]. Callers that already hold a
75    /// classified [`InstallSource`] (e.g. `pkg_repair`) should call the
76    /// typed API directly to avoid re-classifying a stale string.
77    pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
78        let source = classify_install_url(&url);
79        self.pkg_install_typed(source, name).await
80    }
81
82    /// Typed install dispatch. Does no string re-classification; branches
83    /// explicitly on the already-classified [`InstallSource`].
84    pub(crate) async fn pkg_install_typed(
85        &self,
86        source: InstallSource,
87        name: Option<String>,
88    ) -> Result<String, String> {
89        let app_dir = self.log_config.app_dir();
90        let pkg_dir = packages_dir(&app_dir);
91        std::fs::create_dir_all(&pkg_dir)
92            .map_err(|e| ProjectFilesError::PackagesDir {
93                path: pkg_dir.display().to_string(),
94                source: e,
95            })
96            .map_err(|e| e.to_string())?;
97
98        let git_url = match source {
99            InstallSource::LocalPath(path) => {
100                return self.install_from_local_path(&path, &pkg_dir, name).await;
101            }
102            InstallSource::GitUrl(u) => u,
103        };
104        // `url` is the recorded form used for manifest/hub. Normalization
105        // happens in `classify_install_url`, so this is already the
106        // scheme-prefixed form (e.g. `https://github.com/x`).
107        let url = git_url.clone();
108
109        // Clone to temp directory first to detect single vs collection
110        let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
111
112        // Bound `git clone` wall time. Without this a misconfigured remote
113        // (auth prompt, unreachable host, slow network) can block the MCP
114        // tool call indefinitely. 60s covers normal shallow clones of our
115        // bundled-packages-sized repos with margin.
116        let clone_future = tokio::process::Command::new("git")
117            .args([
118                "clone",
119                "--depth",
120                "1",
121                &git_url,
122                &staging.path().to_string_lossy(),
123            ])
124            .output();
125        let output = tokio::time::timeout(std::time::Duration::from_secs(60), clone_future)
126            .await
127            .map_err(|_| format!("git clone timed out after 60s: {git_url}"))?
128            .map_err(|e| format!("Failed to run git: {e}"))?;
129
130        if !output.status.success() {
131            let stderr = String::from_utf8_lossy(&output.stderr);
132            return Err(format!("git clone failed: {stderr}"));
133        }
134
135        // Remove .git dir from staging (best-effort; absent .git would be
136        // surprising but not fatal).
137        if let Err(e) = std::fs::remove_dir_all(staging.path().join(".git")) {
138            if e.kind() != std::io::ErrorKind::NotFound {
139                tracing::warn!(
140                    "pkg_install: failed to strip .git from staging {}: {e}",
141                    staging.path().display()
142                );
143            }
144        }
145
146        // Detect: single package (init.lua at root) vs collection (subdirs with init.lua)
147        if staging.path().join("init.lua").exists() {
148            // Single package mode
149            let name = name.unwrap_or_else(|| {
150                url.trim_end_matches('/')
151                    .rsplit('/')
152                    .next()
153                    .unwrap_or("unknown")
154                    .trim_end_matches(".git")
155                    .to_string()
156            });
157
158            let dest = ContainedPath::child(&pkg_dir, &name)?;
159            if dest.as_ref().exists() {
160                return Err(format!(
161                    "Package '{name}' already exists at {}. Remove it first.",
162                    dest.as_ref().display()
163                ));
164            }
165
166            copy_dir(staging.path(), dest.as_ref())
167                .map_err(|e| format!("Failed to copy package: {e}"))?;
168
169            // Record in manifest + hub registry. Storage failures here
170            // do not roll back the on-disk copy (install already
171            // succeeded) — they surface as `storage_warnings` so the
172            // operator notices that the metadata layer drifted.
173            let mut storage_warnings: Vec<String> = Vec::new();
174            if let Err(e) = manifest::record_install(
175                &app_dir,
176                &name,
177                None,
178                super::super::source::PackageSource::Git {
179                    url: url.clone(),
180                    rev: None,
181                },
182            ) {
183                storage_warnings.push(format!("manifest record_install: {e}"));
184            }
185            if let Err(e) = hub::register_source(&app_dir, &url, "pkg_install") {
186                storage_warnings.push(format!("hub register_source: {e}"));
187            }
188
189            // Update alc.toml + alc.lock if project root is found.
190            // Fatal errors from the update (e.g. alc.toml load failure) are
191            // degraded to warnings — the pkg copy already succeeded.
192            let project_files_warnings = match self
193                .update_project_files_for_install(std::slice::from_ref(&name))
194                .await
195            {
196                Ok(ws) => ws,
197                Err(e) => vec![e.to_string()],
198            };
199
200            let mut response = serde_json::json!({
201                "installed": [name],
202                "mode": "single",
203            });
204            if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
205                response["types_path"] = serde_json::Value::String(tp);
206            }
207            if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
208                response["alc_shapes_types_path"] = serde_json::Value::String(tp);
209            }
210            if !storage_warnings.is_empty() {
211                response["storage_warnings"] = serde_json::json!(storage_warnings);
212            }
213            if !project_files_warnings.is_empty() {
214                response["project_files_warnings"] = serde_json::json!(project_files_warnings);
215            }
216            Ok(response.to_string())
217        } else {
218            // Collection mode: scan for subdirs containing init.lua
219            if name.is_some() {
220                // name parameter is only meaningful for single-package repos
221                return Err(
222                    "The 'name' parameter is only supported for single-package repos (init.lua at root). \
223                     This repository is a collection (subdirs with init.lua)."
224                        .to_string(),
225                );
226            }
227
228            let mut installed = Vec::new();
229            let mut skipped = Vec::new();
230            // Dev symlinks (pkg_link scope=global) previously blocked collection
231            // install with a hard `ContainedPath::child` error because their
232            // `canonicalize` target lives outside the packages base. Collect
233            // them as a distinct "symlink-skipped" bucket, skip install for the
234            // affected pkg, and continue with the rest — the user runs
235            // `pkg_unlink <name>` if they want the git-clone copy to win.
236            let mut skipped_symlinks: Vec<String> = Vec::new();
237
238            let entries = std::fs::read_dir(staging.path())
239                .map_err(|e| format!("Failed to read staging dir: {e}"))?;
240
241            for entry in entries {
242                let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
243                let path = entry.path();
244                if !path.is_dir() {
245                    continue;
246                }
247                if !path.join("init.lua").exists() {
248                    continue;
249                }
250                let pkg_name = entry.file_name().to_string_lossy().to_string();
251
252                // Pre-check: a legitimate `pkg_link` symlink at the destination
253                // points outside the packages base, which would fail
254                // `ContainedPath::child`'s canonicalize-escape check and abort
255                // the whole batch. Detect the symlink first and route to
256                // `skipped_symlinks` so the install proceeds for other pkgs.
257                let candidate = pkg_dir.join(&pkg_name);
258                if candidate
259                    .symlink_metadata()
260                    .map(|m| m.file_type().is_symlink())
261                    .unwrap_or(false)
262                {
263                    tracing::warn!(
264                        "pkg_install: skipping '{pkg_name}' — destination is an existing symlink \
265                         (likely a `pkg_link` dev link); run `pkg_unlink {pkg_name}` to replace it"
266                    );
267                    skipped_symlinks.push(pkg_name);
268                    continue;
269                }
270
271                // Go through ContainedPath::child to block path traversal from
272                // a malicious subdir name (`..`, `foo/../bar`) — the staging
273                // dir is untrusted input in the general case.
274                let dest = ContainedPath::child(&pkg_dir, &pkg_name)?;
275                if dest.as_ref().exists() {
276                    skipped.push(pkg_name);
277                    continue;
278                }
279                copy_dir(&path, dest.as_ref())
280                    .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
281                installed.push(pkg_name);
282            }
283
284            // Import bundled cards from each package's cards/ subdirectory.
285            let mut cards_installed: Vec<String> = Vec::new();
286            for pkg_name in installed.iter().chain(skipped.iter()) {
287                let cards_subdir = staging.path().join(pkg_name).join("cards");
288                if cards_subdir.is_dir() {
289                    let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
290                    cards_installed.extend(imported);
291                }
292            }
293
294            // Install bundled scenarios only when an explicit `scenarios/` subdir exists.
295            let scenarios_subdir = staging.path().join("scenarios");
296            let mut scenarios_installed: Vec<String> = Vec::new();
297            let mut scenarios_failures: DirEntryFailures = Vec::new();
298            if scenarios_subdir.is_dir() {
299                let sc_dir = scenarios_dir(&app_dir);
300                std::fs::create_dir_all(&sc_dir)
301                    .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
302                {
303                    if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
304                        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
305                            if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
306                                scenarios_installed = arr
307                                    .iter()
308                                    .filter_map(|v| v.as_str().map(String::from))
309                                    .collect();
310                            }
311                            if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
312                                scenarios_failures = arr
313                                    .iter()
314                                    .filter_map(|v| v.as_str().map(String::from))
315                                    .collect();
316                            }
317                        }
318                    }
319                }
320            }
321
322            if installed.is_empty() && skipped.is_empty() && skipped_symlinks.is_empty() {
323                return Err(
324                    "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
325                        .to_string(),
326                );
327            }
328
329            // Record in manifest + hub registry. Same propagation
330            // discipline as the single-package branch above.
331            let mut storage_warnings: Vec<String> = Vec::new();
332            if let Err(e) = manifest::record_install_batch(
333                &app_dir,
334                &installed,
335                super::super::source::PackageSource::Git {
336                    url: url.clone(),
337                    rev: None,
338                },
339            ) {
340                storage_warnings.push(format!("manifest record_install_batch: {e}"));
341            }
342            if let Err(e) = hub::register_source(&app_dir, &url, "pkg_install") {
343                storage_warnings.push(format!("hub register_source: {e}"));
344            }
345
346            // Update alc.toml + alc.lock if project root is found.
347            // Fatal errors from the update (e.g. alc.toml load failure) are
348            // degraded to warnings — the pkg copy already succeeded.
349            let project_files_warnings =
350                match self.update_project_files_for_install(&installed).await {
351                    Ok(ws) => ws,
352                    Err(e) => vec![e.to_string()],
353                };
354
355            let mut response = serde_json::json!({
356                "installed": installed,
357                "skipped": skipped,
358                "skipped_symlinks": skipped_symlinks,
359                "cards_installed": cards_installed,
360                "scenarios_installed": scenarios_installed,
361                "scenarios_failures": scenarios_failures,
362                "mode": "collection",
363            });
364            if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
365                response["types_path"] = serde_json::Value::String(tp);
366            }
367            if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
368                response["alc_shapes_types_path"] = serde_json::Value::String(tp);
369            }
370            if !storage_warnings.is_empty() {
371                response["storage_warnings"] = serde_json::json!(storage_warnings);
372            }
373            if !project_files_warnings.is_empty() {
374                response["project_files_warnings"] = serde_json::json!(project_files_warnings);
375            }
376            Ok(response.to_string())
377        }
378    }
379
380    /// Install from a local directory path (supports dirty/uncommitted files).
381    async fn install_from_local_path(
382        &self,
383        source: &Path,
384        pkg_dir: &Path,
385        name: Option<String>,
386    ) -> Result<String, String> {
387        let app_dir = self.log_config.app_dir();
388        // Reject a missing source dir up front. Without this check, a missing
389        // path falls through to the Collection branch (since `init.lua` isn't
390        // present) and surfaces as the misleading "'name' parameter is only
391        // supported for single-package dirs" error when `name` is provided —
392        // which hides the real failure mode (source gone) from the caller.
393        if !source.exists() {
394            return Err(format!(
395                "Source directory does not exist: {}",
396                source.display()
397            ));
398        }
399        if source.join("init.lua").exists() {
400            // Single package
401            let name = name.unwrap_or_else(|| {
402                source
403                    .file_name()
404                    .map(|n| n.to_string_lossy().to_string())
405                    .unwrap_or_else(|| "unknown".to_string())
406            });
407
408            let dest = ContainedPath::child(pkg_dir, &name)?;
409            if dest.as_ref().exists() {
410                // Overwrite for local installs (dev workflow). Log failures —
411                // silent `let _ =` used to hide Permission Denied / Busy
412                // errors and surfaced later as a confusing "File exists" from
413                // copy_dir.
414                if let Err(e) = std::fs::remove_dir_all(&dest) {
415                    tracing::warn!(
416                        "pkg_install: failed to remove existing dest {} before overwrite: {e}",
417                        dest.as_ref().display()
418                    );
419                }
420            }
421
422            copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
423            // Remove .git if copied (best-effort; absent .git is the common case).
424            if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
425                if e.kind() != std::io::ErrorKind::NotFound {
426                    tracing::warn!(
427                        "pkg_install: failed to strip .git from {}: {e}",
428                        dest.as_ref().display()
429                    );
430                }
431            }
432
433            // Record in manifest. Local-path installs are recorded as
434            // `Path { path }` so the original source location is
435            // preserved in the typed form — this keeps `pkg_repair` able
436            // to re-copy from the same source, and `pkg_list` can show
437            // where the bytes came from. (Pre-typed manifests stored the
438            // path as a bare string; `infer_from_legacy_source_string`
439            // coerced it to `Installed`, which lost the path — the typed
440            // form fixes that regression by carrying `path` explicitly.)
441            // Storage failures surface as `storage_warnings` so the
442            // operator notices when metadata drifts from the on-disk copy.
443            let source_str_local = source.display().to_string();
444            let mut storage_warnings: Vec<String> = Vec::new();
445            if let Err(e) = manifest::record_install(
446                &app_dir,
447                &name,
448                None,
449                super::super::source::PackageSource::Path {
450                    path: source_str_local.clone(),
451                },
452            ) {
453                storage_warnings.push(format!("manifest record_install: {e}"));
454            }
455            if let Err(e) = hub::register_source(&app_dir, &source_str_local, "pkg_install") {
456                storage_warnings.push(format!("hub register_source: {e}"));
457            }
458
459            // Update alc.toml + alc.lock if project root is found.
460            // Fatal errors from the update (e.g. alc.toml load failure) are
461            // degraded to warnings — the pkg copy already succeeded.
462            let project_files_warnings = match self
463                .update_project_files_for_install(std::slice::from_ref(&name))
464                .await
465            {
466                Ok(ws) => ws,
467                Err(e) => vec![e.to_string()],
468            };
469
470            let mut response = serde_json::json!({
471                "installed": [name],
472                "mode": "local_single",
473            });
474            if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
475                response["types_path"] = serde_json::Value::String(tp);
476            }
477            if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
478                response["alc_shapes_types_path"] = serde_json::Value::String(tp);
479            }
480            if !storage_warnings.is_empty() {
481                response["storage_warnings"] = serde_json::json!(storage_warnings);
482            }
483            if !project_files_warnings.is_empty() {
484                response["project_files_warnings"] = serde_json::json!(project_files_warnings);
485            }
486            Ok(response.to_string())
487        } else {
488            // Collection mode
489            if name.is_some() {
490                return Err(
491                    "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
492                        .to_string(),
493                );
494            }
495
496            let mut installed = Vec::new();
497            let mut updated = Vec::new();
498
499            let entries =
500                std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
501
502            for entry in entries {
503                let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
504                let path = entry.path();
505                if !path.is_dir() || !path.join("init.lua").exists() {
506                    continue;
507                }
508                let pkg_name = entry.file_name().to_string_lossy().to_string();
509                // Guard against traversal-shaped subdir names from an
510                // untrusted source tree, matching the git-clone branch.
511                let dest = ContainedPath::child(pkg_dir, &pkg_name)?;
512                let existed = dest.as_ref().exists();
513                if existed {
514                    if let Err(e) = std::fs::remove_dir_all(dest.as_ref()) {
515                        tracing::warn!(
516                            "pkg_install: failed to remove existing dest {} before overwrite: {e}",
517                            dest.as_ref().display()
518                        );
519                    }
520                }
521                copy_dir(&path, dest.as_ref())
522                    .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
523                if let Err(e) = std::fs::remove_dir_all(dest.as_ref().join(".git")) {
524                    if e.kind() != std::io::ErrorKind::NotFound {
525                        tracing::warn!(
526                            "pkg_install: failed to strip .git from {}: {e}",
527                            dest.as_ref().display()
528                        );
529                    }
530                }
531                if existed {
532                    updated.push(pkg_name);
533                } else {
534                    installed.push(pkg_name);
535                }
536            }
537
538            if installed.is_empty() && updated.is_empty() {
539                return Err(
540                    "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
541                        .to_string(),
542                );
543            }
544
545            // Import bundled cards from each package's cards/ subdirectory.
546            let mut cards_installed: Vec<String> = Vec::new();
547            for pkg_name in installed.iter().chain(updated.iter()) {
548                let cards_subdir = source.join(pkg_name).join("cards");
549                if cards_subdir.is_dir() {
550                    let imported = self.import_pkg_bundled_cards(pkg_name, &cards_subdir);
551                    cards_installed.extend(imported);
552                }
553            }
554
555            // Record in manifest. Batch local-path installs use
556            // `Path { path }` for the same reason as single-install
557            // (preserve the source path in the typed form). Storage
558            // failures surface via `storage_warnings`.
559            let source_str = source.display().to_string();
560            let all_names: Vec<String> = installed.iter().chain(updated.iter()).cloned().collect();
561            let mut storage_warnings: Vec<String> = Vec::new();
562            if let Err(e) = manifest::record_install_batch(
563                &app_dir,
564                &all_names,
565                super::super::source::PackageSource::Path {
566                    path: source_str.clone(),
567                },
568            ) {
569                storage_warnings.push(format!("manifest record_install_batch: {e}"));
570            }
571            if let Err(e) = hub::register_source(&app_dir, &source_str, "pkg_install") {
572                storage_warnings.push(format!("hub register_source: {e}"));
573            }
574
575            // Update alc.toml + alc.lock for newly installed packages.
576            // Fatal errors from the update (e.g. alc.toml load failure) are
577            // degraded to warnings — the pkg copy already succeeded.
578            let project_files_warnings =
579                match self.update_project_files_for_install(&installed).await {
580                    Ok(ws) => ws,
581                    Err(e) => vec![e.to_string()],
582                };
583
584            let mut response = serde_json::json!({
585                "installed": installed,
586                "updated": updated,
587                "cards_installed": cards_installed,
588                "mode": "local_collection",
589            });
590            if let Some(tp) = super::super::resolve::types_stub_path(&app_dir) {
591                response["types_path"] = serde_json::Value::String(tp);
592            }
593            if let Some(tp) = super::super::resolve::alc_shapes_types_stub_path(&app_dir) {
594                response["alc_shapes_types_path"] = serde_json::Value::String(tp);
595            }
596            if !storage_warnings.is_empty() {
597                response["storage_warnings"] = serde_json::json!(storage_warnings);
598            }
599            if !project_files_warnings.is_empty() {
600                response["project_files_warnings"] = serde_json::json!(project_files_warnings);
601            }
602            Ok(response.to_string())
603        }
604    }
605
606    /// After a successful cache install, update `alc.toml` and `alc.lock` if a project
607    /// root (containing `alc.toml`) is found.  Load failures are surfaced as `Err`;
608    /// save failures are collected into the returned warnings vec.  Lock acquisition
609    /// failures are degraded to a single warning so the install result stays `Ok`.
610    async fn update_project_files_for_install(
611        &self,
612        names: &[String],
613    ) -> Result<Vec<String>, ProjectFilesError> {
614        let root = match resolve_project_root(None) {
615            Some(r) => r,
616            None => return Ok(Vec::new()), // No project root → skip (current-compat)
617        };
618
619        // Resolve per-package versions *before* taking the lock, so the
620        // lock-held critical section contains only synchronous I/O
621        // (load → mutate → save). `fetch_pkg_version` dispatches into the
622        // shared Lua executor and may await arbitrarily long.
623        let mut resolved: Vec<(String, Option<String>)> = Vec::with_capacity(names.len());
624        for name in names {
625            let version = self.fetch_pkg_version(name).await;
626            resolved.push((name.clone(), version));
627        }
628
629        // Guard the alc.toml / alc.lock load→modify→save against overlapping
630        // `pkg_install` calls that target the same project root. Without this
631        // advisory lock two concurrent installs can each load the old state,
632        // apply their own mutation, and race to save — the later writer
633        // silently overwrites the earlier's entry.
634        //
635        // `From<LockError> for ProjectFilesError` is implemented via `#[from]` on
636        // the `Lock` variant in `service/error.rs`, so lock acquisition errors
637        // are injected as typed `ProjectFilesError::Lock` values — no `.to_string()`
638        // flattening at this call site.
639        let lock_path = project_files_lock_path(&root);
640        super::super::lock::with_exclusive_lock(&lock_path, move || {
641            let mut warnings: Vec<String> = Vec::new();
642
643            // Load alc.toml document (preserving comments/formatting).
644            // file absent (Ok(None)) is a normal skip; corruption (Err) is fatal.
645            let mut doc = match load_alc_toml_document(&root) {
646                Ok(Some(d)) => d,
647                Ok(None) => return Ok(Vec::new()), // alc.toml not found → skip
648                Err(e) => return Err(ProjectFilesError::AlcTomlLoad(e)),
649            };
650
651            // Load or create alc.lock.
652            // file absent (Ok(None)) → start with empty lockfile (normal init path).
653            // corruption (Err) is fatal — same policy as alc.toml.
654            let mut lock = match load_lockfile(&root) {
655                Ok(Some(l)) => l,
656                Ok(None) => LockFile {
657                    version: 1,
658                    packages: Vec::new(),
659                },
660                Err(e) => return Err(ProjectFilesError::AlcLockLoad(e)),
661            };
662
663            for (name, version) in &resolved {
664                // Add to alc.toml (no-op if already present).
665                add_package_entry(&mut doc, name, &PackageDep::Version("*".to_string()));
666                // Upsert into alc.lock with the pre-resolved version.
667                upsert_lock_entry(
668                    &mut lock,
669                    name.clone(),
670                    version.clone(),
671                    PackageSource::Installed,
672                );
673            }
674
675            // Save failures are non-fatal: collect as warnings so the caller
676            // can surface them in the response JSON. Both saves are attempted
677            // independently (one failure does not skip the other).
678            if let Err(e) = save_alc_toml(&root, &doc) {
679                warnings.push(ProjectFilesError::AlcTomlSave(e).to_string());
680            }
681            if let Err(e) = save_lockfile(&root, &lock) {
682                warnings.push(ProjectFilesError::AlcLockSave(e).to_string());
683            }
684            Ok(warnings)
685        })
686    }
687
688    /// Fetch package version via `eval_simple` (best-effort; returns `None` on failure).
689    async fn fetch_pkg_version(&self, name: &str) -> Option<String> {
690        if !is_safe_pkg_name(name) {
691            return None;
692        }
693        let code = format!(
694            r#"package.loaded["{name}"] = nil
695local pkg = require("{name}")
696return (pkg.meta or {{}}).version"#
697        );
698        match self.executor.eval_simple(code).await {
699            Ok(serde_json::Value::String(v)) if !v.is_empty() => Some(v),
700            _ => None,
701        }
702    }
703
704    /// Install all bundled sources (collections + single packages).
705    pub(in crate::service) async fn auto_install_bundled_packages(&self) -> Result<(), String> {
706        let mut errors: Vec<String> = Vec::new();
707        for url in AUTO_INSTALL_SOURCES {
708            tracing::info!("auto-installing from {url}");
709            if let Err(e) = self.pkg_install(url.to_string(), None).await {
710                tracing::warn!("failed to auto-install from {url}: {e}");
711                errors.push(format!("{url}: {e}"));
712            }
713        }
714        // Fail only if ALL sources failed
715        if errors.len() == AUTO_INSTALL_SOURCES.len() {
716            return Err(format!(
717                "Failed to auto-install bundled packages: {}",
718                errors.join("; ")
719            ));
720        }
721        Ok(())
722    }
723}
724
725// ─── Helpers ────────────────────────────────────────────────────────────────
726
727/// Path to the advisory lock file guarding `alc.toml` + `alc.lock` updates
728/// within a project root. The lock file sits alongside the project files so
729/// two processes working in the same checkout serialize on the same path.
730///
731/// The filename is deliberately distinct from `alc.lock` itself — the latter
732/// is the dependency lockfile users read, while `.alc-install.lock` is an
733/// internal flock companion. Consumers who share a project tree via `.gitignore`
734/// should ignore it alongside other temp files; algocline does not add it
735/// automatically today.
736fn project_files_lock_path(root: &std::path::Path) -> std::path::PathBuf {
737    root.join(".alc-install.lock")
738}
739
740/// Returns `true` iff `name` is safe to interpolate into a Lua source string.
741fn is_safe_pkg_name(name: &str) -> bool {
742    !name.is_empty()
743        && name
744            .bytes()
745            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
746}
747
748/// Insert or update a `LockPackage` entry in the lockfile.
749fn upsert_lock_entry(
750    lock: &mut LockFile,
751    name: String,
752    version: Option<String>,
753    source: PackageSource,
754) {
755    if let Some(existing) = lock.packages.iter_mut().find(|p| p.name == name) {
756        existing.version = version;
757        existing.source = source;
758    } else {
759        lock.packages.push(LockPackage {
760            name,
761            version,
762            source,
763        });
764    }
765}
766
767#[cfg(test)]
768mod tests {
769    use super::super::super::alc_toml::save_alc_toml;
770    use super::super::super::lock::with_exclusive_lock;
771    use super::super::super::lockfile::save_lockfile;
772    use super::*;
773
774    // ── (b) closure load failure → fatal Err propagated through Result ────────
775
776    /// When `alc.toml` exists but is corrupt (unparseable TOML), the closure
777    /// must return `Err(...)` rather than silently skipping.
778    #[test]
779    fn load_alc_toml_corrupt_yields_fatal_err() {
780        let tmp = tempfile::tempdir().unwrap();
781        let root = tmp.path();
782        // Write corrupt TOML
783        std::fs::write(root.join("alc.toml"), b"[[not valid toml = {").unwrap();
784
785        let lock_path = root.join(".alc-install.lock");
786        let result: Result<Vec<String>, String> =
787            with_exclusive_lock(&lock_path, move || match load_alc_toml_document(root) {
788                Ok(Some(_d)) => Ok(Vec::new()),
789                Ok(None) => Ok(Vec::new()),
790                Err(e) => Err(format!("alc.toml load: {e}")),
791            });
792
793        assert!(
794            result.is_err(),
795            "Expected Err on corrupt alc.toml, got: {result:?}"
796        );
797        let msg = result.unwrap_err();
798        assert!(
799            msg.contains("alc.toml load:"),
800            "Error should contain 'alc.toml load:', got: {msg}"
801        );
802    }
803
804    /// When `alc.lock` exists but is corrupt, the closure must return `Err`.
805    #[test]
806    fn load_alc_lock_corrupt_yields_fatal_err() {
807        let tmp = tempfile::tempdir().unwrap();
808        let root = tmp.path();
809        // Write a valid alc.toml so it passes the first check
810        std::fs::write(root.join("alc.toml"), b"[packages]\n").unwrap();
811        // Write corrupt alc.lock
812        std::fs::write(root.join("alc.lock"), b"version = 999\n[[package]]\n").unwrap();
813
814        let lock_path = root.join(".alc-install.lock");
815        let result: Result<Vec<String>, String> = with_exclusive_lock(&lock_path, move || {
816            let _doc = match load_alc_toml_document(root) {
817                Ok(Some(d)) => d,
818                Ok(None) => return Ok(Vec::new()),
819                Err(e) => return Err(format!("alc.toml load: {e}")),
820            };
821            match load_lockfile(root) {
822                Ok(Some(_l)) => Ok(Vec::new()),
823                Ok(None) => Ok(Vec::new()),
824                Err(e) => Err(format!("alc.lock load: {e}")),
825            }
826        });
827
828        assert!(
829            result.is_err(),
830            "Expected Err on corrupt alc.lock, got: {result:?}"
831        );
832        let msg = result.unwrap_err();
833        assert!(
834            msg.contains("alc.lock load:"),
835            "Error should contain 'alc.lock load:', got: {msg}"
836        );
837    }
838
839    // ── (a) closure save failure → warnings collected (not fatal) ─────────────
840
841    /// When alc.toml exists and is valid but the save path is non-writable,
842    /// the failure should appear in the returned warnings vec (not as Err).
843    #[test]
844    fn save_failure_produces_warning_not_fatal_err() {
845        let tmp = tempfile::tempdir().unwrap();
846        let root = tmp.path();
847        // Write a minimal valid alc.toml
848        std::fs::write(root.join("alc.toml"), b"[packages]\n").unwrap();
849
850        // Place a regular FILE at bad_root so create_dir_all(bad_root) fails
851        // (cannot create a directory at a path occupied by a non-directory).
852        // Both save_alc_toml and save_lockfile call create_dir_all(parent)
853        // where parent = bad_root, so the file blocker triggers the failure.
854        let bad_root = root.join("blocked_subdir");
855        std::fs::write(&bad_root, b"this is a file, not a dir").unwrap();
856
857        let lock_path = root.join(".alc-install.lock");
858        let root_owned = root.to_path_buf();
859        let bad_root_owned = bad_root.clone();
860        let result: Result<Vec<String>, String> = with_exclusive_lock(&lock_path, move || {
861            let mut warnings: Vec<String> = Vec::new();
862            let doc = match load_alc_toml_document(&root_owned) {
863                Ok(Some(d)) => d,
864                Ok(None) => return Ok(Vec::new()),
865                Err(e) => return Err(format!("alc.toml load: {e}")),
866            };
867            if let Err(e) = save_alc_toml(&bad_root_owned, &doc) {
868                warnings.push(format!("alc.toml save: {e}"));
869            }
870            let lock = LockFile {
871                version: 1,
872                packages: Vec::new(),
873            };
874            if let Err(e) = save_lockfile(&bad_root_owned, &lock) {
875                warnings.push(format!("alc.lock save: {e}"));
876            }
877            Ok(warnings)
878        });
879
880        assert!(
881            result.is_ok(),
882            "Expected Ok even with save failures, got: {result:?}"
883        );
884        let warnings = result.unwrap();
885        assert!(
886            !warnings.is_empty(),
887            "Expected at least one save warning, got empty warnings"
888        );
889        assert!(
890            warnings.iter().any(|w| w.contains("alc.toml save:")),
891            "Expected 'alc.toml save:' warning, got: {warnings:?}"
892        );
893    }
894
895    // ── (c) caller transforms fatal Err into project_files_warnings ───────────
896
897    /// The caller pattern `match result { Ok(ws) => ws, Err(e) => vec![e] }`
898    /// must convert a fatal Err into a single-element warnings vec so the
899    /// install response remains Ok.
900    #[test]
901    fn caller_degrades_fatal_err_to_project_files_warnings() {
902        // Simulate the update returning a fatal Err (e.g. alc.toml load failure)
903        let update_result: Result<Vec<String>, String> =
904            Err("alc.toml load: TOML parse error at line 1".to_string());
905
906        // This is the exact pattern used in each of the 4 callers.
907        let project_files_warnings = match update_result {
908            Ok(ws) => ws,
909            Err(e) => vec![e],
910        };
911
912        assert_eq!(project_files_warnings.len(), 1);
913        assert!(
914            project_files_warnings[0].contains("alc.toml load:"),
915            "Warning should contain the original error message"
916        );
917    }
918
919    /// When update returns Ok with warnings, they pass through unchanged.
920    #[test]
921    fn caller_passes_through_ok_warnings() {
922        let update_result: Result<Vec<String>, String> = Ok(vec![
923            "alc.toml save: permission denied".to_string(),
924            "alc.lock save: no space left".to_string(),
925        ]);
926
927        let project_files_warnings = match update_result {
928            Ok(ws) => ws,
929            Err(e) => vec![e],
930        };
931
932        assert_eq!(project_files_warnings.len(), 2);
933    }
934
935    /// When update returns Ok with no warnings, the empty vec is gated out
936    /// of the response JSON (mirrors storage_warnings convention).
937    #[test]
938    fn empty_warnings_are_not_added_to_response() {
939        let update_result: Result<Vec<String>, String> = Ok(Vec::new());
940
941        let project_files_warnings = match update_result {
942            Ok(ws) => ws,
943            Err(e) => vec![e],
944        };
945
946        // Gate mirrors the `if !project_files_warnings.is_empty()` check in callers
947        let mut response = serde_json::json!({ "installed": ["mypkg"], "mode": "single" });
948        if !project_files_warnings.is_empty() {
949            response["project_files_warnings"] = serde_json::json!(project_files_warnings);
950        }
951
952        assert!(
953            response.get("project_files_warnings").is_none(),
954            "project_files_warnings should not appear when warnings are empty"
955        );
956    }
957
958    // ── Helpers ──────────────────────────────────────────────────────────────
959
960    #[test]
961    fn upsert_lock_entry_inserts_new_package() {
962        let mut lock = LockFile {
963            version: 1,
964            packages: Vec::new(),
965        };
966        upsert_lock_entry(
967            &mut lock,
968            "mypkg".to_string(),
969            Some("1.0.0".to_string()),
970            PackageSource::Installed,
971        );
972        assert_eq!(lock.packages.len(), 1);
973        assert_eq!(lock.packages[0].name, "mypkg");
974        assert_eq!(lock.packages[0].version, Some("1.0.0".to_string()));
975    }
976
977    #[test]
978    fn upsert_lock_entry_updates_existing_package() {
979        let mut lock = LockFile {
980            version: 1,
981            packages: Vec::new(),
982        };
983        upsert_lock_entry(
984            &mut lock,
985            "mypkg".to_string(),
986            Some("1.0.0".to_string()),
987            PackageSource::Installed,
988        );
989        upsert_lock_entry(
990            &mut lock,
991            "mypkg".to_string(),
992            Some("2.0.0".to_string()),
993            PackageSource::Installed,
994        );
995        assert_eq!(lock.packages.len(), 1);
996        assert_eq!(lock.packages[0].version, Some("2.0.0".to_string()));
997    }
998
999    #[test]
1000    fn is_safe_pkg_name_accepts_valid_names() {
1001        assert!(is_safe_pkg_name("my_pkg"));
1002        assert!(is_safe_pkg_name("my-pkg"));
1003        assert!(is_safe_pkg_name("mypkg123"));
1004    }
1005
1006    #[test]
1007    fn is_safe_pkg_name_rejects_invalid_names() {
1008        assert!(!is_safe_pkg_name(""));
1009        assert!(!is_safe_pkg_name("my pkg"));
1010        assert!(!is_safe_pkg_name("../escape"));
1011        assert!(!is_safe_pkg_name("pkg;rm -rf /"));
1012    }
1013}