Skip to main content

conduit_cli/core/mods/
local.rs

1use crate::core::error::{CoreError, CoreResult};
2use crate::core::io::hash::sha256_file;
3use crate::core::io::project::lock::{LockedMod, ModSide};
4use crate::core::io::project::{ConduitConfig, ConduitLock, ProjectFiles};
5use crate::core::mods::inspector::JarInspector;
6use crate::core::paths::CorePaths;
7use std::collections::BTreeSet;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone)]
12pub struct AddedLocalMod {
13    pub key: String,
14    pub filename: String,
15}
16
17#[derive(Debug, Clone, Default)]
18pub struct AddLocalModsReport {
19    pub added: Vec<AddedLocalMod>,
20}
21
22pub fn add_local_mods_to_project(
23    paths: &CorePaths,
24    jar_paths: Vec<PathBuf>,
25    explicit_deps: Vec<PathBuf>,
26    explicit_side: Option<&ModSide>,
27) -> CoreResult<AddLocalModsReport> {
28    let mut config = ProjectFiles::load_manifest(paths)?;
29    let mut lock = ProjectFiles::load_lock(paths)?;
30
31    fs::create_dir_all(paths.mods_dir())?;
32    let mut report = AddLocalModsReport::default();
33
34    let mut dep_keys = Vec::new();
35    for dep_path in explicit_deps {
36        let (key, _) = process_local_jar(
37            paths,
38            &dep_path,
39            &mut config,
40            &mut lock,
41            explicit_side,
42            Vec::new(),
43            false
44        )?;
45        dep_keys.push(key);
46    }
47
48    for jar in jar_paths {
49        let (key, filename) = process_local_jar(
50            paths,
51            &jar,
52            &mut config,
53            &mut lock,
54            explicit_side,
55            dep_keys.clone(),
56            true
57        )?;
58        report.added.push(AddedLocalMod { key, filename });
59    }
60
61    ProjectFiles::save_manifest(paths, &config)?;
62    ProjectFiles::save_lock(paths, &lock)?;
63    Ok(report)
64}
65
66fn process_local_jar(
67    paths: &CorePaths,
68    jar_path: &Path,
69    config: &mut ConduitConfig,
70    lock: &mut ConduitLock,
71    explicit_side: Option<&ModSide>,
72    dependencies: Vec<String>,
73    is_root: bool,
74) -> CoreResult<(String, String)> {
75    let jar = normalize_path(&paths.project_dir, jar_path);
76    if !jar.exists() {
77        return Err(CoreError::Io(std::io::Error::new(
78            std::io::ErrorKind::NotFound,
79            format!("File not found: {}", jar.display()),
80        )));
81    }
82
83    let filename = jar
84        .file_name()
85        .and_then(|n| n.to_str())
86        .ok_or_else(|| {
87            CoreError::Io(std::io::Error::new(
88                std::io::ErrorKind::InvalidInput,
89                "Invalid file name",
90            ))
91        })?
92        .to_string();
93
94    let dest_path = paths.mods_dir().join(&filename);
95    if !dest_path.exists() {
96        fs::copy(&jar, &dest_path)?;
97    }
98
99    let sha256 = sha256_file(&jar)?;
100    let mod_id = JarInspector::extract_primary_mod_id(&jar).ok().flatten();
101
102    let key = local_key(&filename, mod_id.as_deref());
103    let side = explicit_side.copied().unwrap_or_else(|| JarInspector::detect_side(&jar));
104
105    let id = key.clone();
106
107    if is_root {
108        config.mods.insert(key.clone(), "local".to_string());
109    }
110    
111    lock.locked_mods.insert(
112        key.clone(),
113        LockedMod {
114            id,
115            version_id: "local".to_string(),
116            filename: filename.clone(),
117            url: "local".to_string(),
118            hash: sha256,
119            dependencies,
120            side,
121        },
122    );
123
124    Ok((key, filename))
125}
126
127#[derive(Debug, Clone, Default)]
128pub struct MissingLocalReport {
129    pub missing_files: Vec<String>,
130    pub missing_lock_entries: Vec<String>,
131}
132
133pub fn find_missing_local_mods(paths: &CorePaths) -> CoreResult<MissingLocalReport> {
134    let config = ProjectFiles::load_manifest(paths)?;
135    let lock = ProjectFiles::load_lock(paths)?;
136
137    let mut missing_files: BTreeSet<String> = BTreeSet::new();
138    let mut missing_lock_entries: BTreeSet<String> = BTreeSet::new();
139
140    for (key, value) in &config.mods {
141        if value != "local" {
142            continue;
143        }
144
145        if let Some(locked) = lock.locked_mods.get(key) {
146            if locked.url != "local" {
147                continue;
148            }
149            let on_disk = paths.mods_dir().join(&locked.filename).exists();
150            if !on_disk {
151                missing_files.insert(locked.filename.clone());
152            }
153        } else {
154            missing_lock_entries.insert(key.clone());
155        }
156    }
157
158    Ok(MissingLocalReport {
159        missing_files: missing_files.into_iter().collect(),
160        missing_lock_entries: missing_lock_entries.into_iter().collect(),
161    })
162}
163
164fn normalize_path(project_dir: &Path, p: &Path) -> PathBuf {
165    if p.is_absolute() {
166        p.to_path_buf()
167    } else {
168        project_dir.join(p)
169    }
170}
171
172fn local_key(filename: &str, mod_id: Option<&str>) -> String {
173    if let Some(id) = mod_id {
174        return format!("local:{}", id.to_lowercase());
175    }
176    format!("local:{}", local_key_from_filename(filename))
177}
178
179fn local_key_from_filename(filename: &str) -> String {
180    let stem = filename.strip_suffix(".jar").unwrap_or(filename);
181    let mut out = String::new();
182    let mut last_dash = false;
183    for ch in stem.chars() {
184        let c = ch.to_ascii_lowercase();
185        let is_ok = c.is_ascii_alphanumeric();
186        if is_ok {
187            out.push(c);
188            last_dash = false;
189        } else if !last_dash {
190            out.push('-');
191            last_dash = true;
192        }
193    }
194    let trimmed = out.trim_matches('-');
195    if trimmed.is_empty() {
196        "my-local-mod".to_string()
197    } else {
198        trimmed.to_string()
199    }
200}