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(¶ms);
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}