lust/packages/
mod.rs

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(&params);
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}