assemble_freight/
cli.rs

1use std::ffi::OsString;
2
3use std::path::{Path, PathBuf};
4
5use clap::error::ErrorKind;
6use clap::{Args, Command, CommandFactory, Error, FromArgMatches, Parser};
7
8use indicatif::ProgressStyle;
9use itertools::Itertools;
10use merge::Merge;
11
12use assemble_core::logging::LoggingArgs;
13use assemble_core::prelude::BacktraceEmit;
14use assemble_core::project::error::ProjectResult;
15use assemble_core::project::requests::TaskRequests;
16use assemble_core::project::shared::SharedProject;
17
18use crate::ProjectProperties;
19
20/// Command line options for running assemble based projects.
21///
22/// Tasks can either be the full path for the task, or a relative one from the directory
23/// in use within the project.
24///
25/// Task options are configured on per task basis and are fully configured at
26/// compile time. Options for tasks must immediately follow the task request.
27///
28/// When many tasks are matched for the same task request, they all
29/// receive the same task options.
30#[derive(Debug, Parser, Clone, Merge)]
31#[clap(name = "assemble")]
32#[clap(version, author)]
33#[clap(before_help = format!("{} v{}", clap::crate_name!(), clap::crate_version!()))]
34#[clap(after_help = "For project specific information, use the :help task.")]
35#[clap(term_width = 64)]
36pub struct FreightArgs {
37    /// Project lazy_evaluation. Set using -P or --prop
38    #[clap(flatten)]
39    properties: ProjectProperties,
40    /// Log level to run freight in.
41    #[clap(flatten)]
42    logging: LoggingArgs,
43
44    /// The number of workers to use.
45    ///
46    /// Defaults to the number of cpus on the host.
47    #[clap(long, short = 'J')]
48    #[clap(help_heading = None)]
49    #[clap(value_parser = clap::value_parser!(u32).range(1..))]
50    workers: Option<u32>,
51    /// Don't run with parallel tasks
52    #[clap(long)]
53    #[clap(conflicts_with = "workers")]
54    #[clap(help_heading = None)]
55    #[merge(strategy = merge::bool::overwrite_false)]
56    no_parallel: bool,
57
58    /// Display backtraces for errors if possible.
59    #[clap(short = 'b', long)]
60    #[clap(help_heading = None)]
61    #[merge(strategy = merge::bool::overwrite_false)]
62    backtrace: bool,
63
64    /// Display backtraces for errors if possible.
65    #[clap(short = 'B', long)]
66    #[clap(help_heading = None)]
67    #[merge(strategy = merge::bool::overwrite_false)]
68    #[clap(conflicts_with = "backtrace")]
69    long_backtrace: bool,
70
71    /// Forces all tasks to be rerun
72    #[clap(long)]
73    #[clap(help_heading = None)]
74    #[merge(strategy = merge::bool::overwrite_false)]
75    rerun_tasks: bool,
76
77    #[clap(flatten)]
78    bare_task_requests: TaskRequestsArgs,
79}
80
81#[derive(Debug, Clone, Args, merge::Merge)]
82struct TaskRequestsArgs {
83    /// Request tasks to be executed by assemble
84    #[clap(value_name = "TASK [TASK OPTIONS]...")]
85    // #[clap(allow_hyphen_values = true)]
86    #[clap(help_heading = "Tasks")]
87    #[merge(strategy = merge::vec::append)]
88    requests: Vec<String>,
89}
90
91impl<S: AsRef<str>> FromIterator<S> for TaskRequestsArgs {
92    fn from_iter<T: IntoIterator<Item = S>>(iter: T) -> Self {
93        Self {
94            requests: iter.into_iter().map(|s| s.as_ref().to_string()).collect(),
95        }
96    }
97}
98
99impl TaskRequestsArgs {
100    fn requests(&self) -> &Vec<String> {
101        &self.requests
102    }
103}
104
105impl FreightArgs {
106    /// Simulate creating the freight args from the command line
107    pub fn command_line<S: AsRef<str>>(cmd: S) -> Self {
108        Self::try_command_line(cmd).expect("Couldn't parse cmd line")
109    }
110
111    /// Simulate creating the freight args from the command line
112    pub fn try_command_line<S: AsRef<str>>(cmd: S) -> Result<Self, Error> {
113        Self::try_parse(cmd.as_ref().split_whitespace())
114    }
115
116    /// Create a freight args instance from the surrounding environment.
117    pub fn from_env() -> Self {
118        match Self::try_parse(std::env::args_os().skip(1)) {
119            Ok(s) => s,
120            Err(e) => {
121                e.exit();
122            }
123        }
124    }
125
126    fn try_parse<S, I: IntoIterator<Item = S>>(iter: I) -> Result<Self, clap::Error>
127    where
128        S: Into<OsString>,
129    {
130        let mut parsed_freight_args: FreightArgs = Parser::parse_from([""]);
131        let empty = OsString::from("");
132
133        let mut index = 0;
134        let mut window_size = 1;
135
136        let args: Vec<OsString> = iter.into_iter().map(|s: S| s.into()).collect();
137        let mut last_error = None;
138
139        let mut parsed_args = vec![&empty];
140
141        while index + window_size <= args.len() {
142            let mut arg_window = Vec::from_iter(&args[index..][..window_size]);
143            arg_window.insert(0, &empty);
144
145            let intermediate = <FreightArgs as Parser>::try_parse_from(&arg_window);
146
147            match intermediate {
148                Ok(arg_matches) => {
149                    parsed_freight_args.merge(arg_matches);
150                    parsed_args.extend(arg_window);
151
152                    <FreightArgs as Parser>::try_parse_from(&parsed_args)?;
153                    index += window_size;
154                    window_size = 1;
155                }
156                Err(e) => {
157                    last_error = if e.kind() == ErrorKind::UnknownArgument {
158                        // add anyway, maybe a task command
159                        if parsed_freight_args.bare_task_requests.requests.is_empty() {
160                            Some(e);
161                            break;
162                        } else {
163                            parsed_freight_args.bare_task_requests.requests.extend(
164                                arg_window
165                                    .drain(1..)
166                                    .map(|s| s.to_str().unwrap().to_string()),
167                            );
168                            index += 1;
169                            Some(e)
170                        }
171                    } else if e.kind() == ErrorKind::InvalidValue {
172                        window_size += 1;
173                        Some(e)
174                    } else {
175                        return Err(e);
176                    }
177                }
178            }
179        }
180
181        if index == args.len() {
182            Ok(parsed_freight_args)
183        } else if let Some(e) = last_error {
184            Err(e)
185        } else {
186            let mut command: Command = FreightArgs::command();
187            Err(
188                Error::raw(ErrorKind::UnknownArgument, "failed for unknown reason")
189                    .format(&mut command),
190            )
191        }
192    }
193
194    /// Generate a task requests value using a shared project
195    pub fn task_requests(&self, project: &SharedProject) -> ProjectResult<TaskRequests> {
196        TaskRequests::build(project, self.bare_task_requests.requests())
197    }
198
199    /// Generate a task requests value using a shared project
200    pub fn task_requests_raw(&self) -> &[String] {
201        &self.bare_task_requests.requests[..]
202    }
203
204    /// Creates a clone with different tasks requests
205    pub fn with_tasks<'s, I: IntoIterator<Item = &'s str>>(&self, iter: I) -> FreightArgs {
206        let mut clone = self.clone();
207        clone.bare_task_requests = iter.into_iter().map(|s| s.to_string()).collect();
208        clone
209    }
210
211    /// Gets a property.
212    pub fn property(&self, key: impl AsRef<str>) -> Option<&str> {
213        self.properties.property(key)
214    }
215
216    /// Gets the logging args
217    pub fn logging(&self) -> &LoggingArgs {
218        &self.logging
219    }
220
221    /// Gets the number of workers
222    pub fn workers(&self) -> usize {
223        if self.no_parallel {
224            1
225        } else {
226            self.workers
227                .map(|w| w as usize)
228                .unwrap_or_else(num_cpus::get)
229        }
230    }
231
232    /// Get whether to emit backtraces or not.
233    pub fn backtrace(&self) -> BacktraceEmit {
234        match (self.backtrace, self.long_backtrace) {
235            (true, false) => BacktraceEmit::Short,
236            (_, true) => BacktraceEmit::Long,
237            _ => BacktraceEmit::None,
238        }
239    }
240
241    /// Get whether to always rerun tasks.
242    pub fn rerun_tasks(&self) -> bool {
243        self.rerun_tasks
244    }
245    pub fn properties(&self) -> &ProjectProperties {
246        &self.properties
247    }
248}
249
250pub fn main_progress_bar_style(failing: bool) -> ProgressStyle {
251    let template = if failing {
252        "{msg:>12.cyan.bold} [{bar:25.red.bright} {percent:>3}% ({pos}/{len})]  elapsed: {elapsed}"
253    } else {
254        "{msg:>12.cyan.bold} [{bar:25.green.bright} {percent:>3}% ({pos}/{len})]  elapsed: {elapsed}"
255    };
256    ProgressStyle::with_template(template)
257        .unwrap()
258        .progress_chars("=> ")
259}
260
261#[cfg(test)]
262mod test {
263    use assemble_core::logging::ConsoleMode;
264    use clap::{Command, CommandFactory};
265    use log::LevelFilter;
266
267    use crate::cli::FreightArgs;
268
269    #[test]
270    fn can_render_help() {
271        let mut freight_command: Command = FreightArgs::command();
272        let str = freight_command.render_help();
273        println!("{}", str);
274    }
275
276    #[test]
277    fn no_parallel() {
278        let args: FreightArgs = FreightArgs::command_line("--no-parallel");
279        println!("{:#?}", args);
280        assert!(args.no_parallel);
281        assert_eq!(args.workers(), 1);
282    }
283
284    #[test]
285    fn arbitrary_workers() {
286        let args: FreightArgs = FreightArgs::command_line("--workers 13");
287        println!("{:#?}", args);
288        assert_eq!(args.workers(), 13);
289    }
290
291    #[test]
292    fn default_workers_is_num_cpus() {
293        let args: FreightArgs = FreightArgs::command_line("");
294        assert_eq!(args.workers(), num_cpus::get());
295    }
296
297    #[test]
298    fn zero_workers_illegal() {
299        assert!(
300            FreightArgs::try_command_line("-J 0").is_err(),
301            "0 workers is illegal, but error wasn't properly detected"
302        );
303    }
304
305    #[test]
306    fn workers_and_no_parallel_conflicts() {
307        assert!(FreightArgs::try_command_line("-J 2 --no-parallel").is_err());
308    }
309
310    #[test]
311    fn can_set_project_properties() {
312        let args = FreightArgs::command_line("-P hello=world -P key1 -P key2");
313        assert_eq!(args.property("hello"), Some("world"));
314        assert_eq!(args.property("key1"), Some(""));
315        assert_eq!(args.property("key2"), Some(""));
316    }
317
318    #[test]
319    fn arbitrary_task_positions() {
320        let args = FreightArgs::try_command_line(":tasks --all --debug --workers 6 help");
321        if args.is_err() {
322            eprintln!("{}", args.unwrap_err());
323            panic!("Couldn't parse");
324        }
325        let args = args.unwrap();
326        println!("args: {:#?}", args);
327        assert_eq!(
328            args.logging.log_level_filter(),
329            LevelFilter::Debug,
330            "debug log level not set"
331        );
332        assert_eq!(args.workers(), 6, "should set 6 workers");
333        assert_eq!(args.bare_task_requests.requests().len(), 3);
334        assert_eq!(
335            &args.bare_task_requests.requests()[..2],
336            &[":tasks", "--all"],
337            "first task request is tasks --all"
338        );
339        assert_eq!(
340            args.bare_task_requests.requests()[2],
341            "help",
342            "second task request is help"
343        );
344    }
345
346    #[test]
347    fn tasks_last() {
348        let args = FreightArgs::command_line("--debug --workers 6 -- :tasks --all help");
349        println!("args: {:#?}", args);
350        assert_eq!(
351            args.logging.log_level_filter(),
352            LevelFilter::Debug,
353            "debug log level not set"
354        );
355        assert_eq!(args.workers(), 6, "should set 6 workers");
356        assert_eq!(args.bare_task_requests.requests().len(), 3);
357        assert_eq!(
358            &args.bare_task_requests.requests()[..2],
359            &[":tasks", "--all"],
360            "first task request is tasks --all"
361        );
362        assert_eq!(
363            args.bare_task_requests.requests()[2],
364            "help",
365            "second task request is help"
366        );
367    }
368
369    #[test]
370    fn disallow_bare_unexpected_option() {
371        assert!(FreightArgs::try_command_line("--all").is_err());
372    }
373
374    #[test]
375    fn allow_default_tasks() {
376        let args = FreightArgs::try_command_line("--trace --workers 6 --console plain").unwrap();
377        assert_eq!(args.logging().log_level_filter(), LevelFilter::Trace);
378        assert_eq!(args.workers(), 6);
379        assert_eq!(args.logging().console, ConsoleMode::Plain);
380    }
381
382    #[test]
383    fn disallow_multiple_logging() {
384        assert!(FreightArgs::try_command_line("--trace --debug").is_err());
385    }
386}