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 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 pub struct ModId;
88}
89
90define_id_newtype! {
91 pub struct GameId;
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub enum LoadOrderRule {
101 LoadAfter { mod_id: ModId, after: ModId },
103 LoadBefore { mod_id: ModId, before: ModId },
105 Incompatible { mod_a: ModId, mod_b: ModId },
107}
108
109#[derive(Debug, Clone, Default)]
111pub struct ConflictMap {
112 pub files: HashMap<String, HashSet<ModId>>,
113}
114
115impl ConflictMap {
116 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 #[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 #[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 #[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#[derive(Debug, Clone)]
175pub struct ResolvedLoadOrder {
176 pub order: Vec<ModId>,
178}
179
180pub fn resolve(profile: &Profile) -> Result<ResolvedLoadOrder> {
216 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 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 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 LoadOrderRule::LoadAfter { mod_id, after } => (after.as_str(), mod_id.as_str()),
253 LoadOrderRule::LoadBefore { mod_id, before } => (mod_id.as_str(), before.as_str()),
255 LoadOrderRule::Incompatible { .. } => continue,
256 };
257 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 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 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 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 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); 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 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 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 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 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}