aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry/src/installer_executor/mod.rs
blob: c29e32c40607c3d31f0123710b58975414fc779d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
//! Installation execution abstraction.
//!
//! Mirrors `Composer\Installer\InstallationManager`: the per-operation
//! side-effect surface (download, extract, remove from vendor/) lives behind
//! a trait so test code can substitute a recording-only implementation
//! (Composer's `InstallationManagerMock`) without going anywhere near the
//! filesystem or the network.
//!
//! The orchestration loop (computing operations from lock vs installed,
//! emitting console messages, writing `installed.json`, generating the
//! autoloader) stays in the caller. The executor is purely the verb —
//! "install this package" / "uninstall this package" — so test traces match
//! Composer's `(string) $operation` byte-for-byte without the executor
//! having to also reproduce console formatting.

use std::path::PathBuf;

use crate::installed::InstalledPackageEntry;
use crate::lockfile::{LockAlias, LockedPackage};

pub mod filesystem;
pub mod trace_recorder;

pub use filesystem::FilesystemExecutor;
pub use trace_recorder::TraceRecorderExecutor;

/// One install or update operation handed to [`InstallerExecutor::install_package`].
#[derive(Debug, Clone, Copy)]
pub enum PackageOperation<'a> {
    /// First-time install. The whole package directory is created from
    /// `package.dist`/`package.source`.
    Install { package: &'a LockedPackage },
    /// Replace an existing install with a new version. `from_version` is the
    /// pretty version that was installed before (no reference suffix —
    /// drives the upgrade-vs-downgrade direction). `from_full_pretty` /
    /// `to_full_pretty` are the formatted display strings used verbatim in
    /// the trace output; the caller renders them via
    /// [`format_update_pretty_versions`] so the SOURCE_REF / DIST_REF mode
    /// switch from Composer's `UpdateOperation::format` lands on both sides.
    Update {
        from_version: &'a str,
        from_full_pretty: &'a str,
        to_full_pretty: &'a str,
        package: &'a LockedPackage,
    },
    /// Mark an alias of a real package as installed. No filesystem effects —
    /// only the trace recorder needs this. Mirrors Composer's
    /// `MarkAliasInstalledOperation`.
    MarkAliasInstalled {
        /// The alias entry from `composer.lock`'s `aliases[]` block. Carries
        /// pretty + normalized alias version and the target's pretty version.
        alias: &'a LockAlias,
        /// The target package the alias points at — used to source the
        /// reference suffix for the trace line.
        target: &'a LockedPackage,
    },
    /// Mark a previously-installed alias as uninstalled. No filesystem
    /// effects — only the trace recorder cares. Mirrors Composer's
    /// `MarkAliasUninstalledOperation`. Composer derives the AliasPackage
    /// from the previous installed.json entries (via `extra.branch-alias`),
    /// then emits this when the alias is no longer in the result. Caller
    /// pre-renders the display strings so this variant doesn't need to know
    /// how to spelunk the entry.
    MarkAliasUninstalled {
        /// Package name (e.g. `a/a`) used as both the alias's name and the
        /// target's name on the trace line.
        name: &'a str,
        /// Alias's full-pretty form (alias pretty version plus reference
        /// suffix), e.g. `1.0.x-dev master`.
        alias_full: &'a str,
        /// Target's full-pretty form, e.g. `dev-master master`.
        target_full: &'a str,
    },
}

impl<'a> PackageOperation<'a> {
    pub fn package(&self) -> Option<&'a LockedPackage> {
        match self {
            PackageOperation::Install { package } | PackageOperation::Update { package, .. } => {
                Some(package)
            }
            PackageOperation::MarkAliasInstalled { .. }
            | PackageOperation::MarkAliasUninstalled { .. } => None,
        }
    }
}

/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for a `LockedPackage`.
///
/// For dev-stability versions backed by a git/hg source, append the reference
/// (truncated to 7 chars when it looks like a 40-char sha1). Otherwise return
/// the pretty version unchanged.
pub fn format_full_pretty_version(pkg: &LockedPackage) -> String {
    format_full_pretty_with_pretty(&pkg.version, pkg)
}

/// Same as [`format_full_pretty_version`] but lets the caller supply an
/// alternate pretty version (used by `MarkAliasInstalled` so the alias's
/// `3.2.x-dev` text is rendered with the *target's* reference).
pub fn format_full_pretty_with_pretty(pretty_version: &str, pkg: &LockedPackage) -> String {
    let source_ref = pkg.source.as_ref().and_then(|s| s.reference.as_deref());
    let dist_ref = pkg.dist.as_ref().and_then(|d| d.reference.as_deref());
    let source_type = pkg.source.as_ref().map(|s| s.source_type.as_str());
    format_full_pretty_with_refs(
        pretty_version,
        &pkg.version,
        source_ref,
        dist_ref,
        source_type,
    )
}

