Skip to main content

modde_games/
save_patterns.rs

1//! Declarative [`SaveTracker`] implementation that locates game save files by
2//! filename-prefix and extension rules, classifying matches into categories and
3//! summarizing what was captured.
4
5use 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}