use std::sync::OnceLock;
use regex::Regex;
use crate::control::event::ActivityKind;
fn re_awaiting_input() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?m)^\s*>\s").expect("awaiting_input regex"))
}
fn re_tool_use() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)(?:Tool use|Using tool):\s*(?P<name>\S+)").expect("tool_use regex")
})
}
fn re_error() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?m)(?:(?:^|\n)\s*Error:\s*(?P<msg>[^\n]{1,120})|error\[E\d+\])")
.expect("error regex")
})
}
fn re_test_result() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"(?xi)
# cargo test: `test result: ok. N passed; M failed`
test\ result:\ (?:ok|FAILED)\.\ \s*
(?P<p1>\d+)\ passed;\ (?P<f1>\d+)\ failed
|
# pytest style: `N passed` or `N passed, M failed`
(?P<p2>\d+)\ passed(?:,\s*(?P<f2>\d+)\ failed)?
|
# also match `N tests passed`
(?P<p3>\d+)\ tests?\ passed
",
)
.expect("test_result regex")
})
}
fn re_git_op() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"(?xi)
(?:
# git commit output: [branch abc1234] message
# Require a real short-SHA length (7–40 hex chars) so that
# short identifiers like `[session a1b2c3]` (6 chars) or
# `[error 404abc]` (6 chars) do NOT match.
\[(?P<branch>\S+)\s+[0-9a-f]{7,40}\]\s+(?P<msg>.{1,80})
|
# explicit `git <verb>` invocation
\bgit\s+(?P<verb>push|pull|merge|fetch|commit|rebase|clone)(?:ing|ed)?\b
)",
)
.expect("git_op regex")
})
}
fn re_auth_prompt() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"(?i)(?:please\s+log\s*in|claude\.ai.*login|oauth|log\s*in\s+to\s+claude|authentication\s+required|run\s+claude\s+auth\s+login)",
)
.expect("auth_prompt regex")
})
}
fn re_session_complete() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?i)session\s+complete").expect("session_complete regex"))
}
fn re_rate_limited() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)(?:rate.?limit|too\s+many\s+requests|429)").expect("rate_limited regex")
})
}
fn re_pending_decision() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"(?x)
(?P<question>[^\n]{10,200}?) # question text (non-greedy)
\s*
(?:
\[(?P<yes>[Yy])/(?P<no>[Nn])\] # [Y/n] or [y/N]
|
\((?P<pyes>(?:yes|no|Yes|No)) # (Yes/no) / (yes/No) / (yes/no)
/(?P<pno>(?:yes|no|Yes|No))\)
)
\s*:?\s*$ # optional colon + line end
",
)
.expect("pending_decision regex")
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseResult {
pub kind: ActivityKind,
pub summary: String,
pub pending_decision: Option<String>,
pub proposed_default: Option<String>,
}
pub struct ActivityParser;
impl ActivityParser {
pub fn parse_output(text: &str) -> Option<ParseResult> {
if re_auth_prompt().is_match(text) {
return Some(ParseResult {
kind: ActivityKind::AuthPrompt,
summary: "authentication / login prompt detected".into(),
pending_decision: None,
proposed_default: None,
});
}
if re_rate_limited().is_match(text) {
return Some(ParseResult {
kind: ActivityKind::RateLimited,
summary: "rate-limit error detected".into(),
pending_decision: None,
proposed_default: None,
});
}
if re_session_complete().is_match(text) {
return Some(ParseResult {
kind: ActivityKind::SessionComplete,
summary: "session completed normally".into(),
pending_decision: None,
proposed_default: None,
});
}
if let Some(caps) = re_tool_use().captures(text) {
let name = caps
.name("name")
.map_or("unknown", |m| m.as_str())
.to_owned();
let summary = format!("tool use: {name}");
return Some(ParseResult {
kind: ActivityKind::ToolUse { name },
summary,
pending_decision: None,
proposed_default: None,
});
}
if let Some(caps) = re_error().captures(text) {
let excerpt = caps
.name("msg")
.map_or_else(
|| text.lines().next().unwrap_or("error").trim(),
|m| m.as_str(),
)
.chars()
.take(100)
.collect::<String>();
let summary = format!("error: {excerpt}");
return Some(ParseResult {
kind: ActivityKind::Error { excerpt },
summary,
pending_decision: None,
proposed_default: None,
});
}
if let Some(caps) = re_test_result().captures(text) {
let passed = caps
.name("p1")
.or_else(|| caps.name("p2"))
.or_else(|| caps.name("p3"))
.and_then(|m| m.as_str().parse::<u32>().ok())
.unwrap_or(0);
let failed = caps
.name("f1")
.or_else(|| caps.name("f2"))
.and_then(|m| m.as_str().parse::<u32>().ok())
.unwrap_or(0);
let summary = format!("tests: {passed} passed, {failed} failed");
return Some(ParseResult {
kind: ActivityKind::TestResult { passed, failed },
summary,
pending_decision: None,
proposed_default: None,
});
}
if let Some(caps) = re_git_op().captures(text) {
let verb = caps
.name("verb")
.map(|m| m.as_str().to_owned())
.unwrap_or_else(|| {
let branch = caps.name("branch").map_or("", |m| m.as_str());
if branch.is_empty() {
"commit".to_owned()
} else {
format!("commit ({branch})")
}
});
let summary = format!("git: {verb}");
return Some(ParseResult {
kind: ActivityKind::GitOp { verb },
summary,
pending_decision: None,
proposed_default: None,
});
}
if let Some(caps) = re_pending_decision().captures(text) {
let question = caps
.name("question")
.map_or(text, |m| m.as_str())
.trim()
.to_owned();
let proposed_default = if let Some(m) = caps.name("yes") {
Some(
if m.as_str().chars().next().is_some_and(|c| c.is_uppercase()) {
"yes".to_owned()
} else {
"no".to_owned()
},
)
} else if let Some(pyes) = caps.name("pyes") {
let pno_upper = caps
.name("pno")
.is_some_and(|m| m.as_str().chars().next().is_some_and(|c| c.is_uppercase()));
let pyes_upper = pyes
.as_str()
.chars()
.next()
.is_some_and(|c| c.is_uppercase());
if pyes_upper {
Some("yes".to_owned())
} else if pno_upper {
Some("no".to_owned())
} else {
None
}
} else {
None
};
let summary = format!("awaiting decision: {question}");
return Some(ParseResult {
kind: ActivityKind::AwaitingInput,
summary,
pending_decision: Some(question),
proposed_default,
});
}
if re_awaiting_input().is_match(text) {
return Some(ParseResult {
kind: ActivityKind::AwaitingInput,
summary: "awaiting input".into(),
pending_decision: None,
proposed_default: None,
});
}
None
}
}
#[cfg(test)]
#[path = "activity_tests.rs"]
mod tests;