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