lust/packages/
mod.rs

1use crate::{NativeExport, VM};
2use libloading::Library;
3use serde::Deserialize;
4use std::{
5    collections::{BTreeMap, HashMap},
6    env,
7    ffi::OsStr,
8    fs, io,
9    path::{Path, PathBuf},
10    process::{Command, ExitStatus, Stdio},
11    sync::{Mutex, OnceLock},
12};
13use thiserror::Error;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum PackageKind {
17    LustLibrary,
18    RustExtension,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct PackageSpecifier {
23    pub name: String,
24    pub version: Option<String>,
25    pub kind: PackageKind,
26}
27
28impl PackageSpecifier {
29    pub fn new(name: impl Into<String>, kind: PackageKind) -> Self {
30        Self {
31            name: name.into(),
32            version: None,
33            kind,
34        }
35    }
36
37    pub fn with_version(mut self, version: impl Into<String>) -> Self {
38        self.version = Some(version.into());
39        self
40    }
41}
42
43pub struct PackageManager {
44    root: PathBuf,
45}
46
47impl PackageManager {
48    pub fn new(root: impl Into<PathBuf>) -> Self {
49        Self { root: root.into() }
50    }
51
52    pub fn default_root() -> PathBuf {
53        let mut base = dirs::data_local_dir().unwrap_or_else(|| PathBuf::from(".lust/cache"));
54        base.push("lust");
55        base.push("packages");
56        base
57    }
58
59    pub fn root(&self) -> &Path {
60        &self.root
61    }
62
63    pub fn ensure_layout(&self) -> io::Result<()> {
64        fs::create_dir_all(&self.root)
65    }
66}
67
68#[derive(Debug, Error)]
69pub enum LocalModuleError {
70    #[error("I/O error: {0}")]
71    Io(#[from] io::Error),
72
73    #[error("failed to parse Cargo manifest {path}: {source}")]
74    Manifest {
75        path: PathBuf,
76        #[source]
77        source: toml::de::Error,
78    },
79
80    #[error("cargo build failed for '{module}' with status {status}: {output}")]
81    CargoBuild {
82        module: String,
83        status: ExitStatus,
84        output: String,
85    },
86
87    #[error("built library not found at {0}")]
88    LibraryMissing(PathBuf),
89
90    #[error("failed to load dynamic library: {0}")]
91    LibraryLoad(#[from] libloading::Error),
92
93    #[error("register function 'lust_extension_register' missing in {0}")]
94    RegisterSymbolMissing(String),
95
96    #[error("register function reported failure in {0}")]
97    RegisterFailed(String),
98}
99
100#[derive(Debug, Clone)]
101pub struct LocalBuildOutput {
102    pub name: String,
103    pub library_path: PathBuf,
104}
105
106#[derive(Debug)]
107pub struct LoadedRustModule {
108    name: String,
109}
110
111impl LoadedRustModule {
112    pub fn name(&self) -> &str {
113        &self.name
114    }
115}
116
117#[derive(Debug, Clone)]
118pub struct StubFile {
119    pub relative_path: PathBuf,
120    pub contents: String,
121}
122
123pub fn build_local_module(module_dir: &Path) -> Result<LocalBuildOutput, LocalModuleError> {
124    let crate_name = read_crate_name(module_dir)?;
125    let profile = extension_profile();
126    let mut command = Command::new("cargo");
127    command.arg("build");
128    command.arg("--quiet");
129    match profile.as_str() {
130        "release" => {
131            command.arg("--release");
132        }
133        "debug" => {}
134        other => {
135            command.args(["--profile", other]);
136        }
137    }
138    command.current_dir(module_dir);
139    command.stdout(Stdio::piped()).stderr(Stdio::piped());
140    let output = command.output()?;
141    if !output.status.success() {
142        let mut message = String::new();
143        if !output.stdout.is_empty() {
144            message.push_str(&String::from_utf8_lossy(&output.stdout));
145        }
146        if !output.stderr.is_empty() {
147            if !message.is_empty() {
148                message.push('\n');
149            }
150            message.push_str(&String::from_utf8_lossy(&output.stderr));
151        }
152        return Err(LocalModuleError::CargoBuild {
153            module: crate_name,
154            status: output.status,
155            output: message,
156        });
157    }
158
159    let artifact = module_dir
160        .join("target")
161        .join(&profile)
162        .join(library_file_name(&crate_name));
163    if !artifact.exists() {
164        return Err(LocalModuleError::LibraryMissing(artifact));
165    }
166
167    Ok(LocalBuildOutput {
168        name: crate_name,
169        library_path: artifact,
170    })
171}
172
173pub fn load_local_module(
174    vm: &mut VM,
175    build: &LocalBuildOutput,
176) -> Result<LoadedRustModule, LocalModuleError> {
177    let library = get_or_load_library(&build.library_path)?;
178    unsafe {
179        let register = library
180            .get::<unsafe extern "C" fn(*mut VM) -> bool>(b"lust_extension_register\0")
181            .map_err(|_| LocalModuleError::RegisterSymbolMissing(build.name.clone()))?;
182        vm.push_export_prefix(&build.name);
183        let success = register(vm as *mut VM);
184        vm.pop_export_prefix();
185        if !success {
186            return Err(LocalModuleError::RegisterFailed(build.name.clone()));
187        }
188    }
189    Ok(LoadedRustModule {
190        name: build.name.clone(),
191    })
192}
193
194pub fn collect_stub_files(
195    module_dir: &Path,
196    override_dir: Option<&Path>,
197) -> Result<Vec<StubFile>, LocalModuleError> {
198    let base_dir = match override_dir {
199        Some(dir) => dir.to_path_buf(),
200        None => module_dir.join("externs"),
201    };
202    if !base_dir.exists() {
203        return Ok(Vec::new());
204    }
205
206    let mut stubs = Vec::new();
207    visit_stub_dir(&base_dir, PathBuf::new(), &mut stubs)?;
208    Ok(stubs)
209}
210
211pub fn write_stub_files(
212    crate_name: &str,
213    stubs: &[StubFile],
214    output_root: &Path,
215) -> Result<Vec<PathBuf>, LocalModuleError> {
216    let mut written = Vec::new();
217    for stub in stubs {
218        let mut relative = if stub.relative_path.components().next().is_some() {
219            stub.relative_path.clone()
220        } else {
221            let mut path = PathBuf::new();
222            path.push(sanitized_crate_name(crate_name));
223            path.set_extension("lust");
224            path
225        };
226        if relative.extension().is_none() {
227            relative.set_extension("lust");
228        }
229        let destination = output_root.join(&relative);
230        if let Some(parent) = destination.parent() {
231            fs::create_dir_all(parent)?;
232        }
233        fs::write(&destination, &stub.contents)?;
234        written.push(relative);
235    }
236
237    Ok(written)
238}
239
240fn extension_profile() -> String {
241    env::var("LUST_EXTENSION_PROFILE").unwrap_or_else(|_| "release".to_string())
242}
243
244fn library_file_name(crate_name: &str) -> String {
245    let sanitized = sanitized_crate_name(crate_name);
246    #[cfg(target_os = "windows")]
247    {
248        format!("{sanitized}.dll")
249    }
250    #[cfg(target_os = "macos")]
251    {
252        format!("lib{sanitized}.dylib")
253    }
254    #[cfg(all(unix, not(target_os = "macos")))]
255    {
256        format!("lib{sanitized}.so")
257    }
258}
259
260fn sanitized_crate_name(name: &str) -> String {
261    name.replace('-', "_")
262}
263
264fn library_cache() -> &'static Mutex<HashMap<PathBuf, &'static Library>> {
265    static CACHE: OnceLock<Mutex<HashMap<PathBuf, &'static Library>>> = OnceLock::new();
266    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
267}
268
269fn get_or_load_library(path: &Path) -> Result<&'static Library, LocalModuleError> {
270    {
271        let cache = library_cache().lock().unwrap();
272        if let Some(lib) = cache.get(path) {
273            return Ok(*lib);
274        }
275    }
276
277    let library = unsafe { Library::new(path) }.map_err(LocalModuleError::LibraryLoad)?;
278    let leaked = Box::leak(Box::new(library));
279
280    let mut cache = library_cache().lock().unwrap();
281    let entry = cache.entry(path.to_path_buf()).or_insert(leaked);
282    Ok(*entry)
283}
284
285fn visit_stub_dir(
286    current: &Path,
287    relative: PathBuf,
288    stubs: &mut Vec<StubFile>,
289) -> Result<(), LocalModuleError> {
290    for entry in fs::read_dir(current)? {
291        let entry = entry?;
292        let path = entry.path();
293        let next_relative = relative.join(entry.file_name());
294        if path.is_dir() {
295            visit_stub_dir(&path, next_relative, stubs)?;
296        } else if path.extension() == Some(OsStr::new("lust")) {
297            let contents = fs::read_to_string(&path)?;
298            stubs.push(StubFile {
299                relative_path: next_relative,
300                contents,
301            });
302        }
303    }
304
305    Ok(())
306}
307
308pub fn stub_files_from_exports(exports: &[NativeExport]) -> Vec<StubFile> {
309    if exports.is_empty() {
310        return Vec::new();
311    }
312
313    let mut grouped: BTreeMap<String, Vec<&NativeExport>> = BTreeMap::new();
314    for export in exports {
315        let (module, _function) = match export.name().rsplit_once('.') {
316            Some(parts) => parts,
317            None => continue,
318        };
319        grouped.entry(module.to_string()).or_default().push(export);
320    }
321
322    let mut result = Vec::new();
323    for (module, mut items) in grouped {
324        items.sort_by(|a, b| a.name().cmp(b.name()));
325        let mut contents = String::new();
326        contents.push_str("pub extern {\n");
327        for export in items {
328            if let Some((_, function)) = export.name().rsplit_once('.') {
329                let params = format_params(export);
330                let return_type = export.return_type();
331                if let Some(doc) = export.doc() {
332                    contents.push_str("    -- ");
333                    contents.push_str(doc);
334                    if !doc.ends_with('\n') {
335                        contents.push('\n');
336                    }
337                }
338                contents.push_str("    function ");
339                contents.push_str(function);
340                contents.push('(');
341                contents.push_str(&params);
342                contents.push(')');
343                if !return_type.trim().is_empty() && return_type.trim() != "()" {
344                    contents.push_str(": ");
345                    contents.push_str(return_type);
346                }
347                contents.push('\n');
348            }
349        }
350        contents.push_str("}\n");
351        let mut relative = relative_stub_path(&module);
352        if relative.extension().is_none() {
353            relative.set_extension("lust");
354        }
355        result.push(StubFile {
356            relative_path: relative,
357            contents,
358        });
359    }
360
361    result
362}
363
364fn format_params(export: &NativeExport) -> String {
365    export
366        .params()
367        .iter()
368        .map(|param| {
369            let ty = param.ty().trim();
370            if ty.is_empty() {
371                "any"
372            } else {
373                ty
374            }
375        })
376        .collect::<Vec<_>>()
377        .join(", ")
378}
379
380fn relative_stub_path(module: &str) -> PathBuf {
381    let mut path = PathBuf::new();
382    let mut segments: Vec<String> = module.split('.').map(|seg| seg.replace('-', "_")).collect();
383    if let Some(first) = segments.first() {
384        if first == "externs" {
385            segments.remove(0);
386        }
387    }
388    for seg in segments {
389        path.push(seg);
390    }
391    path
392}
393
394fn read_crate_name(module_dir: &Path) -> Result<String, LocalModuleError> {
395    let manifest_path = module_dir.join("Cargo.toml");
396    let manifest_str = fs::read_to_string(&manifest_path)?;
397    #[derive(Deserialize)]
398    struct Manifest {
399        package: PackageSection,
400    }
401    #[derive(Deserialize)]
402    struct PackageSection {
403        name: String,
404    }
405    let manifest: Manifest =
406        toml::from_str(&manifest_str).map_err(|source| LocalModuleError::Manifest {
407            path: manifest_path,
408            source,
409        })?;
410    Ok(manifest.package.name)
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn specifier_builder_sets_version() {
419        let spec = PackageSpecifier::new("foo", PackageKind::LustLibrary).with_version("1.2.3");
420        assert_eq!(spec.version.as_deref(), Some("1.2.3"));
421    }
422
423    #[test]
424    fn ensure_layout_creates_directories() {
425        let temp_dir = tempfile::tempdir().expect("temp directory");
426        let root = temp_dir.path().join("pkg");
427        let manager = PackageManager::new(&root);
428        manager.ensure_layout().expect("create dirs");
429        assert!(root.exists());
430        assert!(root.is_dir());
431    }
432
433    #[test]
434    fn library_name_sanitizes_hyphens() {
435        #[cfg(target_os = "windows")]
436        assert_eq!(library_file_name("my-ext"), "my_ext.dll");
437        #[cfg(target_os = "macos")]
438        assert_eq!(library_file_name("my-ext"), "libmy_ext.dylib");
439        #[cfg(all(unix, not(target_os = "macos")))]
440        assert_eq!(library_file_name("my-ext"), "libmy_ext.so");
441    }
442}