Skip to main content

lux_lib/tree/
mod.rs

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