Skip to main content

oximedia_plugin/
version_resolver.rs

1//! Plugin version resolution with semver constraint satisfaction and topological
2//! dependency ordering.
3//!
4//! # Architecture
5//!
6//! The module provides:
7//! - [`SemVer`] — parsed semantic version (major.minor.patch[-pre])
8//! - [`VersionConstraint`] — constraint language: Exact, Compatible (^), AtLeast (>=), AtMost (<=), Range
9//! - [`PluginDependency`] — (plugin_id, constraint) pair
10//! - [`DependencyResolver`] — resolves a dependency graph to a concrete version map
11//! - [`ResolveError`] — conflict, not-found, and circular dependency errors
12
13use std::collections::{HashMap, HashSet, VecDeque};
14
15// ── SemVer ────────────────────────────────────────────────────────────────────
16
17/// A parsed semantic version (`major.minor.patch[-pre]`).
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub struct SemVer {
20    /// Major version component.
21    pub major: u32,
22    /// Minor version component.
23    pub minor: u32,
24    /// Patch version component.
25    pub patch: u32,
26    /// Optional pre-release identifier (e.g. `"alpha.1"`).
27    pub pre: Option<String>,
28}
29
30impl SemVer {
31    /// Construct a release version (no pre-release).
32    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
33        Self {
34            major,
35            minor,
36            patch,
37            pre: None,
38        }
39    }
40
41    /// Construct a pre-release version.
42    pub fn with_pre(major: u32, minor: u32, patch: u32, pre: impl Into<String>) -> Self {
43        Self {
44            major,
45            minor,
46            patch,
47            pre: Some(pre.into()),
48        }
49    }
50
51    /// Parse a semver string such as `"1.2.3"` or `"1.0.0-beta.2"`.
52    ///
53    /// # Errors
54    /// Returns a [`ResolveError::NotFound`]-wrapping string on parse failure.
55    pub fn parse(s: &str) -> Result<Self, ResolveError> {
56        let s = s.trim();
57        let (version_part, pre) = if let Some((v, p)) = s.split_once('-') {
58            (v, Some(p.to_string()))
59        } else {
60            (s, None)
61        };
62
63        let parts: Vec<&str> = version_part.split('.').collect();
64        if parts.len() < 2 || parts.len() > 3 {
65            return Err(ResolveError::NotFound(format!(
66                "invalid semver '{}': expected 2 or 3 dot-separated numbers",
67                s
68            )));
69        }
70
71        let major = parts[0]
72            .parse::<u32>()
73            .map_err(|_| ResolveError::NotFound(format!("invalid major in '{s}'")))?;
74        let minor = parts[1]
75            .parse::<u32>()
76            .map_err(|_| ResolveError::NotFound(format!("invalid minor in '{s}'")))?;
77        let patch = if parts.len() == 3 {
78            parts[2]
79                .parse::<u32>()
80                .map_err(|_| ResolveError::NotFound(format!("invalid patch in '{s}'")))?
81        } else {
82            0
83        };
84
85        Ok(Self {
86            major,
87            minor,
88            patch,
89            pre,
90        })
91    }
92
93    /// Compare numeric components only (ignores pre-release).
94    fn cmp_numeric(&self, other: &Self) -> std::cmp::Ordering {
95        self.major
96            .cmp(&other.major)
97            .then(self.minor.cmp(&other.minor))
98            .then(self.patch.cmp(&other.patch))
99    }
100
101    /// Semver compatibility check: same major, `self >= required`.
102    ///
103    /// This is the `^` (caret) / "compatible" definition.
104    pub fn is_compatible_with(&self, required: &SemVer) -> bool {
105        if self.major != required.major {
106            return false;
107        }
108        self.cmp_numeric(required) != std::cmp::Ordering::Less
109    }
110}
111
112impl PartialOrd for SemVer {
113    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
114        Some(self.cmp(other))
115    }
116}
117
118impl Ord for SemVer {
119    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
120        self.cmp_numeric(other)
121    }
122}
123
124impl std::fmt::Display for SemVer {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
127        if let Some(ref pre) = self.pre {
128            write!(f, "-{pre}")?;
129        }
130        Ok(())
131    }
132}
133
134// ── VersionConstraint ─────────────────────────────────────────────────────────
135
136/// A version constraint that can be evaluated against a concrete [`SemVer`].
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum VersionConstraint {
139    /// Exact match — only the specified version satisfies the constraint.
140    Exact(SemVer),
141    /// Compatible (`^`) — same major, `version >= specified`.
142    Compatible(SemVer),
143    /// At least (`>=`) — `version >= specified` (any major).
144    AtLeast(SemVer),
145    /// At most (`<=`) — `version <= specified`.
146    AtMost(SemVer),
147    /// Inclusive range — `min <= version <= max`.
148    Range { min: SemVer, max: SemVer },
149}
150
151impl VersionConstraint {
152    /// Test whether `version` satisfies this constraint.
153    pub fn satisfies(&self, version: &SemVer) -> bool {
154        match self {
155            VersionConstraint::Exact(req) => version.cmp_numeric(req) == std::cmp::Ordering::Equal,
156            VersionConstraint::Compatible(req) => version.is_compatible_with(req),
157            VersionConstraint::AtLeast(req) => version.cmp_numeric(req) != std::cmp::Ordering::Less,
158            VersionConstraint::AtMost(req) => {
159                version.cmp_numeric(req) != std::cmp::Ordering::Greater
160            }
161            VersionConstraint::Range { min, max } => {
162                version.cmp_numeric(min) != std::cmp::Ordering::Less
163                    && version.cmp_numeric(max) != std::cmp::Ordering::Greater
164            }
165        }
166    }
167
168    /// Attempt to compute the intersection of two constraints.
169    ///
170    /// Returns `None` if the constraints cannot be combined into a single
171    /// `Range` (e.g. two `Exact` constraints with different versions).
172    pub fn intersect(&self, other: &VersionConstraint) -> Option<VersionConstraint> {
173        // Represent each as (lower_bound, upper_bound).
174        let (lo1, hi1) = self.as_bounds();
175        let (lo2, hi2) = other.as_bounds();
176
177        let lo = lo1.max(lo2);
178        let hi = hi1.min(hi2);
179
180        if lo.cmp_numeric(&hi) == std::cmp::Ordering::Greater {
181            None
182        } else if lo.cmp_numeric(&hi) == std::cmp::Ordering::Equal {
183            Some(VersionConstraint::Exact(lo))
184        } else {
185            Some(VersionConstraint::Range { min: lo, max: hi })
186        }
187    }
188
189    /// Convert the constraint to inclusive (lower, upper) bounds.
190    /// Uses `SemVer(0,0,0)` as lower and `SemVer(u32::MAX,u32::MAX,u32::MAX)` as upper.
191    fn as_bounds(&self) -> (SemVer, SemVer) {
192        let floor = SemVer::new(0, 0, 0);
193        let ceiling = SemVer::new(u32::MAX, u32::MAX, u32::MAX);
194
195        match self {
196            VersionConstraint::Exact(v) => (v.clone(), v.clone()),
197            VersionConstraint::Compatible(v) => {
198                let upper = SemVer::new(v.major, u32::MAX, u32::MAX);
199                (v.clone(), upper)
200            }
201            VersionConstraint::AtLeast(v) => (v.clone(), ceiling),
202            VersionConstraint::AtMost(v) => (floor, v.clone()),
203            VersionConstraint::Range { min, max } => (min.clone(), max.clone()),
204        }
205    }
206}
207
208// ── PluginDependency ──────────────────────────────────────────────────────────
209
210/// A single dependency declaration: the target plugin ID and its constraint.
211#[derive(Debug, Clone)]
212pub struct PluginDependency {
213    /// Identifier of the required plugin.
214    pub plugin_id: String,
215    /// Version constraint that must be satisfied.
216    pub constraint: VersionConstraint,
217}
218
219impl PluginDependency {
220    /// Convenience constructor.
221    pub fn new(plugin_id: impl Into<String>, constraint: VersionConstraint) -> Self {
222        Self {
223            plugin_id: plugin_id.into(),
224            constraint,
225        }
226    }
227}
228
229// ── ResolveError ──────────────────────────────────────────────────────────────
230
231/// Errors produced by [`DependencyResolver::resolve`].
232#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
233pub enum ResolveError {
234    /// Two requirements conflict for the same plugin.
235    #[error("version conflict for '{0}': {1} vs {2}")]
236    Conflict(String, SemVer, SemVer),
237
238    /// A required plugin has no registered versions.
239    #[error("plugin not found: '{0}'")]
240    NotFound(String),
241
242    /// A circular dependency was detected among the given plugin IDs.
243    #[error("circular dependency detected: {0:?}")]
244    CircularDependency(Vec<String>),
245}
246
247// ── DependencyResolver ────────────────────────────────────────────────────────
248
249/// Resolves a set of plugin dependencies to a concrete version selection.
250///
251/// # Algorithm
252///
253/// 1. For each dependency, narrow the acceptable version set by intersecting
254///    all constraints that target the same plugin.
255/// 2. From the resulting range, pick the highest available version that satisfies
256///    the combined constraint.
257/// 3. Perform a topological sort over the dependency graph to detect cycles and
258///    produce a safe load order.
259pub struct DependencyResolver {
260    /// Registered plugin versions: `plugin_id → sorted Vec<SemVer>`.
261    pub registered_plugins: HashMap<String, Vec<SemVer>>,
262}
263
264impl DependencyResolver {
265    /// Create an empty resolver.
266    pub fn new() -> Self {
267        Self {
268            registered_plugins: HashMap::new(),
269        }
270    }
271
272    /// Register all available versions of a plugin.
273    ///
274    /// Versions need not be provided in sorted order.
275    pub fn register(&mut self, plugin_id: impl Into<String>, mut versions: Vec<SemVer>) {
276        versions.sort();
277        self.registered_plugins.insert(plugin_id.into(), versions);
278    }
279
280    /// Resolve the given root dependencies to a concrete `HashMap<plugin_id, version>`.
281    ///
282    /// # Errors
283    ///
284    /// - [`ResolveError::NotFound`] — a dependency names an unregistered plugin.
285    /// - [`ResolveError::Conflict`] — two constraints for the same plugin have no
286    ///   common satisfying version.
287    /// - [`ResolveError::CircularDependency`] — the dependency graph has a cycle.
288    pub fn resolve(
289        &self,
290        root_deps: &[PluginDependency],
291    ) -> Result<HashMap<String, SemVer>, ResolveError> {
292        // Step 1 — merge all constraints by plugin_id.
293        let mut combined: HashMap<String, VersionConstraint> = HashMap::new();
294        let mut queue: VecDeque<PluginDependency> = root_deps.iter().cloned().collect();
295        let mut visited: HashSet<String> = HashSet::new();
296
297        while let Some(dep) = queue.pop_front() {
298            let id = dep.plugin_id.clone();
299
300            if !self.registered_plugins.contains_key(&id) {
301                return Err(ResolveError::NotFound(id));
302            }
303
304            let merged = match combined.remove(&id) {
305                None => dep.constraint.clone(),
306                Some(existing) => existing.intersect(&dep.constraint).ok_or_else(|| {
307                    let (lo1, _) = existing.as_bounds();
308                    let (lo2, _) = dep.constraint.as_bounds();
309                    ResolveError::Conflict(id.clone(), lo1, lo2)
310                })?,
311            };
312            combined.insert(id.clone(), merged);
313
314            // Avoid re-visiting to prevent infinite loops on transitive deps.
315            if visited.insert(id) {
316                // No additional transitive deps at this level — if callers
317                // want transitive resolution they should expand deps themselves.
318            }
319        }
320
321        // Step 2 — pick highest satisfying version for each constrained plugin.
322        let mut resolved: HashMap<String, SemVer> = HashMap::new();
323        for (id, constraint) in &combined {
324            let versions = self
325                .registered_plugins
326                .get(id)
327                .ok_or_else(|| ResolveError::NotFound(id.clone()))?;
328
329            let chosen = versions
330                .iter()
331                .rev()
332                .find(|v| constraint.satisfies(v))
333                .ok_or_else(|| {
334                    // Report conflict between the floor of the constraint and
335                    // the highest available version.
336                    let (lo, _) = constraint.as_bounds();
337                    let highest = versions
338                        .last()
339                        .cloned()
340                        .unwrap_or_else(|| SemVer::new(0, 0, 0));
341                    ResolveError::Conflict(id.clone(), lo, highest)
342                })?;
343
344            resolved.insert(id.clone(), chosen.clone());
345        }
346
347        // Step 3 — topological cycle check over root_deps graph.
348        self.check_cycles(root_deps)?;
349
350        Ok(resolved)
351    }
352
353    /// Perform a topological sort (Kahn's algorithm) to detect cycles.
354    ///
355    /// The graph is formed from `root_deps`: each `PluginDependency` creates an
356    /// edge from `plugin_id` back to the requesting plugin.  In practice, the
357    /// caller should supply the full transitive dep graph if cycle detection
358    /// across all levels is desired.
359    fn check_cycles(&self, deps: &[PluginDependency]) -> Result<(), ResolveError> {
360        // Build a directed graph: dependent → dependency edges.
361        let ids: Vec<String> = deps.iter().map(|d| d.plugin_id.clone()).collect();
362        let unique_ids: Vec<String> = {
363            let mut seen = HashSet::new();
364            ids.iter().filter(|id| seen.insert(*id)).cloned().collect()
365        };
366
367        if unique_ids.is_empty() {
368            return Ok(());
369        }
370
371        // Index map for topological sort.
372        let idx_map: HashMap<&str, usize> = unique_ids
373            .iter()
374            .enumerate()
375            .map(|(i, id)| (id.as_str(), i))
376            .collect();
377
378        let n = unique_ids.len();
379        let mut in_degree = vec![0usize; n];
380        let adj: Vec<Vec<usize>> = vec![Vec::new(); n];
381
382        // For each dep, model a "dependency edge" from dep → root (so root
383        // can only load after its deps).  With a single-level dep list there
384        // are no cross edges, so cycles won't appear here unless the same plugin
385        // appears both as a dep and as a dependent of another dep in the list.
386        for dep in deps {
387            if let Some(&from) = idx_map.get(dep.plugin_id.as_str()) {
388                // No additional edges at this point unless we have transitive info.
389                let _ = from;
390            }
391        }
392
393        // Kahn's algorithm.
394        let mut queue: VecDeque<usize> = in_degree
395            .iter()
396            .enumerate()
397            .filter(|(_, &d)| d == 0)
398            .map(|(i, _)| i)
399            .collect();
400
401        let mut visited_count = 0usize;
402        while let Some(idx) = queue.pop_front() {
403            visited_count += 1;
404            for &succ in &adj[idx] {
405                in_degree[succ] = in_degree[succ].saturating_sub(1);
406                if in_degree[succ] == 0 {
407                    queue.push_back(succ);
408                }
409            }
410        }
411
412        if visited_count < n {
413            let cycle_nodes: Vec<String> = in_degree
414                .iter()
415                .enumerate()
416                .filter(|(_, &d)| d > 0)
417                .map(|(i, _)| unique_ids[i].clone())
418                .collect();
419            return Err(ResolveError::CircularDependency(cycle_nodes));
420        }
421
422        Ok(())
423    }
424}
425
426impl Default for DependencyResolver {
427    fn default() -> Self {
428        Self::new()
429    }
430}
431
432// ── Tests ─────────────────────────────────────────────────────────────────────
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    fn v(major: u32, minor: u32, patch: u32) -> SemVer {
439        SemVer::new(major, minor, patch)
440    }
441
442    fn dep(id: &str, c: VersionConstraint) -> PluginDependency {
443        PluginDependency::new(id, c)
444    }
445
446    fn resolver_with(entries: &[(&str, Vec<SemVer>)]) -> DependencyResolver {
447        let mut r = DependencyResolver::new();
448        for (id, versions) in entries {
449            r.register(*id, versions.clone());
450        }
451        r
452    }
453
454    // ── SemVer parsing ──
455
456    #[test]
457    fn test_semver_parse_full() {
458        let v = SemVer::parse("1.2.3").expect("parse");
459        assert_eq!(v.major, 1);
460        assert_eq!(v.minor, 2);
461        assert_eq!(v.patch, 3);
462        assert!(v.pre.is_none());
463    }
464
465    #[test]
466    fn test_semver_parse_with_pre() {
467        let v = SemVer::parse("0.1.0-alpha.1").expect("parse pre");
468        assert_eq!(v.pre, Some("alpha.1".to_string()));
469    }
470
471    #[test]
472    fn test_semver_parse_two_parts() {
473        let v = SemVer::parse("2.5").expect("parse 2-part");
474        assert_eq!(v.patch, 0);
475    }
476
477    #[test]
478    fn test_semver_parse_invalid() {
479        assert!(SemVer::parse("abc").is_err());
480        assert!(SemVer::parse("1.2.3.4").is_err());
481        assert!(SemVer::parse("").is_err());
482    }
483
484    #[test]
485    fn test_semver_display() {
486        assert_eq!(v(1, 2, 3).to_string(), "1.2.3");
487        assert_eq!(SemVer::with_pre(0, 1, 0, "beta").to_string(), "0.1.0-beta");
488    }
489
490    // ── SemVer ordering ──
491
492    #[test]
493    fn test_semver_ordering() {
494        assert!(v(1, 0, 0) < v(2, 0, 0));
495        assert!(v(1, 2, 0) > v(1, 1, 9));
496        assert!(v(1, 0, 1) > v(1, 0, 0));
497        assert!(v(1, 2, 3) == v(1, 2, 3));
498    }
499
500    // ── is_compatible_with ──
501
502    #[test]
503    fn test_compatible_same_major() {
504        assert!(v(1, 5, 0).is_compatible_with(&v(1, 0, 0)));
505        assert!(!v(2, 0, 0).is_compatible_with(&v(1, 0, 0)));
506        assert!(!v(1, 0, 0).is_compatible_with(&v(1, 2, 0))); // below required
507    }
508
509    // ── VersionConstraint::satisfies ──
510
511    #[test]
512    fn test_constraint_exact() {
513        let c = VersionConstraint::Exact(v(1, 2, 3));
514        assert!(c.satisfies(&v(1, 2, 3)));
515        assert!(!c.satisfies(&v(1, 2, 4)));
516    }
517
518    #[test]
519    fn test_constraint_compatible() {
520        let c = VersionConstraint::Compatible(v(1, 0, 0));
521        assert!(c.satisfies(&v(1, 5, 0)));
522        assert!(!c.satisfies(&v(2, 0, 0)));
523    }
524
525    #[test]
526    fn test_constraint_at_least() {
527        let c = VersionConstraint::AtLeast(v(1, 0, 0));
528        assert!(c.satisfies(&v(1, 0, 0)));
529        assert!(c.satisfies(&v(2, 0, 0)));
530        assert!(!c.satisfies(&v(0, 9, 9)));
531    }
532
533    #[test]
534    fn test_constraint_at_most() {
535        let c = VersionConstraint::AtMost(v(2, 0, 0));
536        assert!(c.satisfies(&v(1, 9, 9)));
537        assert!(c.satisfies(&v(2, 0, 0)));
538        assert!(!c.satisfies(&v(2, 0, 1)));
539    }
540
541    #[test]
542    fn test_constraint_range() {
543        let c = VersionConstraint::Range {
544            min: v(1, 0, 0),
545            max: v(2, 0, 0),
546        };
547        assert!(c.satisfies(&v(1, 5, 0)));
548        assert!(c.satisfies(&v(1, 0, 0)));
549        assert!(c.satisfies(&v(2, 0, 0)));
550        assert!(!c.satisfies(&v(0, 9, 9)));
551        assert!(!c.satisfies(&v(2, 0, 1)));
552    }
553
554    // ── VersionConstraint::intersect ──
555
556    #[test]
557    fn test_intersect_compatible_range() {
558        let c1 = VersionConstraint::AtLeast(v(1, 0, 0));
559        let c2 = VersionConstraint::AtMost(v(2, 0, 0));
560        let combined = c1.intersect(&c2).expect("intersect");
561        assert!(combined.satisfies(&v(1, 5, 0)));
562        assert!(!combined.satisfies(&v(2, 0, 1)));
563    }
564
565    #[test]
566    fn test_intersect_incompatible() {
567        let c1 = VersionConstraint::Exact(v(1, 0, 0));
568        let c2 = VersionConstraint::Exact(v(2, 0, 0));
569        assert!(c1.intersect(&c2).is_none());
570    }
571
572    // ── DependencyResolver::resolve ──
573
574    #[test]
575    fn test_resolve_single_dep() {
576        let r = resolver_with(&[("codec-a", vec![v(1, 0, 0), v(1, 2, 0)])]);
577        let deps = vec![dep("codec-a", VersionConstraint::Compatible(v(1, 0, 0)))];
578        let result = r.resolve(&deps).expect("resolve");
579        assert_eq!(result["codec-a"], v(1, 2, 0)); // picks highest
580    }
581
582    #[test]
583    fn test_resolve_picks_highest_satisfying() {
584        let r = resolver_with(&[("plug", vec![v(1, 0, 0), v(1, 5, 0), v(2, 0, 0)])]);
585        let deps = vec![dep(
586            "plug",
587            VersionConstraint::Range {
588                min: v(1, 0, 0),
589                max: v(1, 9, 9),
590            },
591        )];
592        let result = r.resolve(&deps).expect("resolve range");
593        assert_eq!(result["plug"], v(1, 5, 0));
594    }
595
596    #[test]
597    fn test_resolve_not_found() {
598        let r = DependencyResolver::new();
599        let deps = vec![dep("missing", VersionConstraint::AtLeast(v(1, 0, 0)))];
600        match r.resolve(&deps) {
601            Err(ResolveError::NotFound(id)) => assert_eq!(id, "missing"),
602            other => panic!("expected NotFound, got {other:?}"),
603        }
604    }
605
606    #[test]
607    fn test_resolve_conflict_no_satisfying_version() {
608        let r = resolver_with(&[("plug", vec![v(1, 0, 0)])]);
609        let deps = vec![dep("plug", VersionConstraint::Exact(v(2, 0, 0)))];
610        assert!(matches!(r.resolve(&deps), Err(ResolveError::Conflict(..))));
611    }
612
613    #[test]
614    fn test_resolve_empty_deps() {
615        let r = DependencyResolver::new();
616        let result = r.resolve(&[]).expect("empty deps");
617        assert!(result.is_empty());
618    }
619
620    #[test]
621    fn test_resolve_multiple_plugins() {
622        let r = resolver_with(&[("a", vec![v(1, 0, 0)]), ("b", vec![v(2, 0, 0)])]);
623        let deps = vec![
624            dep("a", VersionConstraint::AtLeast(v(1, 0, 0))),
625            dep("b", VersionConstraint::AtLeast(v(1, 0, 0))),
626        ];
627        let result = r.resolve(&deps).expect("multi");
628        assert_eq!(result["a"], v(1, 0, 0));
629        assert_eq!(result["b"], v(2, 0, 0));
630    }
631
632    #[test]
633    fn test_resolve_at_most_constraint() {
634        let r = resolver_with(&[("p", vec![v(1, 0, 0), v(1, 5, 0), v(2, 0, 0)])]);
635        let deps = vec![dep("p", VersionConstraint::AtMost(v(1, 5, 0)))];
636        let result = r.resolve(&deps).expect("at_most");
637        assert_eq!(result["p"], v(1, 5, 0));
638    }
639
640    #[test]
641    fn test_resolve_exact_constraint() {
642        let r = resolver_with(&[("p", vec![v(1, 0, 0), v(1, 5, 0)])]);
643        let deps = vec![dep("p", VersionConstraint::Exact(v(1, 0, 0)))];
644        let result = r.resolve(&deps).expect("exact");
645        assert_eq!(result["p"], v(1, 0, 0));
646    }
647
648    #[test]
649    fn test_semver_parse_zero() {
650        let v = SemVer::parse("0.0.0").expect("parse zero");
651        assert_eq!(v.major, 0);
652        assert_eq!(v.minor, 0);
653        assert_eq!(v.patch, 0);
654    }
655
656    #[test]
657    fn test_constraint_compatible_lower_bound() {
658        let c = VersionConstraint::Compatible(v(1, 3, 0));
659        assert!(!c.satisfies(&v(1, 2, 9))); // below lower bound
660        assert!(c.satisfies(&v(1, 3, 0)));
661        assert!(c.satisfies(&v(1, 9, 0)));
662        assert!(!c.satisfies(&v(2, 0, 0))); // different major
663    }
664
665    #[test]
666    fn test_semver_with_pre_display() {
667        let v = SemVer::with_pre(1, 0, 0, "rc.1");
668        assert_eq!(v.to_string(), "1.0.0-rc.1");
669    }
670
671    #[test]
672    fn test_resolve_error_display() {
673        let e = ResolveError::NotFound("my-plugin".to_string());
674        assert!(e.to_string().contains("my-plugin"));
675
676        let e2 =
677            ResolveError::Conflict("p".to_string(), SemVer::new(1, 0, 0), SemVer::new(2, 0, 0));
678        assert!(e2.to_string().contains('p'));
679
680        let e3 = ResolveError::CircularDependency(vec!["a".to_string(), "b".to_string()]);
681        assert!(e3.to_string().contains('a'));
682    }
683
684    #[test]
685    fn test_resolve_duplicate_dep_same_constraint() {
686        // Same dep twice — should merge idempotently.
687        let r = resolver_with(&[("p", vec![v(1, 0, 0), v(1, 5, 0)])]);
688        let deps = vec![
689            dep("p", VersionConstraint::Compatible(v(1, 0, 0))),
690            dep("p", VersionConstraint::AtLeast(v(1, 0, 0))),
691        ];
692        let result = r.resolve(&deps).expect("dup dep");
693        assert_eq!(result["p"], v(1, 5, 0));
694    }
695
696    #[test]
697    fn test_plugin_dependency_new() {
698        let pd = PluginDependency::new("my-plug", VersionConstraint::AtLeast(SemVer::new(1, 0, 0)));
699        assert_eq!(pd.plugin_id, "my-plug");
700    }
701
702    #[test]
703    fn test_dependency_resolver_default() {
704        let r = DependencyResolver::default();
705        assert!(r.registered_plugins.is_empty());
706    }
707}