Skip to main content

libprotonup/
utils.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use tempfile::TempDir;
4
5use crate::constants;
6
7/// Builds the fallback temp directory path from the constant.
8/// The FALLBACK_TEMP_DIR constant contains the full relative path (e.g., ".local/state/protonup-rs/tmp")
9/// This function splits it by '/' and joins each component to the base directory.
10fn build_fallback_dir(base_dir: &Path) -> PathBuf {
11    constants::FALLBACK_TEMP_DIR
12        .split('/')
13        .fold(base_dir.to_path_buf(), |acc, component| acc.join(component))
14}
15
16/// Checks available disk space and returns an appropriate temp directory.
17/// If /tmp has less than 1GB available, returns a fallback directory under
18/// .local/state/protonup-rs/tmp. Creates the fallback directory if it doesn't exist.
19pub fn get_temp_dir() -> std::io::Result<PathBuf> {
20    // Check available space on tmp dir
21    let tmp_path = TempDir::with_prefix("protonup-rs-").map(|dir| dir.keep());
22
23    if let Ok(temp_dir) = tmp_path {
24        let available_space = fs4::available_space(&temp_dir).unwrap_or(0);
25
26        if available_space >= constants::MIN_TEMP_SPACE_BYTES {
27            // Enough space, use standard tempdir
28            return Ok(temp_dir);
29        }
30    }
31
32    // Not enough space, or failed creating the temp dir, use fallback directory
33    let fallback_dir = match dirs::state_dir() {
34        Some(state_dir) => build_fallback_dir(&state_dir),
35        None => {
36            // Fallback to home directory if state_dir is not available
37            let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
38            build_fallback_dir(&home_dir)
39        }
40    };
41
42    // Create the fallback directory if it doesn't exist
43    fs::create_dir_all(&fallback_dir)?;
44
45    Ok(fallback_dir)
46}
47
48/// Creates a temporary download directory for a specific version.
49/// Handles disk space checking and creates the necessary directory structure.
50/// Returns the full path where the download file should be saved.
51pub fn create_download_temp_dir(version: &str, download_url: &str) -> std::io::Result<PathBuf> {
52    let temp_dir = get_temp_dir()?;
53
54    let download_dir = temp_dir.join(format!("{}.{}", version, "download"));
55
56    // Create the version-specific subdirectory
57    fs::create_dir_all(&download_dir)?;
58
59    // Determine the file extension from the download URL
60    let ext = crate::files::check_supported_extension(download_url)
61        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
62
63    let download_path = download_dir.join(format!("{}.{}", version, ext));
64
65    Ok(download_path)
66}
67
68/// Cleans up the fallback temp directory contents.
69/// This should be called on application exit to remove temporary files.
70pub fn cleanup_fallback_temp_dir() -> std::io::Result<()> {
71    let fallback_dir = match dirs::state_dir() {
72        Some(state_dir) => build_fallback_dir(&state_dir),
73        None => {
74            let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
75            build_fallback_dir(&home_dir)
76        }
77    };
78
79    if fallback_dir.exists() {
80        // Remove all contents of the fallback directory
81        for entry in fs::read_dir(&fallback_dir)? {
82            let entry = entry?;
83            let path = entry.path();
84            if path.is_dir() {
85                fs::remove_dir_all(&path)?;
86            } else {
87                fs::remove_file(&path)?;
88            }
89        }
90    }
91
92    Ok(())
93}
94
95pub fn expand_tilde<P: AsRef<Path>>(path_user_input: P) -> Option<PathBuf> {
96    let p = path_user_input.as_ref();
97    if !p.starts_with("~") {
98        return Some(p.to_path_buf());
99    }
100    if p == Path::new("~") {
101        return dirs::home_dir();
102    }
103    dirs::home_dir().map(|mut h| {
104        if h == Path::new("/") {
105            // Corner case: `h` root directory;
106            // don't prepend extra `/`, just drop the tilde.
107            p.strip_prefix("~").unwrap().to_path_buf()
108        } else {
109            h.push(p.strip_prefix("~/").unwrap());
110            h
111        }
112    })
113}
114
115/// Matches a user-provided version string against a release tag name.
116/// Handles various version formats like "GE-Proton8-26", "8.7-GE-1-LoL", etc.
117///
118/// Matching strategies (in order):
119/// 1. Exact match
120/// 2. Prefix match (tag starts with user input)
121/// 3. Component match - extract numeric components and check if user's components appear in tag
122pub fn match_version(version_str: &str, tag_name: &str) -> bool {
123    // Reject empty input
124    if version_str.is_empty() {
125        return false;
126    }
127
128    // Exact match
129    if tag_name == version_str {
130        return true;
131    }
132
133    // Prefix match
134    if tag_name.starts_with(version_str) {
135        return true;
136    }
137
138    // Extract numeric components from user input (e.g., "10.6" -> ["10", "6"])
139    let user_components: Vec<&str> = version_str
140        .split(|c: char| !c.is_ascii_digit())
141        .filter(|s| !s.is_empty())
142        .collect();
143
144    if user_components.is_empty() {
145        return false;
146    }
147
148    // Extract numeric components from tag name
149    let tag_components: Vec<&str> = tag_name
150        .split(|c: char| !c.is_ascii_digit())
151        .filter(|s| !s.is_empty())
152        .collect();
153
154    // Check if user's components appear in sequence in the tag's components
155    // e.g., user "8.26" should match "GE-Proton8-26"
156    if tag_components
157        .windows(user_components.len())
158        .any(|window| window == user_components.as_slice())
159    {
160        return true;
161    }
162
163    // Also try matching just the major version if user provided multiple components
164    // e.g., user "10" should match "GE-Proton10-6"
165    if user_components.len() == 1 {
166        return tag_components.contains(&user_components[0]);
167    }
168
169    false
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    // Test cases for match_version function
177    // Format: (user_input, tag_name, expected_match)
178    const MATCH_TEST_CASES: &[(&str, &str, bool)] = &[
179        // --- Exact matches ---
180        ("v2.7.1", "v2.7.1", true),
181        ("v2.14", "v2.14", true),
182        ("v0.5.4", "v0.5.4", true),
183        //
184        // --- CachyOS style: cachyos-10.0-20260228-slr ---
185        ("10.0", "cachyos-10.0-20260228-slr", true),
186        ("10", "cachyos-10.0-20260228-slr", true),
187        ("20260228", "cachyos-10.0-20260228-slr", true),
188        ("10.1", "cachyos-10.0-20260228-slr", false),
189        //
190        // --- GE-Proton style: GE-Proton10-26-rtsp20 ---
191        ("10.26", "GE-Proton10-26-rtsp20", true),
192        ("10", "GE-Proton10-26-rtsp20", true),
193        ("26", "GE-Proton10-26-rtsp20", true),
194        ("20", "GE-Proton10-26-rtsp20", true), // rtsp20 contains 20
195        ("10.27", "GE-Proton10-26-rtsp20", false),
196        //
197        // --- GE-Proton standard: GE-Proton8-26, GE-Proton10-32 ---
198        ("8.26", "GE-Proton8-26", true),
199        ("8", "GE-Proton8-26", true),
200        ("26", "GE-Proton8-26", true),
201        ("10.32", "GE-Proton10-32", true),
202        ("10", "GE-Proton10-32", true),
203        ("32", "GE-Proton10-32", true),
204        ("9.26", "GE-Proton8-26", false),
205        //
206        // --- Simple v-prefixed versions ---
207        ("2.7.1", "v2.7.1", true),
208        ("2.7", "v2.7.1", true), // First two components match
209        ("2", "v2.7.1", true),
210        ("2.14", "v2.14", true),
211        ("2", "v2.14", true),
212        ("0.5.4", "v0.5.4", true),
213        ("0.5", "v0.5.4", true),
214        ("3.0", "v3.0b", true), // Components match, ignores trailing 'b'
215        ("3", "v3.0b", true),
216        ("76.2.0", "v76.2.0", true),
217        ("76.2", "v76.2.0", true),
218        ("76", "v76.2.0", true),
219        //
220        // --- Edge cases ---
221        ("", "v2.7.1", false),      // Empty input
222        ("abc", "v2.7.1", false),   // Non-numeric input
223        ("2.7.1", "v2.7.2", false), // Different patch version
224        ("2.8", "v2.7.1", false),   // Different minor version
225        ("3", "v2.7.1", false),     // Different major version
226        //
227        // --- Prefix matching ---
228        ("v2", "v2.7.1", true),
229        ("GE-Proton8", "GE-Proton8-26", true),
230        ("cachyos", "cachyos-10.0-20260228-slr", true),
231    ];
232
233    #[test]
234    fn test_match_version() {
235        for (user_input, tag_name, expected) in MATCH_TEST_CASES {
236            let result = match_version(user_input, tag_name);
237            assert_eq!(
238                result, *expected,
239                "match_version(\"{}\", \"{}\") - expected {}, got {}",
240                user_input, tag_name, expected, result
241            );
242        }
243    }
244
245    #[test]
246    fn test_match_version_geproton_variants() {
247        // Test GE-Proton specific patterns
248        assert!(match_version("8-26", "GE-Proton8-26"));
249        assert!(match_version("10-32", "GE-Proton10-32"));
250        assert!(!match_version("8-27", "GE-Proton8-26"));
251        //
252        // Test with LoL variants
253        assert!(match_version("8-27", "GE-Proton8-27-LoL"));
254        assert!(match_version("8.27", "GE-Proton8-27-LoL"));
255    }
256
257    #[test]
258    fn test_match_version_component_extraction() {
259        // Test that numeric components are correctly extracted and matched
260        assert!(match_version("1.2.3", "tool-1.2.3-release"));
261        assert!(match_version("1.2", "tool-1.2.3-release"));
262        assert!(match_version("1", "tool-1.2.3-release"));
263        assert!(!match_version("2.3.4", "tool-1.2.3-release")); // Different components
264        assert!(!match_version("4.5.6", "tool-1.2.3-release")); // Completely different
265    }
266
267    #[test]
268    fn test_match_version_prefix_fallback() {
269        // Test prefix matching as fallback
270        assert!(match_version("GE-Proton", "GE-Proton8-26"));
271        assert!(match_version("v2", "v2.7.1"));
272        assert!(match_version("cachyos-", "cachyos-10.0-20260228-slr"));
273    }
274
275    #[test]
276    fn test_match_version_consecutive_components() {
277        // Test matching consecutive numeric components
278        assert!(match_version("10.0", "cachyos-10.0-20260228-slr"));
279        assert!(match_version("10.26", "GE-Proton10-26-rtsp20"));
280        assert!(!match_version("10.27", "GE-Proton10-26-rtsp20"));
281        // Note: "26.20" matches because tag components are [GE, Proton, 10, 26, rtsp, 20]
282        // and [26, 20] are indeed consecutive in this list (after rtsp is split)
283        // This is expected behavior - user input "26.20" will match tags containing those
284        // consecutive numeric components
285        assert!(match_version("26.20", "GE-Proton10-26-rtsp20"));
286    }
287}