aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-core/src/installer/installed_repo.rs
blob: 8361158f23a44b4d49a89f28081cd7cf1ccc63d1 (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
//! Lightweight stand-in for `Composer\Repository\InstalledRepository`.
//!
//! Composer's `InstalledRepository` is a composite over `LockArrayRepository`,
//! `InstalledRepositoryInterface`, `RootPackageRepository`, and
//! `PlatformRepository`. Mozart does not (yet) expose a unified repository
//! abstraction, so this struct is the smallest layer we need to support the
//! handful of commands that drive their behavior off
//! `findPackagesWithReplacersAndProviders` (currently `check-platform-reqs`
//! and `suggests`; later candidates: `depends`/`prohibits`, `audit`).
//!
//! The struct serves two roles:
//!
//! - As a lower-cased name set: callers `insert(name)` whatever they want
//!   visible to `contains` / suggestion-filter logic.
//! - As a candidate index: callers `add_candidate(InstalledCandidate)` and
//!   then resolve a require name to the candidate(s) that satisfy it directly
//!   or through a `provide` / `replace` link.

use indexmap::IndexSet;
use std::collections::BTreeMap;

/// One installed package, in the shape `findPackagesWithReplacersAndProviders`
/// needs. Mirrors the fields of `Composer\Package\PackageInterface` that the
/// PHP implementation reads — name, version, provides, replaces.
#[derive(Debug, Clone)]
pub struct InstalledCandidate {
    /// Lower-cased package name, used for matching.
    pub name: String,
    /// Original-case package name, used in user-facing output.
    pub pretty_name: String,
    /// Normalized version (what the constraint matcher consumes).
    pub version: String,
    /// Original-case version, used in user-facing output.
    pub pretty_version: String,
    /// `provide` map: target package name → constraint string.
    pub provides: BTreeMap<String, String>,
    /// `replace` map: target package name → constraint string.
    pub replaces: BTreeMap<String, String>,
}

#[derive(Debug, Clone, Default)]
pub struct InstalledRepoLite {
    /// Lower-cased names of every package, plus every `provide`/`replace`
    /// target that any candidate exposes. `contains` queries this set.
    pub names: IndexSet<String>,
    /// Full candidate records, in insertion order.
    pub candidates: Vec<InstalledCandidate>,
}

impl InstalledRepoLite {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn insert(&mut self, name: &str) {
        self.names.insert(name.to_lowercase());
    }

    pub fn contains(&self, name: &str) -> bool {
        self.names.contains(&name.to_lowercase())
    }

    /// Add a full candidate record. Also inserts the candidate's own name and
    /// every `provide` / `replace` target into the names set so `contains`
    /// keeps reflecting all installed virtuals.
    pub fn add_candidate(&mut self, candidate: InstalledCandidate) {
        self.names.insert(candidate.name.clone());
        for target in candidate.provides.keys().chain(candidate.replaces.keys()) {
            self.names.insert(target.to_lowercase());
        }
        self.candidates.push(candidate);
    }

    /// Mirrors `Composer\Repository\InstalledRepository::findPackagesWithReplacersAndProviders`
    /// without the optional constraint filter — callers in
    /// `check-platform-reqs` apply their own per-link constraint check after
    /// they have the candidate list. Returns each candidate at most once.
    pub fn find_with_replacers_and_providers(&self, require: &str) -> Vec<&InstalledCandidate> {
        let needle = require.to_lowercase();
        let mut matches: Vec<&InstalledCandidate> = Vec::new();
        for candidate in &self.candidates {
            if candidate.name == needle {
                matches.push(candidate);
                continue;
            }
            let provides_or_replaces = candidate
                .provides
                .keys()
                .chain(candidate.replaces.keys())
                .any(|target| target.to_lowercase() == needle);
            if provides_or_replaces {
                matches.push(candidate);
            }
        }
        matches
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_candidate(name: &str, version: &str) -> InstalledCandidate {
        InstalledCandidate {
            name: name.to_lowercase(),
            pretty_name: name.to_string(),
            version: version.to_string(),
            pretty_version: version.to_string(),
            provides: BTreeMap::new(),
            replaces: BTreeMap::new(),
        }
    }

    #[test]
    fn insert_and_contains_lowercase() {
        let mut repo = InstalledRepoLite::new();
        repo.insert("Vendor/Pkg");
        assert!(repo.contains("vendor/pkg"));
        assert!(repo.contains("VENDOR/PKG"));
    }

    #[test]
    fn add_candidate_registers_name_and_virtuals() {
        let mut c = make_candidate("vendor/poly", "1.0.0");
        c.provides.insert("ext-mbstring".into(), "1.0".into());
        c.replaces.insert("ext-iconv".into(), "*".into());

        let mut repo = InstalledRepoLite::new();
        repo.add_candidate(c);

        assert!(repo.contains("vendor/poly"));
        assert!(repo.contains("ext-mbstring"));
        assert!(repo.contains("ext-iconv"));
    }

    #[test]
    fn find_returns_direct_match() {
        let mut repo = InstalledRepoLite::new();
        repo.add_candidate(make_candidate("php", "8.2.1"));
        let hits = repo.find_with_replacers_and_providers("php");
        assert_eq!(hits.len(), 1);
        assert_eq!(hits[0].name, "php");
    }

    #[test]
    fn find_returns_provider() {
        let mut c = make_candidate("symfony/polyfill-mbstring", "1.30.0");
        c.provides.insert("ext-mbstring".into(), "*".into());

        let mut repo = InstalledRepoLite::new();
        repo.add_candidate(c);

        let hits = repo.find_with_replacers_and_providers("ext-mbstring");
        assert_eq!(hits.len(), 1);
        assert_eq!(hits[0].name, "symfony/polyfill-mbstring");
    }

    #[test]
    fn find_returns_replacer() {
        let mut c = make_candidate("vendor/forklift", "2.0.0");
        c.replaces.insert("vendor/legacy".into(), "1.*".into());

        let mut repo = InstalledRepoLite::new();
        repo.add_candidate(c);

        let hits = repo.find_with_replacers_and_providers("vendor/legacy");
        assert_eq!(hits.len(), 1);
        assert_eq!(hits[0].name, "vendor/forklift");
    }

    #[test]
    fn find_returns_empty_when_unknown() {
        let mut repo = InstalledRepoLite::new();
        repo.add_candidate(make_candidate("php", "8.2.1"));
        assert!(
            repo.find_with_replacers_and_providers("ext-foobar")
                .is_empty()
        );
    }

    #[test]
    fn find_includes_each_candidate_at_most_once() {
        let mut c = make_candidate("vendor/poly", "1.0.0");
        // Same target listed in both maps — should still only return one hit.
        c.provides.insert("ext-x".into(), "*".into());
        c.replaces.insert("ext-x".into(), "*".into());

        let mut repo = InstalledRepoLite::new();
        repo.add_candidate(c);

        let hits = repo.find_with_replacers_and_providers("ext-x");
        assert_eq!(hits.len(), 1);
    }
}