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`] 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::nexus_id::{NexusFileId, NexusModId};
31use crate::paths::modde_data_dir;
32
33use super::fs::walk_files;
34use super::types::{InstallMethod, InstallerResult};
35
36/// Context about the mod the analyzer could not classify. Filled in by
37/// the CLI / UI caller — the installer crate itself doesn't fetch Nexus
38/// metadata.
39#[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    /// xxh64 hex digest of the source archive, for dedup.
51    pub source_archive_hash: String,
52}
53
54/// A slug identifying the dossier dir. Stable across retries.
55#[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/// Root directory holding all unknown-installer dossiers.
75#[must_use]
76pub fn dossiers_dir() -> PathBuf {
77    modde_data_dir().join("unknown-installers")
78}
79
80/// Path to the dossier for a specific unknown mod.
81#[must_use]
82pub fn dossier_path(ctx: &DossierContext) -> PathBuf {
83    dossiers_dir().join(dossier_slug(ctx))
84}
85
86/// Write the dossier to disk. Overwrites any existing dossier with the
87/// same slug (this is idempotent — the skill path consumes by renaming
88/// to `.resolved`, so a pre-existing dir here means a prior failed
89/// attempt we're retrying).
90pub 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    // metadata.json
103    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    // archive_tree.txt
108    write_archive_tree(extracted_dir, &out.join("archive_tree.txt"))?;
109
110    // file_samples/
111    copy_text_samples(extracted_dir, &out.join("file_samples"))?;
112
113    // analyzer_trace.json
114    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    // PROMPT.md
119    write_prompt(&out, ctx, method)?;
120
121    Ok(out)
122}
123
124/// A single probe's decision, recorded so the skill knows which paths
125/// were already tried.
126#[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        // Flatten the rel path into a single filename so the samples
194        // directory stays shallow.
195        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}