shulkerscript_cli/subcommands/
watch.rs

1use std::{
2    env, io, iter,
3    path::PathBuf,
4    process::{self, ExitStatus},
5    thread,
6    time::Duration,
7};
8
9use clap::Parser;
10use colored::Colorize;
11use notify_debouncer_mini::{new_debouncer, notify::*, DebounceEventResult};
12
13use crate::{
14    cli::Args,
15    error::Result,
16    terminal_output::{print_error, print_info, print_warning},
17    util,
18};
19
20#[derive(Debug, clap::Args, Clone)]
21pub struct WatchArgs {
22    /// The path of the project to watch.
23    #[arg(default_value = ".")]
24    pub path: PathBuf,
25    /// Only run after changes are detected.
26    ///
27    /// Skips the initial run of the commands.
28    #[arg(short, long)]
29    pub no_inital: bool,
30    /// The time to wait in ms before running the command after changes are detected.
31    #[arg(short, long, value_name = "TIME_IN_MS", default_value = "2000")]
32    pub debounce_time: u64,
33    /// Additional paths to watch for changes.
34    ///
35    /// By default, the `src` directory, `pack.png`, and `pack.toml` as well as the defined
36    /// assets directory in the config are watched.
37    #[arg(short, long, value_name = "PATH")]
38    pub watch: Vec<PathBuf>,
39    /// The shulkerscript commands to run in the project directory when changes are detected.
40    ///
41    /// Use multiple times to run multiple commands.
42    /// Internal commands will always run before shell commands and a command will only run if the
43    /// previous one exited successfully.
44    ///
45    /// Use the `--no-execute` flag to disable running these commands, useful when only wanting to
46    /// run shell commands and not default build command.
47    #[arg(short = 'x', long, value_name = "COMMAND", default_value = "build .")]
48    pub execute: Vec<String>,
49    /// Do not run the internal shulkerscript commands specified by `--execute` (and the default one).
50    #[arg(short = 'X', long)]
51    pub no_execute: bool,
52    /// The shell commands to run in the project directory when changes are detected.
53    ///
54    /// Use multiple times to run multiple commands.
55    /// Shell commands will always run after shulkerscript commands and a command will only run
56    /// if the previous one exited successfully.
57    #[arg(short, long, value_name = "COMMAND")]
58    pub shell: Vec<String>,
59}
60
61pub fn watch(args: &WatchArgs) -> Result<()> {
62    let path = util::get_project_path(&args.path).unwrap_or(args.path.clone());
63    print_info(format!("Watching project at {}", path.display()));
64    print_info(format!(
65        "Press {} to stop watching",
66        "Ctrl-C".underline().blue()
67    ));
68
69    let commands = args
70        .execute
71        .iter()
72        .map(|cmd| {
73            let split = cmd.split_whitespace();
74            let prog_name = std::env::args()
75                .next()
76                .unwrap_or(env!("CARGO_PKG_NAME").to_string());
77            Args::parse_from(iter::once(prog_name.as_str()).chain(split.clone()))
78        })
79        .collect::<Vec<_>>();
80
81    let current_dir = if args.no_inital {
82        print_info("Skipping initial commands because of cli flag.");
83        None
84    } else {
85        env::current_dir().ok()
86    };
87
88    if !args.no_inital && (current_dir.is_none() || env::set_current_dir(&path).is_err()) {
89        print_warning("Failed to change working directory to project path. Commands may not work.");
90    }
91
92    #[allow(clippy::collapsible_if)]
93    if !args.no_inital {
94        run_cmds(&commands, args.no_execute, &args.shell, true);
95    }
96
97    ctrlc::set_handler(move || {
98        print_info("Stopping watcher...");
99        process::exit(0);
100    })
101    .expect("Error setting Ctrl-C handler");
102
103    let shell_commands = args.shell.clone();
104    let no_execute = args.no_execute;
105
106    let mut debouncer = new_debouncer(
107        Duration::from_millis(args.debounce_time),
108        move |res: DebounceEventResult| {
109            if res.is_ok() {
110                run_cmds(&commands, no_execute, &shell_commands, false)
111            } else {
112                process::exit(1);
113            }
114        },
115    )
116    .expect("Failed to initialize watcher");
117
118    if let Some(prev_cwd) = current_dir {
119        env::set_current_dir(prev_cwd).expect("Failed to change working directory back");
120    }
121
122    let assets_path = super::build::get_pack_config(&path)
123        .ok()
124        .and_then(|(conf, _)| conf.compiler.and_then(|c| c.assets));
125
126    let watcher = debouncer.watcher();
127    watcher
128        .watch(path.join("src").as_path(), RecursiveMode::Recursive)
129        .expect("Failed to watch project src");
130    watcher
131        .watch(path.join("pack.png").as_path(), RecursiveMode::NonRecursive)
132        .expect("Failed to watch project pack.png");
133    watcher
134        .watch(
135            path.join("pack.toml").as_path(),
136            RecursiveMode::NonRecursive,
137        )
138        .expect("Failed to watch project pack.toml");
139    if let Some(assets_path) = assets_path {
140        let full_assets_path = path.join(assets_path);
141        if full_assets_path.exists() {
142            watcher
143                .watch(full_assets_path.as_path(), RecursiveMode::Recursive)
144                .expect("Failed to watch project assets");
145        }
146    }
147
148    // custom watch paths
149    for path in args.watch.iter() {
150        if path.exists() {
151            watcher
152                .watch(path, RecursiveMode::Recursive)
153                .expect("Failed to watch custom path");
154        } else {
155            print_warning(format!(
156                "Path {} does not exist. Skipping...",
157                path.display()
158            ));
159        }
160    }
161
162    if env::set_current_dir(path).is_err() {
163        print_warning("Failed to change working directory to project path. Commands may not work.");
164    }
165
166    loop {
167        thread::sleep(Duration::from_secs(60));
168    }
169}
170
171fn run_cmds(cmds: &[Args], no_execute: bool, shell_cmds: &[String], initial: bool) {
172    if initial {
173        print_info("Running commands initially...");
174    } else {
175        print_info("Changes have been detected. Running commands...");
176    }
177    if !no_execute {
178        for (index, args) in cmds.iter().enumerate() {
179            if args.run().is_err() {
180                print_error(format!("Error running command: {}", index + 1));
181                print_error("Not running further commands.");
182                return;
183            }
184        }
185    }
186    for (index, cmd) in shell_cmds.iter().enumerate() {
187        let status = run_shell_cmd(cmd);
188        match status {
189            Ok(status) if !status.success() => {
190                print_error(format!(
191                    "Shell command {} exited unsuccessfully with status code {}",
192                    index + 1,
193                    status.code().unwrap_or(1)
194                ));
195                print_error("Not running further shell commands.");
196                return;
197            }
198            Ok(_) => {}
199            Err(_) => {
200                print_error(format!("Error running shell command: {}", index + 1));
201                print_error("Not running further shell commands.");
202                return;
203            }
204        }
205    }
206}
207
208fn run_shell_cmd(cmd: &str) -> io::Result<ExitStatus> {
209    let mut command = if cfg!(target_os = "windows") {
210        let mut command = process::Command::new("cmd");
211        command.arg("/C");
212        command
213    } else {
214        let mut command = process::Command::new(env::var("SHELL").unwrap_or("sh".to_string()));
215        command.arg("-c");
216        command
217    };
218
219    command.arg(cmd).status()
220}