/// Render an alias's full pretty version: the alias's own pretty form for
/// the visible text, the alias's *normalized* version for the dev-stability
/// gate, and the target package's source/dist references for the suffix.
/// Mirrors `AliasPackage::getFullPrettyVersion`, where the alias decides on
/// its own whether to append a reference based on its own stability — so a
/// stable alias like `1.0.0` skips the suffix even when the target is a dev
/// branch.
pub fn format_full_pretty_alias(
    alias_pretty: &str,
    alias_version: &str,
    target: &LockedPackage,
) -> String {
    let source_ref = target.source.as_ref().and_then(|s| s.reference.as_deref());
    let dist_ref = target.dist.as_ref().and_then(|d| d.reference.as_deref());
    let source_type = target.source.as_ref().map(|s| s.source_type.as_str());
    format_full_pretty_with_refs(
        alias_pretty,
        alias_version,
        source_ref,
        dist_ref,
        source_type,
    )
}

/// Same as [`format_full_pretty_version_for_installed`] but lets the caller
/// supply an alternate pretty version. Used when emitting
/// `MarkAliasUninstalled`: the alias's `1.0.x-dev` text needs to be rendered
/// with the *target installed entry's* reference suffix.
pub fn format_full_pretty_with_pretty_for_installed(
    pretty_version: &str,
    entry: &InstalledPackageEntry,
) -> String {
    let source_ref = entry
        .source
        .as_ref()
        .and_then(|v| v.get("reference"))
        .and_then(|v| v.as_str());
    let dist_ref = entry
        .dist
        .as_ref()
        .and_then(|v| v.get("reference"))
        .and_then(|v| v.as_str());
    let source_type = entry
        .source
        .as_ref()
        .and_then(|v| v.get("type"))
        .and_then(|v| v.as_str());
    format_full_pretty_with_refs(
        pretty_version,
        &entry.version,
        source_ref,
        dist_ref,
        source_type,
    )
}

/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for an
/// `InstalledPackageEntry`. Same display rules as
/// [`format_full_pretty_version`] but pulls source/dist info out of the
/// installed.json `source`/`dist` JSON values.
pub fn format_full_pretty_version_for_installed(entry: &InstalledPackageEntry) -> String {
    format_full_pretty_with_pretty_for_installed(&entry.version, entry)
}

/// Render the from/to display strings for an update trace line, mirroring
/// Composer's `UpdateOperation::format`. Defaults to `DISPLAY_SOURCE_REF_IF_DEV`,
/// then if both sides render identically:
///
/// - source references differ → re-render in `DISPLAY_SOURCE_REF` mode,
/// - else dist references differ → re-render in `DISPLAY_DIST_REF` mode.
///
/// Without the switch, two same-version-different-reference packages would
/// produce a useless `pkg (X => X)` trace line.
pub fn format_update_pretty_versions(
    from_entry: &InstalledPackageEntry,
    to_pkg: &LockedPackage,
) -> (String, String) {
    let from_default = format_full_pretty_version_for_installed(from_entry);
    let to_default = format_full_pretty_version(to_pkg);
    if from_default != to_default {
        return (from_default, to_default);
    }

    let from_source_ref = from_entry
        .source
        .as_ref()
        .and_then(|v| v.get("reference"))
        .and_then(|v| v.as_str());
    let from_source_type = from_entry
        .source
        .as_ref()
        .and_then(|v| v.get("type"))
        .and_then(|v| v.as_str());
    let to_source_ref = to_pkg.source.as_ref().and_then(|s| s.reference.as_deref());
    let to_source_type = to_pkg.source.as_ref().map(|s| s.source_type.as_str());

    if from_source_ref != to_source_ref {
        return (
            format_with_explicit_reference(&from_entry.version, from_source_ref, from_source_type),
            format_with_explicit_reference(&to_pkg.version, to_source_ref, to_source_type),
        );
    }

    let from_dist_ref = from_entry
        .dist
        .as_ref()
        .and_then(|v| v.get("reference"))
        .and_then(|v| v.as_str());
    let to_dist_ref = to_pkg.dist.as_ref().and_then(|d| d.reference.as_deref());

    if from_dist_ref != to_dist_ref {
        return (
            format_with_explicit_reference(&from_entry.version, from_dist_ref, from_source_type),
            format_with_explicit_reference(&to_pkg.version, to_dist_ref, to_source_type),
        );
    }

    (from_default, to_default)
}

