use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use colored::Colorize;
use fallow_config::OutputFormat;
use ignore::Match;
use notify::{RecommendedWatcher, Watcher};
use rustc_hash::FxHashSet;
use crate::report;
use crate::runtime_support::load_config;
const CLEAR_SCREEN: &str = "\x1B[2J\x1B[3J\x1B[H";
const DEBOUNCE_WINDOW: Duration = Duration::from_millis(500);
const ROOT_POLL_INTERVAL: Duration = Duration::from_secs(1);
const REATTACH_ERROR_INTERVAL: Duration = Duration::from_secs(5);
pub struct WatchOptions<'a> {
pub root: &'a Path,
pub config_path: &'a Option<PathBuf>,
pub output: OutputFormat,
pub no_cache: bool,
pub threads: usize,
pub quiet: bool,
pub production: bool,
pub clear_screen: bool,
pub explain: bool,
pub include_entry_exports: bool,
}
type LoadConfigFn = fn(
root: &Path,
config_path: &Option<PathBuf>,
output: OutputFormat,
no_cache: bool,
threads: usize,
production: bool,
quiet: bool,
) -> Result<fallow_config::ResolvedConfig, ExitCode>;
fn is_relevant_source(path: &Path) -> bool {
path.extension()
.and_then(|s| s.to_str())
.is_some_and(|ext| fallow_core::discover::SOURCE_EXTENSIONS.contains(&ext))
}
fn is_relevant_config(path: &Path) -> bool {
path.file_name()
.and_then(|s| s.to_str())
.is_some_and(|name| {
matches!(
name,
"package.json"
| ".fallowrc.json"
| ".fallowrc.jsonc"
| "fallow.toml"
| ".fallow.toml"
| "tsconfig.json"
)
})
}
fn has_disallowed_hidden_dir(relative: &Path) -> bool {
relative.parent().is_some_and(|parent| {
parent.components().any(|component| {
let name = component.as_os_str();
name.to_string_lossy().starts_with('.')
&& !fallow_core::discover::is_allowed_hidden_dir(name)
})
})
}
fn build_production_glob_set() -> Option<globset::GlobSet> {
let mut builder = globset::GlobSetBuilder::new();
for pattern in fallow_core::discover::PRODUCTION_EXCLUDE_PATTERNS {
if let Ok(glob) = globset::GlobBuilder::new(pattern)
.literal_separator(true)
.build()
{
builder.add(glob);
}
}
builder.build().ok()
}
#[derive(Clone)]
struct WatchFilter {
root: PathBuf,
ignore_patterns: globset::GlobSet,
production_excludes: Option<globset::GlobSet>,
gitignores: Vec<ignore::gitignore::Gitignore>,
global_gitignore: ignore::gitignore::Gitignore,
}
impl WatchFilter {
fn new(config: &fallow_config::ResolvedConfig) -> Self {
let gitignores = build_project_gitignores(config);
let (global_gitignore, _) = ignore::gitignore::Gitignore::global();
Self {
root: config.root.clone(),
ignore_patterns: config.ignore_patterns.clone(),
production_excludes: config.production.then(build_production_glob_set).flatten(),
gitignores,
global_gitignore,
}
}
fn allows(&self, path: &Path) -> bool {
if !path.starts_with(&self.root) {
return false;
}
let relative = path.strip_prefix(&self.root).unwrap_or(path);
if has_disallowed_hidden_dir(relative) {
return false;
}
if self.ignore_patterns.is_match(relative) {
return false;
}
if self
.production_excludes
.as_ref()
.is_some_and(|excludes| excludes.is_match(relative))
{
return false;
}
let is_dir = path.is_dir();
match self.project_gitignore_match(path, is_dir) {
Some(true) => return false,
Some(false) => {}
None => {
if matches!(
self.global_gitignore.matched(path, is_dir),
Match::Ignore(_)
) {
return false;
}
}
}
is_relevant_source(path) || is_relevant_config(path) || path == self.root.join(".gitignore")
}
fn project_gitignore_match(&self, path: &Path, is_dir: bool) -> Option<bool> {
let mut ignored = None;
for gitignore in &self.gitignores {
match gitignore.matched_path_or_any_parents(path, is_dir) {
Match::Ignore(_) => ignored = Some(true),
Match::Whitelist(_) => ignored = Some(false),
Match::None => {}
}
}
ignored
}
}
fn build_project_gitignores(
config: &fallow_config::ResolvedConfig,
) -> Vec<ignore::gitignore::Gitignore> {
let root = &config.root;
let mut gitignores = Vec::new();
let git_exclude = root.join(".git/info/exclude");
if let Some(gitignore) = build_gitignore(root, &git_exclude) {
gitignores.push(gitignore);
}
for path in discover_project_gitignores(root, &config.ignore_patterns) {
if let Some(base) = path.parent()
&& let Some(gitignore) = build_gitignore(base, &path)
{
gitignores.push(gitignore);
}
}
gitignores
}
fn build_gitignore(base: &Path, path: &Path) -> Option<ignore::gitignore::Gitignore> {
let mut builder = ignore::gitignore::GitignoreBuilder::new(base);
let _ = builder.add(path);
builder.build().ok()
}
fn discover_project_gitignores(root: &Path, ignore_patterns: &globset::GlobSet) -> Vec<PathBuf> {
let root = root.to_path_buf();
let ignore_patterns = ignore_patterns.clone();
let filter_root = root.clone();
let mut walk_builder = ignore::WalkBuilder::new(&root);
walk_builder
.hidden(false)
.git_ignore(false)
.git_global(false)
.git_exclude(false)
.filter_entry(move |entry| {
let relative = entry
.path()
.strip_prefix(&filter_root)
.unwrap_or_else(|_| entry.path());
!has_disallowed_hidden_dir(relative) && !ignore_patterns.is_match(relative)
});
let mut paths = Vec::new();
for entry in walk_builder.build().flatten() {
if entry
.file_type()
.is_some_and(|file_type| !file_type.is_dir())
&& entry.file_name() == ".gitignore"
{
paths.push(entry.into_path());
}
}
paths.sort_unstable();
paths
}
fn filter_event_paths(event: notify::Event, filter: &WatchFilter) -> Vec<PathBuf> {
let mut seen = FxHashSet::default();
let mut paths = Vec::new();
for path in event.paths {
if !filter.allows(&path) {
continue;
}
if seen.insert(path.clone()) {
paths.push(path);
}
}
paths
}
#[derive(Debug, Default)]
struct PathDebouncer {
paths: Vec<PathBuf>,
seen: FxHashSet<PathBuf>,
last_update: Option<Instant>,
}
impl PathDebouncer {
fn push_paths(&mut self, paths: Vec<PathBuf>, now: Instant) {
if paths.is_empty() {
return;
}
for path in paths {
if self.seen.insert(path.clone()) {
self.paths.push(path);
}
}
self.last_update = Some(now);
}
fn drain_ready(&mut self, now: Instant, timeout: Duration) -> Option<Vec<PathBuf>> {
if self
.last_update
.is_some_and(|updated| now.duration_since(updated) >= timeout)
{
self.last_update = None;
self.seen.clear();
Some(std::mem::take(&mut self.paths))
} else {
None
}
}
fn clear(&mut self) {
self.paths.clear();
self.seen.clear();
self.last_update = None;
}
}
fn display_changed_paths(paths: Vec<PathBuf>, root: &Path) -> Vec<String> {
let mut seen = FxHashSet::default();
let mut display_paths = Vec::with_capacity(paths.len());
for path in paths {
let display = path
.strip_prefix(root)
.unwrap_or(&path)
.display()
.to_string();
if seen.insert(display.clone()) {
display_paths.push(display);
}
}
display_paths
}
fn print_waiting(opts: &WatchOptions<'_>) {
if opts.quiet {
return;
}
eprintln!(
"\n{}",
"Watching for changes... (press Ctrl+C to stop)".dimmed()
);
}
fn analyze_and_report(config: &fallow_config::ResolvedConfig, opts: &WatchOptions<'_>) -> ExitCode {
let start = Instant::now();
#[expect(
deprecated,
reason = "ADR-008 deprecates fallow_core::analyze externally; the CLI still uses the workspace path dependency"
)]
let results = match fallow_core::analyze(config) {
Ok(r) => r,
Err(e) => {
eprintln!("Analysis error: {e}");
return ExitCode::from(2);
}
};
let elapsed = start.elapsed();
let ctx = report::ReportContext {
root: &config.root,
rules: &config.rules,
elapsed,
quiet: opts.quiet,
explain: opts.explain,
group_by: None,
top: None,
summary: false,
summary_heading: true,
show_explain_tip: true,
baseline_matched: None,
config_fixable: crate::fix::is_config_fixable(&config.root, opts.config_path.as_ref()),
skip_score_and_trend: false,
};
let report_code = report::print_results(&results, &ctx, config.output, None);
if report_code != ExitCode::SUCCESS {
eprintln!("Warning: report output failed");
}
ExitCode::SUCCESS
}
fn reload_config_or_keep_previous(
config: &mut fallow_config::ResolvedConfig,
opts: &WatchOptions<'_>,
load: LoadConfigFn,
) {
match load(
opts.root,
opts.config_path,
opts.output,
opts.no_cache,
opts.threads,
opts.production,
opts.quiet,
) {
Ok(mut reloaded) => {
if opts.include_entry_exports {
reloaded.include_entry_exports = true;
}
*config = reloaded;
}
Err(_) => {
eprintln!("Warning: failed to reload config, using previous configuration");
}
}
}
pub fn run_watch(opts: &WatchOptions<'_>) -> ExitCode {
use std::sync::mpsc;
let _ = crate::signal::install_handlers();
let _graceful = crate::signal::GracefulModeGuard::new();
let mut config = match load_config(
opts.root,
opts.config_path,
opts.output,
opts.no_cache,
opts.threads,
opts.production,
opts.quiet,
) {
Ok(mut c) => {
if opts.include_entry_exports {
c.include_entry_exports = true;
}
c
}
Err(code) => return code,
};
let initial_status = analyze_and_report(&config, opts);
if initial_status != ExitCode::SUCCESS {
return initial_status;
}
print_waiting(opts);
let (tx, rx) = mpsc::channel();
let filter = Arc::new(Mutex::new(WatchFilter::new(&config)));
let mut watcher = match create_watcher(opts.root, Arc::clone(&filter), tx.clone()) {
Ok(w) => Some(w),
Err(e) => {
eprintln!("Failed to create file watcher: {e}");
return ExitCode::from(2);
}
};
let mut debouncer = PathDebouncer::default();
let mut detached = false;
let mut next_root_check = Instant::now() + ROOT_POLL_INTERVAL;
let mut last_reattach_error = None;
loop {
if crate::signal::is_shutting_down() {
eprintln!("Watch stopped.");
return ExitCode::SUCCESS;
}
let now = Instant::now();
if now >= next_root_check {
next_root_check = now + ROOT_POLL_INTERVAL;
handle_root_lifecycle(
opts,
&mut config,
&filter,
&mut watcher,
&tx,
&mut debouncer,
&mut detached,
&mut last_reattach_error,
);
}
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(Ok(paths)) => {
if detached {
continue;
}
debouncer.push_paths(paths, Instant::now());
}
Ok(Err(e)) => {
eprintln!("Watch error: {e:?}");
}
Err(mpsc::RecvTimeoutError::Timeout) => {
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
eprintln!("Channel error: notify sender disconnected");
return ExitCode::from(2);
}
}
if !detached && let Some(paths) = debouncer.drain_ready(Instant::now(), DEBOUNCE_WINDOW) {
let changed = display_changed_paths(paths, opts.root);
if changed.is_empty() {
continue;
}
if opts.clear_screen && std::io::stderr().is_terminal() {
eprint!("{CLEAR_SCREEN}");
}
for path in &changed {
eprintln!("{} {path}", "Changed:".dimmed());
}
eprintln!();
reload_config_or_keep_previous(&mut config, opts, load_config);
let status = analyze_and_report(&config, opts);
if status != ExitCode::SUCCESS {
eprintln!("Watch analysis failed; continuing to watch for changes");
}
print_waiting(opts);
}
}
}
type WatchEvent = Result<Vec<PathBuf>, notify::Error>;
fn create_watcher(
root: &Path,
filter: Arc<Mutex<WatchFilter>>,
tx: std::sync::mpsc::Sender<WatchEvent>,
) -> notify::Result<RecommendedWatcher> {
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
let event = match event {
Ok(event) => event,
Err(err) => {
let _ = tx.send(Err(err));
return;
}
};
let Ok(filter) = filter.lock() else {
return;
};
let paths = filter_event_paths(event, &filter);
if !paths.is_empty() {
let _ = tx.send(Ok(paths));
}
})?;
watcher.watch(root, notify::RecursiveMode::Recursive)?;
Ok(watcher)
}
fn replace_watch_filter(filter: &Arc<Mutex<WatchFilter>>, config: &fallow_config::ResolvedConfig) {
if let Ok(mut guard) = filter.lock() {
*guard = WatchFilter::new(config);
}
}
#[expect(
clippy::too_many_arguments,
reason = "watch root lifecycle owns the explicit state slots mutated by the main loop"
)]
fn handle_root_lifecycle(
opts: &WatchOptions<'_>,
config: &mut fallow_config::ResolvedConfig,
filter: &Arc<Mutex<WatchFilter>>,
watcher: &mut Option<RecommendedWatcher>,
tx: &std::sync::mpsc::Sender<WatchEvent>,
debouncer: &mut PathDebouncer,
detached: &mut bool,
last_reattach_error: &mut Option<Instant>,
) {
let root_exists = opts.root.metadata().is_ok();
if !root_exists {
if !*detached {
watcher.take();
debouncer.clear();
*detached = true;
*last_reattach_error = None;
if !opts.quiet {
eprintln!("Watch root disappeared; waiting for it to reappear...");
}
}
return;
}
if !*detached {
return;
}
reload_config_or_keep_previous(config, opts, load_config);
replace_watch_filter(filter, config);
match create_watcher(opts.root, Arc::clone(filter), tx.clone()) {
Ok(new_watcher) => {
*watcher = Some(new_watcher);
debouncer.clear();
*detached = false;
*last_reattach_error = None;
if !opts.quiet {
eprintln!("Watch root re-attached; running analysis...");
}
let status = analyze_and_report(config, opts);
if status != ExitCode::SUCCESS {
eprintln!("Watch analysis failed; continuing to watch for changes");
}
print_waiting(opts);
}
Err(e) => {
let now = Instant::now();
if last_reattach_error
.is_none_or(|last| now.duration_since(last) >= REATTACH_ERROR_INTERVAL)
{
if !opts.quiet {
eprintln!("Failed to re-attach watch root: {e}");
}
*last_reattach_error = Some(now);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use fallow_config::FallowConfig;
use notify::event::EventKind;
#[test]
fn relevant_source_ts_extensions() {
assert!(is_relevant_source(Path::new("src/index.ts")));
assert!(is_relevant_source(Path::new("app.tsx")));
assert!(is_relevant_source(Path::new("lib/utils.mts")));
assert!(is_relevant_source(Path::new("lib/utils.cts")));
}
#[test]
fn relevant_source_js_extensions() {
assert!(is_relevant_source(Path::new("src/index.js")));
assert!(is_relevant_source(Path::new("app.jsx")));
assert!(is_relevant_source(Path::new("lib/utils.mjs")));
assert!(is_relevant_source(Path::new("lib/utils.cjs")));
}
#[test]
fn relevant_source_framework_extensions() {
assert!(is_relevant_source(Path::new("App.vue")));
assert!(is_relevant_source(Path::new("Page.svelte")));
assert!(is_relevant_source(Path::new("page.astro")));
assert!(is_relevant_source(Path::new("doc.mdx")));
}
#[test]
fn relevant_source_style_extensions() {
assert!(is_relevant_source(Path::new("styles.css")));
assert!(is_relevant_source(Path::new("theme.scss")));
}
#[test]
fn not_relevant_source() {
assert!(!is_relevant_source(Path::new("README.md")));
assert!(!is_relevant_source(Path::new("image.png")));
assert!(!is_relevant_source(Path::new("data.json")));
assert!(!is_relevant_source(Path::new("script.py")));
assert!(!is_relevant_source(Path::new("Cargo.toml")));
assert!(!is_relevant_source(Path::new("no_extension")));
}
#[test]
fn relevant_config_files() {
assert!(is_relevant_config(Path::new("package.json")));
assert!(is_relevant_config(Path::new("/project/package.json")));
assert!(is_relevant_config(Path::new(".fallowrc.json")));
assert!(is_relevant_config(Path::new(".fallowrc.jsonc")));
assert!(is_relevant_config(Path::new("fallow.toml")));
assert!(is_relevant_config(Path::new(".fallow.toml")));
assert!(is_relevant_config(Path::new("tsconfig.json")));
}
#[test]
fn not_relevant_config() {
assert!(!is_relevant_config(Path::new("eslint.config.js")));
assert!(!is_relevant_config(Path::new("jest.config.ts")));
assert!(!is_relevant_config(Path::new("package-lock.json")));
assert!(!is_relevant_config(Path::new("tsconfig.build.json")));
assert!(!is_relevant_config(Path::new("README.md")));
}
#[test]
fn disallowed_hidden_dirs_match_discovery_filter() {
assert!(has_disallowed_hidden_dir(Path::new(".fallow/.gitignore")));
assert!(has_disallowed_hidden_dir(Path::new(".cache/file.ts")));
assert!(!has_disallowed_hidden_dir(Path::new(".storybook/main.ts")));
assert!(!has_disallowed_hidden_dir(Path::new("src/.generated.ts")));
}
fn make_event(paths: &[&Path]) -> notify::Event {
let mut event = notify::Event::new(EventKind::Any);
for path in paths {
event = event.add_path((*path).to_path_buf());
}
event
}
#[test]
fn watch_filter_rejects_non_source() {
let root = PathBuf::from("/project");
let config = make_config(&root, OutputFormat::Human, 1, false);
let filter = WatchFilter::new(&config);
let event = make_event(&[
Path::new("/project/src/index.ts"),
Path::new("/project/README.md"),
Path::new("/project/image.png"),
]);
let paths = filter_event_paths(event, &filter);
assert_eq!(display_changed_paths(paths, &root), vec!["src/index.ts"]);
}
#[test]
fn watch_filter_includes_config() {
let root = PathBuf::from("/project");
let config = make_config(&root, OutputFormat::Human, 1, false);
let filter = WatchFilter::new(&config);
let event = make_event(&[
Path::new("/project/package.json"),
Path::new("/project/.fallowrc.json"),
]);
let paths = display_changed_paths(filter_event_paths(event, &filter), &root);
assert_eq!(paths.len(), 2);
assert!(paths.contains(&"package.json".to_string()));
assert!(paths.contains(&".fallowrc.json".to_string()));
}
#[test]
fn watch_filter_deduplicates() {
let root = PathBuf::from("/project");
let config = make_config(&root, OutputFormat::Human, 1, false);
let filter = WatchFilter::new(&config);
let event = make_event(&[
Path::new("/project/src/index.ts"),
Path::new("/project/src/index.ts"),
Path::new("/project/src/index.ts"),
]);
let paths = display_changed_paths(filter_event_paths(event, &filter), &root);
assert_eq!(paths, vec!["src/index.ts"]);
}
#[test]
fn watch_filter_rejects_default_ignored_paths() {
let root = PathBuf::from("/project");
let config = make_config(&root, OutputFormat::Human, 1, false);
let filter = WatchFilter::new(&config);
let event = make_event(&[
Path::new("/project/node_modules/foo/index.ts"),
Path::new("/project/dist/index.ts"),
Path::new("/project/.git/config"),
Path::new("/project/build/index.ts"),
Path::new("/project/src/vendor.min.js"),
]);
let paths = filter_event_paths(event, &filter);
assert!(paths.is_empty());
}
#[test]
fn watch_filter_allows_root_gitignore_but_rejects_internal_gitignore() {
let root = PathBuf::from("/project");
let config = make_config(&root, OutputFormat::Human, 1, false);
let filter = WatchFilter::new(&config);
let event = make_event(&[
Path::new("/project/.gitignore"),
Path::new("/project/.fallow/.gitignore"),
]);
let paths = display_changed_paths(filter_event_paths(event, &filter), &root);
assert_eq!(paths, vec![".gitignore"]);
}
#[test]
fn watch_filter_rejects_user_ignore_patterns() {
let root = PathBuf::from("/project");
let config = make_config_with_ignores(
&root,
OutputFormat::Human,
1,
false,
vec!["src/generated/**".to_string()],
);
let filter = WatchFilter::new(&config);
let event = make_event(&[Path::new("/project/src/generated/client.ts")]);
let paths = filter_event_paths(event, &filter);
assert!(paths.is_empty());
}
#[test]
fn watch_filter_rejects_gitignored_paths() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join(".gitignore"), "ignored/**\n").expect("write gitignore");
let config = make_config(dir.path(), OutputFormat::Human, 1, false);
let filter = WatchFilter::new(&config);
let event = make_event(&[&dir.path().join("ignored/file.ts")]);
let paths = filter_event_paths(event, &filter);
assert!(paths.is_empty());
}
#[test]
fn watch_filter_rejects_nested_gitignored_paths() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join("packages/web")).expect("create package dir");
std::fs::write(dir.path().join("packages/web/.gitignore"), "generated/**\n")
.expect("write nested gitignore");
let config = make_config(dir.path(), OutputFormat::Human, 1, false);
let filter = WatchFilter::new(&config);
let event = make_event(&[&dir.path().join("packages/web/generated/client.ts")]);
let paths = filter_event_paths(event, &filter);
assert!(paths.is_empty());
}
#[test]
fn watch_filter_project_whitelist_overrides_parent_ignore() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join("packages/web/generated"))
.expect("create package dir");
std::fs::write(dir.path().join(".gitignore"), "packages/web/generated/**\n")
.expect("write root gitignore");
std::fs::write(
dir.path().join("packages/web/.gitignore"),
"!generated/client.ts\n",
)
.expect("write nested gitignore");
let config = make_config(dir.path(), OutputFormat::Human, 1, false);
let filter = WatchFilter::new(&config);
let event = make_event(&[&dir.path().join("packages/web/generated/client.ts")]);
let paths = display_changed_paths(filter_event_paths(event, &filter), dir.path());
assert_eq!(paths, vec!["packages/web/generated/client.ts"]);
}
#[test]
fn watch_filter_rejects_ignored_config_files() {
let root = PathBuf::from("/project");
let config = make_config_with_ignores(
&root,
OutputFormat::Human,
1,
false,
vec!["package.json".to_string()],
);
let filter = WatchFilter::new(&config);
let event = make_event(&[Path::new("/project/package.json")]);
let paths = filter_event_paths(event, &filter);
assert!(paths.is_empty());
}
#[test]
fn display_changed_paths_strips_root_prefix() {
let root = PathBuf::from("/project");
let paths = display_changed_paths(
vec![PathBuf::from("/project/src/deep/nested/file.tsx")],
&root,
);
assert_eq!(paths, vec!["src/deep/nested/file.tsx"]);
}
#[test]
fn path_debouncer_emits_one_deduplicated_batch_after_quiet_window() {
let start = Instant::now();
let mut debouncer = PathDebouncer::default();
debouncer.push_paths(
vec![
PathBuf::from("/project/src/index.ts"),
PathBuf::from("/project/src/index.ts"),
],
start,
);
assert!(
debouncer
.drain_ready(start + Duration::from_millis(499), DEBOUNCE_WINDOW)
.is_none()
);
let paths = debouncer
.drain_ready(start + DEBOUNCE_WINDOW, DEBOUNCE_WINDOW)
.expect("ready batch");
assert_eq!(paths, vec![PathBuf::from("/project/src/index.ts")]);
}
#[test]
fn empty_or_ignored_batches_do_not_extend_debounce_window() {
let start = Instant::now();
let mut debouncer = PathDebouncer::default();
debouncer.push_paths(vec![PathBuf::from("/project/src/index.ts")], start);
debouncer.push_paths(Vec::new(), start + Duration::from_millis(400));
assert!(
debouncer
.drain_ready(start + DEBOUNCE_WINDOW, DEBOUNCE_WINDOW)
.is_some()
);
}
#[test]
fn root_lifecycle_detaches_and_reattaches() {
let temp = tempfile::tempdir().expect("tempdir");
let root = temp.path().join("project");
std::fs::create_dir(&root).expect("create root");
let mut config = make_config(&root, OutputFormat::Human, 1, true);
let opts = make_watch_options(&root, OutputFormat::Human, 1, true);
let filter = Arc::new(Mutex::new(WatchFilter::new(&config)));
let (tx, _rx) = std::sync::mpsc::channel();
let mut watcher = None;
let mut debouncer = PathDebouncer::default();
let mut detached = false;
let mut last_reattach_error = None;
std::fs::remove_dir(&root).expect("remove root");
handle_root_lifecycle(
&opts,
&mut config,
&filter,
&mut watcher,
&tx,
&mut debouncer,
&mut detached,
&mut last_reattach_error,
);
assert!(detached);
assert!(watcher.is_none());
std::fs::create_dir(&root).expect("recreate root");
handle_root_lifecycle(
&opts,
&mut config,
&filter,
&mut watcher,
&tx,
&mut debouncer,
&mut detached,
&mut last_reattach_error,
);
assert!(!detached);
assert!(watcher.is_some());
}
fn make_config(
root: &Path,
output: OutputFormat,
threads: usize,
quiet: bool,
) -> fallow_config::ResolvedConfig {
make_config_with_ignores(root, output, threads, quiet, Vec::new())
}
fn make_config_with_ignores(
root: &Path,
output: OutputFormat,
threads: usize,
quiet: bool,
ignore_patterns: Vec<String>,
) -> fallow_config::ResolvedConfig {
FallowConfig {
schema: None,
extends: vec![],
entry: vec![],
ignore_patterns,
framework: vec![],
workspaces: None,
ignore_dependencies: vec![],
ignore_exports: vec![],
ignore_catalog_references: vec![],
ignore_dependency_overrides: vec![],
ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
used_class_members: vec![],
ignore_decorators: vec![],
duplicates: fallow_config::DuplicatesConfig::default(),
health: fallow_config::HealthConfig::default(),
rules: fallow_config::RulesConfig::default(),
boundaries: fallow_config::BoundaryConfig::default(),
production: false.into(),
plugins: vec![],
dynamically_loaded: vec![],
overrides: vec![],
regression: None,
audit: fallow_config::AuditConfig::default(),
codeowners: None,
public_packages: vec![],
flags: fallow_config::FlagsConfig::default(),
fix: fallow_config::FixConfig::default(),
resolve: fallow_config::ResolveConfig::default(),
sealed: false,
include_entry_exports: false,
cache: fallow_config::CacheConfig::default(),
}
.resolve(root.to_path_buf(), output, threads, false, quiet, None)
}
fn make_watch_options(
root: &Path,
output: OutputFormat,
threads: usize,
quiet: bool,
) -> WatchOptions<'_> {
WatchOptions {
root,
config_path: &None,
output,
no_cache: false,
threads,
quiet,
production: false,
clear_screen: false,
explain: false,
include_entry_exports: false,
}
}
#[test]
fn reload_config_successfully_replaces_previous_config() {
let root = Path::new("/project");
let mut config = make_config(root, OutputFormat::Human, 1, false);
let opts = make_watch_options(root, OutputFormat::Json, 8, true);
reload_config_or_keep_previous(
&mut config,
&opts,
|_root, _config_path, output, _no_cache, threads, _production, quiet| {
Ok(make_config(Path::new("/project"), output, threads, quiet))
},
);
assert!(matches!(config.output, OutputFormat::Json));
assert_eq!(config.threads, 8);
assert!(config.quiet);
}
#[test]
fn reload_config_applies_include_entry_exports_override() {
let root = Path::new("/project");
let mut config = make_config(root, OutputFormat::Human, 1, false);
assert!(!config.include_entry_exports);
let mut opts = make_watch_options(root, OutputFormat::Json, 8, true);
opts.include_entry_exports = true;
reload_config_or_keep_previous(
&mut config,
&opts,
|_root, _config_path, output, _no_cache, threads, _production, quiet| {
Ok(make_config(Path::new("/project"), output, threads, quiet))
},
);
assert!(
config.include_entry_exports,
"CLI flag should OR into reloaded config"
);
}
#[test]
fn reload_config_failure_keeps_previous_config() {
let root = Path::new("/project");
let mut config = make_config(root, OutputFormat::Human, 1, false);
let opts = make_watch_options(root, OutputFormat::Json, 8, true);
reload_config_or_keep_previous(
&mut config,
&opts,
|_root, _config_path, _output, _no_cache, _threads, _production, _quiet| {
Err(ExitCode::from(2))
},
);
assert!(matches!(config.output, OutputFormat::Human));
assert_eq!(config.threads, 1);
assert!(!config.quiet);
}
}