Skip to main content

agg/
lib.rs

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
135/// Parse one or more newline-field [`GitStat`] records.
136///
137/// # Errors
138/// Returns [`ParseError`] if the record has an incomplete field count or any field is invalid.
139pub 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    /// Parse a serialized tab indicator.
287    ///
288    /// # Errors
289    /// Returns [`ParseError`] when `s` is not a supported indicator value.
290    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    /// Parse a serialized agent state.
342    ///
343    /// # Errors
344    /// Returns [`ParseError`] when `s` is not a supported agent state value.
345    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}