modde_games/
save_patterns.rs1use std::borrow::Cow;
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9use anyhow::{Context, Result};
10use smallvec::SmallVec;
11
12use crate::traits::{DetectedSave, SaveTracker};
13
14pub type SaveLabelExtractor = fn(&Path, &str) -> Option<String>;
15
16#[derive(Debug, Clone, Copy)]
17pub struct PrefixSaveRule {
18 pub prefix: &'static str,
19 pub category: &'static str,
20}
21
22#[derive(Debug, Clone, Copy)]
23pub enum CaptureSummary {
24 Default,
25 ByCategory,
26}
27
28#[derive(Debug, Clone, Copy)]
29pub struct PatternSaveTracker {
30 pub prefix_rules: &'static [PrefixSaveRule],
31 pub file_extensions: &'static [&'static str],
32 pub default_category: &'static str,
33 pub recursive: bool,
34 pub exclude_patterns: &'static [&'static str],
35 pub label_extractor: SaveLabelExtractor,
36 pub summary: CaptureSummary,
37}
38
39impl PatternSaveTracker {
40 #[must_use]
41 pub fn save_patterns(self) -> SmallVec<[String; 2]> {
42 let mut patterns: SmallVec<[String; 2]> = self
43 .prefix_rules
44 .iter()
45 .map(|rule| format!("{}*", rule.prefix))
46 .collect();
47 patterns.extend(self.file_extensions.iter().map(|ext| {
48 if self.recursive {
49 format!("**/*.{ext}")
50 } else {
51 format!("*.{ext}")
52 }
53 }));
54 patterns
55 }
56
57 #[must_use]
58 pub fn exclude_patterns(self) -> SmallVec<[String; 2]> {
59 self.exclude_patterns
60 .iter()
61 .map(|pattern| (*pattern).to_string())
62 .collect()
63 }
64
65 pub fn detect_saves(self, save_dir: &Path) -> Result<Vec<DetectedSave>> {
66 let mut saves = Vec::new();
67 if !save_dir.exists() {
68 return Ok(saves);
69 }
70
71 self.detect_in_dir(save_dir, save_dir, &mut saves)?;
72
73 saves.sort_by(|a, b| b.modified.cmp(&a.modified));
74 Ok(saves)
75 }
76
77 fn detect_in_dir(self, base: &Path, dir: &Path, saves: &mut Vec<DetectedSave>) -> Result<()> {
78 for entry in std::fs::read_dir(dir)
79 .with_context(|| format!("failed to read directory: {}", dir.display()))?
80 {
81 let entry = entry?;
82 let path = entry.path();
83 let metadata = entry.metadata()?;
84 if metadata.is_dir() && self.recursive {
85 self.detect_in_dir(base, &path, saves)?;
86 }
87
88 let name = entry.file_name();
89 let name_str = name.to_string_lossy();
90 let extension_matches =
91 path.extension()
92 .and_then(|ext| ext.to_str())
93 .is_some_and(|ext| {
94 self.file_extensions
95 .iter()
96 .any(|candidate| ext.eq_ignore_ascii_case(candidate))
97 });
98 let category = self
99 .prefix_rules
100 .iter()
101 .filter(|_| self.file_extensions.is_empty() || extension_matches)
102 .find(|rule| name_str.starts_with(rule.prefix))
103 .map(|rule| rule.category)
104 .or_else(|| extension_matches.then_some(self.default_category));
105
106 let Some(category) = category else {
107 continue;
108 };
109
110 let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
111 let rel_path = path
112 .strip_prefix(base)
113 .map_or_else(|_| PathBuf::from(&name), PathBuf::from);
114 let rel_name = rel_path.to_string_lossy();
115 let label = (self.label_extractor)(&path, &rel_name);
116
117 saves.push(DetectedSave {
118 rel_path,
119 category: Cow::Borrowed(category),
120 label,
121 modified,
122 });
123 }
124
125 Ok(())
126 }
127
128 #[must_use]
129 pub fn describe_capture(self, saves: &[DetectedSave]) -> String {
130 match self.summary {
131 CaptureSummary::Default => default_capture_summary(saves),
132 CaptureSummary::ByCategory => category_capture_summary(saves),
133 }
134 }
135}
136
137impl SaveTracker for PatternSaveTracker {
138 fn save_patterns(&self) -> SmallVec<[String; 2]> {
139 PatternSaveTracker::save_patterns(*self)
140 }
141
142 fn detect_saves(&self, save_dir: &Path) -> Result<Vec<DetectedSave>> {
143 PatternSaveTracker::detect_saves(*self, save_dir)
144 }
145
146 fn exclude_patterns(&self) -> SmallVec<[String; 2]> {
147 PatternSaveTracker::exclude_patterns(*self)
148 }
149
150 fn describe_capture(&self, saves: &[DetectedSave]) -> String {
151 PatternSaveTracker::describe_capture(*self, saves)
152 }
153}
154
155fn default_capture_summary(saves: &[DetectedSave]) -> String {
156 match saves.len() {
157 0 => "capture: no new saves".into(),
158 1 => {
159 let save = &saves[0];
160 let name = save
161 .label
162 .as_deref()
163 .unwrap_or_else(|| save.rel_path.to_str().unwrap_or("unknown"));
164 format!("capture: {} [{}]", name, save.category)
165 }
166 n => format!("capture: {n} saves"),
167 }
168}
169
170fn category_capture_summary(saves: &[DetectedSave]) -> String {
171 match saves.len() {
172 0 | 1 => default_capture_summary(saves),
173 n => {
174 let mut categories = std::collections::BTreeMap::new();
175 for save in saves {
176 *categories.entry(&*save.category).or_insert(0u32) += 1;
177 }
178 let summary = categories
179 .into_iter()
180 .map(|(category, count)| format!("{count} {category}"))
181 .collect::<Vec<_>>()
182 .join(", ");
183 format!("capture: {n} saves ({summary})")
184 }
185 }
186}