Skip to main content

supreme_cleaner/
dependency_walker.rs

1use std::collections::{HashSet, VecDeque};
2use std::io;
3use std::path::PathBuf;
4
5use supreme_fs::GameStorage;
6
7use crate::path_utils::{build_path_index, resolve_path_from_index};
8
9/// Recursively find all game dependencies starting from known configuration files.
10///
11/// Roots:
12/// - `object_classes.txt`
13/// - `board_types.txt`
14/// - `player_types.txt`
15/// - *cnf.txt
16///
17/// From there, it parses these files to find references to other files (textures, models, other configs),
18/// and recursively resolves them.
19pub fn walk_dependencies(
20    storage: &GameStorage,
21    all_files: &[PathBuf],
22    roots: &[PathBuf],
23    ignored_extensions: &[&str],
24) -> io::Result<Vec<PathBuf>> {
25    let root_path = storage.game_path();
26
27    // 1. Build an index for fast resolution from provided files
28    let path_index = build_path_index(root_path, all_files);
29
30    // 2. Identify initial roots
31    let mut queue = VecDeque::new();
32    let mut visited = HashSet::new();
33
34    // Start with explicitly provided roots
35    for root in roots {
36        if root.exists() && visited.insert(root.clone()) {
37            queue.push_back(root.clone());
38        }
39    }
40
41    // 3. BFS
42    while let Some(current_path) = queue.pop_front() {
43        // Only parse text-based config files
44        // We assume anything we "found" and put in the queue is a dependency we want to keep.
45        // We only *parse* it for MORE dependencies if it looks like a config file.
46        let is_config = if let Some(ext) = current_path.extension().and_then(|s| s.to_str()) {
47            let lower_ext = ext.to_lowercase();
48            lower_ext == "txt"
49        } else {
50            false
51        };
52
53        if !is_config {
54            continue;
55        }
56
57        // Parse content
58        if let Ok(content) = storage.read_text(&current_path) {
59            let current_dir = current_path.parent().unwrap_or(root_path);
60
61            for line in content.lines() {
62                // Heuristic parsing: look for quoted strings that look like paths
63                // Logic adapted from txt_processor but simplified for read-only extracting
64                extract_paths_from_line(line, |extracted_path| {
65                    // Try to resolve this path
66                    let normalized = extracted_path.replace('\\', "/");
67
68                    // Ignore obvious non-paths or empty
69                    if normalized.trim().is_empty()
70                        || (!normalized.contains('.') && !normalized.contains('/'))
71                    {
72                        return;
73                    }
74
75                    // 1. Try absolute resolution (relative to game root)
76                    if let Some(resolved) =
77                        resolve_path_from_index(root_path, &normalized, &path_index)
78                    {
79                        // Ignore disallowed extensions
80                        if let Some(ext) = resolved.extension().and_then(|e| e.to_str()) {
81                            let ext_lower = ext.to_lowercase();
82                            if ignored_extensions.contains(&ext_lower.as_str()) {
83                                return;
84                            }
85                        }
86
87                        if visited.insert(resolved.clone()) {
88                            queue.push_back(resolved);
89                        }
90                        return;
91                    }
92
93                    // 2. Try relative resolution (relative to current file)
94                    // We construct a potential absolute path and check if it exists in our index (by path lookup or file check)
95                    // Since index keys are relative to root, we need to convert our potential absolute path to relative to root first?
96                    // Or more simply: construct the absolute path, and check if it exists on disk (or in our all_files set for speed?)
97
98                    // Construct absolute path candidates
99                    let candidate_abs = current_dir.join(&normalized);
100
101                    // We need to resolve this candidate case-insensitively against our known files
102                    // Try to look it up in the path_index when its relative path from root can be computed
103                    if let Ok(rel_candidate) = candidate_abs.strip_prefix(root_path) {
104                        let rel_key = rel_candidate
105                            .to_string_lossy()
106                            .replace('\\', "/")
107                            .to_lowercase();
108                        if let Some(resolved) = path_index.get(&rel_key) {
109                            // Ignore disallowed extensions
110                            if let Some(ext) = resolved.extension().and_then(|e| e.to_str()) {
111                                let ext_lower = ext.to_lowercase();
112                                if ignored_extensions.contains(&ext_lower.as_str()) {
113                                    return;
114                                }
115                            }
116
117                            if visited.insert(resolved.clone()) {
118                                queue.push_back(resolved.clone());
119                            }
120                        }
121                    }
122                });
123            }
124        }
125    }
126
127    // Return all visited files
128    Ok(visited.into_iter().collect())
129}
130
131/// Extract all quoted strings from a line that might be paths.
132fn extract_paths_from_line<F>(line: &str, mut cb: F)
133where
134    F: FnMut(&str),
135{
136    let mut search_pos = 0;
137    while let Some(quote_start) = line[search_pos..].find('"') {
138        let abs_start = search_pos + quote_start + 1;
139        if let Some(quote_end) = line[abs_start..].find('"') {
140            let abs_end = abs_start + quote_end;
141            let value = &line[abs_start..abs_end];
142
143            // Also handle comma separated lists inside quotes in some rare cases?
144            // For now assume one path per quoted string, or split by comma if commonly done?
145            // "Texture1.bmp, Texture2.bmp" is sometimes seen.
146            for part in value.split(',') {
147                let trimmed = part.trim();
148                if !trimmed.is_empty() {
149                    cb(trimmed);
150                }
151            }
152
153            search_pos = abs_end + 1;
154        } else {
155            break;
156        }
157    }
158}