with-watch 0.1.2

Watch command inputs and rerun commands when they change
Documentation
pub mod analysis;
pub mod cli;
pub mod error;
pub mod logging;
pub mod parser;
pub mod runner;
pub mod snapshot;
pub mod watch;

use std::path::Path;

use analysis::{analyze_argv, analyze_shell_expression, CommandAnalysis, CommandAnalysisStatus};
use cli::{Cli, CommandMode};
use error::{Result, WithWatchError};
use parser::parse_shell_expression;
use runner::{ExecutionMetadata, ExecutionPlan, RunnerOptions};
use snapshot::{ChangeDetectionMode, WatchInput, WatchInputKind};
use tracing::debug;

pub fn run_cli(cli: Cli, options: RunnerOptions) -> Result<i32> {
    let mode = cli.command_mode()?;
    let cwd = std::env::current_dir().map_err(WithWatchError::CurrentDirectory)?;
    let detection_mode = cli.change_detection_mode();
    let plan = build_execution_plan(mode, detection_mode, &cwd)?;
    runner::run(plan, options)
}

fn build_execution_plan(
    mode: CommandMode,
    detection_mode: ChangeDetectionMode,
    cwd: &Path,
) -> Result<ExecutionPlan> {
    match mode {
        CommandMode::Passthrough { argv } => {
            let analysis = analyze_argv(&argv, cwd)?;
            log_analysis("passthrough", &analysis);
            let inputs = require_inferred_inputs(&analysis)?;
            Ok(ExecutionPlan::passthrough(
                argv,
                inputs,
                detection_mode,
                execution_metadata(&analysis),
            ))
        }
        CommandMode::Shell { expression } => {
            let parsed = parse_shell_expression(&expression)?;
            let analysis = analyze_shell_expression(&parsed, cwd)?;
            log_analysis("shell", &analysis);
            let inputs = require_inferred_inputs(&analysis)?;
            Ok(ExecutionPlan::shell(
                expression,
                inputs,
                detection_mode,
                execution_metadata(&analysis),
            ))
        }
        CommandMode::Exec { inputs, argv } => {
            let analysis = analyze_argv(&argv, cwd)?;
            log_analysis("exec", &analysis);
            let planned_inputs = explicit_watch_inputs(&inputs, cwd)?;
            Ok(ExecutionPlan::exec(
                argv,
                planned_inputs,
                detection_mode,
                execution_metadata(&analysis),
            ))
        }
    }
}

fn require_inferred_inputs(analysis: &CommandAnalysis) -> Result<Vec<WatchInput>> {
    if analysis.status == CommandAnalysisStatus::Resolved && !analysis.inputs.is_empty() {
        return Ok(analysis.inputs.clone());
    }

    debug!(
        adapter_id = analysis.adapter_field(),
        inference_status = analysis.status.as_str(),
        fallback_used = analysis.fallback_used,
        filtered_output_count = analysis.filtered_output_count,
        "Command analysis did not yield safe inferred inputs"
    );

    Err(WithWatchError::NoWatchInputs)
}

fn execution_metadata(analysis: &CommandAnalysis) -> ExecutionMetadata {
    ExecutionMetadata {
        adapter_ids: analysis.adapter_ids.clone(),
        fallback_used: analysis.fallback_used,
        default_watch_root_used: analysis.default_watch_root_used,
        filtered_output_count: analysis.filtered_output_count,
        side_effect_profile: analysis.side_effect_profile,
        status: analysis.status,
    }
}

fn log_analysis(mode: &str, analysis: &CommandAnalysis) {
    debug!(
        mode,
        adapter_id = analysis.adapter_field(),
        fallback_used = analysis.fallback_used,
        default_watch_root_used = analysis.default_watch_root_used,
        filtered_output_count = analysis.filtered_output_count,
        side_effect_profile = analysis.side_effect_profile.as_str(),
        inference_status = analysis.status.as_str(),
        inferred_input_count = analysis.inputs.len(),
        "Built command analysis"
    );
}

fn explicit_watch_inputs(raw_inputs: &[String], cwd: &Path) -> Result<Vec<WatchInput>> {
    let mut inputs = Vec::new();
    for raw in raw_inputs {
        let trimmed = raw.trim();
        if trimmed.is_empty() {
            continue;
        }
        let input = if has_glob_magic(trimmed) {
            WatchInput::glob(trimmed, cwd)?
        } else {
            WatchInput::path(trimmed, cwd, WatchInputKind::Explicit)?
        };
        push_unique_input(&mut inputs, input);
    }
    if inputs.is_empty() {
        return Err(WithWatchError::NoWatchInputs);
    }
    Ok(inputs)
}

fn push_unique_input(inputs: &mut Vec<WatchInput>, input: WatchInput) {
    if !inputs.contains(&input) {
        inputs.push(input);
    }
}

fn has_glob_magic(raw: &str) -> bool {
    raw.contains('*') || raw.contains('?') || raw.contains('[')
}

#[cfg(test)]
mod tests {
    use super::explicit_watch_inputs;
    use crate::error::WithWatchError;

    #[test]
    fn explicit_inputs_accept_globs_and_paths() {
        let temp_dir = tempfile::tempdir().expect("create tempdir");
        let inputs = explicit_watch_inputs(
            &["src/**/*.rs".to_string(), "README.md".to_string()],
            temp_dir.path(),
        )
        .expect("explicit inputs");

        assert_eq!(inputs.len(), 2);
    }

    #[test]
    fn explicit_inputs_reject_blank_values() {
        let temp_dir = tempfile::tempdir().expect("create tempdir");
        let error = explicit_watch_inputs(&["   ".to_string(), "\t".to_string()], temp_dir.path())
            .expect_err("blank inputs should fail");

        assert!(matches!(error, WithWatchError::NoWatchInputs));
    }
}