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