conduit_cli/core/mods/
local.rs1use 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}