use super::{ChainType, CommandEntry}; #[derive(Debug, Clone, Copy, PartialEq)] enum ParseState { Normal, InSingleQuote, InDoubleQuote, Escaped(EscapeContext), } #[derive(Debug, Clone, Copy, PartialEq)] enum EscapeContext { Normal, DoubleQuote, } pub fn split_by_chain_operators(text: &str) -> Vec<(String, Option)> { let mut segments = Vec::new(); let mut current_segment = String::new(); let mut state = ParseState::Normal; let chars: Vec = text.chars().collect(); let mut i = 0; while i < chars.len() { let ch = chars[i]; match state { ParseState::Normal => { if ch == '\\' { state = ParseState::Escaped(EscapeContext::Normal); current_segment.push(ch); i += 1; } else if ch == '\'' { state = ParseState::InSingleQuote; current_segment.push(ch); i += 1; } else if ch == '"' { state = ParseState::InDoubleQuote; current_segment.push(ch); i += 1; } else if ch == '&' && i + 1 < chars.len() && chars[i + 1] == '&' { let segment_text = current_segment.trim().to_string(); if !segment_text.is_empty() { segments.push((segment_text, Some(ChainType::And))); } current_segment.clear(); i += 2; } else if ch == '|' && i + 1 < chars.len() && chars[i + 1] == '|' { let segment_text = current_segment.trim().to_string(); if !segment_text.is_empty() { segments.push((segment_text, Some(ChainType::Or))); } current_segment.clear(); i += 2; } else if ch == ';' { let segment_text = current_segment.trim().to_string(); if !segment_text.is_empty() { segments.push((segment_text, Some(ChainType::Then))); } current_segment.clear(); i += 1; } else { current_segment.push(ch); i += 1; } }, ParseState::InSingleQuote => { current_segment.push(ch); if ch == '\'' { state = ParseState::Normal; } i += 1; }, ParseState::InDoubleQuote => { if ch == '\\' { state = ParseState::Escaped(EscapeContext::DoubleQuote); current_segment.push(ch); i += 1; } else if ch == '"' { current_segment.push(ch); state = ParseState::Normal; i += 1; } else { current_segment.push(ch); i += 1; } }, ParseState::Escaped(context) => { current_segment.push(ch); state = match context { EscapeContext::Normal => ParseState::Normal, EscapeContext::DoubleQuote => ParseState::InDoubleQuote, }; i += 1; }, } } let final_segment = current_segment.trim().to_string(); if !final_segment.is_empty() { segments.push((final_segment, None)); } segments } pub fn detect_cd_command(text: &str) -> Option { let trimmed = text.trim(); if trimmed == "cd" { return Some("~".to_string()); } if trimmed.starts_with("cd ") { let path = trimmed[3..].trim(); return Some(path.to_string()); } None } /// Serialize a sequence of commands to editor format. /// CWD changes are emitted as `cd ` lines; chain type as trailing ` &&`, ` ||`, ` ;`. pub fn serialize_sequence_to_editor(commands: &[CommandEntry]) -> String { let mut result = String::new(); result.push_str("# One cmd per line. Use && || ; to chain. cd sets cwd.\n"); result.push_str("\n"); let mut prev_cwd: Option = None; for cmd in commands { if cmd.get_text().trim().is_empty() { continue; } let cmd_cwd = cmd.get_cwd(); if cmd_cwd != prev_cwd && cmd_cwd.is_some() { let path = cmd_cwd.as_ref().unwrap(); result.push_str(&format!("cd {}\n", path.display())); } prev_cwd = cmd_cwd; let op_suffix = match cmd.get_chain_type() { ChainType::And => " &&", ChainType::Or => " ||", ChainType::Then => " ;", ChainType::None => "", }; result.push_str(&format!("{}{}\n", cmd.get_text(), op_suffix)); } result } /// Parse command text into a Vec. /// `cd ` lines set the cwd for subsequent commands. /// Blank lines and `#` comment lines are ignored. pub fn parse_commands( contents: &str, initial_cwd: Option, ) -> Vec { use crate::path_formatting; let mut commands = Vec::new(); let mut current_cwd = initial_cwd; for line in contents.lines() { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; } // Split by chain operators first so that "cd /path &&" doesn't absorb the operator // into the path. Each resulting segment is then checked for cd independently. let segments = split_by_chain_operators(trimmed); for (text, chain_type_opt) in segments { let text = text.trim().to_string(); if text.is_empty() { continue; } if let Some(path) = detect_cd_command(&text) { if let Some(new_cwd) = path_formatting::resolve_path(current_cwd.as_ref(), &path) { current_cwd = Some(new_cwd); } // cd segments are not added as command entries; their chain type is discarded } else { let mut entry = CommandEntry::new(&text, current_cwd.clone()); if let Some(chain_type) = chain_type_opt { entry.set_chain_type(chain_type); } commands.push(entry); } } } // Commands with no explicit operator default to && if !commands.is_empty() { let last_idx = commands.len() - 1; for cmd in &mut commands[..last_idx] { cmd.fill_chain_type_if_empty(); } } commands } #[cfg(test)] mod tests { use super::*; #[test] fn test_single_command_no_operator() { let result = split_by_chain_operators("ls"); assert_eq!(result, vec![("ls".to_string(), None)]); } #[test] fn test_two_commands_with_and() { let result = split_by_chain_operators("ls && pwd"); assert_eq!( result, vec![ ("ls".to_string(), Some(ChainType::And)), ("pwd".to_string(), None) ] ); } #[test] fn test_two_commands_with_or() { let result = split_by_chain_operators("cmd1 || cmd2"); assert_eq!( result, vec![ ("cmd1".to_string(), Some(ChainType::Or)), ("cmd2".to_string(), None) ] ); } #[test] fn test_two_commands_with_semicolon() { let result = split_by_chain_operators("echo hi ; ls"); assert_eq!( result, vec![ ("echo hi".to_string(), Some(ChainType::Then)), ("ls".to_string(), None) ] ); } #[test] fn test_three_commands_mixed() { let result = split_by_chain_operators("a && b || c"); assert_eq!( result, vec![ ("a".to_string(), Some(ChainType::And)), ("b".to_string(), Some(ChainType::Or)), ("c".to_string(), None) ] ); } #[test] fn test_operator_in_single_quotes() { let result = split_by_chain_operators("echo '&&' && ls"); assert_eq!( result, vec![ ("echo '&&'".to_string(), Some(ChainType::And)), ("ls".to_string(), None) ] ); } #[test] fn test_operator_in_double_quotes() { let result = split_by_chain_operators("echo \"||\" || pwd"); assert_eq!( result, vec![ ("echo \"||\"".to_string(), Some(ChainType::Or)), ("pwd".to_string(), None) ] ); } #[test] fn test_escaped_operator() { let result = split_by_chain_operators("echo \\&& test"); assert_eq!(result, vec![("echo \\&& test".to_string(), None)]); } #[test] fn test_complex_quoting() { let result = split_by_chain_operators("cmd1 && echo \"a || b\" && cmd2"); assert_eq!( result, vec![ ("cmd1".to_string(), Some(ChainType::And)), ("echo \"a || b\"".to_string(), Some(ChainType::And)), ("cmd2".to_string(), None) ] ); } #[test] fn test_empty_segments_filtered() { let result = split_by_chain_operators("cmd1 && && cmd2"); assert_eq!( result, vec![ ("cmd1".to_string(), Some(ChainType::And)), ("cmd2".to_string(), None) ] ); } #[test] fn test_whitespace_trimming() { let result = split_by_chain_operators(" cmd1 && cmd2 "); assert_eq!( result, vec![ ("cmd1".to_string(), Some(ChainType::And)), ("cmd2".to_string(), None) ] ); } #[test] fn test_trailing_operator_with_space() { let result = split_by_chain_operators("ls && "); assert_eq!(result, vec![("ls".to_string(), Some(ChainType::And))]); } #[test] fn test_editor_file_no_operator_defaults_to_and() { let contents = "cmd1\ncmd2\ncmd3"; let result = parse_commands(contents, None); assert_eq!(result.len(), 3); assert_eq!(result[0].get_chain_type(), ChainType::And); assert_eq!(result[1].get_chain_type(), ChainType::And); assert_eq!(result[2].get_chain_type(), ChainType::None); } #[test] fn test_editor_file_explicit_operator_preserved() { let contents = "cmd1 ||\ncmd2"; let result = parse_commands(contents, None); assert_eq!(result.len(), 2); assert_eq!(result[0].get_chain_type(), ChainType::Or); assert_eq!(result[1].get_chain_type(), ChainType::None); } #[test] fn test_cd_with_chain_operator_does_not_absorb_operator_into_path() { use super::super::CommandEntry; use std::path::PathBuf; let contents = "cd /some/path && cargo build"; let result = parse_commands(contents, None); assert_eq!(result.len(), 1); assert_eq!(result[0].get_text(), "cargo build"); assert_eq!(result[0].get_cwd(), Some(PathBuf::from("/some/path"))); } }