Skip to main content

modde_core/installer/
dossier.rs

1//! Unknown-mod dossier writer.
2//!
3//! When [`analyze`](super::analyze::analyze) returns
4//! [`InstallMethod::Unknown`](super::types::InstallMethod::Unknown) or
5//! [`execute`](super::execute::execute) surfaces
6//! [`InstallerError::UnknownMethod`](super::types::InstallerError::UnknownMethod),
7//! the caller dumps a dossier so a Claude Code skill can extend the
8//! installer to handle this layout.
9//!
10//! Layout on disk:
11//!
12//! ```text
13//! $XDG_DATA_HOME/modde/unknown-installers/<slug>/
14//!   ├─ metadata.json        — Nexus mod metadata + context
15//!   ├─ archive_tree.txt     — recursive ls (up to 500 entries)
16//!   ├─ file_samples/        — up to 5 small text files verbatim
17//!   ├─ analyzer_trace.json  — which probes ran, what they rejected
18//!   └─ PROMPT.md            — self-contained skill prompt
19//! ```
20//!
21//! Once a skill has landed a fix, the dossier directory is renamed to
22//! `<slug>.resolved` and the UI surfaces a **Retry Install** button.
23
24use 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/// Context about the mod the analyzer could not classify. Filled in by
36/// the CLI / UI caller — the installer crate itself doesn't fetch Nexus
37/// metadata.
38#[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    /// xxh64 hex digest of the source archive, for dedup.
50    pub source_archive_hash: String,
51}
52
53/// A slug identifying the dossier dir. Stable across retries.
54pub 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
64/// Root directory holding all unknown-installer dossiers.
65pub fn dossiers_dir() -> PathBuf {
66    modde_data_dir().join("unknown-installers")
67}
68
69/// Path to the dossier for a specific unknown mod.
70pub fn dossier_path(ctx: &DossierContext) -> PathBuf {
71    dossiers_dir().join(dossier_slug(ctx))
72}
73
74/// Write the dossier to disk. Overwrites any existing dossier with the
75/// same slug (this is idempotent — the skill path consumes by renaming
76/// to `.resolved`, so a pre-existing dir here means a prior failed
77/// attempt we're retrying).
78pub 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    // metadata.json
91    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    // archive_tree.txt
96    write_archive_tree(extracted_dir, &out.join("archive_tree.txt"))?;
97
98    // file_samples/
99    copy_text_samples(extracted_dir, &out.join("file_samples"))?;
100
101    // analyzer_trace.json
102    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    // PROMPT.md
107    write_prompt(&out, ctx, method)?;
108
109    Ok(out)
110}
111
112/// A single probe's decision, recorded so the skill knows which paths
113/// were already tried.
114#[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        // Flatten the rel path into a single filename so the samples
170        // directory stays shallow.
171        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}