cicero_commands/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::{process::Command, sync::OnceLock};
4
5#[cfg(feature = "task_venv")]
6pub mod venv;
7
8mod install;
9
10
11pub use cicero_core::Result;
12
13/// Description of a crate you want to install.
14#[derive(Debug)]
15pub struct Crate {
16    name: &'static str,
17    install_args: &'static [&'static str],
18}
19impl Crate {
20    /// Construct a `Crate` object from the given `name`.
21    pub const fn new(name: &'static str) -> Self {
22        Self {
23            name,
24            install_args: &[],
25        }
26    }
27
28    /// Add arguments to pass to `cargo install` when performing the installation.
29    pub const fn with_install_args(mut self, install_args: &'static [&'static str]) -> Self {
30        self.install_args = install_args;
31        self
32    }
33
34    /// Convert to a [Cli] object, for further configuration and later retrieving [Command] objects.
35    pub const fn into_cli(self) -> Cli {
36        Cli::new(CliInstallKind::Crate(self))
37    }
38}
39
40/// Description of a CLI or dependency that should already be installed on the system.
41pub struct ExpectProgram {
42    name: &'static str,
43    install_instruction: Option<&'static str>,
44}
45impl ExpectProgram {
46    /// Expect a given program to be available via the operating system PATH.
47    pub const fn new(name: &'static str) -> Self {
48        Self {
49            name,
50            install_instruction: None,
51        }
52    }
53
54    /// Add instructions to be provided to the user for how to install the expected software, if it isn't already installed on the system.
55    pub const fn with_install_instruction(mut self, install_instruction: &'static str) -> Self {
56        self.install_instruction = Some(install_instruction);
57        self
58    }
59
60    /// Convert to a [Cli] object, for further configuration and later retrieving [Command] objects.
61    pub const fn into_cli(self) -> Cli {
62        Cli::new(CliInstallKind::ExpectProgram(self))
63    }
64}
65
66
67/// Used to define the CLI you want to use.
68pub struct Cli {
69    install_kind: CliInstallKind,
70    program_name: OnceLock<ProgramName>,
71    command_fn: Option<CommandFn>,
72    crate_dependencies: &'static [Crate],
73    expected_dependencies: &'static [ExpectProgram],
74}
75enum CliInstallKind {
76    Crate(Crate),
77    ExpectProgram(ExpectProgram),
78}
79
80impl Cli {
81
82    /// Command to return when calling [Cli::command].
83    /// This allows you to specify arguments which should be included for all calls to this CLI.
84    /// ```no_run
85    ///# use cicero_commands::{Cli, Crate};
86    ///#
87    /// static CROSS_BUILD: Cli = Crate::new("cross")
88    ///     .into_cli()
89    ///     .with_base_command(&|mut command| {
90    ///         command
91    ///             .arg("build")
92    ///             .arg("--release");
93    ///         command
94    ///     });
95    ///
96    ///
97    /// // It can then be used like so:
98    ///
99    /// CROSS_BUILD.command()
100    ///     .arg("--target=aarch64-unknown-linux-gnu")
101    ///     .status();
102    /// ```
103    pub const fn with_base_command(mut self, command_fn: CommandFn) -> Self {
104        self.command_fn = Some(command_fn);
105        self
106    }
107
108    /// Additional crates to install, which are depended on by the CLI, but don't provide the CLI themselves.
109    /// ```no_run
110    ///# use cicero_commands::{Cli, Crate};
111    ///#
112    /// static MDBOOK: Cli = Crate::new("mdbook")
113    ///     .into_cli()
114    ///     .with_crate_dependencies(&[
115    ///         Crate::new("mdbook-keeper"),
116    ///         Crate::new("mdbook-plantuml").with_install_args(&["--locked"]),
117    ///     ]);
118    /// ```
119    pub const fn with_crate_dependencies(mut self, dependency_crates: &'static [Crate]) -> Self {
120        self.crate_dependencies = dependency_crates;
121        self
122    }
123
124    /// Additional dependencies expected on the system, which are depended on by the CLI, but don't provide the CLI themselves.
125    /// ```no_run
126    ///# use cicero_commands::{Cli, Crate, ExpectProgram};
127    ///#
128    /// static MDBOOK: Cli = Crate::new("mdbook")
129    ///     .into_cli()
130    ///     .with_expected_dependencies(&[
131    ///         ExpectProgram::new("mdbook-keeper"),
132    ///         ExpectProgram::new("mdbook-plantuml"),
133    ///     ]);
134    /// ```
135    pub const fn with_expected_dependencies(mut self, expected_dependencies: &'static [ExpectProgram]) -> Self {
136        self.expected_dependencies = expected_dependencies;
137        self
138    }
139
140    /// Constructs a new [std::process::Command] instance, from the specified default command or the crate name.
141    ///
142    /// This will trigger the installation, if it's needed.
143    pub fn command(&self) -> Command {
144
145        let program_name = self.program_name.get_or_init(|| {
146
147            for expected_dependency in self.expected_dependencies {
148                install::expect_command_installed(expected_dependency.name, expected_dependency.install_instruction);
149            }
150
151            for crate_dependency in self.crate_dependencies {
152                install::dependency_crate(crate_dependency)
153                    .unwrap_or_else(|cause| panic!("Failed to install crate dependency {}: {cause}", crate_dependency.name));
154            }
155
156            match self.install_kind {
157                CliInstallKind::Crate(ref crate_to_install) => {
158                    install::cli_crate(crate_to_install)
159                        .unwrap_or_else(|cause| panic!("Failed to install CLI crate {}: {cause}", crate_to_install.name))
160                }
161                CliInstallKind::ExpectProgram(ref expect) => {
162                    install::expect_command_installed(expect.name, expect.install_instruction);
163                    ProgramName { value: expect.name.to_owned() }
164                }
165            }
166        });
167
168        let mut default_command = Command::new(&program_name.value);
169
170        if let CliInstallKind::Crate(_) = self.install_kind {
171            default_command
172                .env("PATH", crate::install::calculate_PATH());
173        }
174
175        match self.command_fn {
176            Some(command) => command(default_command),
177            None => default_command,
178        }
179    }
180
181    const fn new(install_kind: CliInstallKind) -> Self {
182        Self {
183            install_kind,
184            program_name: OnceLock::new(),
185            command_fn: None,
186            crate_dependencies: &[],
187            expected_dependencies: &[],
188        }
189    }
190}
191
192struct ProgramName { pub value: String }
193
194type CommandFn = &'static (dyn Fn(Command) -> Command + Send + Sync);
195
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn should_expect_installed() {
203        ExpectProgram::new("cargo")
204            .into_cli()
205            .command();
206    }
207
208    #[test]
209    #[should_panic]
210    fn should_abort_when_expected_command_not_installed() {
211        ExpectProgram::new("non-existent command")
212            .into_cli()
213            .command();
214    }
215}