pub const SEG_MARKER:&str = "__APHRODITE_SEG__";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Segment {
pub index:usize,
pub command:String,
}
pub fn split_chain(command:&str) -> Option<Vec<Segment>> {
let mut segments = Vec::new();
let mut cur = String::new();
let mut chars = command.chars().peekable();
let mut in_single = false;
let mut in_double = false;
let mut seg_index = 0usize;
while let Some(c) = chars.next() {
match c {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'\\' => {
if let Some(n) = chars.next() {
cur.push('\\');
cur.push(n);
}
continue;
},
'&' if !in_single && !in_double => {
if chars.peek() == Some(&'&') {
chars.next();
push_segment(&mut segments, &mut cur, seg_index);
seg_index += 1;
continue;
}
cur.push(c);
continue;
},
';' if !in_single && !in_double => {
push_segment(&mut segments, &mut cur, seg_index);
seg_index += 1;
continue;
},
'\n' if !in_single && !in_double => {
push_segment(&mut segments, &mut cur, seg_index);
seg_index += 1;
continue;
},
_ => {},
}
cur.push(c);
}
push_segment(&mut segments, &mut cur, seg_index);
let segments:Vec<Segment> = segments.into_iter().filter(|s| !s.command.trim().is_empty()).collect();
if segments.len() < 2 {
return None;
}
for s in &segments {
let lower = s.command.to_lowercase();
if lower.contains("<<") || lower.contains("for ") || lower.contains("while ") {
return None;
}
}
Some(segments)
}
fn push_segment(segments:&mut Vec<Segment>, cur:&mut String, index:usize) {
if !cur.trim().is_empty() {
segments.push(Segment { index, command:cur.trim().to_string() });
}
cur.clear();
}
pub fn build_marked_command(segments:&[Segment]) -> String {
let mut out = String::new();
for (i, seg) in segments.iter().enumerate() {
if i > 0 {
out.push_str(&format!("echo {} 1>&2 ; ", SEG_MARKER));
}
out.push_str(&seg.command);
out.push_str(" ; ");
}
if out.ends_with(" ; ") {
out.truncate(out.len() - 3);
}
out
}
pub fn split_marked_output(output:&str) -> Vec<(usize, String)> {
let mut parts:Vec<(usize, String)> = Vec::new();
let mut cur = String::new();
let mut cur_idx = 0usize;
for line in output.lines() {
let trimmed = line.trim();
if trimmed == SEG_MARKER {
if !cur.trim().is_empty() {
parts.push((cur_idx, cur.trim().to_string()));
}
cur_idx += 1;
cur = String::new();
} else {
cur.push_str(line);
cur.push('\n');
}
}
if !cur.trim().is_empty() {
parts.push((cur_idx, cur.trim().to_string()));
}
parts
}
pub fn segment_error_hint(content:&str) -> Option<String> {
let mut first_signal:Option<&str> = None;
for line in content.lines() {
let l = line.trim();
if l.is_empty() {
continue;
}
let lower = l.to_lowercase();
let is_error = l.contains("exit code:")
|| lower.contains("error")
|| lower.contains("failed")
|| lower.contains("failure")
|| lower.contains("panic")
|| lower.contains("traceback")
|| lower.contains("fatal")
|| lower.contains("cannot")
|| lower.contains("not found")
|| lower.contains("no such file")
|| lower.contains("permission denied")
|| lower.contains("unresolved import")
|| lower.contains("undefined");
if !is_error {
continue;
}
if l.contains("error[") {
return Some(l.chars().take(60).collect());
}
if first_signal.is_none() {
first_signal = Some(l);
}
}
first_signal.map(|l| l.chars().take(60).collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_on_and_and_semicolon() {
let segs = split_chain("cd x && cargo build ; cargo test").unwrap();
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].command, "cd x");
assert_eq!(segs[1].command, "cargo build");
assert_eq!(segs[2].command, "cargo test");
}
#[test]
fn respects_quotes() {
let segs = split_chain("echo 'a && b' ; echo \"c ; d\"").unwrap();
assert_eq!(segs.len(), 2);
assert_eq!(segs[0].command, "echo 'a && b'");
assert_eq!(segs[1].command, "echo \"c ; d\"");
}
#[test]
fn single_segment_is_none() {
assert!(split_chain("cargo build").is_none());
assert!(split_chain("").is_none());
}
#[test]
fn heredoc_refused() {
assert!(split_chain("cat << EOF && echo hi").is_none());
}
#[test]
fn marked_command_roundtrip() {
let segs = split_chain("a && b && c").unwrap();
let marked = build_marked_command(&segs);
assert!(marked.contains("__APHRODITE_SEG__"));
assert!(marked.contains("1>&2"));
assert!(!marked.contains("echo __APHRODITE_SEG__ ;"));
let out = format!("seg a output\n{}\nseg b output\n{}\nseg c output\n", SEG_MARKER, SEG_MARKER);
let parts = split_marked_output(&out);
assert_eq!(parts.len(), 3);
assert_eq!(parts[0], (0, "seg a output".to_string()));
assert_eq!(parts[1], (1, "seg b output".to_string()));
assert_eq!(parts[2], (2, "seg c output".to_string()));
}
#[test]
fn newline_separator() {
let segs = split_chain("cd /tmp\ngit status\necho done").unwrap();
assert_eq!(segs.len(), 3);
}
#[test]
fn error_hint_none_for_clean_output() {
assert_eq!(segment_error_hint("everything fine\nno problems here"), None);
assert_eq!(segment_error_hint(""), None);
}
#[test]
fn error_hint_finds_first_signal() {
let out = "compiling...\nerror: could not compile `demo`\nfailed\n";
assert_eq!(segment_error_hint(out).as_deref(), Some("error: could not compile `demo`"));
}
#[test]
fn error_hint_prefers_error_code_line() {
let out = "warning: unused\nfatal: something\nerror[E0432]: unresolved import `x`\n";
assert_eq!(segment_error_hint(out).as_deref(), Some("error[E0432]: unresolved import `x`"));
}
#[test]
fn error_hint_catches_exit_code() {
assert_eq!(segment_error_hint("done\n").as_deref(), None);
assert_eq!(
segment_error_hint("make: *** [all] Error 2\n").as_deref(),
Some("make: *** [all] Error 2")
);
}
#[test]
fn error_hint_truncates_long_lines() {
let long = format!("error: {}", "x".repeat(200));
let hint = segment_error_hint(&long).unwrap();
assert!(hint.len() <= 60);
assert!(hint.starts_with("error: "));
}
}