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