@@ -256,6 +256,153 @@ fn tokenize(input: &str) -> Vec<Token> {
256256 tokens
257257}
258258
259+ /// Parse a pipeline command (tokens containing `|` operators).
260+ ///
261+ /// Splits the token stream on Pipe operators, parses each stage as (program, args),
262+ /// and extracts final redirections from the last stage.
263+ ///
264+ /// # Arguments
265+ /// * `tokens` - Token stream containing at least one `Token::Pipe`
266+ ///
267+ /// # Returns
268+ /// [`Command::Pipeline`] with stages and final redirections
269+ ///
270+ /// # Errors
271+ /// Returns error if:
272+ /// - Pipeline has empty stages
273+ /// - Pipeline has less than 2 stages
274+ /// - Stage has no command name
275+ /// - Invalid redirections in last stage
276+ fn parse_pipeline ( tokens : & [ Token ] ) -> Result < Command > {
277+ // Split token stream on Pipe tokens
278+ let mut stages: Vec < Vec < Token > > = Vec :: new ( ) ;
279+ let mut current_stage: Vec < Token > = Vec :: new ( ) ;
280+
281+ for token in tokens {
282+ match token {
283+ Token :: Pipe => {
284+ if current_stage. is_empty ( ) {
285+ return Err ( anyhow ! ( "Empty pipeline stage" ) ) ;
286+ }
287+ stages. push ( current_stage. clone ( ) ) ;
288+ current_stage. clear ( ) ;
289+ }
290+ _ => current_stage. push ( token. clone ( ) ) ,
291+ }
292+ }
293+
294+ // Add final stage
295+ if current_stage. is_empty ( ) {
296+ return Err ( anyhow ! ( "Pipeline ends with |" ) ) ;
297+ }
298+ stages. push ( current_stage) ;
299+
300+ if stages. len ( ) < 2 {
301+ return Err ( anyhow ! ( "Pipeline must have at least 2 stages" ) ) ;
302+ }
303+
304+ // Extract final redirections from last stage
305+ let last_stage_idx = stages. len ( ) - 1 ;
306+ let ( last_command_tokens, final_redirects) =
307+ extract_redirections_from_tokens ( & stages[ last_stage_idx] ) ?;
308+ stages[ last_stage_idx] = last_command_tokens;
309+
310+ // Parse each stage as (program, args)
311+ let mut parsed_stages: Vec < ( String , Vec < String > ) > = Vec :: new ( ) ;
312+
313+ for stage_tokens in stages {
314+ if stage_tokens. is_empty ( ) {
315+ return Err ( anyhow ! ( "Empty pipeline stage" ) ) ;
316+ }
317+
318+ let words: Vec < String > = stage_tokens
319+ . iter ( )
320+ . filter_map ( |t| match t {
321+ Token :: Word ( w) => Some ( w. clone ( ) ) ,
322+ _ => None ,
323+ } )
324+ . collect ( ) ;
325+
326+ if words. is_empty ( ) {
327+ return Err ( anyhow ! ( "Pipeline stage has no command" ) ) ;
328+ }
329+
330+ let program = words[ 0 ] . clone ( ) ;
331+ let args = words[ 1 ..] . to_vec ( ) ;
332+ parsed_stages. push ( ( program, args) ) ;
333+ }
334+
335+ Ok ( Command :: Pipeline {
336+ stages : parsed_stages,
337+ redirects : final_redirects,
338+ } )
339+ }
340+
341+ /// Extract redirections from a token slice, returning (command_tokens, redirections).
342+ ///
343+ /// Separates command words from redirection operators and their targets.
344+ fn extract_redirections_from_tokens ( tokens : & [ Token ] ) -> Result < ( Vec < Token > , Vec < Redirection > ) > {
345+ let mut command_tokens = Vec :: new ( ) ;
346+ let mut redirects = Vec :: new ( ) ;
347+
348+ let mut i = 0 ;
349+ while i < tokens. len ( ) {
350+ match & tokens[ i] {
351+ Token :: Word ( _) => {
352+ command_tokens. push ( tokens[ i] . clone ( ) ) ;
353+ i += 1 ;
354+ }
355+
356+ Token :: OutputRedirect => {
357+ let file = expect_word ( tokens, i + 1 , "output redirection" ) ?;
358+ redirects. push ( Redirection :: Output { file } ) ;
359+ i += 2 ;
360+ }
361+
362+ Token :: AppendRedirect => {
363+ let file = expect_word ( tokens, i + 1 , "append redirection" ) ?;
364+ redirects. push ( Redirection :: Append { file } ) ;
365+ i += 2 ;
366+ }
367+
368+ Token :: InputRedirect => {
369+ let file = expect_word ( tokens, i + 1 , "input redirection" ) ?;
370+ redirects. push ( Redirection :: Input { file } ) ;
371+ i += 2 ;
372+ }
373+
374+ Token :: ErrorRedirect => {
375+ let file = expect_word ( tokens, i + 1 , "error redirection" ) ?;
376+ redirects. push ( Redirection :: ErrorOutput { file } ) ;
377+ i += 2 ;
378+ }
379+
380+ Token :: ErrorAppendRedirect => {
381+ let file = expect_word ( tokens, i + 1 , "error append redirection" ) ?;
382+ redirects. push ( Redirection :: ErrorAppend { file } ) ;
383+ i += 2 ;
384+ }
385+
386+ Token :: ErrorToOutput => {
387+ redirects. push ( Redirection :: ErrorToOutput ) ;
388+ i += 1 ;
389+ }
390+
391+ Token :: BothRedirect => {
392+ let file = expect_word ( tokens, i + 1 , "both redirection" ) ?;
393+ redirects. push ( Redirection :: BothOutput { file } ) ;
394+ i += 2 ;
395+ }
396+
397+ Token :: Pipe => {
398+ return Err ( anyhow ! ( "Unexpected pipe in stage" ) ) ;
399+ }
400+ }
401+ }
402+
403+ Ok ( ( command_tokens, redirects) )
404+ }
405+
259406/// Parse a command line into a structured [`Command`] with redirections.
260407///
261408/// Tokenizes the input, identifies built-in commands vs external programs,
@@ -289,6 +436,11 @@ pub fn parse_command(input: &str) -> Result<Command> {
289436 return Err ( anyhow ! ( "Empty command" ) ) ;
290437 }
291438
439+ // Check if input contains pipes - if so, parse as pipeline
440+ if tokens. iter ( ) . any ( |t| matches ! ( t, Token :: Pipe ) ) {
441+ return parse_pipeline ( & tokens) ;
442+ }
443+
292444 // Separate command tokens from redirections
293445 let mut command_tokens = Vec :: new ( ) ;
294446 let mut redirects = Vec :: new ( ) ;
@@ -343,7 +495,8 @@ pub fn parse_command(input: &str) -> Result<Command> {
343495 }
344496
345497 Token :: Pipe => {
346- return Err ( anyhow ! ( "Pipelines not yet implemented (Phase 6 M3)" ) ) ;
498+ // Should never reach here - pipes are caught at parse_command() entry
499+ return Err ( anyhow ! ( "Unexpected pipe token in single command" ) ) ;
347500 }
348501 }
349502 }
@@ -773,4 +926,76 @@ mod tests {
773926 assert ! ( parse_command( "" ) . is_err( ) ) ;
774927 assert ! ( parse_command( " " ) . is_err( ) ) ;
775928 }
929+
930+ #[ test]
931+ fn test_parse_simple_pipeline ( ) {
932+ let cmd = parse_command ( "ls | grep test" ) . unwrap ( ) ;
933+ match cmd {
934+ Command :: Pipeline { stages, redirects } => {
935+ assert_eq ! ( stages. len( ) , 2 ) ;
936+ assert_eq ! ( stages[ 0 ] . 0 , "ls" ) ;
937+ assert_eq ! ( stages[ 0 ] . 1 . len( ) , 0 ) ;
938+ assert_eq ! ( stages[ 1 ] . 0 , "grep" ) ;
939+ assert_eq ! ( stages[ 1 ] . 1 , vec![ "test" ] ) ;
940+ assert ! ( redirects. is_empty( ) ) ;
941+ }
942+ _ => panic ! ( "Expected Pipeline" ) ,
943+ }
944+ }
945+
946+ #[ test]
947+ fn test_parse_multi_stage_pipeline ( ) {
948+ let cmd = parse_command ( "cat file.txt | grep foo | wc -l" ) . unwrap ( ) ;
949+ match cmd {
950+ Command :: Pipeline { stages, .. } => {
951+ assert_eq ! ( stages. len( ) , 3 ) ;
952+ assert_eq ! ( stages[ 0 ] . 0 , "cat" ) ;
953+ assert_eq ! ( stages[ 0 ] . 1 , vec![ "file.txt" ] ) ;
954+ assert_eq ! ( stages[ 1 ] . 0 , "grep" ) ;
955+ assert_eq ! ( stages[ 1 ] . 1 , vec![ "foo" ] ) ;
956+ assert_eq ! ( stages[ 2 ] . 0 , "wc" ) ;
957+ assert_eq ! ( stages[ 2 ] . 1 , vec![ "-l" ] ) ;
958+ }
959+ _ => panic ! ( "Expected Pipeline" ) ,
960+ }
961+ }
962+
963+ #[ test]
964+ fn test_parse_pipeline_with_redirect ( ) {
965+ let cmd = parse_command ( "ls | grep test > output.txt" ) . unwrap ( ) ;
966+ match cmd {
967+ Command :: Pipeline { stages, redirects } => {
968+ assert_eq ! ( stages. len( ) , 2 ) ;
969+ assert_eq ! ( redirects. len( ) , 1 ) ;
970+ match & redirects[ 0 ] {
971+ Redirection :: Output { file } => assert_eq ! ( file, "output.txt" ) ,
972+ _ => panic ! ( "Expected Output redirection" ) ,
973+ }
974+ }
975+ _ => panic ! ( "Expected Pipeline" ) ,
976+ }
977+ }
978+
979+ #[ test]
980+ fn test_parse_pipeline_empty_stage_error ( ) {
981+ assert ! ( parse_command( "ls |" ) . is_err( ) ) ;
982+ assert ! ( parse_command( "| grep test" ) . is_err( ) ) ;
983+ }
984+
985+ #[ test]
986+ fn test_parse_pipeline_single_stage_not_pipeline ( ) {
987+ // Single command with no pipe should not create pipeline
988+ let cmd = parse_command ( "ls" ) . unwrap ( ) ;
989+ match cmd {
990+ Command :: Ls { .. } => { } , // Built-in command
991+ _ => panic ! ( "Single command should not create pipeline" ) ,
992+ }
993+
994+ // External command without pipe should also not create pipeline
995+ let cmd2 = parse_command ( "echo hello" ) . unwrap ( ) ;
996+ match cmd2 {
997+ Command :: External { .. } => { } ,
998+ _ => panic ! ( "Single external command should not create pipeline" ) ,
999+ }
1000+ }
7761001}
0 commit comments