dors/
lib.rs

1#![deny(clippy::print_stdout)]
2mod dorsfile;
3mod error;
4mod take_while_ext;
5
6pub use crate::error::{DorsError, Error};
7
8use cargo_metadata::MetadataCommand;
9use colored::Colorize;
10use dorsfile::{Dorsfile, MemberModifiers, Run};
11use std::collections::{HashMap, HashSet};
12use std::path::{Path, PathBuf};
13use std::process::Command;
14use std::process::ExitStatus;
15use take_while_ext::TakeWhileLastExt;
16
17#[derive(Debug)]
18struct DorsfileGetter {
19    workspace_root: PathBuf,
20    workspace_dorsfile: Option<Dorsfile>,
21}
22impl DorsfileGetter {
23    pub fn new<P: AsRef<Path>>(workspace_root: P) -> Result<DorsfileGetter, Box<dyn Error>> {
24        let workspace_dorsfile_path = workspace_root.as_ref().join("./Dorsfile.toml");
25        Ok(DorsfileGetter {
26            workspace_root: workspace_root.as_ref().into(),
27            workspace_dorsfile: if workspace_dorsfile_path.exists() {
28                Some(Dorsfile::load(&workspace_dorsfile_path)?)
29            } else {
30                None
31            },
32        })
33    }
34
35    pub fn get<P: AsRef<Path>>(&self, crate_path: P) -> Result<Dorsfile, Box<dyn Error>> {
36        let crate_path = crate_path.as_ref();
37        if crate_path.canonicalize().unwrap() == self.workspace_root.canonicalize().unwrap() {
38            return Ok(self
39                .workspace_dorsfile
40                .as_ref()
41                .cloned()
42                .ok_or(DorsError::NoDorsfile)?);
43        }
44        let local = crate_path.join("./Dorsfile.toml");
45
46        let mut dorsfile = match (local.exists(), self.workspace_dorsfile.is_some()) {
47            (true, true) => {
48                // Extend local dorsfile with workspace dorsfile
49                let mut curr = Dorsfile::load(local)?;
50                let workspace_dorsfile = self.workspace_dorsfile.as_ref().unwrap();
51                let mut env = workspace_dorsfile.env.clone();
52                let mut task = workspace_dorsfile.task.clone();
53
54                task.values_mut().for_each(|task| {
55                    // Clear all befores and afters from member task
56                    // so that they are not ran on both member and workspace root
57                    task.before = None;
58                    task.after = None;
59
60                    // Clear any 'run-from = "member"' from the workspace, as we ARE running
61                    // from the member
62                    if let Run::Members = task.run_from {
63                        task.run_from = Run::Here;
64                    }
65                });
66
67                env.extend(curr.env.drain(..));
68                task.extend(curr.task.drain());
69                curr.env = env;
70                curr.task = task;
71                curr
72            }
73            (true, false) => Dorsfile::load(local)?,
74            (false, true) => {
75                let mut curr = self.workspace_dorsfile.as_ref().cloned().unwrap();
76
77                let mut task = curr.task.clone();
78                task.values_mut().for_each(|task| {
79                    // Clear all befores and afters from member task
80                    // so that they are not ran on both member and workspace root
81                    task.before = None;
82                    task.after = None;
83
84                    // Clear any 'run-from = "member"' from the workspace, as we ARE running
85                    // from the member
86                    if let Run::Members = task.run_from {
87                        task.run_from = Run::Here;
88                    }
89                });
90
91                curr.task = task;
92                curr
93            }
94            (false, false) => return Err(DorsError::NoMemberDorsfile.into()),
95        };
96
97        // extend environment
98        let builtins: HashMap<_, _> = [(
99            "CARGO_WORKSPACE_ROOT",
100            self.workspace_root.to_str().unwrap(),
101        )]
102        .iter()
103        .cloned()
104        .map(|(key, value)| (key.to_string(), value.to_string()))
105        .collect();
106        let mut env = vec![builtins];
107        env.extend(dorsfile.env.drain(..));
108        dorsfile.env = env;
109        Ok(dorsfile)
110    }
111}
112
113struct CargoWorkspaceInfo {
114    members: HashMap<String, PathBuf>,
115    root: PathBuf,
116}
117
118impl CargoWorkspaceInfo {
119    fn new(dir: &Path) -> CargoWorkspaceInfo {
120        let metadata = MetadataCommand::new().current_dir(&dir).exec().unwrap();
121        let root = metadata.workspace_root;
122        // allow O(1) referencing of package information
123        let packages: HashMap<_, _> = metadata
124            .packages
125            .iter()
126            .map(|package| (package.id.clone(), package))
127            .collect();
128        let members = metadata
129            .workspace_members
130            .into_iter()
131            .map(|member| {
132                let package = packages[&member];
133                (
134                    package.name.clone(),
135                    package.manifest_path.parent().unwrap().into(),
136                )
137            })
138            .collect();
139        CargoWorkspaceInfo { members, root }
140    }
141}
142
143fn run_command(
144    command: &str,
145    workdir: &Path,
146    env: &[HashMap<String, String>],
147    args: &[String],
148) -> ExitStatus {
149    use rand::distributions::Alphanumeric;
150    use rand::{thread_rng, Rng};
151    use std::iter;
152    let mut rng = thread_rng();
153    let chars: String = iter::repeat(())
154        .map(|()| rng.sample(Alphanumeric))
155        .take(10)
156        .collect();
157    let file = Path::new("./")
158        .canonicalize()
159        .unwrap()
160        .join(format!("tmp-{}.sh", chars));
161    let mut script = env
162        .iter()
163        .flatten()
164        .fold("".to_string(), |mut acc, (k, v)| {
165            acc.push_str(&format!("export {}={}\n", k, v));
166            acc
167        });
168    script.push_str(command);
169    script.push_str("\n");
170    std::fs::write(&file, &script).unwrap();
171    let exit_status = Command::new("bash")
172        .arg("-e")
173        .arg(file.to_str().unwrap())
174        .args(args)
175        .current_dir(workdir)
176        .spawn()
177        .unwrap()
178        .wait()
179        .unwrap();
180    std::fs::remove_file(file).unwrap();
181    exit_status
182}
183
184pub fn all_tasks<P: AsRef<Path>>(dir: P) -> Result<Vec<String>, Box<dyn Error>> {
185    let workspace = CargoWorkspaceInfo::new(dir.as_ref());
186    let dorsfiles = DorsfileGetter::new(&workspace.root)?;
187    Ok(dorsfiles
188        .get(dir.as_ref())?
189        .task
190        .keys()
191        .cloned()
192        .collect::<Vec<_>>())
193}
194
195fn print_task(task_name: &str, path: &Path) {
196    // TODO convert absolute path to relative
197    eprintln!(
198        "      {} Running {} from `{}`",
199        "[Dors]".yellow().bold(),
200        task_name.bold(),
201        path.to_str().unwrap().bold()
202    );
203}
204
205pub fn run<P: AsRef<Path>>(task: &str, dir: P) -> Result<ExitStatus, Box<dyn Error>> {
206    run_with_args(task, dir, &[])
207}
208
209struct TaskRunner {
210    workspace: CargoWorkspaceInfo,
211    dorsfiles: DorsfileGetter,
212}
213
214pub fn run_with_args<P: AsRef<Path>>(
215    task: &str,
216    dir: P,
217    args: &[String],
218) -> Result<ExitStatus, Box<dyn Error>> {
219    let dir = dir.as_ref();
220    let workspace = CargoWorkspaceInfo::new(&dir);
221    let dorsfiles = DorsfileGetter::new(&workspace.root)?;
222    let dorsfile = dorsfiles.get(&dir)?;
223
224    TaskRunner {
225        workspace,
226        dorsfiles,
227    }
228    // seed recursion
229    .run_task(
230        task,
231        &dorsfile,
232        &dir,
233        args,
234        &mut HashSet::new(),
235        &mut HashSet::new(),
236    )
237}
238
239impl TaskRunner {
240    fn run_task(
241        &self,
242        task_name: &str,
243        dorsfile: &Dorsfile,
244        dir: &Path,
245        args: &[String],
246        already_ran_befores: &mut HashSet<String>,
247        already_ran_afters: &mut HashSet<String>,
248    ) -> Result<ExitStatus, Box<dyn Error>> {
249        let task = dorsfile
250            .task
251            .get(task_name)
252            .ok_or_else(|| DorsError::NoTask(task_name.to_string()))?;
253
254        // Handle befores
255        if let Some(ref befores) = task.before {
256            if let Some(befores_result) = befores
257                .iter()
258                .filter_map(|before_task_name| {
259                    if !already_ran_befores.contains(before_task_name) {
260                        already_ran_befores.insert(before_task_name.into());
261                        Some(self.run_task(
262                            before_task_name,
263                            dorsfile,
264                            dir,
265                            &[],
266                            already_ran_befores,
267                            &mut HashSet::new(),
268                        ))
269                    } else {
270                        None
271                    }
272                })
273                .take_while_last(|result| result.is_ok() && result.as_ref().unwrap().success())
274                .last()
275            {
276                if befores_result.is_err() || !befores_result.as_ref().unwrap().success() {
277                    return befores_result;
278                }
279            }
280        }
281
282        // run command
283        let result = match task.run_from {
284            Run::Here => {
285                print_task(task_name, &dir);
286                run_command(&task.command, dir, &dorsfile.env, args)
287            }
288            Run::WorkspaceRoot => {
289                // TODO error gracefully when someone messes this up
290                let path = &self.workspace.root;
291                print_task(task_name, path);
292                run_command(&task.command, path, &dorsfile.env, args)
293            }
294            Run::Members => {
295                if dir.canonicalize().unwrap() != self.workspace.root.canonicalize().unwrap() {
296                    panic!("cannot run from members from outside workspace root");
297                }
298                self.workspace
299                    .members
300                    .iter()
301                    .filter_map(|(name, path)| {
302                        let short_path = if path.is_relative() {
303                            path
304                        } else {
305                            path.strip_prefix(&self.workspace.root).unwrap()
306                        };
307                        match task.member_modifiers {
308                            Some(ref modifiers) => match modifiers {
309                                MemberModifiers::SkipMembers(skips) => {
310                                    if skips.contains(name)
311                                        || skips.contains(&short_path.to_str().unwrap().to_string())
312                                    {
313                                        None
314                                    } else {
315                                        Some(path)
316                                    }
317                                }
318                                MemberModifiers::OnlyMembers(onlys) => {
319                                    if onlys.contains(name)
320                                        || onlys.contains(&short_path.to_str().unwrap().to_string())
321                                    {
322                                        Some(path)
323                                    } else {
324                                        None
325                                    }
326                                }
327                            },
328                            None => Some(path),
329                        }
330                    })
331                    .map(|path| {
332                        let dorsfile = self.dorsfiles.get(&path)?;
333                        self.run_task(
334                            task_name,
335                            &dorsfile,
336                            &path,
337                            args,
338                            &mut HashSet::new(),
339                            &mut HashSet::new(),
340                        )
341                    })
342                    .take_while_last(|result| result.is_ok() && result.as_ref().unwrap().success())
343                    .last()
344                    .unwrap()?
345            }
346            Run::Path(ref target_path) => {
347                print_task(task_name, &target_path);
348                run_command(&task.command, &dir.join(target_path), &dorsfile.env, args)
349            }
350        };
351
352        if !result.success() {
353            return Ok(result);
354        }
355
356        // handle afters
357        if let Some(ref afters) = task.after {
358            if let Some(afters_result) = afters
359                .iter()
360                .filter_map(|after_task_name| {
361                    if !already_ran_afters.contains(after_task_name) {
362                        already_ran_afters.insert(after_task_name.into());
363                        Some(self.run_task(
364                            after_task_name,
365                            dorsfile,
366                            dir,
367                            &[],
368                            &mut HashSet::new(),
369                            already_ran_afters,
370                        ))
371                    } else {
372                        None
373                    }
374                })
375                .take_while_last(|result| result.is_ok() && result.as_ref().unwrap().success())
376                .last()
377            {
378                if afters_result.is_err() || !afters_result.as_ref().unwrap().success() {
379                    return afters_result;
380                }
381            }
382        }
383
384        Ok(result)
385    }
386}
387
388#[allow(clippy::print_stdout)]
389pub fn process_cmd<'a>(matches: &clap::ArgMatches<'a>) -> i32 {
390    let directory = match matches.value_of("subdirectory") {
391        Some(directory) => directory.into(),
392        None => std::env::current_dir().unwrap(),
393    };
394    if matches.is_present("list") {
395        let mut tasks = match all_tasks(directory) {
396            Ok(tasks) => tasks,
397            Err(e) => {
398                println!("{}", e);
399                return 1;
400            }
401        };
402        tasks.sort();
403        tasks.iter().for_each(|task| println!("{}", task));
404        return 0;
405    }
406
407    if matches.is_present("completions") {
408        println!(r#"complete -C "cargo dors -l" cargo dors"#);
409        println!(r#"complete -C "cargo dors -l" dors"#);
410        return 0;
411    }
412
413    if let Some(task) = matches.value_of("TASK") {
414        let args = match matches.values_of("TASK_ARGS") {
415            Some(values) => values.map(|s| s.to_string()).collect(),
416            None => vec![],
417        };
418        match run_with_args(&task, directory, &args) {
419            Ok(resp) => return resp.code().unwrap(),
420            Err(e) => {
421                println!("{}", e);
422                return 1;
423            }
424        }
425    }
426
427    let mut tasks = match all_tasks(directory) {
428        Ok(tasks) => tasks,
429        Err(e) => {
430            println!("{}", e);
431            return 1;
432        }
433    };
434    tasks.sort();
435
436    if matches.is_present("list") {
437        tasks.iter().for_each(|task| println!("{}", task));
438        return 0;
439    }
440
441    println!("{}: Please select a task to run:", "Error".red());
442    tasks.iter().for_each(|task| println!("{}", task.bold()));
443    1
444}
445
446pub fn get_about() -> &'static str {
447    "No-fuss workspace-aware task runner for rust"
448}
449
450pub fn set_app_options<'a, 'b>(app: clap::App<'a, 'b>) -> clap::App<'a, 'b> {
451    app.version(env!("CARGO_PKG_VERSION"))
452        .author("Andrew Klitzke <andrewknpe@gmail.com>")
453        .setting(clap::AppSettings::TrailingVarArg)
454        .setting(clap::AppSettings::ColoredHelp)
455        .setting(clap::AppSettings::DontCollapseArgsInUsage)
456        .about(get_about())
457        .arg(
458            clap::Arg::with_name("subdirectory")
459                .short("d")
460                .long("subdirectory")
461                .conflicts_with_all(&["completions"])
462                .display_order(0)
463                .takes_value(true)
464                .help("only run task on a specified subdirectory"),
465        )
466        .arg(
467            clap::Arg::with_name("list")
468                .short("l")
469                .long("list")
470                .conflicts_with_all(&["TASK", "TASK_ARGS", "completions"])
471                .display_order(1)
472                .help("list all the available tasks"),
473        )
474        .arg(
475            clap::Arg::with_name("completions")
476                .long("completions")
477                .help(
478                    "Generate bash/zsh completions. Install once, and will automatically \
479                        update when Dorsfiles are modified. Usually added to \
480                        .bashrc or similar file to take effect. See `rustup completions`\
481                        for a detailed explanation of how to install the output of this
482                        command",
483                ),
484        )
485        .arg(clap::Arg::with_name("TASK").help("the name of the task to run"))
486        .arg(
487            clap::Arg::with_name("TASK_ARGS")
488                .help("arguments to pass to the task")
489                .requires("TASK")
490                .multiple(true),
491        )
492}