1use crate::error::{Result, WithWatchError};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct ParsedShellExpression {
5 pub expression: String,
6 pub commands: Vec<ShellCommand>,
7}
8
9#[derive(Debug, Clone, PartialEq, Eq, Default)]
10pub struct ShellCommand {
11 pub env_assignments: Vec<ShellEnvAssignment>,
12 pub argv: Vec<String>,
13 pub redirects: Vec<ShellRedirect>,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ShellEnvAssignment {
18 pub key: String,
19 pub value: String,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ShellRedirect {
24 pub operator: ShellRedirectOperator,
25 pub target: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ShellRedirectOperator {
30 Read,
31 ReadWrite,
32 Write,
33 Append,
34 WriteAll,
35 AppendAll,
36 Clobber,
37 Other(String),
38}
39
40impl ShellRedirectOperator {
41 pub fn as_str(&self) -> &str {
42 match self {
43 Self::Read => "<",
44 Self::ReadWrite => "<>",
45 Self::Write => ">",
46 Self::Append => ">>",
47 Self::WriteAll => "&>",
48 Self::AppendAll => "&>>",
49 Self::Clobber => ">|",
50 Self::Other(operator) => operator.as_str(),
51 }
52 }
53
54 pub fn reads_input(&self) -> bool {
55 matches!(self, Self::Read | Self::ReadWrite)
56 }
57
58 pub fn writes_output(&self) -> bool {
59 matches!(
60 self,
61 Self::Write | Self::Append | Self::WriteAll | Self::AppendAll | Self::Clobber
62 )
63 }
64}
65
66pub fn parse_shell_expression(expression: &str) -> Result<ParsedShellExpression> {
67 let parsed = starbase_args::parse(expression).map_err(|error| WithWatchError::ShellParse {
68 message: error.to_string(),
69 })?;
70
71 let mut commands = Vec::new();
72 for pipeline in parsed.0 {
73 collect_pipeline_commands(pipeline, &mut commands)?;
74 }
75
76 Ok(ParsedShellExpression {
77 expression: expression.to_string(),
78 commands,
79 })
80}
81
82fn collect_pipeline_commands(
83 pipeline: starbase_args::Pipeline,
84 commands: &mut Vec<ShellCommand>,
85) -> Result<()> {
86 match pipeline {
87 starbase_args::Pipeline::Start(command_list)
88 | starbase_args::Pipeline::StartNegated(command_list)
89 | starbase_args::Pipeline::Pipe(command_list)
90 | starbase_args::Pipeline::PipeAll(command_list)
91 | starbase_args::Pipeline::PipeWith(command_list, _) => {
92 collect_command_list_commands(command_list, commands)
93 }
94 }
95}
96
97fn collect_command_list_commands(
98 command_list: starbase_args::CommandList,
99 commands: &mut Vec<ShellCommand>,
100) -> Result<()> {
101 let mut current_command_index: Option<usize> = None;
102
103 for sequence in command_list.0 {
104 match sequence {
105 starbase_args::Sequence::Start(command)
106 | starbase_args::Sequence::Then(command)
107 | starbase_args::Sequence::AndThen(command)
108 | starbase_args::Sequence::OrElse(command)
109 | starbase_args::Sequence::Passthrough(command) => {
110 let shell_command = build_shell_command(command)?;
111 if !shell_command.argv.is_empty() {
112 commands.push(shell_command);
113 current_command_index = Some(commands.len() - 1);
114 }
115 }
116 starbase_args::Sequence::Redirect(command, operator) => {
117 if let Some(index) = current_command_index {
118 collect_redirects(command, operator, &mut commands[index].redirects);
119 }
120 }
121 starbase_args::Sequence::Stop(_) => {}
122 }
123 }
124
125 Ok(())
126}
127
128fn build_shell_command(command: starbase_args::Command) -> Result<ShellCommand> {
129 let mut shell_command = ShellCommand::default();
130
131 for argument in command.0 {
132 match argument {
133 starbase_args::Argument::EnvVar(key, value, _) => {
134 shell_command.env_assignments.push(ShellEnvAssignment {
135 key,
136 value: value.as_str().to_string(),
137 });
138 }
139 starbase_args::Argument::FlagGroup(flag) | starbase_args::Argument::Flag(flag) => {
140 shell_command.argv.push(flag);
141 }
142 starbase_args::Argument::Option(option, Some(value)) => {
143 shell_command.argv.push(option);
144 shell_command.argv.push(value.as_str().to_string());
145 }
146 starbase_args::Argument::Option(option, None) => {
147 shell_command.argv.push(option);
148 }
149 starbase_args::Argument::Value(value) => {
150 if shell_command.argv.is_empty() {
151 validate_command_name(value.as_str())?;
152 }
153 shell_command.argv.push(value.as_str().to_string());
154 }
155 }
156 }
157
158 Ok(shell_command)
159}
160
161fn collect_redirects(
162 command: starbase_args::Command,
163 operator: String,
164 redirects: &mut Vec<ShellRedirect>,
165) {
166 for argument in command.0 {
167 match argument {
168 starbase_args::Argument::Option(_, _)
169 | starbase_args::Argument::Flag(_)
170 | starbase_args::Argument::FlagGroup(_)
171 | starbase_args::Argument::EnvVar(_, _, _) => {}
172 starbase_args::Argument::Value(value) => redirects.push(ShellRedirect {
173 operator: classify_redirect_operator(&operator),
174 target: value.as_str().to_string(),
175 }),
176 }
177 }
178}
179
180fn classify_redirect_operator(operator: &str) -> ShellRedirectOperator {
181 match operator {
182 "<" => ShellRedirectOperator::Read,
183 "<>" => ShellRedirectOperator::ReadWrite,
184 ">" => ShellRedirectOperator::Write,
185 ">>" => ShellRedirectOperator::Append,
186 "&>" | "1&>" | "2&>" => ShellRedirectOperator::WriteAll,
187 "&>>" | "1&>>" | "2&>>" => ShellRedirectOperator::AppendAll,
188 ">|" => ShellRedirectOperator::Clobber,
189 other => ShellRedirectOperator::Other(other.to_string()),
190 }
191}
192
193fn validate_command_name(command_name: &str) -> Result<()> {
194 let lowered = command_name.trim().to_ascii_lowercase();
195 let unsupported = matches!(
196 lowered.as_str(),
197 "if" | "then"
198 | "else"
199 | "elif"
200 | "fi"
201 | "for"
202 | "while"
203 | "until"
204 | "do"
205 | "done"
206 | "case"
207 | "esac"
208 | "function"
209 | "{"
210 | "}"
211 );
212
213 if unsupported {
214 return Err(WithWatchError::UnsupportedShellConstruct {
215 construct: command_name.to_string(),
216 });
217 }
218
219 Ok(())
220}
221
222#[cfg(test)]
223mod tests {
224 use super::{parse_shell_expression, ShellRedirectOperator};
225
226 #[test]
227 fn parses_command_lines_with_and_or_and_pipeline_operators() {
228 let parsed = parse_shell_expression("cp src.txt dest.txt && cat dest.txt | grep hello")
229 .expect("parse shell");
230
231 assert_eq!(parsed.commands.len(), 3);
232 assert_eq!(parsed.commands[0].argv, vec!["cp", "src.txt", "dest.txt"]);
233 assert_eq!(parsed.commands[1].argv, vec!["cat", "dest.txt"]);
234 assert_eq!(parsed.commands[2].argv, vec!["grep", "hello"]);
235 }
236
237 #[test]
238 fn preserves_redirect_targets_as_structured_metadata() {
239 let parsed =
240 parse_shell_expression("grep hello < input.txt > output.txt").expect("parse shell");
241
242 assert_eq!(parsed.commands.len(), 1);
243 let command = &parsed.commands[0];
244 assert_eq!(command.argv, vec!["grep", "hello"]);
245 assert_eq!(command.redirects.len(), 2);
246 assert_eq!(command.redirects[0].operator, ShellRedirectOperator::Read);
247 assert_eq!(command.redirects[0].target, "input.txt");
248 assert_eq!(command.redirects[1].operator, ShellRedirectOperator::Write);
249 assert_eq!(command.redirects[1].target, "output.txt");
250 }
251
252 #[test]
253 fn preserves_shell_option_values_as_separate_tokens() {
254 let parsed = parse_shell_expression("grep -f patterns.txt file.txt").expect("parse shell");
255
256 assert_eq!(parsed.commands.len(), 1);
257 assert_eq!(
258 parsed.commands[0].argv,
259 vec!["grep", "-f", "patterns.txt", "file.txt"]
260 );
261 }
262
263 #[test]
264 fn rejects_shell_control_flow_keywords() {
265 let error =
266 parse_shell_expression("if true; then echo hi; fi").expect_err("expected error");
267 assert!(error
268 .to_string()
269 .contains("Shell control-flow is out of scope"));
270 }
271}