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, ResolvedLustDependency,
30 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 pub export_with_extern_namespace: bool,
153}
154
155#[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
156#[derive(Debug, Clone)]
157pub struct StubRoot {
158 pub prefix: String,
159 pub directory: PathBuf,
160}
161
162#[derive(Debug, Clone)]
163pub struct BuildOptions<'a> {
164 pub features: &'a [String],
165 pub default_features: bool,
166}
167
168impl<'a> Default for BuildOptions<'a> {
169 fn default() -> Self {
170 Self {
171 features: &[],
172 default_features: true,
173 }
174 }
175}
176
177pub fn build_local_module(
178 module_dir: &Path,
179 options: BuildOptions<'_>,
180) -> Result<LocalBuildOutput, LocalModuleError> {
181 let crate_name = read_crate_name(module_dir)?;
182 let profile = extension_profile();
183 let mut command = Command::new("cargo");
184 command.arg("build");
185 command.arg("--quiet");
186 match profile.as_str() {
187 "release" => {
188 command.arg("--release");
189 }
190 "debug" => {}
191 other => {
192 command.args(["--profile", other]);
193 }
194 }
195 if !options.default_features {
196 command.arg("--no-default-features");
197 }
198 if !options.features.is_empty() {
199 command.arg("--features");
200 command.arg(options.features.join(","));
201 }
202 command.current_dir(module_dir);
203 command.stdout(Stdio::piped()).stderr(Stdio::piped());
204 let output = command.output()?;
205 if !output.status.success() {
206 let mut message = String::new();
207 if !output.stdout.is_empty() {
208 message.push_str(&String::from_utf8_lossy(&output.stdout));
209 }
210 if !output.stderr.is_empty() {
211 if !message.is_empty() {
212 message.push('\n');
213 }
214 message.push_str(&String::from_utf8_lossy(&output.stderr));
215 }
216 return Err(LocalModuleError::CargoBuild {
217 module: crate_name,
218 status: output.status,
219 output: message,
220 });
221 }
222
223 let artifact = module_dir
224 .join("target")
225 .join(&profile)
226 .join(library_file_name(&crate_name));
227 if !artifact.exists() {
228 return Err(LocalModuleError::LibraryMissing(artifact));
229 }
230
231 Ok(LocalBuildOutput {
232 name: crate_name,
233 library_path: artifact,
234 })
235}
236
237pub fn load_local_module(
238 vm: &mut VM,
239 build: &LocalBuildOutput,
240) -> Result<LoadedRustModule, LocalModuleError> {
241 load_local_module_with_namespace(vm, build, true)
242}
243
244pub fn load_local_module_with_namespace(
245 vm: &mut VM,
246 build: &LocalBuildOutput,
247 include_extern_namespace: bool,
248) -> Result<LoadedRustModule, LocalModuleError> {
249 let library = get_or_load_library(&build.library_path)?;
250 unsafe {
251 let register = library
252 .get::<unsafe extern "C" fn(*mut VM) -> bool>(b"lust_extension_register\0")
253 .map_err(|_| LocalModuleError::RegisterSymbolMissing(build.name.clone()))?;
254 vm.push_export_prefix(&build.name, include_extern_namespace);
255 let success = register(vm as *mut VM);
256 vm.pop_export_prefix();
257 if !success {
258 return Err(LocalModuleError::RegisterFailed(build.name.clone()));
259 }
260 }
261 Ok(LoadedRustModule {
262 name: build.name.clone(),
263 })
264}
265
266pub fn collect_stub_files(
267 module_dir: &Path,
268 override_dir: Option<&Path>,
269) -> Result<Vec<StubFile>, LocalModuleError> {
270 let base_dir = match override_dir {
271 Some(dir) => dir.to_path_buf(),
272 None => module_dir.join("externs"),
273 };
274 if !base_dir.exists() {
275 return Ok(Vec::new());
276 }
277
278 let mut stubs = Vec::new();
279 visit_stub_dir(&base_dir, PathBuf::new(), &mut stubs)?;
280 Ok(stubs)
281}
282
283pub fn write_stub_files(
284 crate_name: &str,
285 stubs: &[StubFile],
286 output_root: &Path,
287) -> Result<Vec<PathBuf>, LocalModuleError> {
288 let mut written = Vec::new();
289 for stub in stubs {
290 let mut relative = if stub.relative_path.components().next().is_some() {
291 stub.relative_path.clone()
292 } else {
293 let mut path = PathBuf::new();
294 path.push(sanitized_crate_name(crate_name));
295 path.set_extension("lust");
296 path
297 };
298 if relative.extension().is_none() {
299 relative.set_extension("lust");
300 }
301 let destination = output_root.join(&relative);
302 if let Some(parent) = destination.parent() {
303 fs::create_dir_all(parent)?;
304 }
305 fs::write(&destination, &stub.contents)?;
306 written.push(relative);
307 }
308
309 Ok(written)
310}
311
312#[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
313pub fn collect_rust_dependency_artifacts(
314 dep: &ResolvedRustDependency,
315) -> Result<(LocalBuildOutput, Vec<StubFile>), String> {
316 let build = build_local_module(
317 &dep.crate_dir,
318 BuildOptions {
319 features: &dep.features,
320 default_features: dep.default_features,
321 },
322 )
323 .map_err(|err| format!("{}: {}", dep.crate_dir.display(), err))?;
324
325 let include_extern_namespace = dep.cache_stub_dir.is_none();
326 let mut preview_vm = VM::new();
327 let preview_module =
328 load_local_module_with_namespace(&mut preview_vm, &build, include_extern_namespace)
329 .map_err(|err| format!("{}: {}", dep.crate_dir.display(), err))?;
330 let exports = preview_vm.take_exported_natives();
331 let type_stubs = preview_vm.take_type_stubs();
332 preview_vm.clear_native_functions();
333 drop(preview_module);
334
335 let mut stubs = stub_files_from_exports(&exports, &type_stubs);
336 let manual_stubs = collect_stub_files(&dep.crate_dir, dep.externs_override.as_deref())
337 .map_err(|err| format!("{}: {}", dep.crate_dir.display(), err))?;
338 if !manual_stubs.is_empty() {
339 stubs.extend(manual_stubs);
340 }
341
342 Ok((build, stubs))
343}
344
345#[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
346pub fn prepare_rust_dependencies(
347 deps: &DependencyResolution,
348 project_dir: &Path,
349) -> Result<Vec<PreparedRustDependency>, String> {
350 if deps.rust().is_empty() {
351 return Ok(Vec::new());
352 }
353
354 let mut prepared = Vec::new();
355 let mut project_extern_root: Option<PathBuf> = None;
356
357 for dep in deps.rust() {
358 let include_extern_namespace = dep.cache_stub_dir.is_none();
359 let (build, stubs) = collect_rust_dependency_artifacts(dep)?;
360 let mut stub_roots = Vec::new();
361 let sanitized_prefix = sanitized_crate_name(&build.name);
362 let mut register_root = |dir: &Path| {
363 let dir_buf = dir.to_path_buf();
364 if include_extern_namespace
365 && !stub_roots
366 .iter()
367 .any(|root: &StubRoot| root.prefix == "externs" && root.directory == dir_buf)
368 {
369 stub_roots.push(StubRoot {
370 prefix: "externs".to_string(),
371 directory: dir_buf.clone(),
372 });
373 }
374 if !stub_roots
375 .iter()
376 .any(|root: &StubRoot| root.prefix == sanitized_prefix && root.directory == dir_buf)
377 {
378 stub_roots.push(StubRoot {
379 prefix: sanitized_prefix.clone(),
380 directory: dir_buf,
381 });
382 }
383 };
384
385 if let Some(cache_dir) = &dep.cache_stub_dir {
386 fs::create_dir_all(cache_dir).map_err(|err| {
387 format!(
388 "failed to create extern cache '{}': {}",
389 cache_dir.display(),
390 err
391 )
392 })?;
393 if !stubs.is_empty() {
394 write_stub_files(&build.name, &stubs, cache_dir)
395 .map_err(|err| format!("{}: {}", cache_dir.display(), err))?;
396 }
397 if cache_dir.exists() {
398 register_root(cache_dir);
399 }
400 } else {
401 let root = project_extern_root
402 .get_or_insert_with(|| project_dir.join("externs"))
403 .clone();
404 if !stubs.is_empty() {
405 fs::create_dir_all(&root).map_err(|err| format!("{}: {}", root.display(), err))?;
406 write_stub_files(&build.name, &stubs, &root)
407 .map_err(|err| format!("{}: {}", root.display(), err))?;
408 register_root(&root);
409 } else if root.exists() {
410 register_root(&root);
411 }
412 }
413
414 prepared.push(PreparedRustDependency {
415 dependency: dep.clone(),
416 build,
417 stub_roots,
418 export_with_extern_namespace: include_extern_namespace,
419 });
420 }
421
422 Ok(prepared)
423}
424
425#[cfg(all(feature = "packages", not(target_arch = "wasm32")))]
426pub fn load_prepared_rust_dependencies(
427 prepared: &[PreparedRustDependency],
428 vm: &mut VM,
429) -> Result<Vec<LoadedRustModule>, String> {
430 let mut loaded = Vec::new();
431 for item in prepared {
432 let module =
433 load_local_module_with_namespace(vm, &item.build, item.export_with_extern_namespace)
434 .map_err(|err| format!("{}: {}", item.dependency.crate_dir.display(), err))?;
435 loaded.push(module);
436 }
437 Ok(loaded)
438}
439
440fn extension_profile() -> String {
441 env::var("LUST_EXTENSION_PROFILE").unwrap_or_else(|_| "release".to_string())
442}
443
444fn library_file_name(crate_name: &str) -> String {
445 let sanitized = sanitized_crate_name(crate_name);
446 #[cfg(target_os = "windows")]
447 {
448 format!("{sanitized}.dll")
449 }
450 #[cfg(target_os = "macos")]
451 {
452 format!("lib{sanitized}.dylib")
453 }
454 #[cfg(all(unix, not(target_os = "macos")))]
455 {
456 format!("lib{sanitized}.so")
457 }
458}
459
460fn sanitized_crate_name(name: &str) -> String {
461 name.replace('-', "_")
462}
463
464fn library_cache() -> &'static Mutex<HashMap<PathBuf, &'static Library>> {
465 static CACHE: OnceLock<Mutex<HashMap<PathBuf, &'static Library>>> = OnceLock::new();
466 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
467}
468
469fn get_or_load_library(path: &Path) -> Result<&'static Library, LocalModuleError> {
470 {
471 let cache = library_cache().lock().unwrap();
472 if let Some(lib) = cache.get(path) {
473 return Ok(*lib);
474 }
475 }
476
477 let library = unsafe { Library::new(path) }.map_err(LocalModuleError::LibraryLoad)?;
478 let leaked = Box::leak(Box::new(library));
479
480 let mut cache = library_cache().lock().unwrap();
481 let entry = cache.entry(path.to_path_buf()).or_insert(leaked);
482 Ok(*entry)
483}
484
485fn visit_stub_dir(
486 current: &Path,
487 relative: PathBuf,
488 stubs: &mut Vec<StubFile>,
489) -> Result<(), LocalModuleError> {
490 for entry in fs::read_dir(current)? {
491 let entry = entry?;
492 let path = entry.path();
493 let next_relative = relative.join(entry.file_name());
494 if path.is_dir() {
495 visit_stub_dir(&path, next_relative, stubs)?;
496 } else if path.extension() == Some(OsStr::new("lust")) {
497 let contents = fs::read_to_string(&path)?;
498 stubs.push(StubFile {
499 relative_path: next_relative,
500 contents,
501 });
502 }
503 }
504
505 Ok(())
506}
507
508pub fn stub_files_from_exports(
509 exports: &[NativeExport],
510 type_stubs: &[ModuleStub],
511) -> Vec<StubFile> {
512 if exports.is_empty() && type_stubs.iter().all(ModuleStub::is_empty) {
513 return Vec::new();
514 }
515
516 #[derive(Default)]
517 struct CombinedModule<'a> {
518 type_stub: ModuleStub,
519 functions: Vec<&'a NativeExport>,
520 }
521
522 let mut combined: BTreeMap<String, CombinedModule<'_>> = BTreeMap::new();
523 for stub in type_stubs {
524 if stub.is_empty() {
525 continue;
526 }
527 let entry = combined
528 .entry(stub.module.clone())
529 .or_insert_with(|| CombinedModule {
530 type_stub: ModuleStub {
531 module: stub.module.clone(),
532 ..ModuleStub::default()
533 },
534 ..CombinedModule::default()
535 });
536 entry.type_stub.struct_defs.extend(stub.struct_defs.clone());
537 entry.type_stub.enum_defs.extend(stub.enum_defs.clone());
538 entry.type_stub.trait_defs.extend(stub.trait_defs.clone());
539 }
540
541 for export in exports {
542 let (module, _) = match export.name().rsplit_once('.') {
543 Some(parts) => parts,
544 None => continue,
545 };
546 let entry = combined
547 .entry(module.to_string())
548 .or_insert_with(|| CombinedModule {
549 type_stub: ModuleStub {
550 module: module.to_string(),
551 ..ModuleStub::default()
552 },
553 ..CombinedModule::default()
554 });
555 entry.functions.push(export);
556 }
557
558 let mut result = Vec::new();
559 for (module, mut combined_entry) in combined {
560 combined_entry
561 .functions
562 .sort_by(|a, b| a.name().cmp(b.name()));
563 let mut contents = String::new();
564
565 let mut wrote_type = false;
566 let append_defs = |defs: &Vec<String>, contents: &mut String, wrote_flag: &mut bool| {
567 if defs.is_empty() {
568 return;
569 }
570 if *wrote_flag && !contents.ends_with("\n\n") && !contents.is_empty() {
571 contents.push('\n');
572 }
573 for def in defs {
574 contents.push_str(def);
575 if !def.ends_with('\n') {
576 contents.push('\n');
577 }
578 }
579 *wrote_flag = true;
580 };
581
582 append_defs(
583 &combined_entry.type_stub.struct_defs,
584 &mut contents,
585 &mut wrote_type,
586 );
587 append_defs(
588 &combined_entry.type_stub.enum_defs,
589 &mut contents,
590 &mut wrote_type,
591 );
592 append_defs(
593 &combined_entry.type_stub.trait_defs,
594 &mut contents,
595 &mut wrote_type,
596 );
597
598 if !combined_entry.functions.is_empty() {
599 if wrote_type && !contents.ends_with("\n\n") {
600 contents.push('\n');
601 }
602 contents.push_str("pub extern\n");
603 for export in combined_entry.functions {
604 if let Some((_, function)) = export.name().rsplit_once('.') {
605 let params = format_params(export);
606 let return_type = export.return_type();
607 if let Some(doc) = export.doc() {
608 contents.push_str(" -- ");
609 contents.push_str(doc);
610 if !doc.ends_with('\n') {
611 contents.push('\n');
612 }
613 }
614 contents.push_str(" function ");
615 contents.push_str(function);
616 contents.push('(');
617 contents.push_str(¶ms);
618 contents.push(')');
619 if !return_type.trim().is_empty() && return_type.trim() != "()" {
620 contents.push_str(": ");
621 contents.push_str(return_type);
622 }
623 contents.push('\n');
624 }
625 }
626 contents.push_str("end\n");
627 }
628
629 if contents.is_empty() {
630 continue;
631 }
632 let mut relative = relative_stub_path(&module);
633 if relative.extension().is_none() {
634 relative.set_extension("lust");
635 }
636 result.push(StubFile {
637 relative_path: relative,
638 contents,
639 });
640 }
641
642 result
643}
644
645fn format_params(export: &NativeExport) -> String {
646 export
647 .params()
648 .iter()
649 .map(|param| {
650 let ty = param.ty().trim();
651 if ty.is_empty() {
652 "any"
653 } else {
654 ty
655 }
656 })
657 .collect::<Vec<_>>()
658 .join(", ")
659}
660
661fn relative_stub_path(module: &str) -> PathBuf {
662 let mut path = PathBuf::new();
663 let mut segments: Vec<String> = module.split('.').map(|seg| seg.replace('-', "_")).collect();
664 if let Some(first) = segments.first() {
665 if first == "externs" {
666 segments.remove(0);
667 }
668 }
669 for seg in segments {
670 path.push(seg);
671 }
672 path
673}
674
675fn read_crate_name(module_dir: &Path) -> Result<String, LocalModuleError> {
676 let manifest_path = module_dir.join("Cargo.toml");
677 let manifest_str = fs::read_to_string(&manifest_path)?;
678 #[derive(Deserialize)]
679 struct Manifest {
680 package: PackageSection,
681 }
682 #[derive(Deserialize)]
683 struct PackageSection {
684 name: String,
685 }
686 let manifest: Manifest =
687 toml::from_str(&manifest_str).map_err(|source| LocalModuleError::Manifest {
688 path: manifest_path,
689 source,
690 })?;
691 Ok(manifest.package.name)
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697
698 #[test]
699 fn specifier_builder_sets_version() {
700 let spec = PackageSpecifier::new("foo", PackageKind::LustLibrary).with_version("1.2.3");
701 assert_eq!(spec.version.as_deref(), Some("1.2.3"));
702 }
703
704 #[test]
705 fn ensure_layout_creates_directories() {
706 let temp_dir = tempfile::tempdir().expect("temp directory");
707 let root = temp_dir.path().join("pkg");
708 let manager = PackageManager::new(&root);
709 manager.ensure_layout().expect("create dirs");
710 assert!(root.exists());
711 assert!(root.is_dir());
712 }
713
714 #[test]
715 fn library_name_sanitizes_hyphens() {
716 #[cfg(target_os = "windows")]
717 assert_eq!(library_file_name("my-ext"), "my_ext.dll");
718 #[cfg(target_os = "macos")]
719 assert_eq!(library_file_name("my-ext"), "libmy_ext.dylib");
720 #[cfg(all(unix, not(target_os = "macos")))]
721 assert_eq!(library_file_name("my-ext"), "libmy_ext.so");
722 }
723}