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