Skip to main content

lux_lib/tree/
mod.rs

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