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}