Skip to main content

lux_lib/tree/
mod.rs

1use crate::{
2    build::utils::format_path,
3    config::Config,
4    fs,
5    lockfile::{LocalPackage, LocalPackageId, Lockfile, LockfileError, ReadOnly},
6    lua_version::LuaVersion,
7    package::{PackageName, PackageReq},
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: &LocalPackage) -> RockLayout;
46    /// Create a [`Lockfile`] for this tree.
47    fn lockfile(&self) -> Result<Lockfile<ReadOnly>, TreeError>;
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, TreeError>;
52    /// The tree in which to install test dependencies.
53    fn test_tree(&self, config: &Config) -> Result<Tree, TreeError>;
54    /// List the packages that are installed in this tree.
55    fn list(&self) -> Result<HashMap<PackageName, Vec<LocalPackage>>, TreeError>;
56    /// Find installed rocks that match the given [`PackageReq`].
57    fn match_rocks(&self, req: &PackageReq) -> Result<RockMatches, TreeError>;
58    /// Create the standard directories (src, lib, etc.) for a package.
59    fn prepare(&self, package: &LocalPackage) -> Result<(), TreeError>;
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: &LocalPackage, _entry_type: EntryType) -> Result<(), TreeError> {
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: &LocalPackage, entry_type: EntryType) -> Result<(), TreeError>;
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 [`LocalPackageId`]
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 TreeError {
148    #[error(transparent)]
149    #[diagnostic(transparent)]
150    Fs(#[from] fs::FsError),
151    #[error(transparent)]
152    #[diagnostic(transparent)]
153    Lockfile(#[from] LockfileError),
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(format_path(&self.root)),
162            "LIBDIR" => Some(format_path(&self.lib)),
163            "LUADIR" => Some(format_path(&self.src)),
164            "BINDIR" => Some(format_path(&self.bin)),
165            "CONFDIR" => Some(format_path(&self.conf)),
166            "DOCDIR" => Some(format_path(&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(
176        root: PathBuf,
177        version: LuaVersion,
178        config: &Config,
179    ) -> Result<Self, TreeError> {
180        let version_dir = root.join(version.to_string());
181        let test_tree_dir = version_dir.join("test_dependencies");
182        let build_tree_dir = version_dir.join("build_dependencies");
183        Self::new_with_paths(root, test_tree_dir, build_tree_dir, version, config)
184    }
185
186    fn new_with_paths(
187        root: PathBuf,
188        test_tree_dir: PathBuf,
189        build_tree_dir: PathBuf,
190        version: LuaVersion,
191        config: &Config,
192    ) -> Result<Self, TreeError> {
193        let path_with_version = root.join(version.to_string());
194
195        // Ensure that the root and the version directory exist.
196        fs::sync::create_dir_all(&path_with_version)?;
197
198        // In case the tree is in a git repository, we tell git to ignore it.
199        let gitignore_file = root.join(".gitignore");
200        fs::sync::write(&gitignore_file, "*")?;
201
202        // Ensure that the bin directory exists.
203        let bin_dir = path_with_version.join("bin");
204        fs::sync::create_dir_all(&bin_dir)?;
205
206        Ok(Self {
207            root_parent: root,
208            version,
209            test_tree_dir,
210            build_tree_dir,
211            entrypoint_layout: config.entrypoint_layout().cloned(),
212        })
213    }
214
215    pub fn match_rocks_and<F>(&self, req: &PackageReq, filter: F) -> Result<RockMatches, TreeError>
216    where
217        F: Fn(&LocalPackage) -> bool,
218    {
219        match self.list()?.get(req.name()) {
220            Some(packages) => {
221                let found_packages = packages
222                    .iter()
223                    .rev()
224                    .filter(|package| {
225                        req.version_req().matches(package.version()) && filter(package)
226                    })
227                    .map(|package| package.id())
228                    .collect_vec();
229
230                Ok(match NonEmpty::try_from(found_packages) {
231                    Ok(found_packages) => {
232                        if found_packages.len() == 1 {
233                            RockMatches::Single(found_packages.last().clone())
234                        } else {
235                            RockMatches::Many(found_packages)
236                        }
237                    }
238                    Err(_) => RockMatches::NotFound(req.clone()),
239                })
240            }
241            None => Ok(RockMatches::NotFound(req.clone())),
242        }
243    }
244}
245
246impl InstallTree for Tree {
247    fn version(&self) -> &LuaVersion {
248        &self.version
249    }
250
251    fn root(&self) -> PathBuf {
252        self.root_parent.join(self.version.to_string())
253    }
254
255    fn prepare(&self, package: &LocalPackage) -> Result<(), TreeError> {
256        let layout = self.layout_for(package);
257        fs::sync::create_dir_all(&layout.root)?;
258        fs::sync::create_dir_all(&layout.lib)?;
259        fs::sync::create_dir_all(&layout.src)?;
260        fs::sync::create_dir_all(&layout.etc)?;
261
262        Ok(())
263    }
264
265    fn finalize(&self, package: &LocalPackage, entry_type: EntryType) -> Result<(), TreeError> {
266        if entry_type.is_entrypoint() {
267            if let Some(custom_layout) = &self.entrypoint_layout {
268                custom_layout.make_symlinks(self, package)?;
269            }
270        }
271
272        Ok(())
273    }
274
275    fn cleanup(&self, package: &LocalPackage, entry_type: EntryType) -> Result<(), TreeError> {
276        if entry_type.is_entrypoint() {
277            if let Some(layout) = &self.entrypoint_layout {
278                layout.remove_symlinks(self, package)?;
279            }
280        }
281
282        let layout = self.layout_for(package);
283        fs::sync::remove_dir_all(&layout.etc)?;
284        fs::sync::remove_dir_all(&layout.root)?;
285
286        for relative_binary_path in package.spec.binaries() {
287            if let Some(binary_file_name) = relative_binary_path.file_name() {
288                let binary_path = self.bin().join(binary_file_name);
289                if binary_path.is_file() {
290                    fs::sync::remove_file(binary_path)?;
291                }
292
293                let unwrapped_binary_path = self.unwrapped_bin().join(binary_file_name);
294                if unwrapped_binary_path.is_file() {
295                    fs::sync::remove_file(unwrapped_binary_path)?;
296                }
297            }
298        }
299
300        Ok(())
301    }
302
303    fn lockfile(&self) -> Result<Lockfile<ReadOnly>, TreeError> {
304        Ok(Lockfile::new(self.lockfile_path())?)
305    }
306
307    fn lockfile_path(&self) -> PathBuf {
308        self.root().join(LOCKFILE_NAME)
309    }
310
311    fn layout_for(&self, package: &LocalPackage) -> RockLayout {
312        RockLayout::new(
313            self.root().join(format!(
314                "{}-{}@{}",
315                package.id(),
316                package.name(),
317                package.version()
318            )),
319            self.bin(),
320        )
321    }
322
323    fn bin(&self) -> PathBuf {
324        self.root().join("bin")
325    }
326
327    fn unwrapped_bin(&self) -> PathBuf {
328        self.bin().join("unwrapped")
329    }
330
331    fn test_tree(&self, config: &Config) -> Result<Self, TreeError> {
332        let test_tree_dir = self.test_tree_dir.clone();
333        let build_tree_dir = self.build_tree_dir.clone();
334        Self::new_with_paths(
335            test_tree_dir.clone(),
336            test_tree_dir,
337            build_tree_dir,
338            self.version.clone(),
339            config,
340        )
341    }
342
343    fn build_tree(&self, config: &Config) -> Result<Self, TreeError> {
344        let test_tree_dir = self.test_tree_dir.clone();
345        let build_tree_dir = self.build_tree_dir.clone();
346        Self::new_with_paths(
347            build_tree_dir.clone(),
348            test_tree_dir,
349            build_tree_dir,
350            self.version.clone(),
351            config,
352        )
353    }
354
355    fn list(&self) -> Result<HashMap<PackageName, Vec<LocalPackage>>, TreeError> {
356        Ok(self.lockfile()?.list())
357    }
358
359    fn match_rocks(&self, req: &PackageReq) -> Result<RockMatches, TreeError> {
360        let found_packages = self.lockfile()?.find_rocks(req);
361        Ok(match NonEmpty::try_from(found_packages) {
362            Ok(found_packages) => {
363                if found_packages.len() == 1 {
364                    RockMatches::Single(found_packages.last().clone())
365                } else {
366                    RockMatches::Many(found_packages)
367                }
368            }
369            Err(_) => RockMatches::NotFound(req.clone()),
370        })
371    }
372}
373
374#[derive(Copy, Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord)]
375pub enum EntryType {
376    Entrypoint,
377    DependencyOnly,
378}
379
380impl EntryType {
381    pub fn is_entrypoint(&self) -> bool {
382        matches!(self, Self::Entrypoint)
383    }
384}
385
386#[derive(Clone, Debug)]
387pub enum RockMatches {
388    NotFound(PackageReq),
389    Single(LocalPackageId),
390    Many(NonEmpty<LocalPackageId>),
391}
392
393// Loosely mimic the Option<T> functions.
394impl RockMatches {
395    pub fn is_found(&self) -> bool {
396        matches!(self, Self::Single(_) | Self::Many(_))
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use assert_fs::prelude::PathCopy;
403    use itertools::Itertools;
404    use std::path::PathBuf;
405
406    use insta::assert_yaml_snapshot;
407
408    use crate::{
409        config::ConfigBuilder,
410        lockfile::{LocalPackage, LocalPackageHashes, LockConstraint},
411        lua_version::LuaVersion,
412        package::{PackageName, PackageSpec, PackageVersion},
413        remote_package_source::RemotePackageSource,
414        rockspec::RockBinaries,
415        tree::{InstallTree, RockLayout},
416        variables,
417    };
418
419    #[test]
420    fn rock_layout() {
421        let tree_path =
422            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-tree");
423
424        let temp = assert_fs::TempDir::new().unwrap();
425        temp.copy_from(&tree_path, &["**"]).unwrap();
426        let tree_path = temp.to_path_buf();
427
428        let config = ConfigBuilder::new()
429            .unwrap()
430            .user_tree(Some(tree_path.clone()))
431            .build()
432            .unwrap();
433        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
434
435        let mock_hashes = LocalPackageHashes {
436            rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
437                .parse()
438                .unwrap(),
439            source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
440                .parse()
441                .unwrap(),
442        };
443
444        let package = LocalPackage::from(
445            &PackageSpec::parse("neorg".into(), "8.0.0-1".into()).unwrap(),
446            LockConstraint::Unconstrained,
447            RockBinaries::default(),
448            RemotePackageSource::Test,
449            None,
450            mock_hashes.clone(),
451        );
452
453        let id = package.id();
454        tree.prepare(&package).unwrap();
455
456        assert_eq!(
457            tree.layout_for(&package),
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<(PackageName, Vec<PackageVersion>)> = result
488            .into_iter()
489            .sorted()
490            .map(|(name, package)| {
491                (
492                    name,
493                    package
494                        .into_iter()
495                        .map(|package| package.spec.version)
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 = LocalPackageHashes {
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 = LocalPackage::from(
531            &PackageSpec::parse("neorg".into(), "8.0.0-1-1".into()).unwrap(),
532            LockConstraint::Unconstrained,
533            RockBinaries::default(),
534            RemotePackageSource::Test,
535            None,
536            mock_hashes.clone(),
537        );
538        let layout = tree.layout_for(&package);
539        let build_variables = vec![
540            "$(PREFIX)",
541            "$(LIBDIR)",
542            "$(LUADIR)",
543            "$(BINDIR)",
544            "$(CONFDIR)",
545            "$(DOCDIR)",
546        ];
547        let result: Vec<String> = build_variables
548            .into_iter()
549            .map(|var| variables::substitute(&[&layout], var))
550            .try_collect()
551            .unwrap();
552        assert_eq!(
553            result,
554            vec![
555                layout.root.to_string_lossy().to_string(),
556                layout.lib.to_string_lossy().to_string(),
557                layout.src.to_string_lossy().to_string(),
558                layout.bin.to_string_lossy().to_string(),
559                layout.conf.to_string_lossy().to_string(),
560                layout.doc.to_string_lossy().to_string(),
561            ]
562        );
563    }
564}