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