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