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