Skip to main content

modde_core/resolver/
mod.rs

1use std::borrow::Borrow;
2use std::cmp::Reverse;
3use std::collections::{BinaryHeap, HashMap, HashSet};
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::{CoreError, Result};
9use crate::profile::Profile;
10
11/// Generates a newtype wrapper around `String` with zero-cost `#[repr(transparent)]`
12/// layout. Provides domain-level type safety — you cannot accidentally pass a `ModId`
13/// where a `GameId` is expected, or vice versa.
14///
15/// Each invocation produces a struct with: `Display`, `From<&str>`, `From<String>`,
16/// `Borrow<str>`, `AsRef<str>`, `PartialEq<str>`, and `PartialEq<&str>`.
17macro_rules! define_id_newtype {
18    (
19        $(#[$meta:meta])*
20        $vis:vis struct $Name:ident;
21    ) => {
22        $(#[$meta])*
23        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
24        #[repr(transparent)]
25        $vis struct $Name(pub String);
26
27        impl $Name {
28            pub fn as_str(&self) -> &str {
29                &self.0
30            }
31        }
32
33        impl fmt::Display for $Name {
34            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35                f.write_str(&self.0)
36            }
37        }
38
39        impl From<&str> for $Name {
40            fn from(s: &str) -> Self {
41                Self(s.to_string())
42            }
43        }
44
45        impl From<String> for $Name {
46            fn from(s: String) -> Self {
47                Self(s)
48            }
49        }
50
51        impl Borrow<str> for $Name {
52            fn borrow(&self) -> &str {
53                &self.0
54            }
55        }
56
57        impl AsRef<str> for $Name {
58            fn as_ref(&self) -> &str {
59                &self.0
60            }
61        }
62
63        impl PartialEq<str> for $Name {
64            fn eq(&self, other: &str) -> bool {
65                self.0 == other
66            }
67        }
68
69        impl PartialEq<&str> for $Name {
70            fn eq(&self, other: &&str) -> bool {
71                self.0 == *other
72            }
73        }
74
75        impl rusqlite::types::ToSql for $Name {
76            fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
77                self.0.to_sql()
78            }
79        }
80    };
81}
82
83define_id_newtype! {
84    /// Unique identifier for a mod within a profile.
85    ///
86    /// Prevents accidental use of arbitrary strings where a mod ID is expected.
87    pub struct ModId;
88}
89
90define_id_newtype! {
91    /// Unique identifier for a supported game (e.g. `"skyrim-se"`, `"cyberpunk2077"`).
92    ///
93    /// Prevents mixing up game IDs with profile names, mod IDs, or other strings
94    /// at the type level. Zero runtime cost via `#[repr(transparent)]`.
95    pub struct GameId;
96}
97
98/// A rule constraining load order.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub enum LoadOrderRule {
101    /// This mod must load after the specified mod.
102    LoadAfter { mod_id: ModId, after: ModId },
103    /// This mod must load before the specified mod.
104    LoadBefore { mod_id: ModId, before: ModId },
105    /// These two mods are incompatible; error if both enabled.
106    Incompatible { mod_a: ModId, mod_b: ModId },
107}
108
109/// Maps each deployed file path to the set of mods that provide it.
110#[derive(Debug, Clone, Default)]
111pub struct ConflictMap {
112    pub files: HashMap<String, HashSet<ModId>>,
113}
114
115impl ConflictMap {
116    /// Register that `mod_id` provides `file_path`.
117    pub fn register(&mut self, file_path: String, mod_id: ModId) {
118        self.files.entry(file_path).or_default().insert(mod_id);
119    }
120
121    /// Return all file paths that have more than one provider.
122    #[must_use]
123    pub fn conflicts(&self) -> Vec<(&str, &HashSet<ModId>)> {
124        self.files
125            .iter()
126            .filter(|(_, mods)| mods.len() > 1)
127            .map(|(path, mods)| (path.as_str(), mods))
128            .collect()
129    }
130
131    /// Determine the winner for a given file path based on mod priority order.
132    ///
133    /// `priority_order` lists mods from lowest to highest priority.
134    /// The last mod in the list that provides the file wins.
135    /// Hidden `(mod_id, rel_path)` pairs are excluded.
136    #[must_use]
137    pub fn winner_for(
138        &self,
139        file_path: &str,
140        priority_order: &[ModId],
141        hidden: &HashSet<(String, String)>,
142    ) -> Option<ModId> {
143        let providers = self.files.get(file_path)?;
144        priority_order
145            .iter()
146            .rev()
147            .find(|mod_id| {
148                providers.contains(*mod_id)
149                    && !hidden.contains(&(mod_id.0.clone(), file_path.to_string()))
150            })
151            .cloned()
152    }
153
154    /// Return all conflicts with their resolved winners.
155    ///
156    /// Returns `(file_path, all_providers, winner)` tuples.
157    #[must_use]
158    pub fn resolved_conflicts(
159        &self,
160        priority_order: &[ModId],
161        hidden: &HashSet<(String, String)>,
162    ) -> Vec<(&str, &HashSet<ModId>, Option<ModId>)> {
163        self.conflicts()
164            .into_iter()
165            .map(|(path, providers)| {
166                let winner = self.winner_for(path, priority_order, hidden);
167                (path, providers, winner)
168            })
169            .collect()
170    }
171}
172
173/// The result of resolving a profile's load order.
174#[derive(Debug, Clone)]
175pub struct ResolvedLoadOrder {
176    /// Mods in final load order (first = lowest priority).
177    pub order: Vec<ModId>,
178}
179
180/// Resolve a profile into a topologically sorted load order.
181///
182/// **Stability contract:** the output preserves `profile.mods` input order
183/// wherever possible, deviating *only* when a `LoadAfter` / `LoadBefore`
184/// rule would otherwise be violated. This means:
185///
186/// 1. **No rules → exact input order.** `resolve(profile).order` equals the
187///    enabled subset of `profile.mods` in the same sequence.
188/// 2. **Round-trip via swap.** Swapping two adjacent mods in `profile.mods`
189///    produces a resolved order with those two mods swapped, as long as no
190///    rule spans the swap. This is what makes `Message::ReorderMod` visible
191///    in the `load_order` view — without stability, reordering could
192///    silently vanish.
193/// 3. **Minimal change under rules.** When a rule *does* force movement,
194///    only the rule-involved pair shifts; unrelated neighbors stay put.
195/// 4. **Deterministic.** Identical inputs always produce identical outputs;
196///    we don't rely on `HashMap` iteration order anywhere.
197///
198/// ## Algorithm
199///
200/// Stable Kahn's with input-position tiebreaking:
201///
202/// 1. Collect enabled mods, recording each `mod_id → input_pos`.
203/// 2. Build adjacency + in-degree from `LoadAfter` / `LoadBefore` rules,
204///    silently dropping edges whose endpoints aren't enabled (matches
205///    the old behaviour).
206/// 3. Seed a min-heap (`BinaryHeap<Reverse<(input_pos, mod_id)>>`) with
207///    every node whose in-degree is 0.
208/// 4. Pop the smallest-input-position ready node, emit it, decrement the
209///    in-degree of its successors, pushing any that hit 0.
210/// 5. If fewer nodes come out than went in, there's a cycle — pick any
211///    remaining node to name in `CoreError::DependencyCycle`.
212///
213/// `Incompatible` rules are checked up-front and short-circuit the
214/// resolution with a `FileConflict` error (unchanged from the old impl).
215pub fn resolve(profile: &Profile) -> Result<ResolvedLoadOrder> {
216    // Enabled mods, in input order. `input_pos[mod_id] = index in this Vec`.
217    let enabled_mods: Vec<&str> = profile
218        .mods
219        .iter()
220        .filter(|m| m.enabled)
221        .map(|m| m.mod_id.as_str())
222        .collect();
223
224    let input_pos: HashMap<&str, usize> = enabled_mods
225        .iter()
226        .enumerate()
227        .map(|(i, &m)| (m, i))
228        .collect();
229    let enabled_set: HashSet<&str> = enabled_mods.iter().copied().collect();
230
231    // Check for incompatible mods — must fail before we try to resolve.
232    for rule in &profile.load_order_rules {
233        if let LoadOrderRule::Incompatible { mod_a, mod_b } = rule
234            && enabled_set.contains(mod_a.as_str())
235            && enabled_set.contains(mod_b.as_str())
236        {
237            return Err(CoreError::FileConflict {
238                path: String::new(),
239                mods: Box::new(smallvec::smallvec![mod_a.0.clone(), mod_b.0.clone()]),
240            });
241        }
242    }
243
244    // Build adjacency + in-degree. `successors[u] = [v, ...]` means "u must
245    // be emitted before v".
246    let mut successors: HashMap<&str, Vec<&str>> = HashMap::new();
247    let mut in_degree: HashMap<&str, usize> = enabled_mods.iter().map(|&m| (m, 0usize)).collect();
248
249    for rule in &profile.load_order_rules {
250        let (from, to) = match rule {
251            // `mod_id must load after after` → `after` must come before `mod_id`
252            LoadOrderRule::LoadAfter { mod_id, after } => (after.as_str(), mod_id.as_str()),
253            // `mod_id must load before before` → `mod_id` must come before `before`
254            LoadOrderRule::LoadBefore { mod_id, before } => (mod_id.as_str(), before.as_str()),
255            LoadOrderRule::Incompatible { .. } => continue,
256        };
257        // Silently drop rules referencing disabled / unknown mods, matching
258        // the old petgraph-based implementation.
259        if !enabled_set.contains(from) || !enabled_set.contains(to) {
260            continue;
261        }
262        successors.entry(from).or_default().push(to);
263        *in_degree.get_mut(to).expect("to is enabled") += 1;
264    }
265
266    // Min-heap keyed on input position. `Reverse` flips the default max-heap
267    // to a min-heap; ties on `input_pos` are impossible because positions
268    // are unique, but we include the mod_id in the tuple for total ordering.
269    let mut ready: BinaryHeap<Reverse<(usize, &str)>> = BinaryHeap::new();
270    for &m in &enabled_mods {
271        if in_degree[m] == 0 {
272            ready.push(Reverse((input_pos[m], m)));
273        }
274    }
275
276    let mut order: Vec<ModId> = Vec::with_capacity(enabled_mods.len());
277    while let Some(Reverse((_, m))) = ready.pop() {
278        order.push(ModId::from(m));
279        if let Some(succs) = successors.get(m) {
280            for &s in succs {
281                let d = in_degree.get_mut(s).expect("successor is enabled");
282                *d -= 1;
283                if *d == 0 {
284                    ready.push(Reverse((input_pos[s], s)));
285                }
286            }
287        }
288    }
289
290    // Cycle detection: if any node still has in_degree > 0, it's part of a
291    // cycle. Name one of the surviving nodes in the error message, matching
292    // the old `toposort` behaviour.
293    if order.len() != enabled_mods.len() {
294        let offender = enabled_mods
295            .iter()
296            .find(|m| in_degree.get(**m).copied().unwrap_or(0) > 0)
297            .copied()
298            .unwrap_or("<unknown>");
299        return Err(CoreError::DependencyCycle(offender.to_string()));
300    }
301
302    Ok(ResolvedLoadOrder { order })
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::profile::{EnabledMod, ProfileSource};
309    use smallvec::{SmallVec, smallvec};
310    use std::path::PathBuf;
311
312    fn make_profile(mods: Vec<&str>, rules: SmallVec<[LoadOrderRule; 4]>) -> Profile {
313        Profile {
314            id: None,
315            name: "test".to_string(),
316            game_id: GameId::from("skyrim-se"),
317            source: ProfileSource::Manual,
318            mods: mods
319                .into_iter()
320                .map(|id| EnabledMod {
321                    mod_id: id.to_string(),
322                    enabled: true,
323                    version: None,
324                    fomod_config: None,
325                    ..Default::default()
326                })
327                .collect(),
328            overrides: PathBuf::from("/tmp/overrides"),
329            load_order_rules: rules,
330            load_order_lock: None,
331        }
332    }
333
334    #[test]
335    fn test_resolve_simple_order() {
336        let profile = make_profile(vec!["mod_a", "mod_b", "mod_c"], smallvec![]);
337        let result = resolve(&profile).unwrap();
338        assert_eq!(result.order.len(), 3);
339    }
340
341    #[test]
342    fn test_resolve_with_load_after() {
343        let profile = make_profile(
344            vec!["mod_a", "mod_b", "mod_c"],
345            smallvec![LoadOrderRule::LoadAfter {
346                mod_id: ModId::from("mod_c"),
347                after: ModId::from("mod_a"),
348            }],
349        );
350        let result = resolve(&profile).unwrap();
351        let pos_a = result.order.iter().position(|m| m == "mod_a").unwrap();
352        let pos_c = result.order.iter().position(|m| m == "mod_c").unwrap();
353        assert!(pos_a < pos_c, "mod_a should come before mod_c");
354    }
355
356    #[test]
357    fn test_resolve_with_load_before() {
358        let profile = make_profile(
359            vec!["mod_a", "mod_b"],
360            smallvec![LoadOrderRule::LoadBefore {
361                mod_id: ModId::from("mod_a"),
362                before: ModId::from("mod_b"),
363            }],
364        );
365        let result = resolve(&profile).unwrap();
366        let pos_a = result.order.iter().position(|m| m == "mod_a").unwrap();
367        let pos_b = result.order.iter().position(|m| m == "mod_b").unwrap();
368        assert!(pos_a < pos_b);
369    }
370
371    #[test]
372    fn test_resolve_cycle_detection() {
373        let profile = make_profile(
374            vec!["mod_a", "mod_b"],
375            smallvec![
376                LoadOrderRule::LoadAfter {
377                    mod_id: ModId::from("mod_b"),
378                    after: ModId::from("mod_a"),
379                },
380                LoadOrderRule::LoadAfter {
381                    mod_id: ModId::from("mod_a"),
382                    after: ModId::from("mod_b"),
383                },
384            ],
385        );
386        let result = resolve(&profile);
387        assert!(result.is_err());
388    }
389
390    #[test]
391    fn test_resolve_incompatible() {
392        let profile = make_profile(
393            vec!["mod_a", "mod_b"],
394            smallvec![LoadOrderRule::Incompatible {
395                mod_a: ModId::from("mod_a"),
396                mod_b: ModId::from("mod_b"),
397            }],
398        );
399        let result = resolve(&profile);
400        assert!(result.is_err());
401    }
402
403    #[test]
404    fn test_conflict_map() {
405        let mut cm = ConflictMap::default();
406        cm.register("textures/sky.dds".to_string(), ModId::from("mod_a"));
407        cm.register("textures/sky.dds".to_string(), ModId::from("mod_b"));
408        cm.register("meshes/tree.nif".to_string(), ModId::from("mod_a"));
409
410        let conflicts = cm.conflicts();
411        assert_eq!(conflicts.len(), 1);
412        assert_eq!(conflicts[0].0, "textures/sky.dds");
413    }
414
415    #[test]
416    fn test_disabled_mods_excluded() {
417        let profile = Profile {
418            id: None,
419            name: "test".to_string(),
420            game_id: GameId::from("skyrim-se"),
421            source: ProfileSource::Manual,
422            mods: vec![
423                EnabledMod {
424                    mod_id: "mod_a".to_string(),
425                    enabled: true,
426                    version: None,
427                    fomod_config: None,
428                    ..Default::default()
429                },
430                EnabledMod {
431                    mod_id: "mod_b".to_string(),
432                    enabled: false,
433                    version: None,
434                    fomod_config: None,
435                    ..Default::default()
436                },
437            ],
438            overrides: PathBuf::from("/tmp"),
439            load_order_rules: smallvec![],
440            load_order_lock: None,
441        };
442        let result = resolve(&profile).unwrap();
443        assert_eq!(result.order.len(), 1);
444        assert_eq!(result.order[0], "mod_a");
445    }
446
447    // ── Stability tests ──────────────────────────────────────────
448    //
449    // These pin down the "resolve is stable wrt profile.mods input order"
450    // contract that makes `Message::ReorderMod` visible in the load_order
451    // view. Before the Kahn's rewrite, `petgraph::toposort` could return
452    // any valid order — so reordering profile.mods without a rule change
453    // didn't necessarily shift anything in `resolved_order`.
454
455    fn ids(order: &[ModId]) -> Vec<&str> {
456        order.iter().map(super::ModId::as_str).collect()
457    }
458
459    #[test]
460    fn stable_no_rules_preserves_input_order() {
461        let profile = make_profile(vec!["c", "a", "b"], smallvec![]);
462        let result = resolve(&profile).unwrap();
463        assert_eq!(
464            ids(&result.order),
465            vec!["c", "a", "b"],
466            "with no rules, resolver must emit mods in their profile.mods order"
467        );
468    }
469
470    #[test]
471    fn stable_after_swap_round_trips() {
472        // Model what `Message::ReorderMod` does: swap two adjacent
473        // entries in profile.mods, then re-resolve. The new resolved
474        // order must reflect the swap.
475        let mut profile = make_profile(vec!["a", "b", "c"], smallvec![]);
476        let before = resolve(&profile).unwrap();
477        assert_eq!(ids(&before.order), vec!["a", "b", "c"]);
478
479        profile.mods.swap(0, 1); // [b, a, c]
480        let after = resolve(&profile).unwrap();
481        assert_eq!(ids(&after.order), vec!["b", "a", "c"]);
482    }
483
484    #[test]
485    fn stable_with_rule_only_preserves_unrelated_neighbors() {
486        // [c, b, a] with rule "a must load after c" — the rule is
487        // already satisfied (a is after c), so nothing needs to move.
488        // Critically, `b` must not drift even though it has no
489        // constraints.
490        let profile = make_profile(
491            vec!["c", "b", "a"],
492            smallvec![LoadOrderRule::LoadAfter {
493                mod_id: ModId::from("a"),
494                after: ModId::from("c"),
495            }],
496        );
497        let result = resolve(&profile).unwrap();
498        assert_eq!(ids(&result.order), vec!["c", "b", "a"]);
499    }
500
501    #[test]
502    fn stable_with_rule_forcing_reorder_is_minimal() {
503        // [c, b, a] with rule "b must load after a" forces b→after a.
504        // The minimal stable fix: emit `c` first (no deps, lowest input
505        // pos), then `a` (in_degree becomes 0 once c is emitted — wait,
506        // no; a has no incoming edges at all in this graph, its input
507        // pos is 2, so after c at 0 we look at the next ready node).
508        // Expected: [c, a, b] — a moves up ahead of b to satisfy the
509        // rule, c stays at position 0 because nothing constrains it.
510        let profile = make_profile(
511            vec!["c", "b", "a"],
512            smallvec![LoadOrderRule::LoadAfter {
513                mod_id: ModId::from("b"),
514                after: ModId::from("a"),
515            }],
516        );
517        let result = resolve(&profile).unwrap();
518        assert_eq!(
519            ids(&result.order),
520            vec!["c", "a", "b"],
521            "c should stay first; a must come before b due to rule"
522        );
523    }
524
525    #[test]
526    fn stable_resolve_is_deterministic() {
527        // Guards against HashMap iteration order sneaking in. Resolve
528        // the same profile twice and assert identical output. Run with
529        // a largeish mod set to give HashMap iteration a chance to
530        // scramble things.
531        let mods: Vec<&str> = vec![
532            "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa",
533            "lambda", "mu", "nu", "xi", "omicron",
534        ];
535        let profile = make_profile(mods.clone(), smallvec![]);
536        let a = resolve(&profile).unwrap();
537        let b = resolve(&profile).unwrap();
538        assert_eq!(ids(&a.order), ids(&b.order));
539        assert_eq!(ids(&a.order), mods);
540    }
541
542    #[test]
543    fn stable_disabled_mod_in_middle_preserves_others_input_order() {
544        // profile.mods = [a, b(disabled), c] — the output should be
545        // [a, c], both in input-position order. The old toposort could
546        // return [c, a] depending on graph iteration.
547        let profile = Profile {
548            id: None,
549            name: "test".to_string(),
550            game_id: GameId::from("skyrim-se"),
551            source: ProfileSource::Manual,
552            mods: vec![
553                EnabledMod {
554                    mod_id: "a".to_string(),
555                    enabled: true,
556                    ..Default::default()
557                },
558                EnabledMod {
559                    mod_id: "b".to_string(),
560                    enabled: false,
561                    ..Default::default()
562                },
563                EnabledMod {
564                    mod_id: "c".to_string(),
565                    enabled: true,
566                    ..Default::default()
567                },
568            ],
569            overrides: PathBuf::from("/tmp"),
570            load_order_rules: smallvec![],
571            load_order_lock: None,
572        };
573        let result = resolve(&profile).unwrap();
574        assert_eq!(ids(&result.order), vec!["a", "c"]);
575    }
576}