lux_lib/tree/
mod.rs

1use crate::{
2    build::utils::format_path,
3    config::{tree::RockLayoutConfig, Config, LuaVersion},
4    lockfile::{LocalPackage, LocalPackageId, Lockfile, LockfileError, OptState, ReadOnly},
5    package::PackageReq,
6    variables::HasVariables,
7};
8use std::{io, path::PathBuf};
9
10use itertools::Itertools;
11use mlua::{ExternalResult, IntoLua};
12use thiserror::Error;
13
14mod list;
15
16const LOCKFILE_NAME: &str = "lux.lock";
17
18/// A tree is a collection of files where installed rocks are located.
19///
20/// `lux` diverges from the traditional hierarchy employed by luarocks.
21/// Instead, we opt for a much simpler approach:
22///
23/// - /rocks/<lua-version> - contains rocks
24/// - /rocks/<lua-version>/<rock>/etc - documentation and supplementary files for the rock
25/// - /rocks/<lua-version>/<rock>/lib - shared libraries (.so files)
26/// - /rocks/<lua-version>/<rock>/src - library code for the rock
27/// - /bin - binary files produced by various rocks
28
29#[derive(Clone, Debug)]
30pub struct Tree {
31    /// The Lua version of the tree.
32    version: LuaVersion,
33    /// The parent of this tree's root directory.
34    root_parent: PathBuf,
35    /// The rock layout config for this tree
36    entrypoint_layout: RockLayoutConfig,
37}
38
39#[derive(Debug, Error)]
40pub enum TreeError {
41    #[error(transparent)]
42    Io(#[from] io::Error),
43    #[error(transparent)]
44    Lockfile(#[from] LockfileError),
45}
46
47/// Change-agnostic way of referencing various paths for a rock.
48#[derive(Debug, PartialEq)]
49pub struct RockLayout {
50    /// The local installation directory.
51    /// Can be substituted in a rockspec's `build.build_variables` and `build.install_variables`
52    /// using `$(PREFIX)`.
53    pub rock_path: PathBuf,
54    /// The `etc` directory, containing resources.
55    pub etc: PathBuf,
56    /// The `lib` directory, containing native libraries.
57    /// Can be substituted in a rockspec's `build.build_variables` and `build.install_variables`
58    /// using `$(LIBDIR)`.
59    pub lib: PathBuf,
60    /// The `src` directory, containing Lua sources.
61    /// Can be substituted in a rockspec's `build.build_variables` and `build.install_variables`
62    /// using `$(LUADIR)`.
63    pub src: PathBuf,
64    /// The `bin` directory, containing executables.
65    /// Can be substituted in a rockspec's `build.build_variables` and `build.install_variables`
66    /// using `$(BINDIR)`.
67    /// This points to a global binary path at the root of the current tree by default.
68    pub bin: PathBuf,
69    /// The `etc/conf` directory, containing configuration files.
70    /// Can be substituted in a rockspec's `build.build_variables` and `build.install_variables`
71    /// using `$(CONFDIR)`.
72    pub conf: PathBuf,
73    /// The `etc/doc` directory, containing documentation files.
74    /// Can be substituted in a rockspec's `build.build_variables` and `build.install_variables`
75    /// using `$(DOCDIR)`.
76    pub doc: PathBuf,
77}
78
79impl RockLayout {
80    pub fn rockspec_path(&self) -> PathBuf {
81        self.rock_path.join("package.rockspec")
82    }
83}
84
85impl HasVariables for RockLayout {
86    fn get_variable(&self, var: &str) -> Option<String> {
87        let path = match var {
88            "PREFIX" => Some(format_path(&self.rock_path)),
89            "LIBDIR" => Some(format_path(&self.lib)),
90            "LUADIR" => Some(format_path(&self.src)),
91            "BINDIR" => Some(format_path(&self.bin)),
92            "CONFDIR" => Some(format_path(&self.conf)),
93            "DOCDIR" => Some(format_path(&self.doc)),
94            _ => None,
95        }?;
96        Some(path)
97    }
98}
99
100impl mlua::UserData for RockLayout {
101    fn add_fields<F: mlua::UserDataFields<Self>>(fields: &mut F) {
102        fields.add_field_method_get("rock_path", |_, this| Ok(this.rock_path.clone()));
103        fields.add_field_method_get("etc", |_, this| Ok(this.etc.clone()));
104        fields.add_field_method_get("lib", |_, this| Ok(this.lib.clone()));
105        fields.add_field_method_get("src", |_, this| Ok(this.src.clone()));
106        fields.add_field_method_get("bin", |_, this| Ok(this.bin.clone()));
107        fields.add_field_method_get("conf", |_, this| Ok(this.conf.clone()));
108        fields.add_field_method_get("doc", |_, this| Ok(this.doc.clone()));
109    }
110}
111
112impl Tree {
113    /// NOTE: This is exposed for use by the config module.
114    /// Use `Config::tree()`
115    pub(crate) fn new(
116        root: PathBuf,
117        version: LuaVersion,
118        config: &Config,
119    ) -> Result<Self, TreeError> {
120        let path_with_version = root.join(version.to_string());
121
122        // Ensure that the root and the version directory exist.
123        std::fs::create_dir_all(&path_with_version)?;
124
125        // In case the tree is in a git repository, we tell git to ignore it.
126        std::fs::write(root.join(".gitignore"), "*")?;
127
128        // Ensure that the bin directory exists.
129        std::fs::create_dir_all(path_with_version.join("bin"))?;
130        let lockfile_path = root.join(LOCKFILE_NAME);
131        let rock_layout_config = if lockfile_path.is_file() {
132            let lockfile = Lockfile::load(lockfile_path, None)?;
133            lockfile.entrypoint_layout
134        } else {
135            config.entrypoint_layout().clone()
136        };
137        Ok(Self {
138            root_parent: root,
139            version,
140            entrypoint_layout: rock_layout_config,
141        })
142    }
143
144    /// The root of the tree
145    pub fn root(&self) -> PathBuf {
146        self.root_parent.join(self.version.to_string())
147    }
148
149    pub fn version(&self) -> &LuaVersion {
150        &self.version
151    }
152
153    pub fn root_for(&self, package: &LocalPackage) -> PathBuf {
154        self.root().join(format!(
155            "{}-{}@{}",
156            package.id(),
157            package.name(),
158            package.version()
159        ))
160    }
161
162    pub fn bin(&self) -> PathBuf {
163        self.root().join("bin")
164    }
165
166    /// Directory containing unwrapped Lua scripts
167    /// The wrapped scripts are in `Self::bin()`
168    pub(crate) fn unwrapped_bin(&self) -> PathBuf {
169        self.bin().join("unwrapped")
170    }
171
172    pub fn match_rocks(&self, req: &PackageReq) -> Result<RockMatches, TreeError> {
173        let mut found_packages = self.lockfile()?.find_rocks(req);
174        Ok(match found_packages.len() {
175            0 => RockMatches::NotFound(req.clone()),
176            1 => RockMatches::Single(found_packages.pop().unwrap()),
177            2.. => RockMatches::Many(found_packages),
178        })
179    }
180
181    pub fn match_rocks_and<F>(&self, req: &PackageReq, filter: F) -> Result<RockMatches, TreeError>
182    where
183        F: Fn(&LocalPackage) -> bool,
184    {
185        match self.list()?.get(req.name()) {
186            Some(packages) => {
187                let mut found_packages = packages
188                    .iter()
189                    .rev()
190                    .filter(|package| {
191                        req.version_req().matches(package.version()) && filter(package)
192                    })
193                    .map(|package| package.id())
194                    .collect_vec();
195
196                Ok(match found_packages.len() {
197                    0 => RockMatches::NotFound(req.clone()),
198                    1 => RockMatches::Single(found_packages.pop().unwrap()),
199                    2.. => RockMatches::Many(found_packages),
200                })
201            }
202            None => Ok(RockMatches::NotFound(req.clone())),
203        }
204    }
205
206    /// Get the `RockLayout` for an installed package.
207    pub fn installed_rock_layout(&self, package: &LocalPackage) -> Result<RockLayout, TreeError> {
208        let lockfile = self.lockfile()?;
209        if lockfile.is_entrypoint(&package.id()) {
210            Ok(self.entrypoint_layout(package))
211        } else {
212            Ok(self.dependency_layout(package))
213        }
214    }
215
216    /// Create a `RockLayout` for an entrypoint
217    pub fn entrypoint_layout(&self, package: &LocalPackage) -> RockLayout {
218        self.mk_rock_layout(package, &self.entrypoint_layout)
219    }
220
221    /// Create a `RockLayout` for a dependency
222    pub fn dependency_layout(&self, package: &LocalPackage) -> RockLayout {
223        self.mk_rock_layout(package, &RockLayoutConfig::default())
224    }
225
226    /// Create a `RockLayout` for a package.
227    fn mk_rock_layout(
228        &self,
229        package: &LocalPackage,
230        layout_config: &RockLayoutConfig,
231    ) -> RockLayout {
232        let rock_path = self.root_for(package);
233        let bin = self.bin();
234        let etc_root = match layout_config.etc_root {
235            Some(ref etc_root) => self.root().join(etc_root),
236            None => rock_path.clone(),
237        };
238        let mut etc = match package.spec.opt {
239            OptState::Required => etc_root.join(&layout_config.etc),
240            OptState::Optional => etc_root.join(&layout_config.opt_etc),
241        };
242        if layout_config.etc_root.is_some() {
243            etc = etc.join(format!("{}", package.name()));
244        }
245        let lib = rock_path.join("lib");
246        let src = rock_path.join("src");
247        let conf = etc.join(&layout_config.conf);
248        let doc = etc.join(&layout_config.doc);
249
250        RockLayout {
251            rock_path,
252            etc,
253            lib,
254            src,
255            bin,
256            conf,
257            doc,
258        }
259    }
260
261    /// Create a `RockLayout` for an entrypoint package, creating the `lib` and `src` directories.
262    pub fn entrypoint(&self, package: &LocalPackage) -> io::Result<RockLayout> {
263        let rock_layout = self.entrypoint_layout(package);
264        std::fs::create_dir_all(&rock_layout.lib)?;
265        std::fs::create_dir_all(&rock_layout.src)?;
266        Ok(rock_layout)
267    }
268
269    /// Create a `RockLayout` for a dependency package, creating the `lib` and `src` directories.
270    pub fn dependency(&self, package: &LocalPackage) -> io::Result<RockLayout> {
271        let rock_layout = self.dependency_layout(package);
272        std::fs::create_dir_all(&rock_layout.lib)?;
273        std::fs::create_dir_all(&rock_layout.src)?;
274        Ok(rock_layout)
275    }
276
277    pub fn lockfile(&self) -> Result<Lockfile<ReadOnly>, TreeError> {
278        Ok(Lockfile::new(
279            self.lockfile_path(),
280            self.entrypoint_layout.clone(),
281        )?)
282    }
283
284    /// Get this tree's lockfile path.
285    pub fn lockfile_path(&self) -> PathBuf {
286        self.root().join(LOCKFILE_NAME)
287    }
288
289    /// The tree in which to install test dependencies
290    pub fn test_tree(&self, config: &Config) -> Result<Tree, TreeError> {
291        let test_tree_root = self.root().join("test_dependencies");
292        Tree::new(test_tree_root, self.version.clone(), config)
293    }
294
295    /// The tree in which to install build dependencies
296    pub fn build_tree(&self, config: &Config) -> Result<Tree, TreeError> {
297        let build_tree_root = self.root().join("build_dependencies");
298        Tree::new(build_tree_root, self.version.clone(), config)
299    }
300}
301
302impl mlua::UserData for Tree {
303    fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
304        methods.add_method("root", |_, this, ()| Ok(this.root()));
305        methods.add_method("root_for", |_, this, package: LocalPackage| {
306            Ok(this.root_for(&package))
307        });
308        methods.add_method("bin", |_, this, ()| Ok(this.bin()));
309        methods.add_method("match_rocks", |_, this, req: PackageReq| {
310            this.match_rocks(&req)
311                .map_err(|err| mlua::Error::RuntimeError(err.to_string()))
312        });
313        methods.add_method(
314            "match_rock_and",
315            |_, this, (req, callback): (PackageReq, mlua::Function)| {
316                this.match_rocks_and(&req, |package: &LocalPackage| {
317                    callback.call(package.clone()).unwrap_or(false)
318                })
319                .into_lua_err()
320            },
321        );
322        methods.add_method("rock_layout", |_, this, package: LocalPackage| {
323            this.installed_rock_layout(&package)
324                .map_err(|err| mlua::Error::RuntimeError(err.to_string()))
325        });
326        methods.add_method("rock", |_, this, package: LocalPackage| {
327            this.dependency(&package).into_lua_err()
328        });
329        methods.add_method("lockfile", |_, this, ()| this.lockfile().into_lua_err());
330    }
331}
332
333#[derive(Copy, Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord)]
334pub enum EntryType {
335    Entrypoint,
336    DependencyOnly,
337}
338
339impl EntryType {
340    pub fn is_entrypoint(&self) -> bool {
341        matches!(self, Self::Entrypoint)
342    }
343}
344
345#[derive(Clone, Debug)]
346pub enum RockMatches {
347    NotFound(PackageReq),
348    Single(LocalPackageId),
349    Many(Vec<LocalPackageId>),
350}
351
352// Loosely mimic the Option<T> functions.
353impl RockMatches {
354    pub fn is_found(&self) -> bool {
355        matches!(self, Self::Single(_) | Self::Many(_))
356    }
357}
358
359impl IntoLua for RockMatches {
360    fn into_lua(self, lua: &mlua::Lua) -> mlua::Result<mlua::Value> {
361        let table = lua.create_table()?;
362        let is_found = self.is_found();
363
364        table.set("is_found", lua.create_function(move |_, ()| Ok(is_found))?)?;
365
366        match self {
367            RockMatches::NotFound(package_req) => table.set("not_found", package_req)?,
368            RockMatches::Single(local_package_id) => table.set("single", local_package_id)?,
369            RockMatches::Many(local_package_ids) => table.set("many", local_package_ids)?,
370        }
371
372        Ok(mlua::Value::Table(table))
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use assert_fs::prelude::PathCopy;
379    use itertools::Itertools;
380    use std::path::PathBuf;
381
382    use insta::assert_yaml_snapshot;
383
384    use crate::{
385        config::{ConfigBuilder, LuaVersion},
386        lockfile::{LocalPackage, LocalPackageHashes, LockConstraint},
387        package::{PackageName, PackageSpec, PackageVersion},
388        remote_package_source::RemotePackageSource,
389        rockspec::RockBinaries,
390        tree::RockLayout,
391        variables,
392    };
393
394    #[test]
395    fn rock_layout() {
396        let tree_path =
397            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
398
399        let temp = assert_fs::TempDir::new().unwrap();
400        temp.copy_from(&tree_path, &["**"]).unwrap();
401        let tree_path = temp.to_path_buf();
402
403        let config = ConfigBuilder::new()
404            .unwrap()
405            .user_tree(Some(tree_path.clone()))
406            .build()
407            .unwrap();
408        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
409
410        let mock_hashes = LocalPackageHashes {
411            rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
412                .parse()
413                .unwrap(),
414            source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
415                .parse()
416                .unwrap(),
417        };
418
419        let package = LocalPackage::from(
420            &PackageSpec::parse("neorg".into(), "8.0.0-1".into()).unwrap(),
421            LockConstraint::Unconstrained,
422            RockBinaries::default(),
423            RemotePackageSource::Test,
424            None,
425            mock_hashes.clone(),
426        );
427
428        let id = package.id();
429
430        let neorg = tree.dependency(&package).unwrap();
431
432        assert_eq!(
433            neorg,
434            RockLayout {
435                bin: tree_path.join("5.1/bin"),
436                rock_path: tree_path.join(format!("5.1/{id}-neorg@8.0.0-1")),
437                etc: tree_path.join(format!("5.1/{id}-neorg@8.0.0-1/etc")),
438                lib: tree_path.join(format!("5.1/{id}-neorg@8.0.0-1/lib")),
439                src: tree_path.join(format!("5.1/{id}-neorg@8.0.0-1/src")),
440                conf: tree_path.join(format!("5.1/{id}-neorg@8.0.0-1/etc/conf")),
441                doc: tree_path.join(format!("5.1/{id}-neorg@8.0.0-1/etc/doc")),
442            }
443        );
444
445        let package = LocalPackage::from(
446            &PackageSpec::parse("lua-cjson".into(), "2.1.0-1".into()).unwrap(),
447            LockConstraint::Unconstrained,
448            RockBinaries::default(),
449            RemotePackageSource::Test,
450            None,
451            mock_hashes.clone(),
452        );
453
454        let id = package.id();
455
456        let lua_cjson = tree.dependency(&package).unwrap();
457
458        assert_eq!(
459            lua_cjson,
460            RockLayout {
461                bin: tree_path.join("5.1/bin"),
462                rock_path: tree_path.join(format!("5.1/{id}-lua-cjson@2.1.0-1")),
463                etc: tree_path.join(format!("5.1/{id}-lua-cjson@2.1.0-1/etc")),
464                lib: tree_path.join(format!("5.1/{id}-lua-cjson@2.1.0-1/lib")),
465                src: tree_path.join(format!("5.1/{id}-lua-cjson@2.1.0-1/src")),
466                conf: tree_path.join(format!("5.1/{id}-lua-cjson@2.1.0-1/etc/conf")),
467                doc: tree_path.join(format!("5.1/{id}-lua-cjson@2.1.0-1/etc/doc")),
468            }
469        );
470    }
471
472    #[test]
473    fn tree_list() {
474        let tree_path =
475            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
476
477        let temp = assert_fs::TempDir::new().unwrap();
478        temp.copy_from(&tree_path, &["**"]).unwrap();
479        let tree_path = temp.to_path_buf();
480
481        let config = ConfigBuilder::new()
482            .unwrap()
483            .user_tree(Some(tree_path.clone()))
484            .build()
485            .unwrap();
486        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
487        let result = tree.list().unwrap();
488        // note: sorted_redaction doesn't work because we have a nested Vec
489        let sorted_result: Vec<(PackageName, Vec<PackageVersion>)> = result
490            .into_iter()
491            .sorted()
492            .map(|(name, package)| {
493                (
494                    name,
495                    package
496                        .into_iter()
497                        .map(|package| package.spec.version)
498                        .sorted()
499                        .collect_vec(),
500                )
501            })
502            .collect_vec();
503
504        assert_yaml_snapshot!(sorted_result)
505    }
506
507    #[test]
508    fn rock_layout_substitute() {
509        let tree_path =
510            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
511
512        let temp = assert_fs::TempDir::new().unwrap();
513        temp.copy_from(&tree_path, &["**"]).unwrap();
514        let tree_path = temp.to_path_buf();
515
516        let config = ConfigBuilder::new()
517            .unwrap()
518            .user_tree(Some(tree_path.clone()))
519            .build()
520            .unwrap();
521        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
522
523        let mock_hashes = LocalPackageHashes {
524            rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
525                .parse()
526                .unwrap(),
527            source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
528                .parse()
529                .unwrap(),
530        };
531
532        let neorg = tree
533            .dependency(&LocalPackage::from(
534                &PackageSpec::parse("neorg".into(), "8.0.0-1-1".into()).unwrap(),
535                LockConstraint::Unconstrained,
536                RockBinaries::default(),
537                RemotePackageSource::Test,
538                None,
539                mock_hashes.clone(),
540            ))
541            .unwrap();
542        let build_variables = vec![
543            "$(PREFIX)",
544            "$(LIBDIR)",
545            "$(LUADIR)",
546            "$(BINDIR)",
547            "$(CONFDIR)",
548            "$(DOCDIR)",
549        ];
550        let result: Vec<String> = build_variables
551            .into_iter()
552            .map(|var| variables::substitute(&[&neorg], var))
553            .try_collect()
554            .unwrap();
555        assert_eq!(
556            result,
557            vec![
558                neorg.rock_path.to_string_lossy().to_string(),
559                neorg.lib.to_string_lossy().to_string(),
560                neorg.src.to_string_lossy().to_string(),
561                neorg.bin.to_string_lossy().to_string(),
562                neorg.conf.to_string_lossy().to_string(),
563                neorg.doc.to_string_lossy().to_string(),
564            ]
565        );
566    }
567}