Skip to main content

lux_lib/operations/
exec.rs

1use std::io;
2use tokio::process::Command;
3
4use crate::{
5    build::BuildBehaviour,
6    config::Config,
7    drivers::install_packages::{InstallPackages, InstallPackagesError},
8    lua_rockspec::LuaVersionError,
9    lua_version::{LuaVersion, LuaVersionUnset},
10    operations::{BuildWorkspace, BuildWorkspaceError},
11    package::{PackageReq, PackageVersionReqError},
12    package_db::{PackageDB, PackageDBError},
13    path::{Paths, PathsError},
14    tree::{self, InstallTree, TreeError},
15    workspace::{Workspace, WorkspaceTreeError},
16};
17use bon::Builder;
18use itertools::Itertools;
19use miette::Diagnostic;
20use thiserror::Error;
21use which::which;
22
23use super::PackageInstallSpec;
24
25/// Rocks package runner, providing fine-grained control
26/// over how a package should be run.
27#[derive(Builder)]
28#[builder(start_fn = new, finish_fn(name = _exec, vis = ""))]
29pub struct Exec<'a> {
30    #[builder(start_fn)]
31    command: &'a str,
32    #[builder(start_fn)]
33    workspace: Option<&'a Workspace>,
34    #[builder(start_fn)]
35    config: &'a Config,
36
37    #[builder(field)]
38    args: Vec<String>,
39
40    disable_loader: Option<bool>,
41}
42
43impl<State: exec_builder::State> ExecBuilder<'_, State> {
44    pub fn arg(mut self, arg: impl Into<String>) -> Self {
45        self.args.push(arg.into());
46        self
47    }
48
49    pub fn args(mut self, args: impl IntoIterator<Item: Into<String>>) -> Self {
50        self.args.extend(args.into_iter().map_into());
51        self
52    }
53}
54
55impl<State> ExecBuilder<'_, State>
56where
57    State: exec_builder::State + exec_builder::IsComplete,
58{
59    pub async fn exec(self) -> Result<(), ExecError>
60    where
61        State: exec_builder::IsComplete,
62    {
63        exec(self._exec()).await
64    }
65}
66
67#[derive(Error, Debug, Diagnostic)]
68pub enum ExecError {
69    #[error("failed to run {cmd}: {source}")]
70    RunCommandFailed {
71        cmd: String,
72        source: io::Error,
73        #[help]
74        help: Option<String>,
75    },
76    #[error(
77        "{cmd} exited with non-zero exit code: {}",
78        exit_code.map(|code| code.to_string()).unwrap_or("unknown".into())
79    )]
80    #[diagnostic(help("check the command's output for errors."))]
81    RunCommandNonZeroExitCode { cmd: String, exit_code: Option<i32> },
82    #[error(transparent)]
83    #[diagnostic(transparent)]
84    LuaVersionUnset(#[from] LuaVersionUnset),
85    #[error(transparent)]
86    #[diagnostic(transparent)]
87    Tree(#[from] TreeError),
88    #[error(transparent)]
89    #[diagnostic(transparent)]
90    Paths(#[from] PathsError),
91    #[error(transparent)]
92    #[diagnostic(transparent)]
93    LuaVersionError(#[from] LuaVersionError),
94    #[error(transparent)]
95    #[diagnostic(transparent)]
96    BuildWorkspace(#[from] Box<BuildWorkspaceError>),
97    #[error(transparent)]
98    #[diagnostic(transparent)]
99    InstallCommand(#[from] Box<InstallCommandError>),
100    #[error(transparent)]
101    #[diagnostic(transparent)]
102    WorkspaceTree(#[from] WorkspaceTreeError),
103    #[error("failed to execute '{0}':\n{1}")]
104    Io(String, io::Error),
105}
106
107impl From<BuildWorkspaceError> for ExecError {
108    fn from(source: BuildWorkspaceError) -> Self {
109        Self::BuildWorkspace(Box::new(source))
110    }
111}
112
113impl From<InstallCommandError> for ExecError {
114    fn from(source: InstallCommandError) -> Self {
115        Self::InstallCommand(Box::new(source))
116    }
117}
118
119#[derive(Error, Debug, Diagnostic)]
120#[error(transparent)]
121pub enum InstallCommandError {
122    InstallError(#[from] Box<InstallPackagesError>),
123    PackageVersionReqError(#[from] PackageVersionReqError),
124    PackageDBError(#[from] PackageDBError),
125    Tree(#[from] TreeError),
126    LuaVersionUnset(#[from] LuaVersionUnset),
127}
128
129impl From<InstallPackagesError> for InstallCommandError {
130    fn from(source: InstallPackagesError) -> Self {
131        Self::InstallError(Box::new(source))
132    }
133}
134
135async fn exec(run: Exec<'_>) -> Result<(), ExecError> {
136    let lua_version = run
137        .workspace
138        .map(|project| project.lua_version(run.config))
139        .transpose()?
140        .unwrap_or(LuaVersion::from(run.config)?.clone());
141
142    if let Some(project) = run.workspace {
143        BuildWorkspace::new(project, run.config)
144            .no_lock(false)
145            .only_deps(false)
146            .build()
147            .await?;
148    } else if which(run.command).is_err() {
149        install_command(run.command, run.config).await?
150    };
151
152    let user_tree = run.config.user_tree(lua_version.clone())?;
153
154    let paths = if let Some(project) = run.workspace {
155        let workspace_paths = Paths::new(&project.tree(run.config)?)?;
156        match Paths::new(&user_tree) {
157            Ok(mut user_paths) => {
158                user_paths.prepend(&workspace_paths);
159                user_paths
160            }
161            Err(err) => {
162                tracing::warn!(
163                    "failed to load the user tree at '{}': {err}",
164                    user_tree.root().display()
165                );
166                workspace_paths
167            }
168        }
169    } else {
170        Paths::new(&user_tree)?
171    };
172
173    let lua_init = if user_tree.version().is_luau() || run.disable_loader.unwrap_or(false) {
174        None
175    } else if lua_version.lux_lib_dir().is_none() {
176        tracing::warn!(
177            r#"lux-lua library not found.
178Cannot use the `lux.loader`.
179To suppress this warning, set the `--no-loader` option."#
180        );
181        None
182    } else {
183        Some(paths.init())
184    };
185
186    let status = match Command::new(run.command)
187        .args(run.args)
188        .env("PATH", paths.path_prepended().joined())
189        .env("LUA_INIT", lua_init.unwrap_or_default())
190        .env("LUA_PATH", paths.package_path().joined())
191        .env("LUA_CPATH", paths.package_cpath().joined())
192        .status()
193        .await
194    {
195        Ok(status) => Ok(status),
196        Err(err) => {
197            let help = if err.to_string().starts_with("No such file") {
198                Some(format!(
199                    "make sure '{}' is available on your PATH",
200                    run.command
201                ))
202            } else {
203                None
204            };
205            Err(ExecError::RunCommandFailed {
206                cmd: run.command.to_string(),
207                source: err,
208                help,
209            })
210        }
211    }?;
212    if status.success() {
213        Ok(())
214    } else {
215        Err(ExecError::RunCommandNonZeroExitCode {
216            cmd: run.command.to_string(),
217            exit_code: status.code(),
218        })
219    }
220}
221
222/// Ensure that a command is installed.
223/// This defaults to the local project tree if cwd is a project root.
224async fn install_command(command: &str, config: &Config) -> Result<(), InstallCommandError> {
225    let package = PackageReq::new(command.into(), None)?;
226    let tree = config.user_tree(LuaVersion::from(config)?.clone())?;
227
228    if tree
229        .match_rocks(&package)
230        .is_ok_and(|matches| matches.is_found())
231    {
232        return Ok(());
233    }
234    let install_spec = PackageInstallSpec::new(package, tree::EntryType::Entrypoint)
235        .build_behaviour(BuildBehaviour::Force)
236        .build();
237    InstallPackages::new(config, &tree)
238        .package(install_spec)
239        .package_db(PackageDB::from_config(config).await?)
240        .install()
241        .await?;
242    Ok(())
243}