Skip to main content

lux_lib/tree/
mod.rs

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