Skip to main content

agm_core/
lib.rs

1pub mod agent;
2pub mod git_stat;
3
4use std::path::Path;
5use std::path::PathBuf;
6
7use crate::agent::Agent;
8use crate::agent::AgentEventKind;
9use crate::agent::AgentEventPayload;
10use crate::git_stat::GitStat;
11
12pub const EMPTY_FIELD: &str = "--";
13
14#[derive(Debug, PartialEq)]
15pub enum ParseError {
16    Missing(&'static str),
17    Invalid { field: &'static str, value: String },
18}
19
20impl ParseError {
21    pub fn invalid(field: &'static str, value: impl Into<String>) -> Self {
22        Self::Invalid {
23            field,
24            value: value.into(),
25        }
26    }
27}
28
29impl std::fmt::Display for ParseError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            ParseError::Missing(field) => write!(f, "missing {field}"),
33            ParseError::Invalid { field, value } => write!(f, "invalid {field}: {value}"),
34        }
35    }
36}
37
38impl std::error::Error for ParseError {}
39
40/// Render a compact path label using `~/...` when under `home`, abbreviating all
41/// parent directories to a single character and keeping the last segment intact.
42pub fn short_path(path: &Path, home: &Path) -> String {
43    if home != Path::new("/") {
44        if path == home {
45            return "~".into();
46        }
47        if let Ok(rel) = path.strip_prefix(home) {
48            let names = path_dir_names(rel);
49            return if names.is_empty() {
50                "~".into()
51            } else {
52                format!("~/{}", abbrev_path_dirs(&names))
53            };
54        }
55    }
56
57    let names = path_dir_names(path);
58    if names.is_empty() {
59        "/".into()
60    } else {
61        format!("/{}", abbrev_path_dirs(&names))
62    }
63}
64
65fn path_dir_names(path: &Path) -> Vec<String> {
66    path.components()
67        .filter_map(|component| match component {
68            std::path::Component::Normal(segment) => Some(segment.to_string_lossy().into_owned()),
69            std::path::Component::Prefix(_)
70            | std::path::Component::RootDir
71            | std::path::Component::CurDir
72            | std::path::Component::ParentDir => None,
73        })
74        .collect()
75}
76
77fn abbrev_path_dirs(names: &[String]) -> String {
78    match names.len() {
79        0 => String::new(),
80        1 => names.first().cloned().unwrap_or_default(),
81        total => {
82            let mut out = String::new();
83            for (idx, name) in names.iter().enumerate() {
84                if idx > 0 {
85                    out.push('/');
86                }
87                let is_last = idx == total.saturating_sub(1);
88                if is_last {
89                    out.push_str(name);
90                } else {
91                    out.push(name.chars().next().unwrap_or('ยท'));
92                }
93            }
94            out
95        }
96    }
97}
98
99#[derive(Clone, Debug, Default, Eq, PartialEq)]
100pub enum Cmd {
101    #[default]
102    None,
103    Running(String),
104    Agent {
105        agent: Agent,
106        state: AgentState,
107    },
108}
109
110impl Cmd {
111    pub const fn agent(agent: Agent, state: AgentState) -> Self {
112        Self::Agent { agent, state }
113    }
114
115    pub const fn waiting(agent: Agent, seen: bool) -> Self {
116        if seen {
117            Self::agent(agent, AgentState::Acknowledged)
118        } else {
119            Self::agent(agent, AgentState::NeedsAttention)
120        }
121    }
122
123    pub const fn tracked_agent(&self) -> Option<Agent> {
124        match self {
125            Self::Agent { agent, .. } => Some(*agent),
126            Self::None | Self::Running(_) => None,
127        }
128    }
129
130    pub const fn agent_state(&self) -> Option<AgentState> {
131        match self {
132            Self::Agent { state, .. } => Some(*state),
133            Self::None | Self::Running(_) => None,
134        }
135    }
136
137    pub fn agent_name(&self) -> Option<&'static str> {
138        self.tracked_agent().map(Agent::name)
139    }
140
141    pub fn running_cmd(&self) -> Option<&str> {
142        match self {
143            Self::Running(s) => Some(s),
144            Self::None | Self::Agent { .. } => None,
145        }
146    }
147
148    pub fn is_busy(&self) -> bool {
149        matches!(
150            self,
151            Self::Agent {
152                state: AgentState::Busy,
153                ..
154            }
155        )
156    }
157
158    pub fn needs_attention(&self) -> bool {
159        matches!(
160            self,
161            Self::Agent {
162                state: AgentState::NeedsAttention,
163                ..
164            }
165        )
166    }
167
168    pub fn acknowledge(&mut self) -> bool {
169        let Self::Agent { state, .. } = self else {
170            return false;
171        };
172        if *state != AgentState::NeedsAttention {
173            return false;
174        }
175        *state = AgentState::Acknowledged;
176        true
177    }
178
179    pub fn from_parts(agent: Option<Agent>, agent_state: Option<AgentState>, command: Option<String>) -> Self {
180        let Some(agent) = agent else {
181            return command.map_or(Self::None, Self::Running);
182        };
183        Self::agent(agent, agent_state.unwrap_or(AgentState::Acknowledged))
184    }
185
186    pub fn into_parts(self) -> (Option<Agent>, Option<AgentState>, Option<String>) {
187        match self {
188            Self::None => (None, None, None),
189            Self::Running(cmd) => (None, None, Some(cmd)),
190            Self::Agent { agent, state } => (Some(agent), Some(state), None),
191        }
192    }
193}
194
195impl From<&AgentEventPayload> for Cmd {
196    fn from(value: &AgentEventPayload) -> Self {
197        match value.kind {
198            AgentEventKind::Start => Self::agent(value.agent, AgentState::Acknowledged),
199            AgentEventKind::Busy => Self::agent(value.agent, AgentState::Busy),
200            AgentEventKind::Idle => Self::agent(value.agent, AgentState::Acknowledged),
201            AgentEventKind::Exit => Self::None,
202        }
203    }
204}
205
206#[derive(Clone, Copy, Debug, Eq, PartialEq)]
207pub enum AgentState {
208    Busy,
209    NeedsAttention,
210    Acknowledged,
211}
212
213impl AgentState {
214    pub const fn as_str(self) -> &'static str {
215        match self {
216            Self::Busy => "busy",
217            Self::NeedsAttention => "needs_attention",
218            Self::Acknowledged => "acknowledged",
219        }
220    }
221
222    pub fn parse(s: &str) -> Result<Self, ParseError> {
223        match s {
224            "busy" => Ok(Self::Busy),
225            "needs_attention" | "waiting_unseen" => Ok(Self::NeedsAttention),
226            "acknowledged" | "waiting_seen" => Ok(Self::Acknowledged),
227            _ => Err(ParseError::invalid("agent_state", format!("{s:?}"))),
228        }
229    }
230}
231
232#[cfg_attr(test, derive(Debug, Eq, PartialEq))]
233pub struct TabStateEntry {
234    pub tab_id: usize,
235    pub cwd: Option<PathBuf>,
236    pub cmd: Cmd,
237    pub git_stat: GitStat,
238}
239
240impl std::fmt::Display for TabStateEntry {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        let cwd_s = self.cwd.as_ref().map(|p| p.display().to_string());
243        let cmd_s = self.cmd.running_cmd();
244        let agent_state = self.cmd.agent_state().map(AgentState::as_str);
245
246        write!(
247            f,
248            "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n",
249            encode_opt(cwd_s.as_deref()),
250            encode_opt(self.cmd.agent_name()),
251            encode_opt(agent_state),
252            self.git_stat.insertions,
253            self.git_stat.deletions,
254            self.git_stat.new_files,
255            u8::from(self.git_stat.is_worktree),
256            encode_opt(cmd_s),
257        )
258    }
259}
260
261impl std::convert::TryFrom<(usize, &str)> for TabStateEntry {
262    type Error = ParseError;
263
264    fn try_from((tab_id, content): (usize, &str)) -> Result<Self, Self::Error> {
265        let mut l = content.lines();
266        let mut next = |name| l.next().ok_or(ParseError::Missing(name));
267
268        let cwd = decode_opt_path(next("cwd")?);
269        let agent_raw = next("agent")?;
270        let agent = if agent_raw == EMPTY_FIELD {
271            None
272        } else {
273            Some(Agent::from_name(agent_raw)?)
274        };
275        let agent_state = match next("agent_state")? {
276            EMPTY_FIELD => None,
277            "0" => Some(AgentState::Acknowledged),
278            "1" => Some(AgentState::Busy),
279            value => Some(AgentState::parse(value)?),
280        };
281        let insertions = parse_usize(next("ins")?, "ins")?;
282        let deletions = parse_usize(next("del")?, "del")?;
283        let new_files = parse_usize(next("new")?, "new")?;
284        let is_worktree = parse_bool(next("wt")?, "wt")?;
285        let command = decode_opt(next("cmd")?);
286
287        Ok(Self {
288            tab_id,
289            cwd,
290            cmd: Cmd::from_parts(agent, agent_state, command),
291            git_stat: GitStat {
292                insertions,
293                deletions,
294                new_files,
295                is_worktree,
296            },
297        })
298    }
299}
300
301fn encode_opt(val: Option<&str>) -> &str {
302    val.unwrap_or(EMPTY_FIELD)
303}
304
305fn decode_opt(val: &str) -> Option<String> {
306    if val == EMPTY_FIELD { None } else { Some(val.to_owned()) }
307}
308
309fn decode_opt_path(val: &str) -> Option<PathBuf> {
310    if val == EMPTY_FIELD {
311        None
312    } else {
313        Some(PathBuf::from(val))
314    }
315}
316
317fn parse_bool(s: &str, name: &'static str) -> Result<bool, ParseError> {
318    match s {
319        "0" => Ok(false),
320        "1" => Ok(true),
321        _ => Err(ParseError::Invalid {
322            field: name,
323            value: format!("{s:?}"),
324        }),
325    }
326}
327
328fn parse_usize(s: &str, name: &'static str) -> Result<usize, ParseError> {
329    s.parse().map_err(|_| ParseError::Invalid {
330        field: name,
331        value: format!("{s:?}"),
332    })
333}
334
335#[cfg(test)]
336mod tests {
337    use std::convert::TryFrom;
338    use std::path::Path;
339
340    use super::*;
341
342    #[test]
343    fn test_tab_state_entry_serialization_roundtrip_works_as_expected() {
344        let entry = TabStateEntry {
345            tab_id: 1,
346            cwd: Some(PathBuf::from("/tmp")),
347            cmd: Cmd::agent(Agent::Claude, AgentState::NeedsAttention),
348            git_stat: GitStat {
349                insertions: 1,
350                deletions: 2,
351                new_files: 3,
352                is_worktree: true,
353            },
354        };
355
356        let content = entry.to_string();
357        assert2::assert!(let Ok(parsed) = TabStateEntry::try_from((1, content.as_str())));
358        pretty_assertions::assert_eq!(parsed, entry);
359    }
360
361    #[test]
362    fn test_short_path_under_home() {
363        let home = Path::new("/home/user");
364        pretty_assertions::assert_eq!(
365            super::short_path(Path::new("/home/user/src/pkg/myproject"), home),
366            "~/s/p/myproject"
367        );
368    }
369
370    #[test]
371    fn test_short_path_many_dirs() {
372        let home = Path::new("/home/user");
373        pretty_assertions::assert_eq!(
374            super::short_path(Path::new("/home/user/one/two/three/four/five"), home),
375            "~/o/t/t/f/five"
376        );
377    }
378
379    #[test]
380    fn test_short_path_outside_home() {
381        let home = Path::new("/home/user");
382        pretty_assertions::assert_eq!(super::short_path(Path::new("/opt/pkg/foo"), home), "/o/p/foo");
383    }
384
385    #[test]
386    fn test_cmd_acknowledge_needs_attention_transitions_to_acknowledged() {
387        let mut cmd = Cmd::agent(Agent::Codex, AgentState::NeedsAttention);
388
389        assert2::assert!(cmd.needs_attention());
390        assert2::assert!(cmd.acknowledge());
391        pretty_assertions::assert_eq!(cmd, Cmd::agent(Agent::Codex, AgentState::Acknowledged));
392        assert2::assert!(!cmd.needs_attention());
393        assert2::assert!(!cmd.acknowledge());
394    }
395}