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