lust/packages/
mod.rs

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