Skip to main content

with_watch/
lib.rs

1pub mod analysis;
2pub mod cli;
3pub mod error;
4pub mod logging;
5pub mod parser;
6pub mod runner;
7pub mod snapshot;
8pub mod watch;
9
10use std::path::Path;
11
12use analysis::{analyze_argv, analyze_shell_expression, CommandAnalysis, CommandAnalysisStatus};
13use cli::{Cli, CommandMode};
14use error::{Result, WithWatchError};
15use parser::parse_shell_expression;
16use runner::{ExecutionMetadata, ExecutionPlan, OutputRefreshMode, RunnerOptions};
17use snapshot::{ChangeDetectionMode, WatchInput, WatchInputKind};
18use tracing::debug;
19
20pub fn run_cli(cli: Cli, options: RunnerOptions) -> Result<i32> {
21    let mode = cli.command_mode()?;
22    let cwd = std::env::current_dir().map_err(WithWatchError::CurrentDirectory)?;
23    let detection_mode = cli.change_detection_mode();
24    let output_refresh_mode = cli.output_refresh_mode();
25    let plan = build_execution_plan(mode, detection_mode, output_refresh_mode, &cwd)?;
26    runner::run(plan, options)
27}
28
29fn build_execution_plan(
30    mode: CommandMode,
31    detection_mode: ChangeDetectionMode,
32    output_refresh_mode: OutputRefreshMode,
33    cwd: &Path,
34) -> Result<ExecutionPlan> {
35    match mode {
36        CommandMode::Passthrough { argv } => {
37            let analysis = analyze_argv(&argv, cwd)?;
38            log_analysis("passthrough", &analysis);
39            let inputs = require_inferred_inputs(&analysis)?;
40            Ok(ExecutionPlan::passthrough(
41                argv,
42                inputs,
43                detection_mode,
44                output_refresh_mode,
45                execution_metadata(&analysis),
46            ))
47        }
48        CommandMode::Shell { expression } => {
49            let parsed = parse_shell_expression(&expression)?;
50            let analysis = analyze_shell_expression(&parsed, cwd)?;
51            log_analysis("shell", &analysis);
52            let inputs = require_inferred_inputs(&analysis)?;
53            Ok(ExecutionPlan::shell(
54                expression,
55                inputs,
56                detection_mode,
57                output_refresh_mode,
58                execution_metadata(&analysis),
59            ))
60        }
61        CommandMode::Exec { inputs, argv } => {
62            let analysis = analyze_argv(&argv, cwd)?;
63            log_analysis("exec", &analysis);
64            let planned_inputs = explicit_watch_inputs(&inputs, cwd)?;
65            Ok(ExecutionPlan::exec(
66                argv,
67                planned_inputs,
68                detection_mode,
69                output_refresh_mode,
70                execution_metadata(&analysis),
71            ))
72        }
73    }
74}
75
76fn require_inferred_inputs(analysis: &CommandAnalysis) -> Result<Vec<WatchInput>> {
77    if analysis.status == CommandAnalysisStatus::Resolved && !analysis.inputs.is_empty() {
78        return Ok(analysis.inputs.clone());
79    }
80
81    debug!(
82        adapter_id = analysis.adapter_field(),
83        inference_status = analysis.status.as_str(),
84        fallback_used = analysis.fallback_used,
85        filtered_output_count = analysis.filtered_output_count,
86        "Command analysis did not yield safe inferred inputs"
87    );
88
89    Err(WithWatchError::NoWatchInputs)
90}
91
92fn execution_metadata(analysis: &CommandAnalysis) -> ExecutionMetadata {
93    ExecutionMetadata {
94        adapter_ids: analysis.adapter_ids.clone(),
95        fallback_used: analysis.fallback_used,
96        default_watch_root_used: analysis.default_watch_root_used,
97        filtered_output_count: analysis.filtered_output_count,
98        side_effect_profile: analysis.side_effect_profile,
99        status: analysis.status,
100    }
101}
102
103fn log_analysis(mode: &str, analysis: &CommandAnalysis) {
104    debug!(
105        mode,
106        adapter_id = analysis.adapter_field(),
107        fallback_used = analysis.fallback_used,
108        default_watch_root_used = analysis.default_watch_root_used,
109        filtered_output_count = analysis.filtered_output_count,
110        side_effect_profile = analysis.side_effect_profile.as_str(),
111        inference_status = analysis.status.as_str(),
112        inferred_input_count = analysis.inputs.len(),
113        "Built command analysis"
114    );
115}
116
117fn explicit_watch_inputs(raw_inputs: &[String], cwd: &Path) -> Result<Vec<WatchInput>> {
118    let mut inputs = Vec::new();
119    for raw in raw_inputs {
120        let trimmed = raw.trim();
121        if trimmed.is_empty() {
122            continue;
123        }
124        let input = if has_glob_magic(trimmed) {
125            WatchInput::glob(trimmed, cwd)?
126        } else {
127            WatchInput::path(trimmed, cwd, WatchInputKind::Explicit)?
128        };
129        push_unique_input(&mut inputs, input);
130    }
131    if inputs.is_empty() {
132        return Err(WithWatchError::NoWatchInputs);
133    }
134    Ok(inputs)
135}
136
137fn push_unique_input(inputs: &mut Vec<WatchInput>, input: WatchInput) {
138    if !inputs.contains(&input) {
139        inputs.push(input);
140    }
141}
142
143fn has_glob_magic(raw: &str) -> bool {
144    raw.contains('*') || raw.contains('?') || raw.contains('[')
145}
146
147#[cfg(test)]
148mod tests {
149    use super::explicit_watch_inputs;
150    use crate::error::WithWatchError;
151
152    #[test]
153    fn explicit_inputs_accept_globs_and_paths() {
154        let temp_dir = tempfile::tempdir().expect("create tempdir");
155        let inputs = explicit_watch_inputs(
156            &["src/**/*.rs".to_string(), "README.md".to_string()],
157            temp_dir.path(),
158        )
159        .expect("explicit inputs");
160
161        assert_eq!(inputs.len(), 2);
162    }
163
164    #[test]
165    fn explicit_inputs_reject_blank_values() {
166        let temp_dir = tempfile::tempdir().expect("create tempdir");
167        let error = explicit_watch_inputs(&["   ".to_string(), "\t".to_string()], temp_dir.path())
168            .expect_err("blank inputs should fail");
169
170        assert!(matches!(error, WithWatchError::NoWatchInputs));
171    }
172}