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