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
11macro_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 };
78}
79
80define_id_newtype! {
81 pub struct ModId;
85}
86
87define_id_newtype! {
88 pub struct GameId;
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub enum LoadOrderRule {
98 LoadAfter { mod_id: ModId, after: ModId },
100 LoadBefore { mod_id: ModId, before: ModId },
102 Incompatible { mod_a: ModId, mod_b: ModId },
104}
105
106#[derive(Debug, Clone, Default)]
108pub struct ConflictMap {
109 pub files: HashMap<String, HashSet<ModId>>,
110}
111
112impl ConflictMap {
113 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 #[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 #[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 #[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#[derive(Debug, Clone)]
172pub struct ResolvedLoadOrder {
173 pub order: Vec<ModId>,
175}
176
177pub fn resolve(profile: &Profile) -> Result<ResolvedLoadOrder> {
213 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 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 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 LoadOrderRule::LoadAfter { mod_id, after } => (after.as_str(), mod_id.as_str()),
250 LoadOrderRule::LoadBefore { mod_id, before } => (mod_id.as_str(), before.as_str()),
252 LoadOrderRule::Incompatible { .. } => continue,
253 };
254 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 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 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 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 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); 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 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 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 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 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}