/// Render `pretty_version` with an explicitly chosen reference, mirroring
/// Composer's `BasePackage::getFullPrettyVersion` with `DISPLAY_SOURCE_REF`
/// or `DISPLAY_DIST_REF`: skip the dev-stability gate, just truncate sha1
/// references and concatenate. A `None` reference falls back to the bare
/// pretty version.
fn format_with_explicit_reference(
    pretty_version: &str,
    reference: Option<&str>,
    source_type: Option<&str>,
) -> String {
    let Some(reference) = reference else {
        return pretty_version.to_string();
    };
    if matches!(source_type, Some("svn")) {
        return format!("{} {}", pretty_version, reference);
    }
    if reference.len() == 40 {
        return format!("{} {}", pretty_version, &reference[..7]);
    }
    format!("{} {}", pretty_version, reference)
}

/// Core of `BasePackage::getFullPrettyVersion()` factored over raw
/// fields so both [`LockedPackage`] and [`InstalledPackageEntry`] can share
/// the rendering logic. `version` drives the dev-stability check; the result
/// is `pretty_version` plus a reference suffix when the package is a dev
/// branch backed by git/hg (with sha1 references truncated to 7 chars).
fn format_full_pretty_with_refs(
    pretty_version: &str,
    version: &str,
    source_ref: Option<&str>,
    dist_ref: Option<&str>,
    source_type: Option<&str>,
) -> String {
    let is_dev = mozart_semver::Version::parse(version)
        .map(|v| matches!(v.pre_release.as_deref(), Some("dev")) || v.is_dev_branch)
        .unwrap_or(false);
    if !is_dev {
        return pretty_version.to_string();
    }
    // Composer falls back to dist reference only when no source type is set
    // (or the package isn't git/hg — in which case the dev display is skipped
    // entirely above).
    let reference = source_ref.or(match source_type {
        Some("git") | Some("hg") => None,
        _ => dist_ref,
    });
    let Some(reference) = reference else {
        return pretty_version.to_string();
    };
    if matches!(source_type, Some("git") | Some("hg")) && reference.len() == 40 {
        format!("{} {}", pretty_version, &reference[..7])
    } else if matches!(source_type, Some("svn")) {
        // svn references are revision numbers, never truncated
        format!("{} {}", pretty_version, reference)
    } else if reference.len() == 40 {
        // dist-ref fallback (no git/hg source) — Composer truncates here too
        format!("{} {}", pretty_version, &reference[..7])
    } else {
        format!("{} {}", pretty_version, reference)
    }
}

/// Per-call configuration shared across executor methods. Owned by the
/// caller (typically `install_from_lock`) so the executor sees a consistent
/// view across an entire install/update run.
#[derive(Debug, Clone)]
pub struct ExecuteContext {
    pub vendor_dir: PathBuf,
    /// Suppress download progress bars.
    pub no_progress: bool,
    /// Prefer cloning from VCS source over downloading dist archives.
    pub prefer_source: bool,
}

/// Side-effect surface for install/update/uninstall operations.
///
/// Implementations are stateful — `&mut self` lets a recorder accumulate
/// trace lines and lets the filesystem implementation hold long-lived
/// handles (caches, progress bars). All methods return `anyhow::Result` so
/// callers can short-circuit on the first failure, mirroring Composer's
/// fail-fast `InstallationManager::execute`.
#[async_trait::async_trait]
pub trait InstallerExecutor: Send + Sync {
    /// Perform side effects for one install or update operation.
    async fn install_package(
        &mut self,
        op: PackageOperation<'_>,
        ctx: &ExecuteContext,
    ) -> anyhow::Result<()>;

    /// Perform side effects for one uninstall.
    ///
    /// `version` is the previously-installed version (from installed.json),
    /// passed so the trace recorder can format Composer's
    /// `Uninstalling pkg/name (version)` line. The filesystem implementation
    /// ignores it — `name` alone is enough to locate the vendor directory.
    fn uninstall_package(
        &mut self,
        name: &str,
        version: &str,
        ctx: &ExecuteContext,
    ) -> anyhow::Result<()>;

    /// Hook called once after every uninstall has run. Default no-op.
    /// Composer cleans up empty namespace directories here; the recorder
    /// has no work to do.
    fn cleanup_after_uninstalls(&mut self, _ctx: &ExecuteContext) -> anyhow::Result<()> {
        Ok(())
    }
}