Skip to main content

mlua_pkg/
ops.rs

1//! Package-manager operations: `install` / `add` / `update` / `clean`.
2//!
3//! These are the same operations the `mlua-pkg` CLI exposes, as plain
4//! library functions so an embedding application can drive them directly:
5//!
6//! ```rust,no_run
7//! use mlua_pkg::{ops, Config, PkgDir, Project};
8//!
9//! # fn main() -> Result<(), mlua_pkg::PkgError> {
10//! let project = Project::in_dir("/srv/app", PkgDir::default_in("/srv/app"));
11//! let cfg = Config::new(project);            // manifest read from mlua-pkg.toml
12//! let report = ops::install(&cfg)?;
13//! for pkg in &report.packages {
14//!     println!("{} @ {}", pkg.name, pkg.sha);
15//! }
16//! # Ok(())
17//! # }
18//! ```
19//!
20//! To supply the manifest as a value instead of a file, build the
21//! [`Config`] with [`Config::with_manifest`]; see [`crate::config`] for
22//! how `add` / `update` behave in that mode.
23//!
24//! # Contract
25//!
26//! - Every function takes a [`Config`] and returns a report value.  Nothing
27//!   is written to stdout / stderr; the CLI renders the report.
28//! - Errors are [`PkgError`].  Per-dependency fetch failures are wrapped in
29//!   [`PkgError::Fetch`] so the failing name is available structurally.
30//! - No function reads the process environment or the current working
31//!   directory to *decide* a path.  Relative paths inside the [`Project`](crate::Project)
32//!   are resolved by `std::fs` against the cwd like any other path.
33//!
34//! # Operation summary
35//!
36//! | fn | reads | writes | network |
37//! |----|-------|--------|---------|
38//! | [`install`] | manifest | lockfile, `<pkg_dir>/cache`, `<pkg_dir>/vendored` or `target_dir` copies | yes (fetch) |
39//! | [`add`] | manifest (optional) | manifest | no |
40//! | [`update`] | manifest | manifest (only `--force` tag bumps), then everything `install` writes | yes (tag listing + fetch) |
41//! | [`clean`] | lockfile | removes under `<pkg_dir>/cache` | no |
42
43use std::{
44    collections::{HashMap, HashSet, VecDeque},
45    path::{Path, PathBuf},
46};
47
48use crate::{
49    fetcher::{FetchedPkg, Fetcher, GitFetcher},
50    lockfile::{join_entry, LockedPkg, Lockfile},
51    manifest::{Dep, Manifest, Package, PatchDrift},
52    resolve_entry,
53    version::{classify_tag_pin, pick_latest_for_pin, pick_latest_overall, TagPin},
54    Config, ManifestSource, PkgError, Project,
55};
56
57// ── install ───────────────────────────────────────────────────────────────────
58
59/// Where a dependency's **package root** was placed by [`install`].
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum Placement {
62    /// Relative symlink at `<pkg_dir>/vendored/<name>` (the default),
63    /// pointing at the root (cache checkout or `patch_dir`).
64    Symlink(PathBuf),
65    /// Physical copy of the root into `<manifest_root>/<Dep::target_dir>`.
66    Copied(PathBuf),
67}
68
69/// Requester label for a direct dependency (as opposed to a package name
70/// for a transitive one).  Appears in [`InstalledPkg::requested_by`],
71/// [`PkgError::Fetch`], and [`PkgError::DepConflict`].
72pub const MANIFEST_REQUESTER: &str = "<manifest>";
73
74/// One row of an [`InstallReport`].
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct InstalledPkg {
77    /// Local alias (the `[deps.<name>]` key, or the name a package's own
78    /// manifest used for a transitive dep).
79    pub name: String,
80    /// Who asked for it: [`MANIFEST_REQUESTER`] for a direct dep, else the
81    /// name of the package whose `mlua-pkg.toml` declared it.
82    pub requested_by: String,
83    /// Resolved commit SHA (40-char hex).
84    pub sha: String,
85    /// Concrete tag recorded in the lockfile (`v1.0.5` for a `v1.0` prefix
86    /// pin; whatever the manifest declared otherwise).  `None` for
87    /// branch / rev / unpinned deps.
88    pub tag: Option<String>,
89    /// Entry directory relative to the package root (`"src"`, `"."`, …).
90    pub entry: PathBuf,
91    /// Symlink or copy location on disk.  Either way it is the **package
92    /// root**; see [`root`](Self::root) / [`require_dir`](Self::require_dir).
93    pub placement: Placement,
94    /// `true` when the package was resolved from its `patch_dir` (the
95    /// lockfile's `patch_base` matched the pin), `false` for upstream.
96    pub patched: bool,
97}
98
99impl InstalledPkg {
100    /// Package root on disk: the `<vendored>/<name>` symlink or the
101    /// `target_dir` copy.  Sibling directories of the entry (`types/`,
102    /// docs, …) live directly under it.
103    pub fn root(&self) -> &Path {
104        match &self.placement {
105            Placement::Symlink(p) | Placement::Copied(p) => p,
106        }
107    }
108
109    /// The Lua `require` root: `root/<entry>`.
110    pub fn require_dir(&self) -> PathBuf {
111        join_entry(self.root(), &self.entry)
112    }
113}
114
115/// Decide where the package root for `dep` comes from.
116///
117/// With a `patch_dir`, the pin is resolved first (no clone for a full-SHA
118/// `rev`, one `ls-remote` otherwise).  When the lockfile's `patch_base`
119/// equals that commit and the directory exists, the patch is the root and
120/// nothing is fetched.  Otherwise the upstream is fetched, the patch is
121/// left untouched, and a warning names both commits.  Returns the package
122/// and whether it came from the patch.
123fn resolve_root(
124    fetcher: &GitFetcher,
125    project: &Project,
126    name: &str,
127    dep: &Dep,
128    requested_by: &str,
129    prev: Option<&LockedPkg>,
130    warnings: &mut Vec<String>,
131) -> Result<(FetchedPkg, bool), PkgError> {
132    let fetch_err = |e: PkgError| PkgError::Fetch {
133        name: name.to_string(),
134        requested_by: requested_by.to_string(),
135        source: Box::new(e),
136    };
137    let Some(rel_patch) = &dep.patch_dir else {
138        return Ok((fetcher.fetch(dep).map_err(fetch_err)?, false));
139    };
140
141    let patch_root = project.manifest_root().join(rel_patch);
142    let base = prev.and_then(|p| p.patch_base.as_deref());
143    // A short / symbolic rev cannot be resolved without a clone; in that
144    // case the fetch happens first and its SHA is compared.
145    let (sha, tag, upstream) = match fetcher.resolve_sha(dep).map_err(fetch_err)? {
146        Some((sha, tag)) => (sha, tag, None),
147        None => {
148            let f = fetcher.fetch(dep).map_err(fetch_err)?;
149            (f.sha.clone(), f.resolved_tag.clone(), Some(f))
150        }
151    };
152
153    if patch_root.is_dir() && base == Some(sha.as_str()) {
154        return Ok((FetchedPkg::at_root(patch_root, sha, tag)?, true));
155    }
156
157    let reason = if !patch_root.is_dir() {
158        "the directory does not exist".to_string()
159    } else {
160        match base {
161            None => "the lockfile records no patch_base for it".to_string(),
162            Some(b) => format!("it was taken from {b} but the pin resolves to {sha}"),
163        }
164    };
165    if dep.patch_drift == Some(PatchDrift::Error) {
166        return Err(PkgError::PatchDrift {
167            name: name.to_string(),
168            patch_dir: rel_patch.clone(),
169            reason,
170            pinned: sha,
171        });
172    }
173
174    let fetched = match upstream {
175        Some(f) => f,
176        None => fetcher.fetch(dep).map_err(fetch_err)?,
177    };
178    warnings.push(format!(
179        "{name}: patch_dir '{}' not used ({reason}); using upstream {}. \
180         Run `mlua-pkg patch {name} --force` to rebuild the patch from the pinned commit",
181        rel_patch.display(),
182        fetched.sha
183    ));
184    Ok((fetched, false))
185}
186
187/// Result of [`install`].
188#[derive(Debug, Clone, Default, PartialEq, Eq)]
189pub struct InstallReport {
190    /// Every installed package, direct and transitive, sorted by name
191    /// (the same order as the lockfile).
192    pub packages: Vec<InstalledPkg>,
193    /// Number of `[deps.<name>]` entries in the consumer manifest.
194    pub direct: usize,
195    /// Number of packages pulled in only through another package's
196    /// `mlua-pkg.toml` (`packages.len() - direct`).
197    pub transitive: usize,
198    /// Non-fatal observations (e.g. requested tag vs author manifest
199    /// version mismatch).  Empty when everything lined up.
200    pub warnings: Vec<String>,
201}
202
203/// Fetch every dep in the manifest (and their transitive deps), place
204/// them, and write the lockfile.
205///
206/// Resolution is a breadth-first worklist seeded with the manifest's
207/// `[deps]` (sorted by name).  Every fetched package that ships its own
208/// `mlua-pkg.toml` appends its `[deps]` to the worklist, so a consumer
209/// declares only what it uses directly.  All packages land flat under
210/// `<vendored>/<name>` and in the lockfile, which means one name maps to
211/// exactly one spec: a name reached twice with an identical [`Dep`] is
212/// installed once; reached with different specs the install stops with
213/// [`PkgError::DepConflict`] naming both requesters.  There is no version
214/// unification.
215///
216/// Per package:
217///
218/// 1. Pick the package root.  With a `patch_dir` whose lockfile
219///    `patch_base` equals the pinned commit, that directory is the root and
220///    nothing is fetched; otherwise fetch via [`GitFetcher`] rooted at
221///    `project.pkg_dir().cache()` (a stale / missing patch is reported in
222///    `warnings` and left alone).  Prefix tag pins (`tag = "v1.0"`) are
223///    resolved inside the fetcher.
224/// 2. Resolve the entry directory: `dep.entry` > author-manifest entry >
225///    `src/` > `lua/` > `.` (see [`resolve_entry`]).
226/// 3. Place the **root**: relative symlink at `<vendored>/<name>` by
227///    default, or an idempotent physical copy into
228///    `<manifest_root>/<target_dir>` when `target_dir` is set (a transitive
229///    dep's `target_dir` / `patch_dir` is resolved against the *consumer's*
230///    manifest, like a direct one).  The `require` root is `<root>/<entry>`.
231/// 4. Record `(name, source, resolved tag, sha, entry, patch_dir,
232///    patch_base)` for the lockfile; `patch_base` is carried over from the
233///    previous lockfile, never written here.
234///
235/// The lockfile is written once at the end, sorted by name, so a mid-way
236/// failure leaves the previous lockfile untouched and the output does not
237/// depend on discovery order.
238///
239/// # Errors
240///
241/// - [`PkgError::DepConflict`] when one name carries two different specs.
242/// - [`PkgError::Fetch`] wrapping the fetcher error for the failing dep.
243/// - [`PkgError::EntryNotFound`] when no entry candidate exists.
244/// - [`PkgError::Io`] for symlink / copy / directory creation failures.
245pub fn install(cfg: &Config) -> Result<InstallReport, PkgError> {
246    let project = cfg.project();
247    let manifest = cfg.load_manifest()?;
248
249    let cache_dir = project.pkg_dir().cache();
250    let vendored_dir = project.pkg_dir().vendored();
251    let fetcher = GitFetcher::new(cache_dir);
252    std::fs::create_dir_all(&vendored_dir)?;
253
254    let mut report = InstallReport {
255        direct: manifest.deps.len(),
256        ..InstallReport::default()
257    };
258    let mut locked_pkgs: Vec<LockedPkg> = Vec::with_capacity(manifest.deps.len());
259
260    // The previous lockfile is consulted only for `patch_base` (which
261    // `install` preserves but never writes).  A missing lockfile is the
262    // first-install case; any other read failure is surfaced.
263    let prev_lock: HashMap<String, LockedPkg> = match Lockfile::read(project.lock_path()) {
264        Ok(lf) => lf.pkg.into_iter().map(|p| (p.name.clone(), p)).collect(),
265        Err(PkgError::MissingLockfile { .. }) => HashMap::new(),
266        Err(e) => return Err(e),
267    };
268
269    // Worklist over (name, dep, requested_by).  Direct deps seed it; every
270    // fetched package that ships its own `mlua-pkg.toml` appends its `[deps]`.
271    let mut resolved: HashMap<String, (Dep, String)> = HashMap::new();
272    let mut queue: VecDeque<(String, Dep, String)> = VecDeque::new();
273    let mut direct: Vec<(&String, &Dep)> = manifest.deps.iter().collect();
274    direct.sort_by(|a, b| a.0.cmp(b.0));
275    for (name, dep) in direct {
276        queue.push_back((name.clone(), dep.clone(), MANIFEST_REQUESTER.to_string()));
277    }
278
279    while let Some((name, dep, requested_by)) = queue.pop_front() {
280        if let Some((prev, prev_by)) = resolved.get(&name) {
281            if *prev == dep {
282                continue; // same package reached via another path
283            }
284            return Err(PkgError::DepConflict {
285                name,
286                first: prev_by.clone(),
287                second: requested_by,
288            });
289        }
290        resolved.insert(name.clone(), (dep.clone(), requested_by.clone()));
291        let name = &name;
292        let dep = &dep;
293
294        // Package root: the patched copy while its recorded base still
295        // matches the pin, the upstream cache checkout otherwise.
296        let (fetched, patched) = resolve_root(
297            &fetcher,
298            project,
299            name,
300            dep,
301            &requested_by,
302            prev_lock.get(name.as_str()),
303            &mut report.warnings,
304        )?;
305
306        // Transitive deps: enqueue the author's own `[deps]`, resolved later in order.
307        if let Some(author) = &fetched.manifest {
308            let mut sub: Vec<(&String, &Dep)> = author.deps.iter().collect();
309            sub.sort_by(|a, b| a.0.cmp(b.0));
310            for (sub_name, sub_dep) in sub {
311                queue.push_back((sub_name.clone(), sub_dep.clone(), name.clone()));
312            }
313        }
314
315        // Author manifest version-assert: warn on tag mismatch, don't hard-error.
316        if let Some(author) = &fetched.manifest {
317            if let Some(req_tag) = &dep.tag {
318                let av = &author.package.version;
319                let normalized = req_tag.strip_prefix('v').unwrap_or(req_tag.as_str());
320                if av != req_tag && av != normalized {
321                    report.warnings.push(format!(
322                        "{name}: requested tag '{req_tag}' vs author manifest version '{av}'"
323                    ));
324                }
325            }
326        }
327
328        // Entry resolution: dep.entry > author-manifest entry > fallback chain.
329        let author_entry: Option<PathBuf> = fetched
330            .manifest
331            .as_ref()
332            .and_then(|m| m.package.entry.clone());
333        let override_entry: Option<&Path> = dep.entry.as_deref().or(author_entry.as_deref());
334        let entry_abs = resolve_entry(&fetched.cache_path, override_entry)?;
335
336        // The unit placed on disk is the package root; `entry` (relative to
337        // it) is recorded so consumers can find the `require` root inside.
338        let root = &fetched.cache_path;
339        let placement = if let Some(rel_target_dir) = &dep.target_dir {
340            // Physical vendor copy of the whole root: manifest-relative
341            // directory, idempotent.
342            let dest = project.manifest_root().join(rel_target_dir);
343            copy_entry_into(root, &dest)?;
344            Placement::Copied(dest)
345        } else {
346            // Default: relative symlink <vendored>/<name> → root
347            // (../cache/git/… or the manifest's patch_dir).
348            let symlink_path = vendored_dir.join(name);
349            if symlink_path.symlink_metadata().is_ok() {
350                remove_symlink(&symlink_path)?;
351            }
352            let rel_target = relative_path(&vendored_dir, root)?;
353            create_symlink(&rel_target, &symlink_path)?;
354            Placement::Symlink(symlink_path)
355        };
356
357        // Compute entry relative to the package root for the lockfile.
358        let entry = entry_rel_to_pkg(root, &entry_abs);
359
360        // Record the *resolved* tag in the lockfile (concrete release for
361        // prefix pins; falls back to whatever the manifest declared).
362        let locked_tag = fetched.resolved_tag.clone().or_else(|| dep.tag.clone());
363        // `patch_base` is written only by `patch`; install carries it over
364        // (even across a drift, so the patch is not orphaned) and drops it
365        // together with `patch_dir`.
366        let patch_base = dep
367            .patch_dir
368            .as_ref()
369            .and_then(|_| prev_lock.get(name.as_str()))
370            .and_then(|p| p.patch_base.clone());
371        locked_pkgs.push(LockedPkg {
372            name: name.clone(),
373            source: format!("git+{}", dep.git),
374            tag: locked_tag.clone(),
375            rev: dep.rev.clone(),
376            branch: dep.branch.clone(),
377            sha: fetched.sha.clone(),
378            entry: entry.clone(),
379            patch_dir: dep.patch_dir.clone(),
380            patch_base,
381        });
382        report.packages.push(InstalledPkg {
383            name: name.clone(),
384            requested_by,
385            sha: fetched.sha,
386            tag: locked_tag,
387            entry,
388            placement,
389            patched,
390        });
391    }
392
393    // Deterministic order regardless of HashMap iteration / discovery order.
394    locked_pkgs.sort_by(|a, b| a.name.cmp(&b.name));
395    report.packages.sort_by(|a, b| a.name.cmp(&b.name));
396    report.transitive = report.packages.len().saturating_sub(report.direct);
397
398    let lockfile = Lockfile {
399        version: 1,
400        pkg: locked_pkgs,
401    };
402    lockfile.write(project.lock_path())?;
403
404    Ok(report)
405}
406
407/// Recursively copy the contents of `src` into `dest`, removing `dest` first
408/// so the copy is idempotent (subsequent installs reflect upstream changes /
409/// renames / deletions).
410fn copy_entry_into(src: &Path, dest: &Path) -> std::io::Result<()> {
411    if dest.exists() {
412        std::fs::remove_dir_all(dest)?;
413    }
414    std::fs::create_dir_all(dest)?;
415    copy_dir_contents(src, dest)
416}
417
418/// Recursively copy every entry under `src` into `dest`, materialising
419/// symlinks into regular files / directories so the vendored output is
420/// self-contained and never depends on the cache layout.
421fn copy_dir_contents(src: &Path, dest: &Path) -> std::io::Result<()> {
422    for entry in std::fs::read_dir(src)? {
423        let entry = entry?;
424        let from = entry.path();
425        let to = dest.join(entry.file_name());
426        let ft = entry.file_type()?;
427        if ft.is_dir() {
428            std::fs::create_dir_all(&to)?;
429            copy_dir_contents(&from, &to)?;
430        } else if ft.is_symlink() {
431            // Materialise into a regular file/dir (vendored output should not
432            // depend on the cache symlink topology).
433            let resolved = std::fs::canonicalize(&from)?;
434            if resolved.is_dir() {
435                std::fs::create_dir_all(&to)?;
436                copy_dir_contents(&resolved, &to)?;
437            } else {
438                std::fs::copy(&resolved, &to)?;
439            }
440        } else {
441            std::fs::copy(&from, &to)?;
442        }
443    }
444    Ok(())
445}
446
447/// Strip `cache_path` prefix from `entry_abs`; return `"."` for the repo root.
448fn entry_rel_to_pkg(cache_path: &Path, entry_abs: &Path) -> PathBuf {
449    match entry_abs.strip_prefix(cache_path) {
450        Ok(rel) if rel.as_os_str().is_empty() => PathBuf::from("."),
451        Ok(rel) => rel.to_path_buf(),
452        Err(_) => PathBuf::from("."),
453    }
454}
455
456// ── add ───────────────────────────────────────────────────────────────────────
457
458/// What [`add`] should write into `[deps.<name>]`.
459///
460/// At most one of `tag` / `rev` / `branch` may be set.
461#[derive(Debug, Clone, Default, PartialEq, Eq)]
462pub struct AddSpec {
463    /// Local alias used in `require()`.
464    pub name: String,
465    /// Remote git URL.
466    pub git: String,
467    /// Pin to a tag (exact `v1.0.0` or prefix `v1.0`).
468    pub tag: Option<String>,
469    /// Pin to a commit SHA.
470    pub rev: Option<String>,
471    /// Track a branch (non-reproducible).
472    pub branch: Option<String>,
473    /// Override the Lua `require()` entry subdir.
474    pub entry: Option<PathBuf>,
475    /// Physically vendor the entry into this manifest-relative directory.
476    pub target_dir: Option<PathBuf>,
477}
478
479impl AddSpec {
480    /// Minimal spec: name + git URL, no pin.
481    pub fn new(name: impl Into<String>, git: impl Into<String>) -> Self {
482        Self {
483            name: name.into(),
484            git: git.into(),
485            ..Default::default()
486        }
487    }
488}
489
490/// Whether [`add`] inserted a new entry or replaced an existing one.
491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
492pub enum AddOutcome {
493    /// `[deps.<name>]` did not exist before.
494    Added,
495    /// `[deps.<name>]` existed and was overwritten.
496    Replaced,
497}
498
499/// Result of [`add`].
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub struct AddReport {
502    /// The dep name that was written.
503    pub name: String,
504    /// Added vs replaced.
505    pub outcome: AddOutcome,
506    /// Whether the manifest file had to be created from scratch
507    /// (always `false` for [`ManifestSource::Value`]).
508    pub manifest_created: bool,
509    /// The manifest after the insertion.  For [`ManifestSource::File`] this
510    /// is what was written to `manifest_path()`; for
511    /// [`ManifestSource::Value`] it is the only place the change exists.
512    pub manifest: Manifest,
513}
514
515/// Insert (or replace) a `[deps.<name>]` entry in the manifest.
516///
517/// Performs no network I/O and never invokes the fetcher; callers run
518/// [`install`] afterwards.  If the manifest file does not exist yet a
519/// minimal `[package]` block is synthesised, using the manifest directory's
520/// name as the package name.
521///
522/// The manifest is rewritten through `toml::to_string`, so comments and
523/// formatting of an existing file are **not** preserved (this matches the
524/// CLI's historical behaviour; `update` is the format-preserving path).
525///
526/// # Errors
527///
528/// - [`PkgError::Validation`] when more than one of `tag` / `rev` / `branch`
529///   is set.
530/// - [`PkgError::ManifestParse`] / [`PkgError::Io`] for an unreadable
531///   existing manifest.
532/// - [`PkgError::ManifestSerialize`] / [`PkgError::Io`] on write.
533pub fn add(cfg: &Config, spec: AddSpec) -> Result<AddReport, PkgError> {
534    let project = cfg.project();
535    let ref_count = [
536        spec.tag.is_some(),
537        spec.rev.is_some(),
538        spec.branch.is_some(),
539    ]
540    .into_iter()
541    .filter(|&b| b)
542    .count();
543    if ref_count > 1 {
544        return Err(PkgError::Validation {
545            message: "at most one of tag, rev, branch may be specified".to_string(),
546        });
547    }
548
549    let manifest_path = project.manifest_path();
550    let (mut manifest, manifest_created) = match cfg.manifest_source() {
551        ManifestSource::Value(m) => (m.clone(), false),
552        ManifestSource::File if manifest_path.exists() => {
553            (Manifest::from_path(manifest_path)?, false)
554        }
555        ManifestSource::File => (
556            Manifest {
557                package: Package {
558                    name: default_package_name(&project.manifest_root()),
559                    version: "0.1.0".to_string(),
560                    entry: None,
561                },
562                deps: HashMap::new(),
563            },
564            true,
565        ),
566    };
567
568    let AddSpec {
569        name,
570        git,
571        tag,
572        rev,
573        branch,
574        entry,
575        target_dir,
576    } = spec;
577    let dep = Dep {
578        git,
579        tag,
580        rev,
581        branch,
582        entry,
583        target_dir,
584        patch_dir: None,
585        patch_drift: None,
586    };
587    let existed = manifest.deps.insert(name.clone(), dep).is_some();
588
589    if !cfg.manifest_is_value() {
590        let toml_str =
591            toml::to_string(&manifest).map_err(|e| PkgError::ManifestSerialize { source: e })?;
592        std::fs::write(manifest_path, toml_str)?;
593    }
594
595    Ok(AddReport {
596        name,
597        outcome: if existed {
598            AddOutcome::Replaced
599        } else {
600            AddOutcome::Added
601        },
602        manifest_created,
603        manifest,
604    })
605}
606
607/// Package name to synthesise when `add` has to create the manifest:
608/// the (canonicalised) manifest directory's basename, else `"my-project"`.
609fn default_package_name(manifest_root: &Path) -> String {
610    std::fs::canonicalize(manifest_root)
611        .ok()
612        .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
613        .unwrap_or_else(|| "my-project".to_string())
614}
615
616// ── update ────────────────────────────────────────────────────────────────────
617
618/// Options for [`update`].
619#[derive(Debug, Clone, Default, PartialEq, Eq)]
620pub struct UpdateOpts {
621    /// Restrict to one dep.  `None` = every `[deps.<name>]`.
622    pub name: Option<String>,
623    /// Compute the plan but write nothing and do not re-install.
624    pub dry_run: bool,
625    /// Also bump exact (full SemVer) tag pins to the SemVer-max release
626    /// available on the remote.
627    pub force: bool,
628}
629
630/// Per-dep decision made by [`update`].
631#[derive(Debug, Clone, PartialEq, Eq)]
632pub enum UpdateOutcome {
633    /// Exact tag pin rewritten from `old` to `new` (manifest is mutated;
634    /// only emitted under `force`).
635    TagBumped { old: String, new: String },
636    /// Prefix tag pin re-resolves to a concrete release.  The manifest is
637    /// **not** mutated — the prefix stays so it keeps auto-following future
638    /// patches.  `resolved` is reported for transparency.
639    PrefixResolved { pin: String, resolved: String },
640    /// Branch / no-pin dep: just re-install so the lock picks up new HEAD.
641    Refresh,
642    /// Skipped (rev pin, exact tag without `force`, unparseable tag, no
643    /// remote match).  The string is the human-readable reason.
644    Skipped(String),
645}
646
647/// Result of [`update`].
648#[derive(Debug, Clone, Default, PartialEq, Eq)]
649pub struct UpdateReport {
650    /// `(dep name, decision)` for every dep that was considered, in
651    /// manifest iteration order.  Empty when `opts.name` matched nothing.
652    pub entries: Vec<(String, UpdateOutcome)>,
653    /// `true` when the manifest changed (only for `TagBumped` under
654    /// `force`, and never under `dry_run`).  For [`ManifestSource::File`]
655    /// the file was rewritten; for [`ManifestSource::Value`] nothing was
656    /// written and the result is in [`manifest`](Self::manifest).
657    pub manifest_modified: bool,
658    /// The manifest after the update, present only when
659    /// [`manifest_modified`](Self::manifest_modified) is `true`.
660    pub manifest: Option<Manifest>,
661    /// The re-install that followed, if any.  `None` under `dry_run` or
662    /// when every entry was `Skipped`.
663    pub install: Option<InstallReport>,
664}
665
666/// Refresh deps and bump tag pins as appropriate, then re-install.
667///
668/// Walks `[deps]` (or just `opts.name`), classifies each entry via the
669/// decision table below, and — unless `opts.dry_run` — applies the
670/// changes:
671///
672/// | dep pin              | `force` | outcome                                     |
673/// | -------------------- | ------- | ------------------------------------------- |
674/// | `rev = "..."`        | any     | `Skipped("rev pin")`                        |
675/// | `branch = "..."`     | any     | `Refresh`                                   |
676/// | `tag = "..."`, exact | `false` | `Skipped("exact tag pin …")`                |
677/// | `tag = "..."`, exact | `true`  | `TagBumped` (or `Skipped("already at …")`)  |
678/// | `tag = "..."`, prefix| any     | `PrefixResolved` (or `Skipped("no match")`) |
679/// | `tag = "..."`, junk  | any     | `Skipped("tag '…' is not SemVer")`          |
680/// | no pin               | any     | `Refresh`                                   |
681///
682/// `TagBumped` rewrites the manifest's `tag` value in place through
683/// `toml_edit` (comments / key order preserved).  Anything other than
684/// `Skipped` triggers a full [`install`] afterwards.
685///
686/// # Errors
687///
688/// - [`PkgError::UnknownDep`] when `opts.name` is not in `[deps]`.
689/// - [`PkgError::ManifestEdit`] when the manifest cannot be re-parsed or
690///   the `[deps.<name>]` table is not editable.
691/// - Anything [`install`] can return.
692pub fn update(cfg: &Config, opts: UpdateOpts) -> Result<UpdateReport, PkgError> {
693    let project = cfg.project();
694    let manifest_path = project.manifest_path();
695    let manifest = cfg.load_manifest()?;
696
697    if let Some(ref n) = opts.name {
698        if !manifest.deps.contains_key(n) {
699            return Err(PkgError::UnknownDep {
700                name: n.clone(),
701                manifest: manifest_path.to_path_buf(),
702            });
703        }
704    }
705
706    let fetcher = GitFetcher::new(project.pkg_dir().cache());
707    // File mode edits through toml_edit so comments / key order survive;
708    // value mode mutates a clone of the caller's Manifest instead.
709    let mut doc: Option<toml_edit::DocumentMut> = if cfg.manifest_is_value() {
710        None
711    } else {
712        Some(std::fs::read_to_string(manifest_path)?.parse()?)
713    };
714    let mut new_manifest = manifest.clone();
715
716    let mut report = UpdateReport::default();
717    let mut any_refresh = false;
718    let mut manifest_touched = false;
719
720    for (dep_name, dep) in &manifest.deps {
721        if let Some(ref n) = opts.name {
722            if dep_name != n {
723                continue;
724            }
725        }
726
727        let outcome = update_dep(dep_name, dep, &fetcher, opts.force)?;
728        match &outcome {
729            UpdateOutcome::TagBumped { new, .. } => {
730                if !opts.dry_run {
731                    if let Some(doc) = doc.as_mut() {
732                        set_dep_tag(doc, dep_name, new)?;
733                    }
734                    if let Some(d) = new_manifest.deps.get_mut(dep_name) {
735                        d.tag = Some(new.clone());
736                    }
737                    manifest_touched = true;
738                }
739                any_refresh = true;
740            }
741            UpdateOutcome::PrefixResolved { .. } | UpdateOutcome::Refresh => {
742                any_refresh = true;
743            }
744            UpdateOutcome::Skipped(_) => {}
745        }
746        report.entries.push((dep_name.clone(), outcome));
747    }
748
749    if report.entries.is_empty() || opts.dry_run {
750        return Ok(report);
751    }
752
753    if manifest_touched {
754        if let Some(doc) = &doc {
755            std::fs::write(manifest_path, doc.to_string())?;
756        }
757        report.manifest_modified = true;
758        report.manifest = Some(new_manifest.clone());
759    }
760
761    if any_refresh {
762        // Re-install against the manifest as it now is, without re-reading
763        // the file (value mode has no file to re-read).
764        report.install = Some(install(&cfg.replace_manifest(new_manifest))?);
765    }
766    Ok(report)
767}
768
769// ── patch ─────────────────────────────────────────────────────────────────────
770
771/// Options for [`patch`].
772#[derive(Debug, Clone, Default, PartialEq, Eq)]
773pub struct PatchOpts {
774    /// `[deps.<name>]` to materialise; it must declare `patch_dir`.
775    pub name: String,
776    /// Overwrite an existing `patch_dir` (local edits are discarded).
777    pub force: bool,
778}
779
780/// Result of [`patch`].
781#[derive(Debug, Clone, PartialEq, Eq)]
782pub struct PatchReport {
783    /// The dep name.
784    pub name: String,
785    /// Absolute path of the patch directory that was written.
786    pub patch_dir: PathBuf,
787    /// Upstream commit the directory now contains (recorded as `patch_base`).
788    pub base: String,
789    /// `true` when the directory did not exist before; `false` for a
790    /// `--force` rebuild.
791    pub created: bool,
792}
793
794/// Create (or, with `force`, rebuild) the `patch_dir` of one dep from the
795/// commit its pin resolves to, and record that commit as `patch_base` in
796/// the lockfile.
797///
798/// The whole package root is copied, so `types/` and other siblings of the
799/// entry come along.  From then on [`install`] resolves the dep from the
800/// patch for as long as the pin keeps resolving to `patch_base`; edit the
801/// files in place and commit them with the project.  When the pin moves,
802/// `install` falls back to upstream with a warning, and `patch --force`
803/// rebuilds the directory from the new commit (discarding local edits —
804/// re-apply them from your VCS history).
805///
806/// # Errors
807///
808/// - [`PkgError::UnknownDep`] when `name` is not in `[deps]`.
809/// - [`PkgError::Validation`] when the dep has no `patch_dir`, or the
810///   directory exists and `force` is not set.
811/// - [`PkgError::Fetch`] wrapping the fetcher error.
812/// - [`PkgError::Io`] for the copy / lockfile write.
813pub fn patch(cfg: &Config, opts: PatchOpts) -> Result<PatchReport, PkgError> {
814    let project = cfg.project();
815    let manifest = cfg.load_manifest()?;
816    let dep = manifest
817        .deps
818        .get(&opts.name)
819        .ok_or_else(|| PkgError::UnknownDep {
820            name: opts.name.clone(),
821            manifest: project.manifest_path().to_path_buf(),
822        })?;
823    let rel_patch = dep.patch_dir.as_ref().ok_or_else(|| PkgError::Validation {
824        message: format!("dep '{}' declares no patch_dir", opts.name),
825    })?;
826    let dest = project.manifest_root().join(rel_patch);
827    let existed = dest.exists();
828    if existed && !opts.force {
829        return Err(PkgError::Validation {
830            message: format!(
831                "patch_dir '{}' already exists; pass --force to rebuild it from the pinned \
832                 commit (local edits are discarded)",
833                rel_patch.display()
834            ),
835        });
836    }
837
838    let fetcher = GitFetcher::new(project.pkg_dir().cache());
839    let fetched = fetcher.fetch(dep).map_err(|e| PkgError::Fetch {
840        name: opts.name.clone(),
841        requested_by: MANIFEST_REQUESTER.to_string(),
842        source: Box::new(e),
843    })?;
844    copy_entry_into(&fetched.cache_path, &dest)?;
845
846    // Record the base in the lockfile.  A package not yet locked gets a
847    // full entry so the next `install` sees a matching `patch_base`.
848    let mut lock = match Lockfile::read(project.lock_path()) {
849        Ok(lf) => lf,
850        Err(PkgError::MissingLockfile { .. }) => Lockfile::default(),
851        Err(e) => return Err(e),
852    };
853    match lock.pkg.iter_mut().find(|p| p.name == opts.name) {
854        Some(p) => {
855            p.sha = fetched.sha.clone();
856            p.patch_dir = Some(rel_patch.clone());
857            p.patch_base = Some(fetched.sha.clone());
858        }
859        None => {
860            let author_entry = fetched
861                .manifest
862                .as_ref()
863                .and_then(|m| m.package.entry.clone());
864            let override_entry = dep.entry.as_deref().or(author_entry.as_deref());
865            let entry_abs = resolve_entry(&fetched.cache_path, override_entry)?;
866            lock.pkg.push(LockedPkg {
867                name: opts.name.clone(),
868                source: format!("git+{}", dep.git),
869                tag: fetched.resolved_tag.clone().or_else(|| dep.tag.clone()),
870                rev: dep.rev.clone(),
871                branch: dep.branch.clone(),
872                sha: fetched.sha.clone(),
873                entry: entry_rel_to_pkg(&fetched.cache_path, &entry_abs),
874                patch_dir: Some(rel_patch.clone()),
875                patch_base: Some(fetched.sha.clone()),
876            });
877        }
878    }
879    lock.write(project.lock_path())?;
880
881    Ok(PatchReport {
882        name: opts.name,
883        patch_dir: dest,
884        base: fetched.sha,
885        created: !existed,
886    })
887}
888
889/// Rewrite the `tag` value for `[deps.<name>]` in `doc`, preserving formatting.
890fn set_dep_tag(
891    doc: &mut toml_edit::DocumentMut,
892    name: &str,
893    new_tag: &str,
894) -> Result<(), PkgError> {
895    let deps = doc
896        .get_mut("deps")
897        .and_then(|i| i.as_table_like_mut())
898        .ok_or_else(|| PkgError::ManifestEdit {
899            message: "[deps] table missing".to_string(),
900        })?;
901    let entry = deps.get_mut(name).ok_or_else(|| PkgError::ManifestEdit {
902        message: format!("dep '{name}' missing in [deps]"),
903    })?;
904    if let Some(table) = entry.as_table_like_mut() {
905        table.insert("tag", toml_edit::value(new_tag));
906        Ok(())
907    } else {
908        Err(PkgError::ManifestEdit {
909            message: format!("dep '{name}' is not a table-like entry"),
910        })
911    }
912}
913
914/// Decide what [`update`] should do for a single dep.
915///
916/// Pure policy: lists remote tags via the fetcher when needed but does
917/// not write to disk.
918fn update_dep(
919    name: &str,
920    dep: &Dep,
921    fetcher: &GitFetcher,
922    force: bool,
923) -> Result<UpdateOutcome, PkgError> {
924    if dep.rev.is_some() {
925        return Ok(UpdateOutcome::Skipped("rev pin".into()));
926    }
927    if dep.branch.is_some() {
928        return Ok(UpdateOutcome::Refresh);
929    }
930    let Some(current_tag) = &dep.tag else {
931        return Ok(UpdateOutcome::Refresh);
932    };
933
934    let pin = match classify_tag_pin(current_tag) {
935        Some(p) => p,
936        None => {
937            return Ok(UpdateOutcome::Skipped(format!(
938                "tag '{current_tag}' is not SemVer"
939            )))
940        }
941    };
942
943    // Exact pin without force: nothing to do (without listing remote tags).
944    if matches!(pin, TagPin::Exact) && !force {
945        return Ok(UpdateOutcome::Skipped(
946            "exact tag pin (pass --force to bump)".into(),
947        ));
948    }
949
950    let tags = fetcher.list_tags(&dep.git).map_err(|e| PkgError::Fetch {
951        name: name.to_string(),
952        requested_by: MANIFEST_REQUESTER.to_string(),
953        source: Box::new(e),
954    })?;
955
956    match pin {
957        TagPin::Exact => {
958            // force path: bump to the SemVer-max release on the remote.
959            let Some(new_tag) = pick_latest_overall(&tags) else {
960                return Ok(UpdateOutcome::Skipped(
961                    "no matching SemVer release tag on remote".into(),
962                ));
963            };
964            if &new_tag == current_tag {
965                Ok(UpdateOutcome::Skipped(format!("already at {new_tag}")))
966            } else {
967                Ok(UpdateOutcome::TagBumped {
968                    old: current_tag.clone(),
969                    new: new_tag,
970                })
971            }
972        }
973        TagPin::Prefix(p) => {
974            // Prefix pin: resolve to a concrete tag but leave the manifest
975            // untouched so the prefix keeps auto-following future patches.
976            let Some(resolved) = pick_latest_for_pin(&tags, &p) else {
977                return Ok(UpdateOutcome::Skipped(
978                    "no matching SemVer release tag on remote".into(),
979                ));
980            };
981            Ok(UpdateOutcome::PrefixResolved {
982                pin: current_tag.clone(),
983                resolved,
984            })
985        }
986    }
987}
988
989// ── clean ─────────────────────────────────────────────────────────────────────
990
991/// Result of [`clean`].
992#[derive(Debug, Clone, Copy, PartialEq, Eq)]
993pub enum CleanReport {
994    /// `all = true`: the whole cache directory was removed.
995    CacheRemoved,
996    /// No lockfile exists, so nothing was ever installed; nothing removed.
997    NoLockfile,
998    /// Stale-only sweep ran (or `all` on an absent cache); `removed` is the
999    /// number of unreferenced SHA directories deleted (`0` = nothing to do).
1000    StaleRemoved { removed: usize },
1001}
1002
1003/// Remove cached packages.
1004///
1005/// With `all = true`, removes the entire `<pkg_dir>/cache` directory.
1006/// Otherwise reads the lockfile, collects the set of in-use SHAs, and
1007/// recursively deletes any 40-hex SHA directory under `<cache>/git/` that
1008/// is *not* in that set.  A missing lockfile is reported, not an error.
1009///
1010/// Never touches `<pkg_dir>/vendored`; dangling symlinks there are
1011/// repaired by the next [`install`].
1012pub fn clean(cfg: &Config, all: bool) -> Result<CleanReport, PkgError> {
1013    let project = cfg.project();
1014    let cache_dir = project.pkg_dir().cache();
1015
1016    if all {
1017        if cache_dir.exists() {
1018            std::fs::remove_dir_all(&cache_dir)?;
1019            return Ok(CleanReport::CacheRemoved);
1020        }
1021        return Ok(CleanReport::StaleRemoved { removed: 0 });
1022    }
1023
1024    // Read lockfile — absent means nothing was ever installed.
1025    let lockfile = match Lockfile::read(project.lock_path()) {
1026        Ok(lf) => lf,
1027        Err(PkgError::MissingLockfile { .. }) => return Ok(CleanReport::NoLockfile),
1028        Err(e) => return Err(e),
1029    };
1030
1031    let in_use: HashSet<String> = lockfile.pkg.iter().map(|p| p.sha.clone()).collect();
1032
1033    let git_dir = cache_dir.join("git");
1034    if !git_dir.exists() {
1035        return Ok(CleanReport::StaleRemoved { removed: 0 });
1036    }
1037
1038    let mut removed: usize = 0;
1039    remove_stale_sha_dirs(&git_dir, &in_use, &mut removed)?;
1040    Ok(CleanReport::StaleRemoved { removed })
1041}
1042
1043/// Recursively walk `dir` and delete subdirectories whose name is a 40-hex
1044/// SHA that is absent from `in_use`.
1045fn remove_stale_sha_dirs(
1046    dir: &Path,
1047    in_use: &HashSet<String>,
1048    removed: &mut usize,
1049) -> std::io::Result<()> {
1050    for entry in std::fs::read_dir(dir)? {
1051        let entry = entry?;
1052        let path = entry.path();
1053        if !path.is_dir() {
1054            continue;
1055        }
1056        let file_name = entry.file_name();
1057        let name = file_name.to_string_lossy();
1058        if name.len() == 40 && name.chars().all(|c| c.is_ascii_hexdigit()) {
1059            if !in_use.contains(name.as_ref()) {
1060                std::fs::remove_dir_all(&path)?;
1061                *removed += 1;
1062            }
1063        } else {
1064            // Descend deeper (host / org / repo levels).
1065            remove_stale_sha_dirs(&path, in_use, removed)?;
1066        }
1067    }
1068    Ok(())
1069}
1070
1071// ── symlink helpers ───────────────────────────────────────────────────────────
1072
1073/// Create a directory symlink at `link` pointing to `target`.
1074#[cfg(unix)]
1075fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
1076    std::os::unix::fs::symlink(target, link)
1077}
1078
1079#[cfg(windows)]
1080fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
1081    std::os::windows::fs::symlink_dir(target, link)
1082}
1083
1084#[cfg(not(any(unix, windows)))]
1085fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> {
1086    Err(std::io::Error::new(
1087        std::io::ErrorKind::Unsupported,
1088        "symlinks not supported on this platform",
1089    ))
1090}
1091
1092/// Remove a symlink at `path`.
1093#[cfg(unix)]
1094fn remove_symlink(path: &Path) -> std::io::Result<()> {
1095    std::fs::remove_file(path)
1096}
1097
1098#[cfg(windows)]
1099fn remove_symlink(path: &Path) -> std::io::Result<()> {
1100    // On Windows a directory symlink is removed with remove_dir.
1101    if path.is_dir() {
1102        std::fs::remove_dir(path)
1103    } else {
1104        std::fs::remove_file(path)
1105    }
1106}
1107
1108#[cfg(not(any(unix, windows)))]
1109fn remove_symlink(path: &Path) -> std::io::Result<()> {
1110    std::fs::remove_file(path)
1111}
1112
1113/// Compute the path to `to` relative to `from_dir`.
1114///
1115/// Canonicalises both arguments so the result is correct even when the
1116/// process working directory is a symlinked path (e.g. macOS `/Users` →
1117/// `/private/Users`).  Both paths must already exist on the filesystem.
1118fn relative_path(from_dir: &Path, to: &Path) -> std::io::Result<PathBuf> {
1119    let from_abs = std::fs::canonicalize(from_dir)?;
1120    let to_abs = std::fs::canonicalize(to)?;
1121
1122    let from_parts: Vec<_> = from_abs.components().collect();
1123    let to_parts: Vec<_> = to_abs.components().collect();
1124
1125    let common = from_parts
1126        .iter()
1127        .zip(to_parts.iter())
1128        .take_while(|(a, b)| a == b)
1129        .count();
1130
1131    let mut rel = PathBuf::new();
1132    for _ in &from_parts[common..] {
1133        rel.push("..");
1134    }
1135    for c in &to_parts[common..] {
1136        rel.push(c);
1137    }
1138    Ok(rel)
1139}
1140
1141// ── tests ─────────────────────────────────────────────────────────────────────
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::*;
1146    use crate::{PkgDir, Project};
1147    use tempfile::TempDir;
1148
1149    // ── helpers ───────────────────────────────────────────────────────────────
1150
1151    /// Initialise a git repository at `dir`, write a single `main.lua`
1152    /// file, commit it, and return the 40-char commit SHA.
1153    fn init_repo_with_commit(dir: &Path) -> String {
1154        use git2::{Repository, Signature};
1155
1156        let repo = Repository::init(dir).unwrap();
1157        {
1158            let mut cfg = repo.config().unwrap();
1159            cfg.set_str("user.name", "Test").unwrap();
1160            cfg.set_str("user.email", "test@example.com").unwrap();
1161        }
1162
1163        std::fs::write(dir.join("main.lua"), "return {}\n").unwrap();
1164
1165        let mut index = repo.index().unwrap();
1166        index.add_path(Path::new("main.lua")).unwrap();
1167        index.write().unwrap();
1168        let tree_id = index.write_tree().unwrap();
1169        let tree = repo.find_tree(tree_id).unwrap();
1170        let sig = Signature::now("Test", "test@example.com").unwrap();
1171        let oid = repo
1172            .commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
1173            .unwrap();
1174        oid.to_string()
1175    }
1176
1177    /// Add an annotated tag to HEAD of the repo at `dir`.
1178    fn add_tag(dir: &Path, tag: &str) {
1179        use git2::{Repository, Signature};
1180        let repo = Repository::open(dir).unwrap();
1181        let head = repo.head().unwrap().peel_to_commit().unwrap();
1182        let sig = Signature::now("Test", "test@example.com").unwrap();
1183        repo.tag(tag, head.as_object(), &sig, tag, false).unwrap();
1184    }
1185
1186    /// Write `content` to `path` (creating parent dirs as needed).
1187    fn write_file(path: &Path, content: &str) {
1188        if let Some(parent) = path.parent() {
1189            std::fs::create_dir_all(parent).unwrap();
1190        }
1191        std::fs::write(path, content).unwrap();
1192    }
1193
1194    /// Conventional project layout inside `dir`.
1195    fn project_in(dir: &Path) -> Project {
1196        Project::in_dir(dir, PkgDir::default_in(dir))
1197    }
1198
1199    /// File-backed config over `p` (the CLI's mode).
1200    fn cfg(p: &Project) -> Config {
1201        Config::new(p.clone())
1202    }
1203
1204    fn manifest_with_dep(url: &str, dep_body: &str) -> String {
1205        format!(
1206            "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n\
1207             [deps]\nmylib = {{ git = \"{url}\", {dep_body} }}\n"
1208        )
1209    }
1210
1211    // ── install ───────────────────────────────────────────────────────────────
1212
1213    #[test]
1214    fn install_creates_lockfile_and_symlink() {
1215        let remote = TempDir::new().unwrap();
1216        let sha = init_repo_with_commit(remote.path());
1217
1218        let dir = TempDir::new().unwrap();
1219        let project = project_in(dir.path());
1220        let url = format!("file://{}", remote.path().display());
1221        write_file(
1222            project.manifest_path(),
1223            &manifest_with_dep(&url, &format!("rev = \"{sha}\"")),
1224        );
1225
1226        let report = install(&cfg(&project)).unwrap();
1227
1228        assert_eq!(report.packages.len(), 1);
1229        assert_eq!(report.packages[0].name, "mylib");
1230        assert_eq!(report.packages[0].sha, sha);
1231        assert!(report.warnings.is_empty());
1232
1233        // Lockfile exists and has one entry.
1234        assert!(project.lock_path().exists(), "lockfile must be written");
1235        let lf = Lockfile::read(project.lock_path()).unwrap();
1236        assert_eq!(lf.pkg.len(), 1, "one locked package");
1237        assert_eq!(lf.pkg[0].name, "mylib");
1238        assert_eq!(lf.pkg[0].sha, sha);
1239        assert_eq!(lf.pkg[0].source, format!("git+{url}"));
1240
1241        // Vendored symlink exists and is reported.
1242        let symlink = project.pkg_dir().vendored().join("mylib");
1243        assert_eq!(
1244            report.packages[0].placement,
1245            Placement::Symlink(symlink.clone())
1246        );
1247        assert!(
1248            symlink.symlink_metadata().is_ok(),
1249            "symlink <vendored>/mylib must exist"
1250        );
1251        // Symlink target must be relative (not absolute).
1252        let target = std::fs::read_link(&symlink).unwrap();
1253        assert!(
1254            target.is_relative(),
1255            "symlink target must be a relative path, got: {}",
1256            target.display()
1257        );
1258    }
1259
1260    #[test]
1261    fn install_with_target_dir_physically_copies() {
1262        let remote = TempDir::new().unwrap();
1263        let sha = init_repo_with_commit(remote.path());
1264
1265        let dir = TempDir::new().unwrap();
1266        let project = project_in(dir.path());
1267        let url = format!("file://{}", remote.path().display());
1268        write_file(
1269            project.manifest_path(),
1270            &manifest_with_dep(
1271                &url,
1272                &format!("rev = \"{sha}\", target_dir = \"lua/mylib\""),
1273            ),
1274        );
1275
1276        let report = install(&cfg(&project)).unwrap();
1277
1278        // target_dir holds a real file (not a symlink).
1279        let vendored_file = dir.path().join("lua/mylib/main.lua");
1280        assert!(
1281            vendored_file.exists(),
1282            "vendored file must exist at target_dir"
1283        );
1284        let meta = std::fs::symlink_metadata(&vendored_file).unwrap();
1285        assert!(
1286            !meta.file_type().is_symlink(),
1287            "vendored output must be a regular file, not a symlink"
1288        );
1289        assert_eq!(
1290            report.packages[0].placement,
1291            Placement::Copied(dir.path().join("lua/mylib"))
1292        );
1293
1294        // Default symlink path must NOT be created when target_dir is set.
1295        assert!(
1296            project
1297                .pkg_dir()
1298                .vendored()
1299                .join("mylib")
1300                .symlink_metadata()
1301                .is_err(),
1302            "<vendored>/<name> must not be created when target_dir is set"
1303        );
1304
1305        // Lockfile entry still recorded.
1306        let lf = Lockfile::read(project.lock_path()).unwrap();
1307        assert_eq!(lf.pkg.len(), 1);
1308        assert_eq!(lf.pkg[0].sha, sha);
1309    }
1310
1311    #[test]
1312    fn install_with_target_dir_is_idempotent() {
1313        let remote = TempDir::new().unwrap();
1314        let sha = init_repo_with_commit(remote.path());
1315
1316        let dir = TempDir::new().unwrap();
1317        let project = project_in(dir.path());
1318        let url = format!("file://{}", remote.path().display());
1319        write_file(
1320            project.manifest_path(),
1321            &manifest_with_dep(
1322                &url,
1323                &format!("rev = \"{sha}\", target_dir = \"lua/mylib\""),
1324            ),
1325        );
1326
1327        install(&cfg(&project)).unwrap();
1328        install(&cfg(&project)).unwrap();
1329
1330        assert!(dir.path().join("lua/mylib/main.lua").exists());
1331    }
1332
1333    // ── package root as the unit ──────────────────────────────────────────────
1334
1335    /// Commit `files` (path → content) on top of HEAD in `dir`; return the SHA.
1336    fn commit_files(dir: &Path, files: &[(&str, &str)], msg: &str) -> String {
1337        use git2::{Repository, Signature};
1338        let repo = Repository::open(dir).unwrap();
1339        let mut index = repo.index().unwrap();
1340        for (rel, content) in files {
1341            write_file(&dir.join(rel), content);
1342            index.add_path(Path::new(rel)).unwrap();
1343        }
1344        index.write().unwrap();
1345        let tree = repo.find_tree(index.write_tree().unwrap()).unwrap();
1346        let sig = Signature::now("Test", "test@example.com").unwrap();
1347        let parent = repo.head().unwrap().peel_to_commit().unwrap();
1348        repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &[&parent])
1349            .unwrap()
1350            .to_string()
1351    }
1352
1353    /// Remote with `src/main.lua` (entry) and a `types/` sibling; returns its SHA.
1354    fn init_repo_with_src_and_types(dir: &Path) -> String {
1355        init_repo_with_commit(dir);
1356        std::fs::remove_file(dir.join("main.lua")).unwrap();
1357        commit_files(
1358            dir,
1359            &[
1360                ("src/main.lua", "return { v = 1 }\n"),
1361                ("types/main.d.lua", "---@meta\n"),
1362            ],
1363            "layout",
1364        )
1365    }
1366
1367    #[test]
1368    fn install_links_package_root_and_records_entry() {
1369        let remote = TempDir::new().unwrap();
1370        let sha = init_repo_with_src_and_types(remote.path());
1371
1372        let dir = TempDir::new().unwrap();
1373        let project = project_in(dir.path());
1374        let url = format!("file://{}", remote.path().display());
1375        write_file(
1376            project.manifest_path(),
1377            &manifest_with_dep(&url, &format!("rev = \"{sha}\"")),
1378        );
1379
1380        let report = install(&cfg(&project)).unwrap();
1381        let pkg = &report.packages[0];
1382        assert_eq!(pkg.entry, PathBuf::from("src"));
1383        assert!(!pkg.patched);
1384        // The symlink is the package root: types/ sits directly under it.
1385        assert!(pkg.root().join("types/main.d.lua").exists());
1386        assert!(pkg.require_dir().join("main.lua").exists());
1387        assert_eq!(pkg.root(), project.pkg_dir().vendored_root("mylib"));
1388
1389        let lf = Lockfile::read(project.lock_path()).unwrap();
1390        assert_eq!(lf.pkg[0].entry, PathBuf::from("src"));
1391        assert_eq!(
1392            lf.pkg[0].require_dir(pkg.root()),
1393            project.pkg_dir().vendored_root("mylib").join("src")
1394        );
1395        assert_eq!(lf.pkg[0].patch_dir, None);
1396        assert_eq!(lf.pkg[0].patch_base, None);
1397    }
1398
1399    #[test]
1400    fn install_with_target_dir_copies_package_root() {
1401        let remote = TempDir::new().unwrap();
1402        let sha = init_repo_with_src_and_types(remote.path());
1403
1404        let dir = TempDir::new().unwrap();
1405        let project = project_in(dir.path());
1406        let url = format!("file://{}", remote.path().display());
1407        write_file(
1408            project.manifest_path(),
1409            &manifest_with_dep(
1410                &url,
1411                &format!("rev = \"{sha}\", target_dir = \"lua/mylib\""),
1412            ),
1413        );
1414
1415        let report = install(&cfg(&project)).unwrap();
1416        let pkg = &report.packages[0];
1417        assert_eq!(
1418            pkg.placement,
1419            Placement::Copied(dir.path().join("lua/mylib"))
1420        );
1421        assert!(dir.path().join("lua/mylib/src/main.lua").exists());
1422        assert!(dir.path().join("lua/mylib/types/main.d.lua").exists());
1423        assert_eq!(pkg.require_dir(), dir.path().join("lua/mylib/src"));
1424    }
1425
1426    // ── patch_dir ─────────────────────────────────────────────────────────────
1427
1428    fn manifest_with_patched_dep(url: &str, sha: &str) -> String {
1429        manifest_with_dep(
1430            url,
1431            &format!("rev = \"{sha}\", patch_dir = \"patches/mylib\""),
1432        )
1433    }
1434
1435    #[test]
1436    fn patch_creates_dir_and_install_resolves_from_it() {
1437        let remote = TempDir::new().unwrap();
1438        let sha = init_repo_with_src_and_types(remote.path());
1439
1440        let dir = TempDir::new().unwrap();
1441        let project = project_in(dir.path());
1442        let url = format!("file://{}", remote.path().display());
1443        write_file(
1444            project.manifest_path(),
1445            &manifest_with_patched_dep(&url, &sha),
1446        );
1447
1448        // Before `patch`: upstream, with a warning naming the missing dir.
1449        let report = install(&cfg(&project)).unwrap();
1450        assert!(!report.packages[0].patched);
1451        assert_eq!(report.warnings.len(), 1, "{:?}", report.warnings);
1452        assert!(report.warnings[0].contains("patches/mylib"));
1453        assert!(report.warnings[0].contains("does not exist"));
1454
1455        let pr = patch(
1456            &cfg(&project),
1457            PatchOpts {
1458                name: "mylib".into(),
1459                force: false,
1460            },
1461        )
1462        .unwrap();
1463        assert!(pr.created);
1464        assert_eq!(pr.base, sha);
1465        let patch_root = dir.path().join("patches/mylib");
1466        assert_eq!(pr.patch_dir, patch_root);
1467        assert!(patch_root.join("src/main.lua").exists());
1468        assert!(patch_root.join("types/main.d.lua").exists());
1469        let lf = Lockfile::read(project.lock_path()).unwrap();
1470        assert_eq!(lf.pkg[0].patch_base.as_deref(), Some(sha.as_str()));
1471        assert_eq!(lf.pkg[0].patch_dir, Some(PathBuf::from("patches/mylib")));
1472
1473        // Edit the patch, then install: resolved from the patch, no warning.
1474        write_file(
1475            &patch_root.join("src/main.lua"),
1476            "return { v = 'patched' }\n",
1477        );
1478        let report = install(&cfg(&project)).unwrap();
1479        let pkg = &report.packages[0];
1480        assert!(pkg.patched, "{:?}", report.warnings);
1481        assert!(report.warnings.is_empty(), "{:?}", report.warnings);
1482        assert_eq!(pkg.sha, sha);
1483        assert_eq!(
1484            std::fs::canonicalize(pkg.root()).unwrap(),
1485            std::fs::canonicalize(&patch_root).unwrap()
1486        );
1487        assert_eq!(
1488            std::fs::read_to_string(pkg.require_dir().join("main.lua")).unwrap(),
1489            "return { v = 'patched' }\n"
1490        );
1491        let lf = Lockfile::read(project.lock_path()).unwrap();
1492        assert_eq!(lf.pkg[0].patch_base.as_deref(), Some(sha.as_str()));
1493
1494        // A second `patch` without --force must not clobber the edit.
1495        let err = patch(
1496            &cfg(&project),
1497            PatchOpts {
1498                name: "mylib".into(),
1499                force: false,
1500            },
1501        )
1502        .unwrap_err();
1503        assert!(matches!(err, PkgError::Validation { .. }), "{err}");
1504        assert_eq!(
1505            std::fs::read_to_string(patch_root.join("src/main.lua")).unwrap(),
1506            "return { v = 'patched' }\n"
1507        );
1508    }
1509
1510    #[test]
1511    fn patch_drift_falls_back_to_upstream_and_keeps_dir() {
1512        let remote = TempDir::new().unwrap();
1513        let sha1 = init_repo_with_src_and_types(remote.path());
1514
1515        let dir = TempDir::new().unwrap();
1516        let project = project_in(dir.path());
1517        let url = format!("file://{}", remote.path().display());
1518        write_file(
1519            project.manifest_path(),
1520            &manifest_with_patched_dep(&url, &sha1),
1521        );
1522        patch(
1523            &cfg(&project),
1524            PatchOpts {
1525                name: "mylib".into(),
1526                force: false,
1527            },
1528        )
1529        .unwrap();
1530        let patch_root = dir.path().join("patches/mylib");
1531        write_file(
1532            &patch_root.join("src/main.lua"),
1533            "return { v = 'patched' }\n",
1534        );
1535
1536        // Upstream moves and the pin follows it.
1537        let sha2 = commit_files(
1538            remote.path(),
1539            &[("src/main.lua", "return { v = 2 }\n")],
1540            "second",
1541        );
1542        write_file(
1543            project.manifest_path(),
1544            &manifest_with_patched_dep(&url, &sha2),
1545        );
1546
1547        let report = install(&cfg(&project)).unwrap();
1548        let pkg = &report.packages[0];
1549        assert!(!pkg.patched);
1550        assert_eq!(pkg.sha, sha2);
1551        assert_eq!(report.warnings.len(), 1, "{:?}", report.warnings);
1552        assert!(report.warnings[0].contains(&sha1), "{}", report.warnings[0]);
1553        assert!(report.warnings[0].contains(&sha2), "{}", report.warnings[0]);
1554        // The patch is untouched and its base is preserved in the lockfile.
1555        assert_eq!(
1556            std::fs::read_to_string(patch_root.join("src/main.lua")).unwrap(),
1557            "return { v = 'patched' }\n"
1558        );
1559        let lf = Lockfile::read(project.lock_path()).unwrap();
1560        assert_eq!(lf.pkg[0].sha, sha2);
1561        assert_eq!(lf.pkg[0].patch_base.as_deref(), Some(sha1.as_str()));
1562        assert_eq!(
1563            std::fs::read_to_string(pkg.require_dir().join("main.lua")).unwrap(),
1564            "return { v = 2 }\n"
1565        );
1566
1567        // `patch --force` rebases the directory onto the new pin.
1568        let pr = patch(
1569            &cfg(&project),
1570            PatchOpts {
1571                name: "mylib".into(),
1572                force: true,
1573            },
1574        )
1575        .unwrap();
1576        assert!(!pr.created);
1577        assert_eq!(pr.base, sha2);
1578        assert_eq!(
1579            std::fs::read_to_string(patch_root.join("src/main.lua")).unwrap(),
1580            "return { v = 2 }\n"
1581        );
1582        let report = install(&cfg(&project)).unwrap();
1583        assert!(report.packages[0].patched);
1584        assert!(report.warnings.is_empty(), "{:?}", report.warnings);
1585    }
1586
1587    #[test]
1588    fn patch_drift_error_policy_stops_install() {
1589        let remote = TempDir::new().unwrap();
1590        let sha1 = init_repo_with_src_and_types(remote.path());
1591
1592        let dir = TempDir::new().unwrap();
1593        let project = project_in(dir.path());
1594        let url = format!("file://{}", remote.path().display());
1595        let strict = |sha: &str| {
1596            manifest_with_dep(
1597                &url,
1598                &format!("rev = \"{sha}\", patch_dir = \"patches/mylib\", patch_drift = \"error\""),
1599            )
1600        };
1601        write_file(project.manifest_path(), &strict(&sha1));
1602
1603        // No patch yet: strict install refuses instead of warning.
1604        let err = install(&cfg(&project)).unwrap_err();
1605        assert!(
1606            matches!(&err, PkgError::PatchDrift { name, reason, .. }
1607                if name == "mylib" && reason.contains("does not exist")),
1608            "{err}"
1609        );
1610        assert!(
1611            !project.lock_path().exists(),
1612            "lockfile must not be written"
1613        );
1614
1615        patch(
1616            &cfg(&project),
1617            PatchOpts {
1618                name: "mylib".into(),
1619                force: false,
1620            },
1621        )
1622        .unwrap();
1623        let report = install(&cfg(&project)).unwrap();
1624        assert!(report.packages[0].patched);
1625        assert!(report.warnings.is_empty());
1626
1627        // Pin moves: strict install fails, naming both commits, and neither
1628        // the patch nor the lockfile is touched.
1629        let patch_root = dir.path().join("patches/mylib");
1630        write_file(
1631            &patch_root.join("src/main.lua"),
1632            "return { v = 'patched' }\n",
1633        );
1634        let sha2 = commit_files(
1635            remote.path(),
1636            &[("src/main.lua", "return { v = 2 }\n")],
1637            "second",
1638        );
1639        write_file(project.manifest_path(), &strict(&sha2));
1640        let err = install(&cfg(&project)).unwrap_err();
1641        match &err {
1642            PkgError::PatchDrift { reason, pinned, .. } => {
1643                assert!(reason.contains(&sha1), "{reason}");
1644                assert_eq!(pinned, &sha2);
1645            }
1646            other => panic!("expected PatchDrift, got {other}"),
1647        }
1648        assert!(err.to_string().contains("mlua-pkg patch mylib --force"));
1649        assert_eq!(
1650            std::fs::read_to_string(patch_root.join("src/main.lua")).unwrap(),
1651            "return { v = 'patched' }\n"
1652        );
1653        let lf = Lockfile::read(project.lock_path()).unwrap();
1654        assert_eq!(lf.pkg[0].sha, sha1);
1655        assert_eq!(lf.pkg[0].patch_base.as_deref(), Some(sha1.as_str()));
1656    }
1657
1658    #[test]
1659    fn patch_requires_patch_dir_in_manifest() {
1660        let remote = TempDir::new().unwrap();
1661        let sha = init_repo_with_commit(remote.path());
1662        let dir = TempDir::new().unwrap();
1663        let project = project_in(dir.path());
1664        let url = format!("file://{}", remote.path().display());
1665        write_file(
1666            project.manifest_path(),
1667            &manifest_with_dep(&url, &format!("rev = \"{sha}\"")),
1668        );
1669        let err = patch(
1670            &cfg(&project),
1671            PatchOpts {
1672                name: "mylib".into(),
1673                force: false,
1674            },
1675        )
1676        .unwrap_err();
1677        assert!(matches!(err, PkgError::Validation { .. }), "{err}");
1678        let err = patch(
1679            &cfg(&project),
1680            PatchOpts {
1681                name: "nope".into(),
1682                force: false,
1683            },
1684        )
1685        .unwrap_err();
1686        assert!(matches!(err, PkgError::UnknownDep { .. }), "{err}");
1687    }
1688
1689    #[test]
1690    fn install_missing_manifest_returns_error() {
1691        let dir = TempDir::new().unwrap();
1692        let result = install(&cfg(&project_in(dir.path())));
1693        assert!(result.is_err(), "must fail when mlua-pkg.toml is absent");
1694    }
1695
1696    #[test]
1697    fn install_is_idempotent() {
1698        // Running install twice must succeed (symlink replaced, lockfile overwritten).
1699        let remote = TempDir::new().unwrap();
1700        let sha = init_repo_with_commit(remote.path());
1701
1702        let dir = TempDir::new().unwrap();
1703        let project = project_in(dir.path());
1704        let url = format!("file://{}", remote.path().display());
1705        write_file(
1706            project.manifest_path(),
1707            &manifest_with_dep(&url, &format!("rev = \"{sha}\"")),
1708        );
1709
1710        install(&cfg(&project)).unwrap();
1711        install(&cfg(&project)).unwrap();
1712
1713        let lf = Lockfile::read(project.lock_path()).unwrap();
1714        assert_eq!(lf.pkg.len(), 1);
1715    }
1716
1717    #[test]
1718    fn install_with_prefix_pin_resolves_to_concrete_tag() {
1719        let remote = TempDir::new().unwrap();
1720        init_repo_with_commit(remote.path());
1721        add_tag(remote.path(), "v1.0.0");
1722        add_tag(remote.path(), "v1.0.5");
1723        add_tag(remote.path(), "v2.0.0");
1724
1725        let dir = TempDir::new().unwrap();
1726        let project = project_in(dir.path());
1727        let url = format!("file://{}", remote.path().display());
1728        write_file(
1729            project.manifest_path(),
1730            &manifest_with_dep(&url, "tag = \"v1.0\""),
1731        );
1732
1733        let report = install(&cfg(&project)).unwrap();
1734        assert_eq!(report.packages[0].tag.as_deref(), Some("v1.0.5"));
1735
1736        let lf = Lockfile::read(project.lock_path()).unwrap();
1737        assert_eq!(
1738            lf.pkg[0].tag.as_deref(),
1739            Some("v1.0.5"),
1740            "install must resolve prefix v1.0 to concrete v1.0.5 (excluding v2.0.0):\n{lf:?}"
1741        );
1742    }
1743
1744    #[test]
1745    fn install_fetch_failure_names_the_dep() {
1746        let dir = TempDir::new().unwrap();
1747        let project = project_in(dir.path());
1748        write_file(
1749            project.manifest_path(),
1750            &manifest_with_dep("file:///nonexistent/repo", "branch = \"main\""),
1751        );
1752
1753        let err = install(&cfg(&project)).unwrap_err();
1754        assert!(
1755            matches!(&err, PkgError::Fetch { name, .. } if name == "mylib"),
1756            "expected Fetch {{ name: mylib }}, got: {err:?}"
1757        );
1758    }
1759
1760    #[test]
1761    fn install_uses_pkg_dir_not_cwd_convention() {
1762        // The cache / vendored dirs must follow PkgDir, not any fixed name.
1763        let remote = TempDir::new().unwrap();
1764        let sha = init_repo_with_commit(remote.path());
1765
1766        let dir = TempDir::new().unwrap();
1767        let custom = dir.path().join("elsewhere/pkgs");
1768        let project = Project::in_dir(dir.path(), PkgDir::new(&custom));
1769        let url = format!("file://{}", remote.path().display());
1770        write_file(
1771            project.manifest_path(),
1772            &manifest_with_dep(&url, &format!("rev = \"{sha}\"")),
1773        );
1774
1775        install(&cfg(&project)).unwrap();
1776
1777        assert!(custom.join("cache").is_dir());
1778        assert!(custom.join("vendored/mylib").symlink_metadata().is_ok());
1779        assert!(!dir.path().join(".mlua-pkgs").exists());
1780    }
1781
1782    // ── value-mode manifest (Config::with_manifest) ───────────────────────────
1783
1784    #[test]
1785    fn install_value_manifest_never_touches_manifest_path() {
1786        let remote = TempDir::new().unwrap();
1787        let sha = init_repo_with_commit(remote.path());
1788
1789        let dir = TempDir::new().unwrap();
1790        let project = project_in(dir.path());
1791        let url = format!("file://{}", remote.path().display());
1792        let manifest =
1793            Manifest::from_toml_str(&manifest_with_dep(&url, &format!("rev = \"{sha}\""))).unwrap();
1794        let vcfg = Config::with_manifest(project.clone(), manifest);
1795
1796        let report = install(&vcfg).unwrap();
1797
1798        assert_eq!(report.packages[0].sha, sha);
1799        assert!(
1800            !project.manifest_path().exists(),
1801            "value mode must not write the manifest"
1802        );
1803        assert!(project.lock_path().exists(), "lockfile is still an output");
1804        assert!(project
1805            .pkg_dir()
1806            .vendored()
1807            .join("mylib")
1808            .symlink_metadata()
1809            .is_ok());
1810    }
1811
1812    #[test]
1813    fn add_value_mode_returns_manifest_without_writing() {
1814        let dir = TempDir::new().unwrap();
1815        let project = project_in(dir.path());
1816        let base =
1817            Manifest::from_toml_str("[package]\nname = \"v\"\nversion = \"0.1.0\"\n").unwrap();
1818        let vcfg = Config::with_manifest(project.clone(), base);
1819
1820        let report = add(&vcfg, AddSpec::new("lib", "https://github.com/x/lib")).unwrap();
1821
1822        assert_eq!(report.outcome, AddOutcome::Added);
1823        assert!(!report.manifest_created);
1824        assert_eq!(report.manifest.deps["lib"].git, "https://github.com/x/lib");
1825        assert!(
1826            !project.manifest_path().exists(),
1827            "value mode must not write the manifest"
1828        );
1829        // The caller's Config is untouched; the change lives only in the report.
1830        assert!(vcfg.load_manifest().unwrap().deps.is_empty());
1831    }
1832
1833    #[test]
1834    fn update_value_mode_force_returns_new_manifest_and_installs() {
1835        let remote = TempDir::new().unwrap();
1836        init_repo_with_commit(remote.path());
1837        add_tag(remote.path(), "v1.0.0");
1838        add_tag(remote.path(), "v2.0.0");
1839
1840        let dir = TempDir::new().unwrap();
1841        let project = project_in(dir.path());
1842        let url = format!("file://{}", remote.path().display());
1843        let manifest =
1844            Manifest::from_toml_str(&manifest_with_dep(&url, "tag = \"v1.0.0\"")).unwrap();
1845        let vcfg = Config::with_manifest(project.clone(), manifest);
1846
1847        let report = update(
1848            &vcfg,
1849            UpdateOpts {
1850                force: true,
1851                ..Default::default()
1852            },
1853        )
1854        .unwrap();
1855
1856        assert!(report.manifest_modified);
1857        let new_manifest = report.manifest.expect("new manifest returned");
1858        assert_eq!(new_manifest.deps["mylib"].tag.as_deref(), Some("v2.0.0"));
1859        assert!(
1860            !project.manifest_path().exists(),
1861            "value mode must not write the manifest"
1862        );
1863        // Re-install ran against the *new* manifest: lock records v2.0.0.
1864        assert!(report.install.is_some());
1865        let lf = Lockfile::read(project.lock_path()).unwrap();
1866        assert_eq!(lf.pkg[0].tag.as_deref(), Some("v2.0.0"));
1867    }
1868
1869    #[test]
1870    fn update_file_mode_reports_new_manifest_too() {
1871        let remote = TempDir::new().unwrap();
1872        init_repo_with_commit(remote.path());
1873        add_tag(remote.path(), "v1.0.0");
1874        add_tag(remote.path(), "v2.0.0");
1875
1876        let dir = TempDir::new().unwrap();
1877        let project = project_in(dir.path());
1878        let url = format!("file://{}", remote.path().display());
1879        write_file(
1880            project.manifest_path(),
1881            &manifest_with_dep(&url, "tag = \"v1.0.0\""),
1882        );
1883
1884        let report = update(
1885            &cfg(&project),
1886            UpdateOpts {
1887                force: true,
1888                ..Default::default()
1889            },
1890        )
1891        .unwrap();
1892
1893        let new_manifest = report.manifest.expect("new manifest returned");
1894        assert_eq!(new_manifest.deps["mylib"].tag.as_deref(), Some("v2.0.0"));
1895        // ...and it matches what was written to disk.
1896        assert_eq!(
1897            Manifest::from_path(project.manifest_path()).unwrap(),
1898            new_manifest
1899        );
1900    }
1901
1902    // ── transitive deps ───────────────────────────────────────────────────────
1903
1904    /// Initialise a git repository at `dir` with the given files committed;
1905    /// return the commit SHA.
1906    fn init_repo_with_files(dir: &Path, files: &[(&str, &str)]) -> String {
1907        use git2::{Repository, Signature};
1908
1909        let repo = Repository::init(dir).unwrap();
1910        {
1911            let mut cfg = repo.config().unwrap();
1912            cfg.set_str("user.name", "Test").unwrap();
1913            cfg.set_str("user.email", "test@example.com").unwrap();
1914        }
1915        let mut index = repo.index().unwrap();
1916        for (rel, content) in files {
1917            write_file(&dir.join(rel), content);
1918            index.add_path(Path::new(rel)).unwrap();
1919        }
1920        index.write().unwrap();
1921        let tree_id = index.write_tree().unwrap();
1922        let tree = repo.find_tree(tree_id).unwrap();
1923        let sig = Signature::now("Test", "test@example.com").unwrap();
1924        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
1925            .unwrap()
1926            .to_string()
1927    }
1928
1929    /// leaf (plain) + mid (declares leaf in its own mlua-pkg.toml).
1930    /// Returns `(leaf_url, leaf_sha, mid_url, mid_sha)`; the TempDirs are
1931    /// leaked into the returned tuple's lifetime via the caller's bindings.
1932    fn leaf_and_mid(leaf: &TempDir, mid: &TempDir) -> (String, String, String, String) {
1933        let leaf_sha =
1934            init_repo_with_files(leaf.path(), &[("main.lua", "return { leaf = true }\n")]);
1935        let leaf_url = format!("file://{}", leaf.path().display());
1936        let mid_manifest = format!(
1937            "[package]\nname = \"mid\"\nversion = \"0.1.0\"\n\n\
1938             [deps]\nleaf = {{ git = \"{leaf_url}\", rev = \"{leaf_sha}\" }}\n"
1939        );
1940        let mid_sha = init_repo_with_files(
1941            mid.path(),
1942            &[
1943                ("main.lua", "return require('leaf')\n"),
1944                ("mlua-pkg.toml", &mid_manifest),
1945            ],
1946        );
1947        let mid_url = format!("file://{}", mid.path().display());
1948        (leaf_url, leaf_sha, mid_url, mid_sha)
1949    }
1950
1951    #[test]
1952    fn install_resolves_transitive_deps_from_author_manifest() {
1953        let (leaf, mid) = (TempDir::new().unwrap(), TempDir::new().unwrap());
1954        let (_leaf_url, leaf_sha, mid_url, mid_sha) = leaf_and_mid(&leaf, &mid);
1955
1956        // root: only declares mid
1957        let dir = TempDir::new().unwrap();
1958        let project = project_in(dir.path());
1959        write_file(
1960            project.manifest_path(),
1961            &format!(
1962                "[package]\nname = \"root\"\nversion = \"0.1.0\"\n\n\
1963                 [deps]\nmid = {{ git = \"{mid_url}\", rev = \"{mid_sha}\" }}\n"
1964            ),
1965        );
1966
1967        let report = install(&cfg(&project)).unwrap();
1968
1969        assert_eq!(report.direct, 1);
1970        assert_eq!(report.transitive, 1);
1971        let names: Vec<&str> = report.packages.iter().map(|p| p.name.as_str()).collect();
1972        assert_eq!(names, vec!["leaf", "mid"], "report sorted by name");
1973        assert_eq!(report.packages[0].requested_by, "mid");
1974        assert_eq!(report.packages[1].requested_by, MANIFEST_REQUESTER);
1975
1976        let lf = Lockfile::read(project.lock_path()).unwrap();
1977        let names: Vec<&str> = lf.pkg.iter().map(|p| p.name.as_str()).collect();
1978        assert_eq!(
1979            names,
1980            vec!["leaf", "mid"],
1981            "both packages locked, sorted by name"
1982        );
1983        assert_eq!(lf.pkg[0].sha, leaf_sha);
1984        assert_eq!(lf.pkg[1].sha, mid_sha);
1985        let vendored = project.pkg_dir().vendored();
1986        assert!(
1987            vendored.join("leaf").symlink_metadata().is_ok(),
1988            "leaf vendored"
1989        );
1990        assert!(
1991            vendored.join("mid").symlink_metadata().is_ok(),
1992            "mid vendored"
1993        );
1994
1995        // Idempotent.
1996        install(&cfg(&project)).unwrap();
1997        assert_eq!(Lockfile::read(project.lock_path()).unwrap().pkg.len(), 2);
1998    }
1999
2000    #[test]
2001    fn install_same_dep_reached_twice_with_identical_spec_is_fine() {
2002        let (leaf, mid) = (TempDir::new().unwrap(), TempDir::new().unwrap());
2003        let (leaf_url, leaf_sha, mid_url, mid_sha) = leaf_and_mid(&leaf, &mid);
2004
2005        let dir = TempDir::new().unwrap();
2006        let project = project_in(dir.path());
2007        // root declares leaf itself with the *same* spec mid uses.
2008        write_file(
2009            project.manifest_path(),
2010            &format!(
2011                "[package]\nname = \"root\"\nversion = \"0.1.0\"\n\n\
2012                 [deps]\nmid = {{ git = \"{mid_url}\", rev = \"{mid_sha}\" }}\n\
2013                 leaf = {{ git = \"{leaf_url}\", rev = \"{leaf_sha}\" }}\n"
2014            ),
2015        );
2016
2017        let report = install(&cfg(&project)).unwrap();
2018        assert_eq!(report.packages.len(), 2);
2019        assert_eq!((report.direct, report.transitive), (2, 0));
2020        // First requester wins the label: leaf is direct here (sorted before mid).
2021        assert_eq!(report.packages[0].requested_by, MANIFEST_REQUESTER);
2022        assert_eq!(Lockfile::read(project.lock_path()).unwrap().pkg.len(), 2);
2023    }
2024
2025    #[test]
2026    fn install_conflicting_transitive_spec_fails() {
2027        let (leaf, mid) = (TempDir::new().unwrap(), TempDir::new().unwrap());
2028        let (leaf_url, leaf_sha, mid_url, mid_sha) = leaf_and_mid(&leaf, &mid);
2029
2030        // mid pins leaf by rev; root pins the same rev but with an `entry`
2031        // override -> a different `Dep` spec, which is a conflict.
2032        let dir = TempDir::new().unwrap();
2033        let project = project_in(dir.path());
2034        write_file(
2035            project.manifest_path(),
2036            &format!(
2037                "[package]\nname = \"root\"\nversion = \"0.1.0\"\n\n\
2038                 [deps]\nleaf = {{ git = \"{leaf_url}\", rev = \"{leaf_sha}\", entry = \".\" }}\n\
2039                 mid = {{ git = \"{mid_url}\", rev = \"{mid_sha}\" }}\n"
2040            ),
2041        );
2042
2043        let err = install(&cfg(&project)).unwrap_err();
2044        assert!(
2045            matches!(&err, PkgError::DepConflict { name, first, second }
2046                if name == "leaf" && first == MANIFEST_REQUESTER && second == "mid"),
2047            "expected DepConflict for 'leaf', got: {err:?}"
2048        );
2049        assert!(
2050            !project.lock_path().exists(),
2051            "lockfile must not be written on conflict"
2052        );
2053    }
2054
2055    // ── add ───────────────────────────────────────────────────────────────────
2056
2057    #[test]
2058    fn add_creates_manifest_with_dep() {
2059        let dir = TempDir::new().unwrap();
2060        let project = project_in(dir.path());
2061
2062        let report = add(
2063            &cfg(&project),
2064            AddSpec {
2065                tag: Some("v1.0.0".to_string()),
2066                ..AddSpec::new("mylib", "https://github.com/x/mylib")
2067            },
2068        )
2069        .unwrap();
2070        assert_eq!(report.outcome, AddOutcome::Added);
2071        assert!(report.manifest_created);
2072
2073        let manifest = Manifest::from_path(project.manifest_path()).unwrap();
2074        assert!(manifest.deps.contains_key("mylib"), "dep must be present");
2075        let dep = &manifest.deps["mylib"];
2076        assert_eq!(dep.git, "https://github.com/x/mylib");
2077        assert_eq!(dep.tag.as_deref(), Some("v1.0.0"));
2078        assert!(dep.rev.is_none());
2079        assert!(dep.branch.is_none());
2080        // Package name comes from the manifest directory, not the cwd.
2081        let expected = std::fs::canonicalize(dir.path()).unwrap();
2082        assert_eq!(
2083            manifest.package.name,
2084            expected.file_name().unwrap().to_string_lossy()
2085        );
2086    }
2087
2088    #[test]
2089    fn add_to_existing_manifest_preserves_other_deps() {
2090        let dir = TempDir::new().unwrap();
2091        let project = project_in(dir.path());
2092
2093        write_file(
2094            project.manifest_path(),
2095            "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n\
2096             [deps]\nexisting = { git = \"https://github.com/a/b\", branch = \"main\" }\n",
2097        );
2098
2099        let report = add(
2100            &cfg(&project),
2101            AddSpec {
2102                rev: Some("abc1234567890123456789012345678901234567890".to_string()),
2103                ..AddSpec::new("newdep", "https://github.com/x/newdep")
2104            },
2105        )
2106        .unwrap();
2107        assert_eq!(report.outcome, AddOutcome::Added);
2108        assert!(!report.manifest_created);
2109
2110        let manifest = Manifest::from_path(project.manifest_path()).unwrap();
2111        assert_eq!(manifest.deps.len(), 2, "both deps must be present");
2112        assert!(manifest.deps.contains_key("existing"));
2113        assert!(manifest.deps.contains_key("newdep"));
2114    }
2115
2116    #[test]
2117    fn add_existing_name_reports_replaced() {
2118        let dir = TempDir::new().unwrap();
2119        let project = project_in(dir.path());
2120        add(
2121            &cfg(&project),
2122            AddSpec::new("lib", "https://github.com/x/lib"),
2123        )
2124        .unwrap();
2125        let report = add(
2126            &cfg(&project),
2127            AddSpec::new("lib", "https://github.com/y/lib"),
2128        )
2129        .unwrap();
2130        assert_eq!(report.outcome, AddOutcome::Replaced);
2131        let manifest = Manifest::from_path(project.manifest_path()).unwrap();
2132        assert_eq!(manifest.deps["lib"].git, "https://github.com/y/lib");
2133    }
2134
2135    #[test]
2136    fn add_rejects_multiple_ref_fields() {
2137        let dir = TempDir::new().unwrap();
2138        let project = project_in(dir.path());
2139
2140        let result = add(
2141            &cfg(&project),
2142            AddSpec {
2143                tag: Some("v1.0.0".to_string()),
2144                rev: Some("abc123".to_string()),
2145                ..AddSpec::new("lib", "https://github.com/x/lib")
2146            },
2147        );
2148        assert!(
2149            matches!(result, Err(PkgError::Validation { .. })),
2150            "tag + rev together must be rejected: {result:?}"
2151        );
2152    }
2153
2154    // ── update ────────────────────────────────────────────────────────────────
2155    // Pure helpers (classify_tag_pin / pick_latest_*) are covered in
2156    // crate::version unit tests.  The cases here exercise lock + manifest
2157    // mutation behaviour around `update`.
2158
2159    #[test]
2160    fn set_dep_tag_preserves_inline_layout() {
2161        let toml = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n\n\
2162                    [deps]\nfoo = { git = \"https://example.com/foo\", tag = \"v1.0.0\" }\n";
2163        let mut doc: toml_edit::DocumentMut = toml.parse().unwrap();
2164        set_dep_tag(&mut doc, "foo", "v1.0.5").unwrap();
2165        let out = doc.to_string();
2166        assert!(
2167            out.contains("tag = \"v1.0.5\""),
2168            "new tag must be written:\n{out}"
2169        );
2170        assert!(
2171            out.contains("git = \"https://example.com/foo\""),
2172            "git URL preserved"
2173        );
2174    }
2175
2176    #[test]
2177    fn update_prefix_pin_refreshes_lock_without_mutating_manifest() {
2178        let remote = TempDir::new().unwrap();
2179        init_repo_with_commit(remote.path());
2180        add_tag(remote.path(), "v1.0.0");
2181        add_tag(remote.path(), "v1.0.1");
2182        add_tag(remote.path(), "v1.0.5");
2183        add_tag(remote.path(), "v1.1.0");
2184
2185        let dir = TempDir::new().unwrap();
2186        let project = project_in(dir.path());
2187        let url = format!("file://{}", remote.path().display());
2188        let original = manifest_with_dep(&url, "tag = \"v1.0\"");
2189        write_file(project.manifest_path(), &original);
2190
2191        let report = update(&cfg(&project), UpdateOpts::default()).unwrap();
2192
2193        assert_eq!(report.entries.len(), 1);
2194        assert!(matches!(
2195            &report.entries[0].1,
2196            UpdateOutcome::PrefixResolved { pin, resolved }
2197                if pin == "v1.0" && resolved == "v1.0.5"
2198        ));
2199        assert!(!report.manifest_modified);
2200        assert!(
2201            report.install.is_some(),
2202            "prefix pin must trigger re-install"
2203        );
2204
2205        // Manifest stays as the prefix — auto-follow intent is preserved.
2206        let after = std::fs::read_to_string(project.manifest_path()).unwrap();
2207        assert_eq!(
2208            after, original,
2209            "prefix pin manifest must not be rewritten:\n{after}"
2210        );
2211
2212        // Lockfile records the resolved concrete tag (v1.0.5, not v1.1.0).
2213        let lf = Lockfile::read(project.lock_path()).unwrap();
2214        assert_eq!(lf.pkg.len(), 1, "one locked package");
2215        assert_eq!(
2216            lf.pkg[0].tag.as_deref(),
2217            Some("v1.0.5"),
2218            "lock must record resolved concrete tag"
2219        );
2220    }
2221
2222    #[test]
2223    fn update_dry_run_leaves_manifest_unmodified() {
2224        let remote = TempDir::new().unwrap();
2225        init_repo_with_commit(remote.path());
2226        add_tag(remote.path(), "v1.0.0");
2227        add_tag(remote.path(), "v1.0.5");
2228
2229        let dir = TempDir::new().unwrap();
2230        let project = project_in(dir.path());
2231        let url = format!("file://{}", remote.path().display());
2232        let original = manifest_with_dep(&url, "tag = \"v1.0\"");
2233        write_file(project.manifest_path(), &original);
2234
2235        let report = update(
2236            &cfg(&project),
2237            UpdateOpts {
2238                dry_run: true,
2239                ..Default::default()
2240            },
2241        )
2242        .unwrap();
2243
2244        assert_eq!(report.entries.len(), 1);
2245        assert!(report.install.is_none(), "dry-run must not install");
2246        assert!(!report.manifest_modified);
2247        let after = std::fs::read_to_string(project.manifest_path()).unwrap();
2248        assert_eq!(after, original, "dry-run must not modify manifest");
2249        assert!(!project.lock_path().exists(), "dry-run must not write lock");
2250    }
2251
2252    #[test]
2253    fn update_exact_pin_without_force_is_noop() {
2254        let remote = TempDir::new().unwrap();
2255        init_repo_with_commit(remote.path());
2256        add_tag(remote.path(), "v1.0.0");
2257        add_tag(remote.path(), "v1.0.5");
2258
2259        let dir = TempDir::new().unwrap();
2260        let project = project_in(dir.path());
2261        let url = format!("file://{}", remote.path().display());
2262        write_file(
2263            project.manifest_path(),
2264            &manifest_with_dep(&url, "tag = \"v1.0.0\""),
2265        );
2266
2267        let report = update(&cfg(&project), UpdateOpts::default()).unwrap();
2268
2269        assert!(matches!(&report.entries[0].1, UpdateOutcome::Skipped(_)));
2270        assert!(report.install.is_none(), "all-skipped must not install");
2271        let after = std::fs::read_to_string(project.manifest_path()).unwrap();
2272        assert!(
2273            after.contains("tag = \"v1.0.0\""),
2274            "exact pin must remain v1.0.0 without force:\n{after}"
2275        );
2276    }
2277
2278    #[test]
2279    fn update_exact_pin_with_force_bumps_to_latest() {
2280        let remote = TempDir::new().unwrap();
2281        init_repo_with_commit(remote.path());
2282        add_tag(remote.path(), "v1.0.0");
2283        add_tag(remote.path(), "v1.0.5");
2284        add_tag(remote.path(), "v2.0.0");
2285
2286        let dir = TempDir::new().unwrap();
2287        let project = project_in(dir.path());
2288        let url = format!("file://{}", remote.path().display());
2289        write_file(
2290            project.manifest_path(),
2291            &manifest_with_dep(&url, "tag = \"v1.0.0\""),
2292        );
2293
2294        let report = update(
2295            &cfg(&project),
2296            UpdateOpts {
2297                force: true,
2298                ..Default::default()
2299            },
2300        )
2301        .unwrap();
2302
2303        assert!(matches!(
2304            &report.entries[0].1,
2305            UpdateOutcome::TagBumped { old, new } if old == "v1.0.0" && new == "v2.0.0"
2306        ));
2307        assert!(report.manifest_modified);
2308        assert!(report.install.is_some());
2309        let after = std::fs::read_to_string(project.manifest_path()).unwrap();
2310        assert!(
2311            after.contains("tag = \"v2.0.0\""),
2312            "force must bump exact pin to global max v2.0.0:\n{after}"
2313        );
2314    }
2315
2316    #[test]
2317    fn update_unknown_name_returns_error() {
2318        let dir = TempDir::new().unwrap();
2319        let project = project_in(dir.path());
2320        write_file(
2321            project.manifest_path(),
2322            "[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
2323        );
2324
2325        let result = update(
2326            &cfg(&project),
2327            UpdateOpts {
2328                name: Some("nonexistent".to_string()),
2329                ..Default::default()
2330            },
2331        );
2332        assert!(
2333            matches!(result, Err(PkgError::UnknownDep { ref name, .. }) if name == "nonexistent"),
2334            "unknown dep name must return UnknownDep: {result:?}"
2335        );
2336    }
2337
2338    // ── clean ─────────────────────────────────────────────────────────────────
2339
2340    #[test]
2341    fn clean_all_removes_cache() {
2342        let dir = TempDir::new().unwrap();
2343        let project = project_in(dir.path());
2344        let cache_dir = project.pkg_dir().cache();
2345        let git_dir = cache_dir.join("git/example.com/org/repo");
2346        std::fs::create_dir_all(&git_dir).unwrap();
2347        std::fs::write(git_dir.join("sentinel"), "data").unwrap();
2348
2349        let report = clean(&cfg(&project), true).unwrap();
2350
2351        assert_eq!(report, CleanReport::CacheRemoved);
2352        assert!(!cache_dir.exists(), "cache directory must be removed");
2353    }
2354
2355    #[test]
2356    fn clean_all_on_empty_dir_is_noop() {
2357        let dir = TempDir::new().unwrap();
2358        // Cache does not exist — must succeed without error.
2359        let report = clean(&cfg(&project_in(dir.path())), true).unwrap();
2360        assert_eq!(report, CleanReport::StaleRemoved { removed: 0 });
2361    }
2362
2363    #[test]
2364    fn clean_without_lockfile_is_noop() {
2365        let dir = TempDir::new().unwrap();
2366        let report = clean(&cfg(&project_in(dir.path())), false).unwrap();
2367        assert_eq!(report, CleanReport::NoLockfile);
2368    }
2369
2370    #[test]
2371    fn clean_removes_stale_sha_dirs_only() {
2372        let dir = TempDir::new().unwrap();
2373        let project = project_in(dir.path());
2374        let git_base = project.pkg_dir().cache().join("git/gh.com/org/repo");
2375
2376        let sha_in_use = "a".repeat(40);
2377        let sha_stale = "b".repeat(40);
2378
2379        std::fs::create_dir_all(git_base.join(&sha_in_use)).unwrap();
2380        std::fs::create_dir_all(git_base.join(&sha_stale)).unwrap();
2381
2382        // Write a lockfile that references only sha_in_use.
2383        let lf = Lockfile {
2384            version: 1,
2385            pkg: vec![LockedPkg {
2386                name: "lib".to_string(),
2387                source: "git+https://gh.com/org/repo".to_string(),
2388                tag: None,
2389                rev: None,
2390                branch: None,
2391                sha: sha_in_use.clone(),
2392                entry: PathBuf::from("."),
2393                patch_dir: None,
2394                patch_base: None,
2395            }],
2396        };
2397        lf.write(project.lock_path()).unwrap();
2398
2399        let report = clean(&cfg(&project), false).unwrap();
2400
2401        assert_eq!(report, CleanReport::StaleRemoved { removed: 1 });
2402        assert!(
2403            git_base.join(&sha_in_use).exists(),
2404            "in-use SHA dir must be retained"
2405        );
2406        assert!(
2407            !git_base.join(&sha_stale).exists(),
2408            "stale SHA dir must be removed"
2409        );
2410    }
2411
2412    // ── relative_path ─────────────────────────────────────────────────────────
2413
2414    #[test]
2415    fn relative_path_sibling_dirs() {
2416        let tmp = TempDir::new().unwrap();
2417        let from_dir = tmp.path().join("a/b");
2418        let to_dir = tmp.path().join("a/c/d");
2419        std::fs::create_dir_all(&from_dir).unwrap();
2420        std::fs::create_dir_all(&to_dir).unwrap();
2421
2422        let rel = relative_path(&from_dir, &to_dir).unwrap();
2423        // Expect: "../c/d"
2424        assert_eq!(rel, PathBuf::from("../c/d"));
2425    }
2426
2427    #[test]
2428    fn relative_path_vendored_to_cache() {
2429        let tmp = TempDir::new().unwrap();
2430        let vendored = tmp.path().join(".mlua-pkgs/vendored");
2431        let entry = tmp
2432            .path()
2433            .join(".mlua-pkgs/cache/git/gh.com/org/repo/aaaa1234/src");
2434        std::fs::create_dir_all(&vendored).unwrap();
2435        std::fs::create_dir_all(&entry).unwrap();
2436
2437        let rel = relative_path(&vendored, &entry).unwrap();
2438        assert!(
2439            rel.starts_with(".."),
2440            "must navigate up from vendored first"
2441        );
2442        assert!(
2443            rel.to_string_lossy().contains("cache"),
2444            "must contain 'cache' segment"
2445        );
2446    }
2447
2448    // ── entry_rel_to_pkg ──────────────────────────────────────────────────────
2449
2450    #[test]
2451    fn entry_rel_to_pkg_subdir() {
2452        let cache = PathBuf::from("/tmp/repo");
2453        let entry = PathBuf::from("/tmp/repo/src");
2454        assert_eq!(entry_rel_to_pkg(&cache, &entry), PathBuf::from("src"));
2455    }
2456
2457    #[test]
2458    fn entry_rel_to_pkg_root() {
2459        let cache = PathBuf::from("/tmp/repo");
2460        let entry = PathBuf::from("/tmp/repo");
2461        assert_eq!(entry_rel_to_pkg(&cache, &entry), PathBuf::from("."));
2462    }
2463}