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(¤t_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}