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#[derive(Debug, Clone)]
146pub struct BuildOptions<'a> {
147    pub features: &'a [String],
148    pub default_features: bool,
149}
150
151impl<'a> Default for BuildOptions<'a> {
152    fn default() -> Self {
153        Self {
154            features: &[],
155            default_features: true,
156        }
157    }
158}
159
160pub fn build_local_module(
161    module_dir: &Path,
162    options: BuildOptions<'_>,
163) -> Result<LocalBuildOutput, LocalModuleError> {
164    let crate_name = read_crate_name(module_dir)?;
165    let profile = extension_profile();
166    let mut command = Command::new("cargo");
167    command.arg("build");
168    command.arg("--quiet");
169    match profile.as_str() {
170        "release" => {
171            command.arg("--release");
172        }
173        "debug" => {}
174        other => {
175            command.args(["--profile", other]);
176        }
177    }
178    if !options.default_features {
179        command.arg("--no-default-features");
180    }
181    if !options.features.is_empty() {
182        command.arg("--features");
183        command.arg(options.features.join(","));
184    }
185    command.current_dir(module_dir);
186    command.stdout(Stdio::piped()).stderr(Stdio::piped());
187    let output = command.output()?;
188    if !output.status.success() {
189        let mut message = String::new();
190        if !output.stdout.is_empty() {
191            message.push_str(&String::from_utf8_lossy(&output.stdout));
192        }
193        if !output.stderr.is_empty() {
194            if !message.is_empty() {
195                message.push('\n');
196            }
197            message.push_str(&String::from_utf8_lossy(&output.stderr));
198        }
199        return Err(LocalModuleError::CargoBuild {
200            module: crate_name,
201            status: output.status,
202            output: message,
203        });
204    }
205
206    let artifact = module_dir
207        .join("target")
208        .join(&profile)
209        .join(library_file_name(&crate_name));
210    if !artifact.exists() {
211        return Err(LocalModuleError::LibraryMissing(artifact));
212    }
213
214    Ok(LocalBuildOutput {
215        name: crate_name,
216        library_path: artifact,
217    })
218}
219
220pub fn load_local_module(
221    vm: &mut VM,
222    build: &LocalBuildOutput,
223) -> Result<LoadedRustModule, LocalModuleError> {
224    let library = get_or_load_library(&build.library_path)?;
225    unsafe {
226        let register = library
227            .get::<unsafe extern "C" fn(*mut VM) -> bool>(b"lust_extension_register\0")
228            .map_err(|_| LocalModuleError::RegisterSymbolMissing(build.name.clone()))?;
229        vm.push_export_prefix(&build.name);
230        let success = register(vm as *mut VM);
231        vm.pop_export_prefix();
232        if !success {
233            return Err(LocalModuleError::RegisterFailed(build.name.clone()));
234        }
235    }
236    Ok(LoadedRustModule {
237        name: build.name.clone(),
238    })
239}
240
241pub fn collect_stub_files(
242    module_dir: &Path,
243    override_dir: Option<&Path>,
244) -> Result<Vec<StubFile>, LocalModuleError> {
245    let base_dir = match override_dir {
246        Some(dir) => dir.to_path_buf(),
247        None => module_dir.join("externs"),
248    };
249    if !base_dir.exists() {
250        return Ok(Vec::new());
251    }
252
253    let mut stubs = Vec::new();
254    visit_stub_dir(&base_dir, PathBuf::new(), &mut stubs)?;
255    Ok(stubs)
256}
257
258pub fn write_stub_files(
259    crate_name: &str,
260    stubs: &[StubFile],
261    output_root: &Path,
262) -> Result<Vec<PathBuf>, LocalModuleError> {
263    let mut written = Vec::new();
264    for stub in stubs {
265        let mut relative = if stub.relative_path.components().next().is_some() {
266            stub.relative_path.clone()
267        } else {
268            let mut path = PathBuf::new();
269            path.push(sanitized_crate_name(crate_name));
270            path.set_extension("lust");
271            path
272        };
273        if relative.extension().is_none() {
274            relative.set_extension("lust");
275        }
276        let destination = output_root.join(&relative);
277        if let Some(parent) = destination.parent() {
278            fs::create_dir_all(parent)?;
279        }
280        fs::write(&destination, &stub.contents)?;
281        written.push(relative);
282    }
283
284    Ok(written)
285}
286
287fn extension_profile() -> String {
288    env::var("LUST_EXTENSION_PROFILE").unwrap_or_else(|_| "release".to_string())
289}
290
291fn library_file_name(crate_name: &str) -> String {
292    let sanitized = sanitized_crate_name(crate_name);
293    #[cfg(target_os = "windows")]
294    {
295        format!("{sanitized}.dll")
296    }
297    #[cfg(target_os = "macos")]
298    {
299        format!("lib{sanitized}.dylib")
300    }
301    #[cfg(all(unix, not(target_os = "macos")))]
302    {
303        format!("lib{sanitized}.so")
304    }
305}
306
307fn sanitized_crate_name(name: &str) -> String {
308    name.replace('-', "_")
309}
310
311fn library_cache() -> &'static Mutex<HashMap<PathBuf, &'static Library>> {
312    static CACHE: OnceLock<Mutex<HashMap<PathBuf, &'static Library>>> = OnceLock::new();
313    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
314}
315
316fn get_or_load_library(path: &Path) -> Result<&'static Library, LocalModuleError> {
317    {
318        let cache = library_cache().lock().unwrap();
319        if let Some(lib) = cache.get(path) {
320            return Ok(*lib);
321        }
322    }
323
324    let library = unsafe { Library::new(path) }.map_err(LocalModuleError::LibraryLoad)?;
325    let leaked = Box::leak(Box::new(library));
326
327    let mut cache = library_cache().lock().unwrap();
328    let entry = cache.entry(path.to_path_buf()).or_insert(leaked);
329    Ok(*entry)
330}
331
332fn visit_stub_dir(
333    current: &Path,
334    relative: PathBuf,
335    stubs: &mut Vec<StubFile>,
336) -> Result<(), LocalModuleError> {
337    for entry in fs::read_dir(current)? {
338        let entry = entry?;
339        let path = entry.path();
340        let next_relative = relative.join(entry.file_name());
341        if path.is_dir() {
342            visit_stub_dir(&path, next_relative, stubs)?;
343        } else if path.extension() == Some(OsStr::new("lust")) {
344            let contents = fs::read_to_string(&path)?;
345            stubs.push(StubFile {
346                relative_path: next_relative,
347                contents,
348            });
349        }
350    }
351
352    Ok(())
353}
354
355pub fn stub_files_from_exports(exports: &[NativeExport]) -> Vec<StubFile> {
356    if exports.is_empty() {
357        return Vec::new();
358    }
359
360    let mut grouped: BTreeMap<String, Vec<&NativeExport>> = BTreeMap::new();
361    for export in exports {
362        let (module, _function) = match export.name().rsplit_once('.') {
363            Some(parts) => parts,
364            None => continue,
365        };
366        grouped.entry(module.to_string()).or_default().push(export);
367    }
368
369    let mut result = Vec::new();
370    for (module, mut items) in grouped {
371        items.sort_by(|a, b| a.name().cmp(b.name()));
372        let mut contents = String::new();
373        contents.push_str("pub extern {\n");
374        for export in items {
375            if let Some((_, function)) = export.name().rsplit_once('.') {
376                let params = format_params(export);
377                let return_type = export.return_type();
378                if let Some(doc) = export.doc() {
379                    contents.push_str("    -- ");
380                    contents.push_str(doc);
381                    if !doc.ends_with('\n') {
382                        contents.push('\n');
383                    }
384                }
385                contents.push_str("    function ");
386                contents.push_str(function);
387                contents.push('(');
388                contents.push_str(&params);
389                contents.push(')');
390                if !return_type.trim().is_empty() && return_type.trim() != "()" {
391                    contents.push_str(": ");
392                    contents.push_str(return_type);
393                }
394                contents.push('\n');
395            }
396        }
397        contents.push_str("}\n");
398        let mut relative = relative_stub_path(&module);
399        if relative.extension().is_none() {
400            relative.set_extension("lust");
401        }
402        result.push(StubFile {
403            relative_path: relative,
404            contents,
405        });
406    }
407
408    result
409}
410
411fn format_params(export: &NativeExport) -> String {
412    export
413        .params()
414        .iter()
415        .map(|param| {
416            let ty = param.ty().trim();
417            if ty.is_empty() {
418                "any"
419            } else {
420                ty
421            }
422        })
423        .collect::<Vec<_>>()
424        .join(", ")
425}
426
427fn relative_stub_path(module: &str) -> PathBuf {
428    let mut path = PathBuf::new();
429    let mut segments: Vec<String> = module.split('.').map(|seg| seg.replace('-', "_")).collect();
430    if let Some(first) = segments.first() {
431        if first == "externs" {
432            segments.remove(0);
433        }
434    }
435    for seg in segments {
436        path.push(seg);
437    }
438    path
439}
440
441fn read_crate_name(module_dir: &Path) -> Result<String, LocalModuleError> {
442    let manifest_path = module_dir.join("Cargo.toml");
443    let manifest_str = fs::read_to_string(&manifest_path)?;
444    #[derive(Deserialize)]
445    struct Manifest {
446        package: PackageSection,
447    }
448    #[derive(Deserialize)]
449    struct PackageSection {
450        name: String,
451    }
452    let manifest: Manifest =
453        toml::from_str(&manifest_str).map_err(|source| LocalModuleError::Manifest {
454            path: manifest_path,
455            source,
456        })?;
457    Ok(manifest.package.name)
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn specifier_builder_sets_version() {
466        let spec = PackageSpecifier::new("foo", PackageKind::LustLibrary).with_version("1.2.3");
467        assert_eq!(spec.version.as_deref(), Some("1.2.3"));
468    }
469
470    #[test]
471    fn ensure_layout_creates_directories() {
472        let temp_dir = tempfile::tempdir().expect("temp directory");
473        let root = temp_dir.path().join("pkg");
474        let manager = PackageManager::new(&root);
475        manager.ensure_layout().expect("create dirs");
476        assert!(root.exists());
477        assert!(root.is_dir());
478    }
479
480    #[test]
481    fn library_name_sanitizes_hyphens() {
482        #[cfg(target_os = "windows")]
483        assert_eq!(library_file_name("my-ext"), "my_ext.dll");
484        #[cfg(target_os = "macos")]
485        assert_eq!(library_file_name("my-ext"), "libmy_ext.dylib");
486        #[cfg(all(unix, not(target_os = "macos")))]
487        assert_eq!(library_file_name("my-ext"), "libmy_ext.so");
488    }
489}