Skip to main content

lux_lib/tree/
mod.rs

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