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