Skip to main content

lux_lib/operations/
exec.rs

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