Skip to main content

batty_cli/team/
errors.rs

1use thiserror::Error;
2
3#[derive(Debug, Error)]
4pub enum GitError {
5    #[error("transient git error: {message}")]
6    Transient { message: String, stderr: String },
7    #[error("permanent git error: {message}")]
8    Permanent { message: String, stderr: String },
9    #[error("git rebase failed for '{branch}': {stderr}")]
10    RebaseFailed { branch: String, stderr: String },
11    #[error("git merge failed for '{branch}': {stderr}")]
12    MergeFailed { branch: String, stderr: String },
13    #[error("git rev-parse failed for '{spec}': {stderr}")]
14    RevParseFailed { spec: String, stderr: String },
15    #[error("invalid git rev-list count for '{range}': {output}")]
16    InvalidRevListCount { range: String, output: String },
17    #[error("failed to execute git command `{command}`: {source}")]
18    Exec {
19        command: String,
20        #[source]
21        source: std::io::Error,
22    },
23}
24
25impl GitError {
26    pub fn is_transient(&self) -> bool {
27        match self {
28            GitError::Transient { .. } => true,
29            GitError::Exec { source, .. } => matches!(
30                source.kind(),
31                std::io::ErrorKind::NotFound
32                    | std::io::ErrorKind::TimedOut
33                    | std::io::ErrorKind::Interrupted
34                    | std::io::ErrorKind::WouldBlock
35            ),
36            _ => false,
37        }
38    }
39}
40
41#[derive(Debug, Error)]
42pub enum BoardError {
43    #[error("transient board error: {message}")]
44    Transient { message: String, stderr: String },
45    #[error("permanent board error: {message}")]
46    Permanent { message: String, stderr: String },
47    #[error("task #{id} not found")]
48    TaskNotFound { id: String },
49    #[error("task file missing YAML frontmatter: {detail}")]
50    InvalidFrontmatter { detail: String },
51    #[error("failed to determine claim owner for blocked task #{task_id}")]
52    ClaimOwnerUnknown { task_id: String, stderr: String },
53    #[error("failed to execute board command `{command}`: {source}")]
54    Exec {
55        command: String,
56        #[source]
57        source: std::io::Error,
58    },
59}
60
61impl BoardError {
62    pub fn is_transient(&self) -> bool {
63        matches!(self, BoardError::Transient { .. })
64    }
65}
66
67#[derive(Debug, Error)]
68pub enum TmuxError {
69    #[error("failed to execute tmux command `{command}`: {source}")]
70    Exec {
71        command: String,
72        #[source]
73        source: std::io::Error,
74    },
75    #[error("tmux command `{command}` failed{target_suffix}: {stderr}")]
76    CommandFailed {
77        command: String,
78        target: Option<String>,
79        stderr: String,
80        target_suffix: String,
81    },
82    #[error("tmux session '{session}' already exists")]
83    SessionExists { session: String },
84    #[error("tmux session '{session}' not found")]
85    SessionNotFound { session: String },
86    #[error("tmux returned empty pane id for target '{target}'")]
87    EmptyPaneId { target: String },
88    #[error("tmux returned empty {field} for target '{target}'")]
89    EmptyField { target: String, field: &'static str },
90}
91
92impl TmuxError {
93    pub fn command_failed(command: impl Into<String>, target: Option<&str>, stderr: &str) -> Self {
94        let target = target.map(ToOwned::to_owned);
95        let target_suffix = target
96            .as_deref()
97            .map(|value| format!(" for '{value}'"))
98            .unwrap_or_default();
99        Self::CommandFailed {
100            command: command.into(),
101            target,
102            stderr: stderr.to_string(),
103            target_suffix,
104        }
105    }
106
107    pub fn exec(command: impl Into<String>, source: std::io::Error) -> Self {
108        Self::Exec {
109            command: command.into(),
110            source,
111        }
112    }
113}
114
115#[derive(Debug, Error)]
116pub enum DeliveryError {
117    #[error("unsupported delivery channel type '{channel_type}'")]
118    UnsupportedChannel { channel_type: String },
119    #[error("failed to execute delivery provider '{provider}': {source}")]
120    ProviderExec {
121        provider: String,
122        #[source]
123        source: std::io::Error,
124    },
125    #[error("channel delivery failed for '{recipient}': {detail}")]
126    ChannelSend { recipient: String, detail: String },
127    #[error("live pane delivery failed for '{recipient}' via pane '{pane_id}': {detail}")]
128    PaneInject {
129        recipient: String,
130        pane_id: String,
131        detail: String,
132    },
133    #[error("failed to queue inbox delivery for '{recipient}': {detail}")]
134    InboxQueue { recipient: String, detail: String },
135}
136
137impl DeliveryError {
138    pub fn is_transient(&self) -> bool {
139        match self {
140            Self::UnsupportedChannel { .. } => false,
141            Self::ProviderExec { source, .. } => matches!(
142                source.kind(),
143                std::io::ErrorKind::TimedOut
144                    | std::io::ErrorKind::Interrupted
145                    | std::io::ErrorKind::WouldBlock
146                    | std::io::ErrorKind::ConnectionReset
147                    | std::io::ErrorKind::ConnectionAborted
148                    | std::io::ErrorKind::NotConnected
149            ),
150            Self::ChannelSend { detail, .. } | Self::PaneInject { detail, .. } => {
151                detail_is_transient(detail)
152            }
153            Self::InboxQueue { .. } => false,
154        }
155    }
156}
157
158fn detail_is_transient(detail: &str) -> bool {
159    let detail = detail.to_ascii_lowercase();
160    [
161        "429",
162        "too many requests",
163        "timeout",
164        "timed out",
165        "temporary",
166        "temporarily unavailable",
167        "connection reset",
168        "connection aborted",
169        "try again",
170        "retry after",
171        "network",
172    ]
173    .iter()
174    .any(|needle| detail.contains(needle))
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn git_error_marks_only_transient_variants_retryable() {
183        assert!(
184            GitError::Transient {
185                message: "lock".to_string(),
186                stderr: "lock".to_string(),
187            }
188            .is_transient()
189        );
190        assert!(
191            !GitError::Permanent {
192                message: "fatal".to_string(),
193                stderr: "fatal".to_string(),
194            }
195            .is_transient()
196        );
197        assert!(
198            !GitError::RebaseFailed {
199                branch: "topic".to_string(),
200                stderr: "conflict".to_string(),
201            }
202            .is_transient()
203        );
204    }
205
206    #[test]
207    fn board_error_marks_only_transient_variants_retryable() {
208        assert!(
209            BoardError::Transient {
210                message: "lock".to_string(),
211                stderr: "lock".to_string(),
212            }
213            .is_transient()
214        );
215        assert!(
216            !BoardError::TaskNotFound {
217                id: "123".to_string()
218            }
219            .is_transient()
220        );
221    }
222
223    #[test]
224    fn tmux_command_failed_formats_target_suffix() {
225        let error = TmuxError::command_failed("send-keys", Some("%1"), "pane missing");
226        assert!(error.to_string().contains("for '%1'"));
227    }
228
229    #[test]
230    fn delivery_error_marks_transient_channel_failures_retryable() {
231        assert!(
232            DeliveryError::ChannelSend {
233                recipient: "human".to_string(),
234                detail: "429 too many requests".to_string(),
235            }
236            .is_transient()
237        );
238        assert!(
239            !DeliveryError::ChannelSend {
240                recipient: "human".to_string(),
241                detail: "chat not found".to_string(),
242            }
243            .is_transient()
244        );
245    }
246
247    // --- Error path and recovery tests (Task #265) ---
248
249    #[test]
250    fn delivery_error_unsupported_channel_is_never_transient() {
251        let error = DeliveryError::UnsupportedChannel {
252            channel_type: "smoke_signal".to_string(),
253        };
254        assert!(!error.is_transient());
255        assert!(error.to_string().contains("smoke_signal"));
256    }
257
258    #[test]
259    fn delivery_error_provider_exec_timeout_is_transient() {
260        let error = DeliveryError::ProviderExec {
261            provider: "telegram".to_string(),
262            source: std::io::Error::new(std::io::ErrorKind::TimedOut, "connection timed out"),
263        };
264        assert!(error.is_transient());
265    }
266
267    #[test]
268    fn delivery_error_provider_exec_not_found_is_permanent() {
269        let error = DeliveryError::ProviderExec {
270            provider: "telegram".to_string(),
271            source: std::io::Error::new(std::io::ErrorKind::NotFound, "binary not found"),
272        };
273        assert!(!error.is_transient());
274    }
275
276    #[test]
277    fn delivery_error_provider_exec_interrupted_is_transient() {
278        let error = DeliveryError::ProviderExec {
279            provider: "telegram".to_string(),
280            source: std::io::Error::new(std::io::ErrorKind::Interrupted, "signal received"),
281        };
282        assert!(error.is_transient());
283    }
284
285    #[test]
286    fn delivery_error_provider_exec_connection_reset_is_transient() {
287        let error = DeliveryError::ProviderExec {
288            provider: "telegram".to_string(),
289            source: std::io::Error::new(std::io::ErrorKind::ConnectionReset, "peer reset"),
290        };
291        assert!(error.is_transient());
292    }
293
294    #[test]
295    fn delivery_error_pane_inject_transient_detail() {
296        let error = DeliveryError::PaneInject {
297            recipient: "eng-1".to_string(),
298            pane_id: "%5".to_string(),
299            detail: "connection reset by peer".to_string(),
300        };
301        assert!(error.is_transient());
302        assert!(error.to_string().contains("%5"));
303    }
304
305    #[test]
306    fn delivery_error_pane_inject_permanent_detail() {
307        let error = DeliveryError::PaneInject {
308            recipient: "eng-1".to_string(),
309            pane_id: "%5".to_string(),
310            detail: "pane not found".to_string(),
311        };
312        assert!(!error.is_transient());
313    }
314
315    #[test]
316    fn delivery_error_inbox_queue_is_never_transient() {
317        let error = DeliveryError::InboxQueue {
318            recipient: "eng-1".to_string(),
319            detail: "disk full".to_string(),
320        };
321        assert!(!error.is_transient());
322        assert!(error.to_string().contains("eng-1"));
323    }
324
325    #[test]
326    fn delivery_error_channel_send_timeout_is_transient() {
327        let error = DeliveryError::ChannelSend {
328            recipient: "human".to_string(),
329            detail: "request timed out waiting for response".to_string(),
330        };
331        assert!(error.is_transient());
332    }
333
334    #[test]
335    fn delivery_error_channel_send_network_is_transient() {
336        let error = DeliveryError::ChannelSend {
337            recipient: "human".to_string(),
338            detail: "network unreachable".to_string(),
339        };
340        assert!(error.is_transient());
341    }
342
343    #[test]
344    fn delivery_error_channel_send_retry_after_is_transient() {
345        let error = DeliveryError::ChannelSend {
346            recipient: "human".to_string(),
347            detail: "retry after 30 seconds".to_string(),
348        };
349        assert!(error.is_transient());
350    }
351
352    #[test]
353    fn git_error_exec_display_includes_command() {
354        let error = GitError::Exec {
355            command: "git -C /repo merge main".to_string(),
356            source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"),
357        };
358        assert!(error.to_string().contains("git -C /repo merge main"));
359        assert!(!error.is_transient());
360    }
361
362    #[test]
363    fn git_error_exec_not_found_is_transient() {
364        let error = GitError::Exec {
365            command: "git -C /repo checkout main".to_string(),
366            source: std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory"),
367        };
368        assert!(error.is_transient());
369    }
370
371    #[test]
372    fn git_error_exec_timed_out_is_transient() {
373        let error = GitError::Exec {
374            command: "git -C /repo fetch".to_string(),
375            source: std::io::Error::new(std::io::ErrorKind::TimedOut, "operation timed out"),
376        };
377        assert!(error.is_transient());
378    }
379
380    #[test]
381    fn git_error_exec_interrupted_is_transient() {
382        let error = GitError::Exec {
383            command: "git -C /repo status".to_string(),
384            source: std::io::Error::new(std::io::ErrorKind::Interrupted, "signal received"),
385        };
386        assert!(error.is_transient());
387    }
388
389    #[test]
390    fn git_error_exec_permission_denied_is_not_transient() {
391        let error = GitError::Exec {
392            command: "git -C /repo status".to_string(),
393            source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"),
394        };
395        assert!(!error.is_transient());
396    }
397
398    #[test]
399    fn git_error_rebase_failed_not_transient() {
400        let error = GitError::RebaseFailed {
401            branch: "feature-x".to_string(),
402            stderr: "CONFLICT (content): Merge conflict in src/main.rs".to_string(),
403        };
404        assert!(!error.is_transient());
405        assert!(error.to_string().contains("feature-x"));
406    }
407
408    #[test]
409    fn git_error_merge_failed_not_transient() {
410        let error = GitError::MergeFailed {
411            branch: "topic".to_string(),
412            stderr: "Automatic merge failed".to_string(),
413        };
414        assert!(!error.is_transient());
415        assert!(error.to_string().contains("topic"));
416    }
417
418    #[test]
419    fn git_error_rev_parse_failed_not_transient() {
420        let error = GitError::RevParseFailed {
421            spec: "HEAD~5".to_string(),
422            stderr: "unknown revision".to_string(),
423        };
424        assert!(!error.is_transient());
425        assert!(error.to_string().contains("HEAD~5"));
426    }
427
428    #[test]
429    fn git_error_invalid_rev_list_count_not_transient() {
430        let error = GitError::InvalidRevListCount {
431            range: "main..feature".to_string(),
432            output: "not-a-number".to_string(),
433        };
434        assert!(!error.is_transient());
435        assert!(error.to_string().contains("main..feature"));
436    }
437
438    #[test]
439    fn board_error_permanent_not_transient() {
440        let error = BoardError::Permanent {
441            message: "unknown command".to_string(),
442            stderr: "bad args".to_string(),
443        };
444        assert!(!error.is_transient());
445    }
446
447    #[test]
448    fn board_error_exec_not_transient() {
449        let error = BoardError::Exec {
450            command: "kanban-md list".to_string(),
451            source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
452        };
453        assert!(!error.is_transient());
454        assert!(error.to_string().contains("kanban-md list"));
455    }
456
457    #[test]
458    fn board_error_invalid_frontmatter_not_transient() {
459        let error = BoardError::InvalidFrontmatter {
460            detail: "missing status field".to_string(),
461        };
462        assert!(!error.is_transient());
463        assert!(error.to_string().contains("missing status field"));
464    }
465
466    #[test]
467    fn board_error_claim_owner_unknown_not_transient() {
468        let error = BoardError::ClaimOwnerUnknown {
469            task_id: "42".to_string(),
470            stderr: "is claimed by unknown".to_string(),
471        };
472        assert!(!error.is_transient());
473        assert!(error.to_string().contains("42"));
474    }
475
476    #[test]
477    fn tmux_error_session_exists_format() {
478        let error = TmuxError::SessionExists {
479            session: "batty-test".to_string(),
480        };
481        assert!(error.to_string().contains("batty-test"));
482        assert!(error.to_string().contains("already exists"));
483    }
484
485    #[test]
486    fn tmux_error_session_not_found_format() {
487        let error = TmuxError::SessionNotFound {
488            session: "batty-test".to_string(),
489        };
490        assert!(error.to_string().contains("batty-test"));
491        assert!(error.to_string().contains("not found"));
492    }
493
494    #[test]
495    fn tmux_error_empty_pane_id_format() {
496        let error = TmuxError::EmptyPaneId {
497            target: "batty-session:0".to_string(),
498        };
499        assert!(error.to_string().contains("batty-session:0"));
500        assert!(error.to_string().contains("empty pane id"));
501    }
502
503    #[test]
504    fn tmux_error_empty_field_format() {
505        let error = TmuxError::EmptyField {
506            target: "%5".to_string(),
507            field: "pane_pid",
508        };
509        assert!(error.to_string().contains("%5"));
510        assert!(error.to_string().contains("pane_pid"));
511    }
512
513    #[test]
514    fn tmux_error_command_failed_without_target() {
515        let error = TmuxError::command_failed("list-sessions", None, "server not found");
516        let msg = error.to_string();
517        assert!(msg.contains("list-sessions"));
518        assert!(msg.contains("server not found"));
519        assert!(!msg.contains("for '"));
520    }
521
522    #[test]
523    fn tmux_error_exec_format() {
524        let error = TmuxError::exec(
525            "tmux new-session",
526            std::io::Error::new(std::io::ErrorKind::NotFound, "tmux not found"),
527        );
528        assert!(error.to_string().contains("tmux new-session"));
529    }
530
531    #[test]
532    fn detail_is_transient_covers_all_keywords() {
533        assert!(detail_is_transient("HTTP 429 rate limit"));
534        assert!(detail_is_transient("Too Many Requests"));
535        assert!(detail_is_transient("request timeout"));
536        assert!(detail_is_transient("connection timed out"));
537        assert!(detail_is_transient("temporary failure"));
538        assert!(detail_is_transient("temporarily unavailable"));
539        assert!(detail_is_transient("connection reset by peer"));
540        assert!(detail_is_transient("connection aborted"));
541        assert!(detail_is_transient("please try again later"));
542        assert!(detail_is_transient("Retry After: 30"));
543        assert!(detail_is_transient("network error"));
544        // Permanent errors
545        assert!(!detail_is_transient("chat not found"));
546        assert!(!detail_is_transient("invalid token"));
547        assert!(!detail_is_transient("forbidden"));
548        assert!(!detail_is_transient(""));
549    }
550}