1use std::convert::TryFrom;
2use std::fmt;
3use std::fmt::Display;
4use std::fmt::Formatter;
5use std::path::PathBuf;
6use std::str::FromStr;
7
8pub use ytil_agents::ParseError;
9use ytil_agents::agent::Agent;
10use ytil_agents::agent::AgentEventKind;
11use ytil_agents::agent::AgentEventPayload;
12pub use ytil_tui::short_path;
13
14pub const AGENTS_PIPE: &str = "agg-agent";
15pub const EMPTY_FIELD: &str = "--";
16const GIT_STAT_FIELD_COUNT: usize = 9;
17
18#[derive(Clone, Debug, Default, Eq, PartialEq)]
19pub struct GitStat {
20 pub path: PathBuf,
21 pub branch: Option<String>,
22 pub last_commit: Option<LastCommit>,
23 pub insertions: usize,
24 pub deletions: usize,
25 pub new_files: usize,
26 pub is_worktree: bool,
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct LastCommit {
31 pub short_sha: String,
32 pub age: String,
33 pub summary: String,
34}
35
36impl Display for GitStat {
37 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
38 let path = encode_git_stat_field(&self.path.display().to_string());
39 let branch = self.branch.as_deref().map(encode_git_stat_field).unwrap_or_default();
40 let last_commit = self
41 .last_commit
42 .as_ref()
43 .map_or_else(|| "\n\n".to_string(), ToString::to_string);
44 write!(
45 f,
46 "{path}\n{branch}\n{}\n{}\n{}\n{}\n{last_commit}",
47 self.insertions,
48 self.deletions,
49 self.new_files,
50 u8::from(self.is_worktree)
51 )
52 }
53}
54
55impl Display for LastCommit {
56 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
57 let short_sha = encode_git_stat_field(&self.short_sha);
58 let age = encode_git_stat_field(&self.age);
59 let summary = encode_git_stat_field(&self.summary);
60 write!(f, "{short_sha}\n{age}\n{summary}")
61 }
62}
63
64impl FromStr for GitStat {
65 type Err = ParseError;
66
67 fn from_str(record: &str) -> Result<Self, Self::Err> {
68 let mut fields = record.split('\n');
69 let mut next = |name| fields.next().ok_or(ParseError::Missing(name));
70
71 let path = PathBuf::from(decode_git_stat_field(next("path")?, "path")?);
72 let branch = decode_git_stat_field(next("branch")?, "branch")?;
73 let insertions = parse_usize(next("ins")?, "ins")?;
74 let deletions = parse_usize(next("del")?, "del")?;
75 let new_files = parse_usize(next("new")?, "new")?;
76 let is_worktree = parse_bool(next("wt")?, "wt")?;
77 let short_sha_field = next("last_commit_short_sha")?;
78 let age_field = next("last_commit_age")?;
79 let summary_field = next("last_commit_summary")?;
80 let last_commit = if short_sha_field.is_empty() && age_field.is_empty() && summary_field.is_empty() {
81 None
82 } else {
83 Some(format!("{short_sha_field}\n{age_field}\n{summary_field}").parse()?)
84 };
85 if fields.next().is_some() {
86 return Err(ParseError::Invalid {
87 field: "git_stat",
88 value: "too many fields".to_string(),
89 });
90 }
91
92 Ok(Self {
93 path,
94 branch: (!branch.is_empty()).then_some(branch),
95 last_commit,
96 insertions,
97 deletions,
98 new_files,
99 is_worktree,
100 })
101 }
102}
103
104impl FromStr for LastCommit {
105 type Err = ParseError;
106
107 fn from_str(record: &str) -> Result<Self, Self::Err> {
108 let mut fields = record.split('\n');
109 let mut next = |name| fields.next().ok_or(ParseError::Missing(name));
110
111 let short_sha = decode_git_stat_field(next("last_commit_short_sha")?, "last_commit_short_sha")?;
112 let age = decode_git_stat_field(next("last_commit_age")?, "last_commit_age")?;
113 let summary = decode_git_stat_field(next("last_commit_summary")?, "last_commit_summary")?;
114 if fields.next().is_some() {
115 return Err(ParseError::Invalid {
116 field: "last_commit",
117 value: "too many fields".to_string(),
118 });
119 }
120 if short_sha.is_empty() || age.is_empty() {
121 return Err(ParseError::Invalid {
122 field: "last_commit",
123 value: "incomplete".to_string(),
124 });
125 }
126
127 Ok(Self {
128 short_sha,
129 age,
130 summary,
131 })
132 }
133}
134
135pub fn parse_git_stat_records(output: &str) -> Result<Vec<GitStat>, ParseError> {
140 if output.is_empty() {
141 return Ok(Vec::new());
142 }
143
144 let fields = output.split('\n').collect::<Vec<_>>();
145 if fields.len() % GIT_STAT_FIELD_COUNT != 0 {
146 return Err(ParseError::Invalid {
147 field: "git_stat",
148 value: format!(
149 "expected fields in chunks of {GIT_STAT_FIELD_COUNT}, got {}",
150 fields.len()
151 ),
152 });
153 }
154
155 fields
156 .chunks(GIT_STAT_FIELD_COUNT)
157 .map(|fields| fields.join("\n").parse())
158 .collect()
159}
160
161#[derive(Clone, Debug, Default, Eq, PartialEq)]
162pub enum Cmd {
163 #[default]
164 None,
165 Running(String),
166 Agent {
167 agent: Agent,
168 state: AgentState,
169 },
170}
171
172impl Cmd {
173 pub const fn agent(agent: Agent, state: AgentState) -> Self {
174 Self::Agent { agent, state }
175 }
176
177 pub const fn waiting(agent: Agent, seen: bool) -> Self {
178 if seen {
179 Self::agent(agent, AgentState::Acknowledged)
180 } else {
181 Self::agent(agent, AgentState::NeedsAttention)
182 }
183 }
184
185 pub const fn tracked_agent(&self) -> Option<Agent> {
186 match self {
187 Self::Agent { agent, .. } => Some(*agent),
188 Self::None | Self::Running(_) => None,
189 }
190 }
191
192 pub const fn agent_state(&self) -> Option<AgentState> {
193 match self {
194 Self::Agent { state, .. } => Some(*state),
195 Self::None | Self::Running(_) => None,
196 }
197 }
198
199 pub fn agent_name(&self) -> Option<&'static str> {
200 self.tracked_agent().map(Agent::name)
201 }
202
203 pub fn running_cmd(&self) -> Option<&str> {
204 match self {
205 Self::Running(s) => Some(s),
206 Self::None | Self::Agent { .. } => None,
207 }
208 }
209
210 pub const fn is_busy(&self) -> bool {
211 matches!(
212 self,
213 Self::Agent {
214 state: AgentState::Busy,
215 ..
216 }
217 )
218 }
219
220 pub const fn needs_attention(&self) -> bool {
221 matches!(
222 self,
223 Self::Agent {
224 state: AgentState::NeedsAttention,
225 ..
226 }
227 )
228 }
229
230 pub fn acknowledge(&mut self) -> bool {
231 let Self::Agent { state, .. } = self else {
232 return false;
233 };
234 if *state != AgentState::NeedsAttention {
235 return false;
236 }
237 *state = AgentState::Acknowledged;
238 true
239 }
240
241 pub fn from_parts(agent: Option<Agent>, agent_state: Option<AgentState>, command: Option<String>) -> Self {
242 let Some(agent) = agent else {
243 return command.map_or(Self::None, Self::Running);
244 };
245 Self::agent(agent, agent_state.unwrap_or(AgentState::Acknowledged))
246 }
247
248 pub fn into_parts(self) -> (Option<Agent>, Option<AgentState>, Option<String>) {
249 match self {
250 Self::None => (None, None, None),
251 Self::Running(cmd) => (None, None, Some(cmd)),
252 Self::Agent { agent, state } => (Some(agent), Some(state), None),
253 }
254 }
255}
256
257impl From<&AgentEventPayload> for Cmd {
258 fn from(value: &AgentEventPayload) -> Self {
259 match value.kind {
260 AgentEventKind::Busy => Self::agent(value.agent, AgentState::Busy),
261 AgentEventKind::Start | AgentEventKind::Idle => Self::agent(value.agent, AgentState::Acknowledged),
262 AgentEventKind::Exit => Self::None,
263 }
264 }
265}
266
267#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
268pub enum TabIndicator {
269 #[default]
270 NoAgent,
271 Seen,
272 Busy,
273 Unseen,
274}
275
276impl TabIndicator {
277 pub const fn as_str(self) -> &'static str {
278 match self {
279 Self::NoAgent => "no_agent",
280 Self::Seen => "seen",
281 Self::Busy => "busy",
282 Self::Unseen => "unseen",
283 }
284 }
285
286 pub fn parse(s: &str) -> Result<Self, ParseError> {
291 match s {
292 "no_agent" => Ok(Self::NoAgent),
293 "seen" => Ok(Self::Seen),
294 "busy" => Ok(Self::Busy),
295 "unseen" => Ok(Self::Unseen),
296 _ => Err(ParseError::invalid("indicator", format!("{s:?}"))),
297 }
298 }
299
300 pub const fn from_cmd(cmd: &Cmd) -> Self {
301 match cmd {
302 Cmd::Agent {
303 state: AgentState::NeedsAttention,
304 ..
305 } => Self::Unseen,
306 Cmd::Agent {
307 state: AgentState::Busy,
308 ..
309 } => Self::Busy,
310 Cmd::Agent { .. } => Self::Seen,
311 Cmd::None | Cmd::Running(_) => Self::NoAgent,
312 }
313 }
314
315 #[must_use]
316 pub const fn normalize_for_cmd(self, cmd: &Cmd) -> Self {
317 match (self, cmd) {
318 (Self::NoAgent, Cmd::Agent { .. }) => Self::from_cmd(cmd),
319 (Self::Seen, Cmd::None | Cmd::Running(_)) => Self::NoAgent,
320 _ => self,
321 }
322 }
323}
324
325#[derive(Clone, Copy, Debug, Eq, PartialEq)]
326pub enum AgentState {
327 Busy,
328 NeedsAttention,
329 Acknowledged,
330}
331
332impl AgentState {
333 pub const fn as_str(self) -> &'static str {
334 match self {
335 Self::Busy => "busy",
336 Self::NeedsAttention => "needs_attention",
337 Self::Acknowledged => "acknowledged",
338 }
339 }
340
341 pub fn parse(s: &str) -> Result<Self, ParseError> {
346 match s {
347 "busy" => Ok(Self::Busy),
348 "needs_attention" | "waiting_unseen" => Ok(Self::NeedsAttention),
349 "acknowledged" | "waiting_seen" => Ok(Self::Acknowledged),
350 _ => Err(ParseError::invalid("agent_state", format!("{s:?}"))),
351 }
352 }
353}
354
355#[cfg_attr(test, derive(Debug, Eq, PartialEq))]
356pub struct TabStateEntry {
357 pub tab_id: usize,
358 pub cwd: Option<PathBuf>,
359 pub cmd: Cmd,
360 pub indicator: TabIndicator,
361 pub git_stat: GitStat,
362}
363
364impl Display for TabStateEntry {
365 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
366 let cwd_label = self.cwd.as_ref().map(|p| p.display().to_string());
367 let command_label = self.cmd.running_cmd();
368 let agent_state = self.cmd.agent_state().map(AgentState::as_str);
369
370 write!(
371 f,
372 "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n",
373 encode_opt(cwd_label.as_deref()),
374 encode_opt(self.cmd.agent_name()),
375 encode_opt(agent_state),
376 self.indicator.as_str(),
377 self.git_stat.insertions,
378 self.git_stat.deletions,
379 self.git_stat.new_files,
380 u8::from(self.git_stat.is_worktree),
381 encode_opt(command_label),
382 )
383 }
384}
385
386impl TryFrom<(usize, &str)> for TabStateEntry {
387 type Error = ParseError;
388
389 fn try_from((tab_id, content): (usize, &str)) -> Result<Self, Self::Error> {
390 let mut l = content.lines();
391 let mut next = |name| l.next().ok_or(ParseError::Missing(name));
392
393 let cwd = decode_opt_path(next("cwd")?);
394 let agent_raw = next("agent")?;
395 let agent = if agent_raw == EMPTY_FIELD {
396 None
397 } else {
398 Some(Agent::from_name(agent_raw)?)
399 };
400 let agent_state = match next("agent_state")? {
401 EMPTY_FIELD => None,
402 "0" => Some(AgentState::Acknowledged),
403 "1" => Some(AgentState::Busy),
404 value => Some(AgentState::parse(value)?),
405 };
406 let indicator_or_ins = next("indicator")?;
407 let (indicator, insertions, has_explicit_indicator) = match TabIndicator::parse(indicator_or_ins) {
408 Ok(indicator) => (indicator, parse_usize(next("ins")?, "ins")?, true),
409 Err(_) => (TabIndicator::NoAgent, parse_usize(indicator_or_ins, "ins")?, false),
410 };
411 let deletions = parse_usize(next("del")?, "del")?;
412 let new_files = parse_usize(next("new")?, "new")?;
413 let is_worktree = parse_bool(next("wt")?, "wt")?;
414 let command = decode_opt(next("cmd")?);
415 let cmd = Cmd::from_parts(agent, agent_state, command);
416 let indicator = if has_explicit_indicator {
417 indicator.normalize_for_cmd(&cmd)
418 } else {
419 TabIndicator::from_cmd(&cmd)
420 };
421
422 Ok(Self {
423 tab_id,
424 cwd,
425 cmd,
426 indicator,
427 git_stat: GitStat {
428 insertions,
429 deletions,
430 new_files,
431 is_worktree,
432 ..Default::default()
433 },
434 })
435 }
436}
437
438fn encode_opt(val: Option<&str>) -> &str {
439 val.unwrap_or(EMPTY_FIELD)
440}
441
442fn decode_opt(val: &str) -> Option<String> {
443 if val == EMPTY_FIELD { None } else { Some(val.to_owned()) }
444}
445
446fn decode_opt_path(val: &str) -> Option<PathBuf> {
447 if val == EMPTY_FIELD {
448 None
449 } else {
450 Some(PathBuf::from(val))
451 }
452}
453
454fn encode_git_stat_field(value: &str) -> String {
455 let mut out = String::with_capacity(value.len());
456 for ch in value.chars() {
457 match ch {
458 '\\' => out.push_str("\\\\"),
459 '\t' => out.push_str("\\t"),
460 '\n' => out.push_str("\\n"),
461 '\r' => out.push_str("\\r"),
462 _ => out.push(ch),
463 }
464 }
465 out
466}
467
468fn decode_git_stat_field(value: &str, name: &'static str) -> Result<String, ParseError> {
469 let mut out = String::with_capacity(value.len());
470 let mut chars = value.chars();
471 while let Some(ch) = chars.next() {
472 if ch != '\\' {
473 out.push(ch);
474 continue;
475 }
476 let Some(escaped) = chars.next() else {
477 return Err(ParseError::Invalid {
478 field: name,
479 value: "trailing escape".to_string(),
480 });
481 };
482 match escaped {
483 '\\' => out.push('\\'),
484 't' => out.push('\t'),
485 'n' => out.push('\n'),
486 'r' => out.push('\r'),
487 _ => {
488 return Err(ParseError::Invalid {
489 field: name,
490 value: format!("invalid escape \\{escaped}"),
491 });
492 }
493 }
494 }
495 Ok(out)
496}
497
498fn parse_bool(s: &str, name: &'static str) -> Result<bool, ParseError> {
499 match s {
500 "0" => Ok(false),
501 "1" => Ok(true),
502 _ => Err(ParseError::Invalid {
503 field: name,
504 value: format!("{s:?}"),
505 }),
506 }
507}
508
509fn parse_usize(s: &str, name: &'static str) -> Result<usize, ParseError> {
510 s.parse().map_err(|_| ParseError::Invalid {
511 field: name,
512 value: format!("{s:?}"),
513 })
514}
515
516#[cfg(test)]
517mod tests {
518 use std::convert::TryFrom;
519
520 use rstest::rstest;
521
522 use super::*;
523
524 #[test]
525 fn test_git_stat_wire_roundtrip_when_fields_need_escaping_preserves_values() {
526 let stat = GitStat {
527 path: PathBuf::from("/tmp/re\\po\nx"),
528 branch: Some("feat\tone\\two".to_string()),
529 last_commit: Some(LastCommit {
530 short_sha: "abc1234".to_string(),
531 age: "2m".to_string(),
532 summary: "fix ppick\tbranch metadata".to_string(),
533 }),
534 insertions: 2,
535 deletions: 1,
536 new_files: 3,
537 is_worktree: true,
538 };
539
540 let record = stat.to_string();
541 assert2::assert!(let Ok(parsed) = record.parse::<GitStat>());
542
543 pretty_assertions::assert_eq!(
544 record,
545 "/tmp/re\\\\po\\nx\nfeat\\tone\\\\two\n2\n1\n3\n1\nabc1234\n2m\nfix ppick\\tbranch metadata"
546 );
547 pretty_assertions::assert_eq!(parsed, stat);
548 }
549
550 #[test]
551 fn test_git_stat_wire_parse_when_branch_empty_returns_none() {
552 assert2::assert!(let Ok(parsed) = "/tmp/repo\n\n0\n0\n0\n0\n\n\n".parse::<GitStat>());
553
554 pretty_assertions::assert_eq!(
555 parsed,
556 GitStat {
557 path: PathBuf::from("/tmp/repo"),
558 branch: None,
559 ..Default::default()
560 }
561 );
562 }
563
564 #[test]
565 fn test_last_commit_wire_roundtrip_allows_empty_summary() {
566 let commit = LastCommit {
567 short_sha: "abc1234".to_string(),
568 age: "2m".to_string(),
569 summary: String::new(),
570 };
571
572 let record = commit.to_string();
573 assert2::assert!(let Ok(parsed) = record.parse::<LastCommit>());
574
575 pretty_assertions::assert_eq!(record, "abc1234\n2m\n");
576 pretty_assertions::assert_eq!(parsed, commit);
577 }
578
579 #[test]
580 fn test_last_commit_wire_parse_when_required_field_missing_returns_error() {
581 assert2::assert!(let Err(err) = "\n2m\nsummary".parse::<LastCommit>());
582
583 pretty_assertions::assert_eq!(
584 err,
585 ParseError::Invalid {
586 field: "last_commit",
587 value: "incomplete".to_string(),
588 }
589 );
590 }
591
592 #[test]
593 fn test_parse_git_stat_records_when_multiple_records_returns_all() {
594 let first = GitStat {
595 path: PathBuf::from("/tmp/one"),
596 branch: Some("main".to_string()),
597 ..Default::default()
598 };
599 let second = GitStat {
600 path: PathBuf::from("/tmp/two"),
601 branch: Some("next".to_string()),
602 last_commit: Some(LastCommit {
603 short_sha: "def5678".to_string(),
604 age: "1d".to_string(),
605 summary: "ship newline format".to_string(),
606 }),
607 insertions: 4,
608 is_worktree: true,
609 ..Default::default()
610 };
611 let output = format!("{first}\n{second}");
612
613 assert2::assert!(let Ok(parsed) = parse_git_stat_records(&output));
614
615 pretty_assertions::assert_eq!(parsed, vec![first, second]);
616 }
617
618 #[test]
619 fn test_tab_state_entry_serialization_roundtrip_when_entry_valid_preserves_values() {
620 let entry = TabStateEntry {
621 tab_id: 1,
622 cwd: Some(PathBuf::from("/tmp")),
623 cmd: Cmd::agent(Agent::Claude, AgentState::NeedsAttention),
624 indicator: TabIndicator::Unseen,
625 git_stat: GitStat {
626 insertions: 1,
627 deletions: 2,
628 new_files: 3,
629 is_worktree: true,
630 ..Default::default()
631 },
632 };
633
634 let content = entry.to_string();
635 assert2::assert!(let Ok(parsed) = TabStateEntry::try_from((1, content.as_str())));
636 pretty_assertions::assert_eq!(parsed, entry);
637 }
638
639 #[rstest]
640 #[case("no_agent", TabIndicator::NoAgent)]
641 #[case("seen", TabIndicator::Seen)]
642 #[case("busy", TabIndicator::Busy)]
643 #[case("unseen", TabIndicator::Unseen)]
644 fn test_tab_indicator_parse_when_value_is_semantic_returns_expected(
645 #[case] value: &str,
646 #[case] expected: TabIndicator,
647 ) {
648 assert2::assert!(let Ok(parsed) = TabIndicator::parse(value));
649 pretty_assertions::assert_eq!(parsed, expected);
650 }
651
652 #[test]
653 fn test_tab_state_entry_legacy_parse_infers_indicator_from_cmd() {
654 let content = "/tmp\nclaude\nneeds_attention\n1\n2\n3\n1\n--\n";
655
656 assert2::assert!(let Ok(parsed) = TabStateEntry::try_from((1, content)));
657 pretty_assertions::assert_eq!(parsed.indicator, TabIndicator::Unseen);
658 }
659
660 #[test]
661 fn test_tab_state_entry_legacy_parse_infers_no_agent_indicator_for_running_cmd() {
662 let content = "/tmp\n--\n--\n1\n2\n3\n1\ncargo\n";
663
664 assert2::assert!(let Ok(parsed) = TabStateEntry::try_from((1, content)));
665 pretty_assertions::assert_eq!(parsed.indicator, TabIndicator::NoAgent);
666 }
667
668 #[test]
669 fn test_tab_state_entry_explicit_seen_indicator_for_running_cmd_normalizes_to_no_agent() {
670 let content = "/tmp\n--\n--\nseen\n1\n2\n3\n1\ncargo\n";
671
672 assert2::assert!(let Ok(parsed) = TabStateEntry::try_from((1, content)));
673 pretty_assertions::assert_eq!(parsed.indicator, TabIndicator::NoAgent);
674 }
675
676 #[test]
677 fn test_cmd_acknowledge_needs_attention_transitions_to_acknowledged() {
678 let mut cmd = Cmd::agent(Agent::Codex, AgentState::NeedsAttention);
679
680 assert2::assert!(cmd.needs_attention());
681 assert2::assert!(cmd.acknowledge());
682 pretty_assertions::assert_eq!(cmd, Cmd::agent(Agent::Codex, AgentState::Acknowledged));
683 assert2::assert!(!cmd.needs_attention());
684 assert2::assert!(!cmd.acknowledge());
685 }
686}