use std::path::PathBuf;
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
name = "iwatchr",
about = "Watch directories and run a command on every file change",
long_about = None
)]
pub struct Args {
pub paths: Vec<String>,
#[arg(long = "watch", short = 'w', value_name = "DIR")]
pub watch: Vec<PathBuf>,
#[arg(long = "exec", short = 'e', value_name = "CMD")]
pub exec: Option<String>,
#[arg(long, default_value_t = 500, value_name = "MS")]
pub debounce: u64,
#[arg(long = "ignore", short = 'i', value_name = "PATTERN")]
pub ignore: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
pub dirs: Vec<PathBuf>,
pub command: String,
pub debounce_ms: u64,
pub ignore_patterns: Vec<String>,
}
impl Args {
pub fn resolve(self) -> Result<Config, String> {
let (dirs, command) = if let Some(cmd) = self.exec {
let dirs = self.paths.into_iter().map(PathBuf::from).collect();
(dirs, cmd)
} else {
let mut positionals = self.paths;
if positionals.is_empty() {
return Err(
"No command provided. Pass it as the last positional argument or use --exec."
.into(),
);
}
let command = positionals.pop().unwrap();
let dirs = positionals.into_iter().map(PathBuf::from).collect();
(dirs, command)
};
let mut all_dirs: Vec<PathBuf> = dirs;
all_dirs.extend(self.watch);
if all_dirs.is_empty() {
return Err(
"No directories to watch. Provide at least one path or use --watch <DIR>.".into(),
);
}
let mut ignore_patterns = vec![".git/**".to_string()];
ignore_patterns.extend(self.ignore);
Ok(Config {
dirs: all_dirs,
command,
debounce_ms: self.debounce,
ignore_patterns,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Args {
Args::try_parse_from(std::iter::once("iwatchr").chain(args.iter().copied())).unwrap()
}
#[test]
fn positional_last_arg_is_command() {
let config = parse(&["./src", "cargo test"]).resolve().unwrap();
assert_eq!(config.command, "cargo test");
assert_eq!(config.dirs, vec![PathBuf::from("./src")]);
}
#[test]
fn exec_flag_takes_precedence() {
let config = parse(&["--exec", "cargo test", "./src"])
.resolve()
.unwrap();
assert_eq!(config.command, "cargo test");
assert_eq!(config.dirs, vec![PathBuf::from("./src")]);
}
#[test]
fn multiple_positional_dirs_with_command() {
let config = parse(&["./src", "./lib", "echo hi"]).resolve().unwrap();
assert_eq!(
config.dirs,
vec![PathBuf::from("./src"), PathBuf::from("./lib")]
);
assert_eq!(config.command, "echo hi");
}
#[test]
fn watch_flag_merges_with_positional_dirs() {
let config = parse(&["./src", "--watch", "./lib", "echo hi"])
.resolve()
.unwrap();
assert_eq!(
config.dirs,
vec![PathBuf::from("./src"), PathBuf::from("./lib")]
);
}
#[test]
fn watch_flag_only_with_exec() {
let config = parse(&["--watch", "./src", "--exec", "make"])
.resolve()
.unwrap();
assert_eq!(config.dirs, vec![PathBuf::from("./src")]);
assert_eq!(config.command, "make");
}
#[test]
fn default_debounce_is_500ms() {
let config = parse(&["./src", "echo hi"]).resolve().unwrap();
assert_eq!(config.debounce_ms, 500);
}
#[test]
fn custom_debounce_is_respected() {
let config = parse(&["--debounce", "200", "./src", "echo hi"])
.resolve()
.unwrap();
assert_eq!(config.debounce_ms, 200);
}
#[test]
fn git_pattern_always_in_ignore_list() {
let config = parse(&["./src", "echo hi"]).resolve().unwrap();
assert!(config.ignore_patterns.contains(&".git/**".to_string()));
}
#[test]
fn user_ignore_patterns_are_appended() {
let config = parse(&["--ignore", "**/*.tmp", "--ignore", "dist/**", "./src", "echo hi"])
.resolve()
.unwrap();
assert!(config.ignore_patterns.contains(&".git/**".to_string()));
assert!(config.ignore_patterns.contains(&"**/*.tmp".to_string()));
assert!(config.ignore_patterns.contains(&"dist/**".to_string()));
}
#[test]
fn error_when_no_command_and_no_exec() {
let args = Args::try_parse_from(["iwatchr"]).unwrap();
assert!(args.resolve().is_err());
}
#[test]
fn error_when_no_dirs() {
let args = parse(&["--exec", "cargo test"]);
assert!(args.resolve().is_err());
}
#[test]
fn error_message_mentions_exec_or_positional() {
let args = Args::try_parse_from(["iwatchr"]).unwrap();
let err = args.resolve().unwrap_err();
assert!(err.contains("--exec") || err.contains("positional"));
}
}