1use crate::embed::native_types::ModuleStub;
2use crate::{NativeExport, VM};
3use dirs::home_dir;
4use libloading::Library;
5use serde::Deserialize;
6use std::{
7 collections::{BTreeMap, HashMap},
8 env,
9 ffi::OsStr,
10 fs, io,
11 path::{Path, PathBuf},
12 process::{Command, ExitStatus, Stdio},
13 sync::{Mutex, OnceLock},
14};
15use thiserror::Error;
16
17pub mod archive;
18pub mod credentials;
19pub mod dependencies;
20pub mod manifest;
21pub mod registry;
22
23pub use archive::{build_package_archive, ArchiveError, PackageArchive};
24pub use credentials::{
25 clear_credentials, credentials_file, load_credentials, save_credentials, Credentials,
26 CredentialsError,
27};
28pub use dependencies::{
29 resolve_dependencies, DependencyResolution, DependencyResolutionError, ResolvedLuaDependency,
30 ResolvedLustDependency, ResolvedRustDependency,
31};
32pub use manifest::{ManifestError, ManifestKind, PackageManifest, PackageSection};
33pub use registry::{
34 DownloadedArchive, PackageDetails, PackageSearchResponse, PackageSummary, PackageVersionInfo,
35 PublishResponse, RegistryClient, RegistryError, SearchParameters, DEFAULT_BASE_URL,
36};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum PackageKind {
40 LustLibrary,
41 RustExtension,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct PackageSpecifier {
46 pub name: String,
47 pub version: Option<String>,
48 pub kind: PackageKind,
49}
50
51impl PackageSpecifier {
52 pub fn new(name: impl Into<String>, kind: PackageKind) -> Self {
53 Self {
54 name: name.into(),
55 version: None,
56 kind,
57 }
58 }
59
60 pub fn with_version(mut self, version: impl Into<String>) -> Self {
61 self.version = Some(version.into());
62 self
63 }
64}
65
66pub struct PackageManager {
67 root: PathBuf,
68}
69
70impl PackageManager {
71 pub fn new(root: impl Into<PathBuf>) -> Self {
72 Self { root: root.into() }
73 }
74
75 pub fn default_root() -> PathBuf {
76 let mut base = home_dir().unwrap_or_else(|| PathBuf::from("."));
77 base.push(".lust");
78 base.push("packages");
79 base
80 }
81
82 pub fn root(&self) -> &Path {
83 &self.root
84 }
85
86 pub fn ensure_layout(&self) -> io::Result<()> {
87 fs::create_dir_all(&self.root)
88 }
89}
90
91#[derive(Debug, Error)]
92pub enum LocalModuleError {
93 #[error("I/O error: {0}")]
94 Io(#[from] io::Error),
95
96 #[error("failed to parse Cargo manifest {path}: {source}")]
97 Manifest {
98 path: PathBuf,
99 #[source]
100 source: toml::de::Error,
101 },
102
103 #[error("cargo build failed for '{module}' with status {status}: {output}")]
104 CargoBuild {
105 module: String,
106 status: ExitStatus,
107 output: String,
108 },
109
110 #[error("built library not found at {0}")]
111 LibraryMissing(PathBuf),
112
113 #[error("failed to load dynamic library: {0}")]
114 LibraryLoad(#[from] libloading::Error),
115
116 #[error("register function 'lust_extension_register' missing in {0}")]
117 RegisterSymbolMissing(String),
118
119 #[error("register function reported failure in {0}")]
120 RegisterFailed(String),
121}
122
123#[derive(Debug, Clone)]
124pub struct LocalBuildOutput {
125 pub name: String,
126 pub library_path: PathBuf,
127}
128
129#[derive(Debug)]
130pub struct LoadedRustModule {
131 name: String,
132}
133
134impl LoadedRustModule {
135 pub fn name(&self) -> &str {
136 &self.name
137 }
138}
139
140#[derive(Debug, Clone)]
141pub struct StubFile {
142 pub relative_path: PathBuf,
143 pub contents: String,
144}
145
146#[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
147#[derive(Debug, Clone)]
148pub struct PreparedRustDependency {
149 pub dependency: ResolvedRustDependency,
150 pub build: LocalBuildOutput,
151 pub stub_roots: Vec<StubRoot>,
152}
153
154#[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
155#[derive(Debug, Clone)]
156pub struct StubRoot {
157 pub prefix: String,
158 pub directory: PathBuf,
159}
160
161#[derive(Debug, Clone)]
162pub struct BuildOptions<'a> {
163 pub features: &'a [String],
164 pub default_features: bool,
165}
166
167impl<'a> Default for BuildOptions<'a> {
168 fn default() -> Self {
169 Self {
170 features: &[],
171 default_features: true,
172 }
173 }
174}
175
176pub fn build_local_module(
177 module_dir: &Path,
178 options: BuildOptions<'_>,
179) -> Result<LocalBuildOutput, LocalModuleError> {
180 let crate_name = read_crate_name(module_dir)?;
181 let profile = extension_profile();
182 let mut command = Command::new("cargo");
183 command.arg("build");
184 command.arg("--quiet");
185 match profile.as_str() {
186 "release" => {
187 command.arg("--release");
188 }
189 "debug" => {}
190 other => {
191 command.args(["--profile", other]);
192 }
193 }
194 if !options.default_features {
195 command.arg("--no-default-features");
196 }
197 if !options.features.is_empty() {
198 command.arg("--features");
199 command.arg(options.features.join(","));
200 }
201 command.current_dir(module_dir);
202 command.stdout(Stdio::piped()).stderr(Stdio::piped());
203 let output = command.output()?;
204 if !output.status.success() {
205 let mut message = String::new();
206 if !output.stdout.is_empty() {
207 message.push_str(&String::from_utf8_lossy(&output.stdout));
208 }
209 if !output.stderr.is_empty() {
210 if !message.is_empty() {
211 message.push('\n');
212 }
213 message.push_str(&String::from_utf8_lossy(&output.stderr));
214 }
215 return Err(LocalModuleError::CargoBuild {
216 module: crate_name,
217 status: output.status,
218 output: message,
219 });
220 }
221
222 let artifact = module_dir
223 .join("target")
224 .join(&profile)
225 .join(library_file_name(&crate_name));
226 if !artifact.exists() {
227 return Err(LocalModuleError::LibraryMissing(artifact));
228 }
229
230 Ok(LocalBuildOutput {
231 name: crate_name,
232 library_path: artifact,
233 })
234}
235
236pub fn load_local_module(
237 vm: &mut VM,
238 build: &LocalBuildOutput,
239) -> Result<LoadedRustModule, LocalModuleError> {
240 let library = get_or_load_library(&build.library_path)?;
241 unsafe {
242 let register = library
243 .get::<unsafe extern "C" fn(*mut VM) -> bool>(b"lust_extension_register\0")
244 .map_err(|_| LocalModuleError::RegisterSymbolMissing(build.name.clone()))?;
245 vm.push_export_prefix(&build.name);
246 let success = register(vm as *mut VM);
247 vm.pop_export_prefix();
248 if !success {
249 return Err(LocalModuleError::RegisterFailed(build.name.clone()));
250 }
251 }
252 Ok(LoadedRustModule {
253 name: build.name.clone(),
254 })
255}
256
257pub fn collect_stub_files(
258 module_dir: &Path,
259 override_dir: Option<&Path>,
260) -> Result<Vec<StubFile>, LocalModuleError> {
261 let base_dir = match override_dir {
262 Some(dir) => dir.to_path_buf(),
263 None => module_dir.join("externs"),
264 };
265 if !base_dir.exists() {
266 return Ok(Vec::new());
267 }
268
269 let mut stubs = Vec::new();
270 visit_stub_dir(&base_dir, PathBuf::new(), &mut stubs)?;
271 Ok(stubs)
272}
273
274pub fn write_stub_files(
275 crate_name: &str,
276 stubs: &[StubFile],
277 output_root: &Path,
278) -> Result<Vec<PathBuf>, LocalModuleError> {
279 let mut written = Vec::new();
280 for stub in stubs {
281 let mut relative = if stub.relative_path.components().next().is_some() {
282 stub.relative_path.clone()
283 } else {
284 let mut path = PathBuf::new();
285 path.push(sanitized_crate_name(crate_name));
286 path.set_extension("lust");
287 path
288 };
289 if relative.extension().is_none() {
290 relative.set_extension("lust");
291 }
292 let destination = output_root.join(&relative);
293 if let Some(parent) = destination.parent() {
294 fs::create_dir_all(parent)?;
295 }
296 fs::write(&destination, &stub.contents)?;
297 written.push(relative);
298 }
299
300 Ok(written)
301}
302
303#[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
304pub fn collect_rust_dependency_artifacts(
305 dep: &ResolvedRustDependency,
306) -> Result<(LocalBuildOutput, Vec<StubFile>), String> {
307 let build = build_local_module(
308 &dep.crate_dir,
309 BuildOptions {
310 features: &dep.features,
311 default_features: dep.default_features,
312 },
313 )
314 .map_err(|err| format!("{}: {}", dep.crate_dir.display(), err))?;
315
316 let mut preview_vm = VM::new();
317 let preview_module = load_local_module(&mut preview_vm, &build)
318 .map_err(|err| format!("{}: {}", dep.crate_dir.display(), err))?;
319 let exports = preview_vm.take_exported_natives();
320 let type_stubs = preview_vm.take_type_stubs();
321 preview_vm.clear_native_functions();
322 drop(preview_module);
323
324 let mut stubs = stub_files_from_exports(&exports, &type_stubs);
325 let manual_stubs = collect_stub_files(&dep.crate_dir, dep.externs_override.as_deref())
326 .map_err(|err| format!("{}: {}", dep.crate_dir.display(), err))?;
327 if !manual_stubs.is_empty() {
328 stubs.extend(manual_stubs);
329 }
330
331 Ok((build, stubs))
332}
333
334#[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
335pub fn prepare_rust_dependencies(
336 deps: &DependencyResolution,
337 project_dir: &Path,
338) -> Result<Vec<PreparedRustDependency>, String> {
339 if deps.rust().is_empty() {
340 return Ok(Vec::new());
341 }
342
343 let mut prepared = Vec::new();
344 let mut project_extern_root: Option<PathBuf> = None;
345
346 for dep in deps.rust() {
347 let (build, stubs) = collect_rust_dependency_artifacts(dep)?;
348 let mut stub_roots = Vec::new();
349 let sanitized_prefix = sanitized_crate_name(&build.name);
350 let mut register_root = |dir: &Path| {
351 let dir_buf = dir.join(&sanitized_prefix);
352 if dir_buf.exists()
353 && !stub_roots
354 .iter()
355 .any(|root: &StubRoot| root.directory == dir_buf)
356 {
357 stub_roots.push(StubRoot {
358 prefix: sanitized_prefix.clone(),
359 directory: dir_buf,
360 });
361 }
362 };
363
364 let fallback_root = project_dir.join("externs");
365 register_root(&fallback_root);
366
367 if let Some(cache_dir) = &dep.cache_stub_dir {
368 fs::create_dir_all(cache_dir).map_err(|err| {
369 format!(
370 "failed to create extern cache '{}': {}",
371 cache_dir.display(),
372 err
373 )
374 })?;
375 if !stubs.is_empty() {
376 write_stub_files(&build.name, &stubs, cache_dir)
377 .map_err(|err| format!("{}: {}", cache_dir.display(), err))?;
378 }
379 if cache_dir.exists() {
380 register_root(cache_dir);
381 }
382 } else {
383 let root = project_extern_root
384 .get_or_insert_with(|| project_dir.join("externs"))
385 .clone();
386 if !stubs.is_empty() {
387 fs::create_dir_all(&root).map_err(|err| format!("{}: {}", root.display(), err))?;
388 write_stub_files(&build.name, &stubs, &root)
389 .map_err(|err| format!("{}: {}", root.display(), err))?;
390 register_root(&root);
391 } else if root.exists() {
392 register_root(&root);
393 }
394 }
395
396 prepared.push(PreparedRustDependency {
397 dependency: dep.clone(),
398 build,
399 stub_roots,
400 });
401 }
402
403 Ok(prepared)
404}
405
406#[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
407pub fn load_prepared_rust_dependencies(
408 prepared: &[PreparedRustDependency],
409 vm: &mut VM,
410) -> Result<Vec<LoadedRustModule>, String> {
411 let mut loaded = Vec::new();
412 for item in prepared {
413 let module = load_local_module(vm, &item.build)
414 .map_err(|err| format!("{}: {}", item.dependency.crate_dir.display(), err))?;
415 loaded.push(module);
416 }
417 Ok(loaded)
418}
419
420fn extension_profile() -> String {
421 env::var("LUST_EXTENSION_PROFILE").unwrap_or_else(|_| "release".to_string())
422}
423
424fn library_file_name(crate_name: &str) -> String {
425 let sanitized = sanitized_crate_name(crate_name);
426 #[cfg(target_os = "windows")]
427 {
428 format!("{sanitized}.dll")
429 }
430 #[cfg(target_os = "macos")]
431 {
432 format!("lib{sanitized}.dylib")
433 }
434 #[cfg(all(unix, not(target_os = "macos")))]
435 {
436 format!("lib{sanitized}.so")
437 }
438}
439
440fn sanitized_crate_name(name: &str) -> String {
441 name.replace('-', "_")
442}
443
444fn library_cache() -> &'static Mutex<HashMap<PathBuf, &'static Library>> {
445 static CACHE: OnceLock<Mutex<HashMap<PathBuf, &'static Library>>> = OnceLock::new();
446 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
447}
448
449fn get_or_load_library(path: &Path) -> Result<&'static Library, LocalModuleError> {
450 {
451 let cache = library_cache().lock().unwrap();
452 if let Some(lib) = cache.get(path) {
453 return Ok(*lib);
454 }
455 }
456
457 let library = unsafe { Library::new(path) }.map_err(LocalModuleError::LibraryLoad)?;
458 let leaked = Box::leak(Box::new(library));
459
460 let mut cache = library_cache().lock().unwrap();
461 let entry = cache.entry(path.to_path_buf()).or_insert(leaked);
462 Ok(*entry)
463}
464
465fn visit_stub_dir(
466 current: &Path,
467 relative: PathBuf,
468 stubs: &mut Vec<StubFile>,
469) -> Result<(), LocalModuleError> {
470 for entry in fs::read_dir(current)? {
471 let entry = entry?;
472 let path = entry.path();
473 let next_relative = relative.join(entry.file_name());
474 if path.is_dir() {
475 visit_stub_dir(&path, next_relative, stubs)?;
476 } else if path.extension() == Some(OsStr::new("lust")) {
477 let contents = fs::read_to_string(&path)?;
478 stubs.push(StubFile {
479 relative_path: next_relative,
480 contents,
481 });
482 }
483 }
484
485 Ok(())
486}
487
488pub fn stub_files_from_exports(
489 exports: &[NativeExport],
490 type_stubs: &[ModuleStub],
491) -> Vec<StubFile> {
492 if exports.is_empty() && type_stubs.iter().all(ModuleStub::is_empty) {
493 return Vec::new();
494 }
495
496 #[derive(Default)]
497 struct CombinedModule<'a> {
498 type_stub: ModuleStub,
499 functions: Vec<&'a NativeExport>,
500 }
501
502 let mut combined: BTreeMap<String, CombinedModule<'_>> = BTreeMap::new();
503 for stub in type_stubs {
504 if stub.is_empty() {
505 continue;
506 }
507 let entry = combined
508 .entry(stub.module.clone())
509 .or_insert_with(|| CombinedModule {
510 type_stub: ModuleStub {
511 module: stub.module.clone(),
512 ..ModuleStub::default()
513 },
514 ..CombinedModule::default()
515 });
516 entry.type_stub.struct_defs.extend(stub.struct_defs.clone());
517 entry.type_stub.enum_defs.extend(stub.enum_defs.clone());
518 entry.type_stub.trait_defs.extend(stub.trait_defs.clone());
519 entry.type_stub.const_defs.extend(stub.const_defs.clone());
520 }
521
522 for export in exports {
523 let (module, _) = match export.name().rsplit_once('.') {
524 Some(parts) => parts,
525 None => continue,
526 };
527 let entry = combined
528 .entry(module.to_string())
529 .or_insert_with(|| CombinedModule {
530 type_stub: ModuleStub {
531 module: module.to_string(),
532 ..ModuleStub::default()
533 },
534 ..CombinedModule::default()
535 });
536 entry.functions.push(export);
537 }
538
539 let mut result = Vec::new();
540 for (module, mut combined_entry) in combined {
541 combined_entry
542 .functions
543 .sort_by(|a, b| a.name().cmp(b.name()));
544 let mut contents = String::new();
545
546 let mut wrote_type = false;
547 let append_defs = |defs: &Vec<String>, contents: &mut String, wrote_flag: &mut bool| {
548 if defs.is_empty() {
549 return;
550 }
551 if *wrote_flag && !contents.ends_with("\n\n") && !contents.is_empty() {
552 contents.push('\n');
553 }
554 for def in defs {
555 contents.push_str(def);
556 if !def.ends_with('\n') {
557 contents.push('\n');
558 }
559 }
560 *wrote_flag = true;
561 };
562
563 append_defs(
564 &combined_entry.type_stub.struct_defs,
565 &mut contents,
566 &mut wrote_type,
567 );
568 append_defs(
569 &combined_entry.type_stub.enum_defs,
570 &mut contents,
571 &mut wrote_type,
572 );
573 append_defs(
574 &combined_entry.type_stub.trait_defs,
575 &mut contents,
576 &mut wrote_type,
577 );
578 append_defs(
579 &combined_entry.type_stub.const_defs,
580 &mut contents,
581 &mut wrote_type,
582 );
583
584 if !combined_entry.functions.is_empty() {
585 if wrote_type && !contents.ends_with("\n\n") {
586 contents.push('\n');
587 }
588 contents.push_str("pub extern\n");
589 for export in combined_entry.functions {
590 if let Some((_, function)) = export.name().rsplit_once('.') {
591 let params = format_params(export);
592 let return_type = export.return_type();
593 if let Some(doc) = export.doc() {
594 contents.push_str(" -- ");
595 contents.push_str(doc);
596 if !doc.ends_with('\n') {
597 contents.push('\n');
598 }
599 }
600 contents.push_str(" function ");
601 contents.push_str(function);
602 contents.push('(');
603 contents.push_str(¶ms);
604 contents.push(')');
605 if !return_type.trim().is_empty() && return_type.trim() != "()" {
606 contents.push_str(": ");
607 contents.push_str(return_type);
608 }
609 contents.push('\n');
610 }
611 }
612 contents.push_str("end\n");
613 }
614
615 if contents.is_empty() {
616 continue;
617 }
618 let mut relative = relative_stub_path(&module);
619 if relative.extension().is_none() {
620 relative.set_extension("lust");
621 }
622 result.push(StubFile {
623 relative_path: relative,
624 contents,
625 });
626 }
627
628 result
629}
630
631fn format_params(export: &NativeExport) -> String {
632 export
633 .params()
634 .iter()
635 .map(|param| {
636 let ty = param.ty().trim();
637 if ty.is_empty() {
638 "any"
639 } else {
640 ty
641 }
642 })
643 .collect::<Vec<_>>()
644 .join(", ")
645}
646
647fn relative_stub_path(module: &str) -> PathBuf {
648 let mut path = PathBuf::new();
649 let mut segments: Vec<String> = module.split('.').map(|seg| seg.replace('-', "_")).collect();
650 if let Some(first) = segments.first() {
651 if first == "externs" {
652 segments.remove(0);
653 }
654 }
655 if let Some(first) = segments.first() {
656 path.push(first);
657 }
658 if segments.len() > 1 {
659 for seg in &segments[1..segments.len() - 1] {
660 path.push(seg);
661 }
662 path.push(segments.last().unwrap());
663 } else if let Some(first) = segments.first() {
664 path.push(first);
665 }
666 path
667}
668
669fn read_crate_name(module_dir: &Path) -> Result<String, LocalModuleError> {
670 let manifest_path = module_dir.join("Cargo.toml");
671 let manifest_str = fs::read_to_string(&manifest_path)?;
672 #[derive(Deserialize)]
673 struct Manifest {
674 package: PackageSection,
675 }
676 #[derive(Deserialize)]
677 struct PackageSection {
678 name: String,
679 }
680 let manifest: Manifest =
681 toml::from_str(&manifest_str).map_err(|source| LocalModuleError::Manifest {
682 path: manifest_path,
683 source,
684 })?;
685 Ok(manifest.package.name)
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691
692 #[test]
693 fn specifier_builder_sets_version() {
694 let spec = PackageSpecifier::new("foo", PackageKind::LustLibrary).with_version("1.2.3");
695 assert_eq!(spec.version.as_deref(), Some("1.2.3"));
696 }
697
698 #[test]
699 fn ensure_layout_creates_directories() {
700 let temp_dir = tempfile::tempdir().expect("temp directory");
701 let root = temp_dir.path().join("pkg");
702 let manager = PackageManager::new(&root);
703 manager.ensure_layout().expect("create dirs");
704 assert!(root.exists());
705 assert!(root.is_dir());
706 }
707
708 #[test]
709 fn library_name_sanitizes_hyphens() {
710 #[cfg(target_os = "windows")]
711 assert_eq!(library_file_name("my-ext"), "my_ext.dll");
712 #[cfg(target_os = "macos")]
713 assert_eq!(library_file_name("my-ext"), "libmy_ext.dylib");
714 #[cfg(all(unix, not(target_os = "macos")))]
715 assert_eq!(library_file_name("my-ext"), "libmy_ext.so");
716 }
717}