modde_core/installer/
dossier.rs1use std::fs;
25use std::io::Write as _;
26use std::path::{Path, PathBuf};
27
28use serde::{Deserialize, Serialize};
29
30use crate::nexus_id::{NexusFileId, NexusModId};
31use crate::paths::modde_data_dir;
32
33use super::fs::walk_files;
34use super::types::{InstallMethod, InstallerResult};
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct DossierContext {
41 pub game_id: String,
42 pub game_domain: Option<String>,
43 pub nexus_mod_id: Option<NexusModId>,
44 pub nexus_file_id: Option<NexusFileId>,
45 pub mod_name: String,
46 pub mod_author: Option<String>,
47 pub mod_version: Option<String>,
48 pub mod_summary: Option<String>,
49 pub nexus_url: Option<String>,
50 pub source_archive_hash: String,
52}
53
54#[must_use]
56pub fn dossier_slug(ctx: &DossierContext) -> String {
57 match (
58 ctx.game_domain.as_deref(),
59 ctx.nexus_mod_id,
60 ctx.nexus_file_id,
61 ) {
62 (Some(domain), Some(mod_id), Some(file_id)) => {
63 format!("{domain}_{mod_id}_{file_id}")
64 }
65 (Some(domain), Some(mod_id), None) => format!("{domain}_{mod_id}"),
66 _ => format!(
67 "{}_{}",
68 ctx.game_id,
69 &ctx.source_archive_hash[..8.min(ctx.source_archive_hash.len())]
70 ),
71 }
72}
73
74#[must_use]
76pub fn dossiers_dir() -> PathBuf {
77 modde_data_dir().join("unknown-installers")
78}
79
80#[must_use]
82pub fn dossier_path(ctx: &DossierContext) -> PathBuf {
83 dossiers_dir().join(dossier_slug(ctx))
84}
85
86pub fn dump(
91 extracted_dir: &Path,
92 ctx: &DossierContext,
93 method: &InstallMethod,
94 analyzer_trace: Vec<ProbeTrace>,
95) -> InstallerResult<PathBuf> {
96 let out = dossier_path(ctx);
97 if out.exists() {
98 let _ = fs::remove_dir_all(&out);
99 }
100 fs::create_dir_all(&out)?;
101
102 let metadata_path = out.join("metadata.json");
104 let metadata_bytes = serde_json::to_vec_pretty(ctx).map_err(io_err)?;
105 fs::write(&metadata_path, metadata_bytes)?;
106
107 write_archive_tree(extracted_dir, &out.join("archive_tree.txt"))?;
109
110 copy_text_samples(extracted_dir, &out.join("file_samples"))?;
112
113 let trace_path = out.join("analyzer_trace.json");
115 let trace_bytes = serde_json::to_vec_pretty(&analyzer_trace).map_err(io_err)?;
116 fs::write(&trace_path, trace_bytes)?;
117
118 write_prompt(&out, ctx, method)?;
120
121 Ok(out)
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ProbeTrace {
128 pub probe: String,
129 pub matched: bool,
130 pub note: String,
131}
132
133fn write_archive_tree(extracted_dir: &Path, out: &Path) -> InstallerResult<()> {
134 const MAX_ENTRIES: usize = 500;
135 let mut file = fs::File::create(out)?;
136 let files = walk_files(extracted_dir)?;
137 let mut count = 0;
138 for (_, rel) in &files {
139 if count >= MAX_ENTRIES {
140 writeln!(
141 file,
142 "... ({} more entries omitted)",
143 files.len() - MAX_ENTRIES
144 )?;
145 break;
146 }
147 writeln!(file, "{}", rel.display())?;
148 count += 1;
149 }
150 Ok(())
151}
152
153fn copy_text_samples(extracted_dir: &Path, out: &Path) -> InstallerResult<()> {
154 const MAX_SAMPLES: usize = 5;
155 const MAX_BYTES: u64 = 16 * 1024;
156 const INTERESTING: &[&str] = &[
157 "readme",
158 "info.json",
159 "moduleconfig.xml",
160 "meta.ini",
161 "manifest.json",
162 "install.xml",
163 "fomod.xml",
164 "modinfo.xml",
165 ];
166
167 fs::create_dir_all(out)?;
168 let files = walk_files(extracted_dir)?;
169 let mut written = 0;
170 for (abs, rel) in files {
171 if written >= MAX_SAMPLES {
172 break;
173 }
174 let name = abs
175 .file_name()
176 .and_then(|n| n.to_str())
177 .map(str::to_ascii_lowercase)
178 .unwrap_or_default();
179 let matches = INTERESTING.iter().any(|needle| name.contains(needle));
180 if !matches {
181 continue;
182 }
183 let Ok(meta) = fs::metadata(&abs) else {
184 continue;
185 };
186 if meta.len() > MAX_BYTES {
187 continue;
188 }
189 let body = match fs::read_to_string(&abs) {
190 Ok(b) => b,
191 Err(_) => continue,
192 };
193 let flat = rel.to_string_lossy().replace(['/', '\\'], "__");
196 let sample_path = out.join(flat);
197 fs::write(&sample_path, body)?;
198 written += 1;
199 }
200 Ok(())
201}
202
203fn write_prompt(dir: &Path, ctx: &DossierContext, method: &InstallMethod) -> InstallerResult<()> {
204 let mut prompt = String::new();
205 prompt.push_str("# modde unknown installer dossier\n\n");
206 prompt.push_str(&format!("Mod: **{}**\n", ctx.mod_name));
207 if let Some(author) = &ctx.mod_author {
208 prompt.push_str(&format!("Author: {author}\n"));
209 }
210 if let Some(version) = &ctx.mod_version {
211 prompt.push_str(&format!("Version: {version}\n"));
212 }
213 prompt.push_str(&format!("Game: {}\n", ctx.game_id));
214 if let Some(url) = &ctx.nexus_url {
215 prompt.push_str(&format!("Nexus URL: {url}\n"));
216 }
217 prompt.push_str(&format!(
218 "Detection verdict: `{}` (reason: {})\n\n",
219 method.label(),
220 match method {
221 InstallMethod::Unknown { reason } => reason.as_str(),
222 _ => "execute-time rejection",
223 }
224 ));
225
226 prompt.push_str("## What to do\n\n");
227 prompt.push_str(
228 "modde's generic install-type detector couldn't classify this mod. \
229 Extend the installer so the retry path can stage it:\n\n",
230 );
231 prompt.push_str(
232 "1. Read `archive_tree.txt` and `file_samples/` to understand the \
233 layout.\n",
234 );
235 prompt.push_str(
236 "2. Decide where the fix belongs:\n \
237 - **Generic** (e.g. a new package format): add a variant to \
238 `crates/modde-core/src/installer/types.rs::InstallMethod` and a \
239 detection branch in `analyze.rs::detect_method`.\n \
240 - **Game-specific** (e.g. a weird Cyberpunk layout): extend \
241 `crates/modde-games/src/<game>/mod.rs` with the new rule inside \
242 that game's `analyze_mod_archive` impl.\n",
243 );
244 prompt.push_str(
245 "3. Also extend `execute.rs` if the new variant needs custom \
246 staging logic.\n",
247 );
248 prompt.push_str("4. Write a unit test using the file samples in this dossier.\n");
249 prompt.push_str("5. Run `cargo test -p modde-core installer::`.\n");
250 prompt.push_str(
251 "6. Rename this dossier directory by appending `.resolved` so the \
252 UI surfaces a **Retry Install** button.\n\n",
253 );
254
255 prompt.push_str("## Files in this dossier\n\n");
256 prompt.push_str("- `metadata.json` — mod + context info\n");
257 prompt.push_str("- `archive_tree.txt` — recursive listing of the extracted archive\n");
258 prompt.push_str(
259 "- `file_samples/` — verbatim copies of small text files (READMEs, configs, manifests)\n",
260 );
261 prompt.push_str("- `analyzer_trace.json` — which generic probes ran and how they voted\n");
262 prompt.push_str("- `PROMPT.md` — this file\n\n");
263
264 prompt.push_str("## Critical files\n\n");
265 prompt.push_str("- [crates/modde-core/src/installer/types.rs](crates/modde-core/src/installer/types.rs) — `InstallMethod`, `InstallPlan`, `InstallerError`\n");
266 prompt.push_str("- [crates/modde-core/src/installer/analyze.rs](crates/modde-core/src/installer/analyze.rs) — detection pipeline\n");
267 prompt.push_str("- [crates/modde-core/src/installer/execute.rs](crates/modde-core/src/installer/execute.rs) — plan execution\n");
268 prompt.push_str("- [crates/modde-games/src/traits.rs](crates/modde-games/src/traits.rs) — `GamePlugin::analyze_mod_archive` hook\n");
269
270 fs::write(dir.join("PROMPT.md"), prompt)?;
271 Ok(())
272}
273
274fn io_err(e: serde_json::Error) -> std::io::Error {
275 std::io::Error::other(e.to_string())
276}