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