1use crate::{NativeExport, VM};
2use libloading::Library;
3use serde::Deserialize;
4use std::{
5 collections::{BTreeMap, HashMap},
6 env,
7 ffi::OsStr,
8 fs, io,
9 path::{Path, PathBuf},
10 process::{Command, ExitStatus, Stdio},
11 sync::{Mutex, OnceLock},
12};
13use thiserror::Error;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum PackageKind {
17 LustLibrary,
18 RustExtension,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct PackageSpecifier {
23 pub name: String,
24 pub version: Option<String>,
25 pub kind: PackageKind,
26}
27
28impl PackageSpecifier {
29 pub fn new(name: impl Into<String>, kind: PackageKind) -> Self {
30 Self {
31 name: name.into(),
32 version: None,
33 kind,
34 }
35 }
36
37 pub fn with_version(mut self, version: impl Into<String>) -> Self {
38 self.version = Some(version.into());
39 self
40 }
41}
42
43pub struct PackageManager {
44 root: PathBuf,
45}
46
47impl PackageManager {
48 pub fn new(root: impl Into<PathBuf>) -> Self {
49 Self { root: root.into() }
50 }
51
52 pub fn default_root() -> PathBuf {
53 let mut base = dirs::data_local_dir().unwrap_or_else(|| PathBuf::from(".lust/cache"));
54 base.push("lust");
55 base.push("packages");
56 base
57 }
58
59 pub fn root(&self) -> &Path {
60 &self.root
61 }
62
63 pub fn ensure_layout(&self) -> io::Result<()> {
64 fs::create_dir_all(&self.root)
65 }
66}
67
68#[derive(Debug, Error)]
69pub enum LocalModuleError {
70 #[error("I/O error: {0}")]
71 Io(#[from] io::Error),
72
73 #[error("failed to parse Cargo manifest {path}: {source}")]
74 Manifest {
75 path: PathBuf,
76 #[source]
77 source: toml::de::Error,
78 },
79
80 #[error("cargo build failed for '{module}' with status {status}: {output}")]
81 CargoBuild {
82 module: String,
83 status: ExitStatus,
84 output: String,
85 },
86
87 #[error("built library not found at {0}")]
88 LibraryMissing(PathBuf),
89
90 #[error("failed to load dynamic library: {0}")]
91 LibraryLoad(#[from] libloading::Error),
92
93 #[error("register function 'lust_extension_register' missing in {0}")]
94 RegisterSymbolMissing(String),
95
96 #[error("register function reported failure in {0}")]
97 RegisterFailed(String),
98}
99
100#[derive(Debug, Clone)]
101pub struct LocalBuildOutput {
102 pub name: String,
103 pub library_path: PathBuf,
104}
105
106#[derive(Debug)]
107pub struct LoadedRustModule {
108 name: String,
109}
110
111impl LoadedRustModule {
112 pub fn name(&self) -> &str {
113 &self.name
114 }
115}
116
117#[derive(Debug, Clone)]
118pub struct StubFile {
119 pub relative_path: PathBuf,
120 pub contents: String,
121}
122
123pub fn build_local_module(module_dir: &Path) -> Result<LocalBuildOutput, LocalModuleError> {
124 let crate_name = read_crate_name(module_dir)?;
125 let profile = extension_profile();
126 let mut command = Command::new("cargo");
127 command.arg("build");
128 command.arg("--quiet");
129 match profile.as_str() {
130 "release" => {
131 command.arg("--release");
132 }
133 "debug" => {}
134 other => {
135 command.args(["--profile", other]);
136 }
137 }
138 command.current_dir(module_dir);
139 command.stdout(Stdio::piped()).stderr(Stdio::piped());
140 let output = command.output()?;
141 if !output.status.success() {
142 let mut message = String::new();
143 if !output.stdout.is_empty() {
144 message.push_str(&String::from_utf8_lossy(&output.stdout));
145 }
146 if !output.stderr.is_empty() {
147 if !message.is_empty() {
148 message.push('\n');
149 }
150 message.push_str(&String::from_utf8_lossy(&output.stderr));
151 }
152 return Err(LocalModuleError::CargoBuild {
153 module: crate_name,
154 status: output.status,
155 output: message,
156 });
157 }
158
159 let artifact = module_dir
160 .join("target")
161 .join(&profile)
162 .join(library_file_name(&crate_name));
163 if !artifact.exists() {
164 return Err(LocalModuleError::LibraryMissing(artifact));
165 }
166
167 Ok(LocalBuildOutput {
168 name: crate_name,
169 library_path: artifact,
170 })
171}
172
173pub fn load_local_module(
174 vm: &mut VM,
175 build: &LocalBuildOutput,
176) -> Result<LoadedRustModule, LocalModuleError> {
177 let library = get_or_load_library(&build.library_path)?;
178 unsafe {
179 let register = library
180 .get::<unsafe extern "C" fn(*mut VM) -> bool>(b"lust_extension_register\0")
181 .map_err(|_| LocalModuleError::RegisterSymbolMissing(build.name.clone()))?;
182 vm.push_export_prefix(&build.name);
183 let success = register(vm as *mut VM);
184 vm.pop_export_prefix();
185 if !success {
186 return Err(LocalModuleError::RegisterFailed(build.name.clone()));
187 }
188 }
189 Ok(LoadedRustModule {
190 name: build.name.clone(),
191 })
192}
193
194pub fn collect_stub_files(
195 module_dir: &Path,
196 override_dir: Option<&Path>,
197) -> Result<Vec<StubFile>, LocalModuleError> {
198 let base_dir = match override_dir {
199 Some(dir) => dir.to_path_buf(),
200 None => module_dir.join("externs"),
201 };
202 if !base_dir.exists() {
203 return Ok(Vec::new());
204 }
205
206 let mut stubs = Vec::new();
207 visit_stub_dir(&base_dir, PathBuf::new(), &mut stubs)?;
208 Ok(stubs)
209}
210
211pub fn write_stub_files(
212 crate_name: &str,
213 stubs: &[StubFile],
214 output_root: &Path,
215) -> Result<Vec<PathBuf>, LocalModuleError> {
216 let mut written = Vec::new();
217 for stub in stubs {
218 let mut relative = if stub.relative_path.components().next().is_some() {
219 stub.relative_path.clone()
220 } else {
221 let mut path = PathBuf::new();
222 path.push(sanitized_crate_name(crate_name));
223 path.set_extension("lust");
224 path
225 };
226 if relative.extension().is_none() {
227 relative.set_extension("lust");
228 }
229 let destination = output_root.join(&relative);
230 if let Some(parent) = destination.parent() {
231 fs::create_dir_all(parent)?;
232 }
233 fs::write(&destination, &stub.contents)?;
234 written.push(relative);
235 }
236
237 Ok(written)
238}
239
240fn extension_profile() -> String {
241 env::var("LUST_EXTENSION_PROFILE").unwrap_or_else(|_| "release".to_string())
242}
243
244fn library_file_name(crate_name: &str) -> String {
245 let sanitized = sanitized_crate_name(crate_name);
246 #[cfg(target_os = "windows")]
247 {
248 format!("{sanitized}.dll")
249 }
250 #[cfg(target_os = "macos")]
251 {
252 format!("lib{sanitized}.dylib")
253 }
254 #[cfg(all(unix, not(target_os = "macos")))]
255 {
256 format!("lib{sanitized}.so")
257 }
258}
259
260fn sanitized_crate_name(name: &str) -> String {
261 name.replace('-', "_")
262}
263
264fn library_cache() -> &'static Mutex<HashMap<PathBuf, &'static Library>> {
265 static CACHE: OnceLock<Mutex<HashMap<PathBuf, &'static Library>>> = OnceLock::new();
266 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
267}
268
269fn get_or_load_library(path: &Path) -> Result<&'static Library, LocalModuleError> {
270 {
271 let cache = library_cache().lock().unwrap();
272 if let Some(lib) = cache.get(path) {
273 return Ok(*lib);
274 }
275 }
276
277 let library = unsafe { Library::new(path) }.map_err(LocalModuleError::LibraryLoad)?;
278 let leaked = Box::leak(Box::new(library));
279
280 let mut cache = library_cache().lock().unwrap();
281 let entry = cache.entry(path.to_path_buf()).or_insert(leaked);
282 Ok(*entry)
283}
284
285fn visit_stub_dir(
286 current: &Path,
287 relative: PathBuf,
288 stubs: &mut Vec<StubFile>,
289) -> Result<(), LocalModuleError> {
290 for entry in fs::read_dir(current)? {
291 let entry = entry?;
292 let path = entry.path();
293 let next_relative = relative.join(entry.file_name());
294 if path.is_dir() {
295 visit_stub_dir(&path, next_relative, stubs)?;
296 } else if path.extension() == Some(OsStr::new("lust")) {
297 let contents = fs::read_to_string(&path)?;
298 stubs.push(StubFile {
299 relative_path: next_relative,
300 contents,
301 });
302 }
303 }
304
305 Ok(())
306}
307
308pub fn stub_files_from_exports(exports: &[NativeExport]) -> Vec<StubFile> {
309 if exports.is_empty() {
310 return Vec::new();
311 }
312
313 let mut grouped: BTreeMap<String, Vec<&NativeExport>> = BTreeMap::new();
314 for export in exports {
315 let (module, _function) = match export.name().rsplit_once('.') {
316 Some(parts) => parts,
317 None => continue,
318 };
319 grouped.entry(module.to_string()).or_default().push(export);
320 }
321
322 let mut result = Vec::new();
323 for (module, mut items) in grouped {
324 items.sort_by(|a, b| a.name().cmp(b.name()));
325 let mut contents = String::new();
326 contents.push_str("pub extern {\n");
327 for export in items {
328 if let Some((_, function)) = export.name().rsplit_once('.') {
329 let params = format_params(export);
330 let return_type = export.return_type();
331 if let Some(doc) = export.doc() {
332 contents.push_str(" -- ");
333 contents.push_str(doc);
334 if !doc.ends_with('\n') {
335 contents.push('\n');
336 }
337 }
338 contents.push_str(" function ");
339 contents.push_str(function);
340 contents.push('(');
341 contents.push_str(¶ms);
342 contents.push(')');
343 if !return_type.trim().is_empty() && return_type.trim() != "()" {
344 contents.push_str(": ");
345 contents.push_str(return_type);
346 }
347 contents.push('\n');
348 }
349 }
350 contents.push_str("}\n");
351 let mut relative = relative_stub_path(&module);
352 if relative.extension().is_none() {
353 relative.set_extension("lust");
354 }
355 result.push(StubFile {
356 relative_path: relative,
357 contents,
358 });
359 }
360
361 result
362}
363
364fn format_params(export: &NativeExport) -> String {
365 export
366 .params()
367 .iter()
368 .map(|param| {
369 let ty = param.ty().trim();
370 if ty.is_empty() {
371 "any"
372 } else {
373 ty
374 }
375 })
376 .collect::<Vec<_>>()
377 .join(", ")
378}
379
380fn relative_stub_path(module: &str) -> PathBuf {
381 let mut path = PathBuf::new();
382 let mut segments: Vec<String> = module.split('.').map(|seg| seg.replace('-', "_")).collect();
383 if let Some(first) = segments.first() {
384 if first == "externs" {
385 segments.remove(0);
386 }
387 }
388 for seg in segments {
389 path.push(seg);
390 }
391 path
392}
393
394fn read_crate_name(module_dir: &Path) -> Result<String, LocalModuleError> {
395 let manifest_path = module_dir.join("Cargo.toml");
396 let manifest_str = fs::read_to_string(&manifest_path)?;
397 #[derive(Deserialize)]
398 struct Manifest {
399 package: PackageSection,
400 }
401 #[derive(Deserialize)]
402 struct PackageSection {
403 name: String,
404 }
405 let manifest: Manifest =
406 toml::from_str(&manifest_str).map_err(|source| LocalModuleError::Manifest {
407 path: manifest_path,
408 source,
409 })?;
410 Ok(manifest.package.name)
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn specifier_builder_sets_version() {
419 let spec = PackageSpecifier::new("foo", PackageKind::LustLibrary).with_version("1.2.3");
420 assert_eq!(spec.version.as_deref(), Some("1.2.3"));
421 }
422
423 #[test]
424 fn ensure_layout_creates_directories() {
425 let temp_dir = tempfile::tempdir().expect("temp directory");
426 let root = temp_dir.path().join("pkg");
427 let manager = PackageManager::new(&root);
428 manager.ensure_layout().expect("create dirs");
429 assert!(root.exists());
430 assert!(root.is_dir());
431 }
432
433 #[test]
434 fn library_name_sanitizes_hyphens() {
435 #[cfg(target_os = "windows")]
436 assert_eq!(library_file_name("my-ext"), "my_ext.dll");
437 #[cfg(target_os = "macos")]
438 assert_eq!(library_file_name("my-ext"), "libmy_ext.dylib");
439 #[cfg(all(unix, not(target_os = "macos")))]
440 assert_eq!(library_file_name("my-ext"), "libmy_ext.so");
441 }
442}