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 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 pub struct ModId;
95}
96
97define_id_newtype! {
98 pub struct GameId;
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub enum LoadOrderRule {
108 LoadAfter { mod_id: ModId, after: ModId },
110 LoadBefore { mod_id: ModId, before: ModId },
112 Incompatible { mod_a: ModId, mod_b: ModId },
114}
115
116#[derive(Debug, Clone, Default)]
118pub struct ConflictMap {
119 pub files: HashMap<String, HashSet<ModId>>,
120}
121
122impl ConflictMap {
123 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 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 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 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#[derive(Debug, Clone)]
179pub struct ResolvedLoadOrder {
180 pub order: Vec<ModId>,
182}
183
184pub fn resolve(profile: &Profile) -> Result<ResolvedLoadOrder> {
220 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 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 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 LoadOrderRule::LoadAfter { mod_id, after } => (after.as_str(), mod_id.as_str()),
257 LoadOrderRule::LoadBefore { mod_id, before } => (mod_id.as_str(), before.as_str()),
259 LoadOrderRule::Incompatible { .. } => continue,
260 };
261 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 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 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 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 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); 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 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 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 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 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}