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 #[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 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}