1use std::fmt;
2
3use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
4
5use crate::error::{Error, Result};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum TaskState {
9 Proposed,
10 Parked,
11 Ready,
12 Running,
13 NeedsInput,
14 Review,
15 Waiting,
16 Stalled,
17 Done,
18 Rejected,
19}
20
21impl TaskState {
22 pub const ALL: [TaskState; 10] = [
23 TaskState::Proposed,
24 TaskState::Parked,
25 TaskState::Ready,
26 TaskState::Running,
27 TaskState::NeedsInput,
28 TaskState::Review,
29 TaskState::Waiting,
30 TaskState::Stalled,
31 TaskState::Done,
32 TaskState::Rejected,
33 ];
34
35 pub fn as_str(self) -> &'static str {
36 match self {
37 TaskState::Proposed => "proposed",
38 TaskState::Parked => "parked",
39 TaskState::Ready => "ready",
40 TaskState::Running => "running",
41 TaskState::NeedsInput => "needs-input",
42 TaskState::Review => "review",
43 TaskState::Waiting => "waiting",
44 TaskState::Stalled => "stalled",
45 TaskState::Done => "done",
46 TaskState::Rejected => "rejected",
47 }
48 }
49
50 pub fn parse(s: &str) -> Result<TaskState> {
51 Self::ALL
52 .into_iter()
53 .find(|state| state.as_str() == s)
54 .ok_or_else(|| Error::Invalid(format!("unknown task state '{s}'")))
55 }
56
57 pub fn is_terminal(self) -> bool {
59 matches!(self, TaskState::Done | TaskState::Rejected)
60 }
61}
62
63impl fmt::Display for TaskState {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 f.pad(self.as_str())
66 }
67}
68
69impl FromSql for TaskState {
70 fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
71 let s = value.as_str()?;
72 TaskState::parse(s).map_err(|e| FromSqlError::Other(Box::new(e)))
73 }
74}
75
76impl ToSql for TaskState {
77 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
78 Ok(self.as_str().into())
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
83pub enum Priority {
84 P0,
85 P1,
86 P2,
87 P3,
88}
89
90impl Priority {
91 pub fn from_int(n: i64) -> Result<Priority> {
92 match n {
93 0 => Ok(Priority::P0),
94 1 => Ok(Priority::P1),
95 2 => Ok(Priority::P2),
96 3 => Ok(Priority::P3),
97 _ => Err(Error::Invalid(format!("priority {n} out of range 0-3"))),
98 }
99 }
100
101 pub fn as_int(self) -> i64 {
102 match self {
103 Priority::P0 => 0,
104 Priority::P1 => 1,
105 Priority::P2 => 2,
106 Priority::P3 => 3,
107 }
108 }
109
110 pub fn value(self) -> f64 {
112 match self {
113 Priority::P0 => 8.0,
114 Priority::P1 => 4.0,
115 Priority::P2 => 2.0,
116 Priority::P3 => 1.0,
117 }
118 }
119}
120
121impl fmt::Display for Priority {
122 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123 let s = match self {
124 Priority::P0 => "P0",
125 Priority::P1 => "P1",
126 Priority::P2 => "P2",
127 Priority::P3 => "P3",
128 };
129 f.pad(s)
130 }
131}
132
133impl FromSql for Priority {
134 fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
135 Priority::from_int(value.as_i64()?).map_err(|e| FromSqlError::Other(Box::new(e)))
136 }
137}
138
139impl ToSql for Priority {
140 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
141 Ok(self.as_int().into())
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
146pub enum DepKind {
147 Blocks,
148 DiscoveredFrom,
149 Parent,
150 Related,
151}
152
153impl DepKind {
154 pub const ALL: [DepKind; 4] = [
155 DepKind::Blocks,
156 DepKind::DiscoveredFrom,
157 DepKind::Parent,
158 DepKind::Related,
159 ];
160
161 pub fn as_str(self) -> &'static str {
162 match self {
163 DepKind::Blocks => "blocks",
164 DepKind::DiscoveredFrom => "discovered-from",
165 DepKind::Parent => "parent",
166 DepKind::Related => "related",
167 }
168 }
169
170 pub fn parse(s: &str) -> Result<DepKind> {
171 Self::ALL
172 .into_iter()
173 .find(|kind| kind.as_str() == s)
174 .ok_or_else(|| Error::Invalid(format!("unknown dep kind '{s}'")))
175 }
176}
177
178impl fmt::Display for DepKind {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 f.write_str(self.as_str())
181 }
182}
183
184impl FromSql for DepKind {
185 fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
186 DepKind::parse(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e)))
187 }
188}
189
190impl ToSql for DepKind {
191 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
192 Ok(self.as_str().into())
193 }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
197pub enum SessionOutcome {
198 Completed,
199 Asked,
200 Failed,
201 Capped,
202 Aborted,
203}
204
205impl SessionOutcome {
206 pub const ALL: [SessionOutcome; 5] = [
207 SessionOutcome::Completed,
208 SessionOutcome::Asked,
209 SessionOutcome::Failed,
210 SessionOutcome::Capped,
211 SessionOutcome::Aborted,
212 ];
213
214 pub fn as_str(self) -> &'static str {
215 match self {
216 SessionOutcome::Completed => "completed",
217 SessionOutcome::Asked => "asked",
218 SessionOutcome::Failed => "failed",
219 SessionOutcome::Capped => "capped",
220 SessionOutcome::Aborted => "aborted",
221 }
222 }
223
224 pub fn parse(s: &str) -> Result<SessionOutcome> {
225 Self::ALL
226 .into_iter()
227 .find(|outcome| outcome.as_str() == s)
228 .ok_or_else(|| Error::Invalid(format!("unknown session outcome '{s}'")))
229 }
230}
231
232impl fmt::Display for SessionOutcome {
233 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234 f.write_str(self.as_str())
235 }
236}
237
238impl FromSql for SessionOutcome {
239 fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
240 SessionOutcome::parse(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e)))
241 }
242}
243
244impl ToSql for SessionOutcome {
245 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
246 Ok(self.as_str().into())
247 }
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Default)]
254pub enum ReviewAction {
255 #[default]
258 Auto,
259 Pr,
261 Viewer(Option<String>),
264}
265
266impl ReviewAction {
267 pub fn parse(s: &str) -> Result<ReviewAction> {
269 match s {
270 "auto" => Ok(ReviewAction::Auto),
271 "pr" => Ok(ReviewAction::Pr),
272 "viewer" => Ok(ReviewAction::Viewer(None)),
273 other => match other.strip_prefix("viewer:") {
274 Some(name) if !name.trim().is_empty() => {
275 Ok(ReviewAction::Viewer(Some(name.trim().to_string())))
276 }
277 _ => Err(Error::Invalid(format!(
278 "unknown review action '{s}' — expected auto, pr, viewer, or viewer:<name>"
279 ))),
280 },
281 }
282 }
283
284 pub fn resolve(&self, on_github: bool) -> ReviewMedium {
287 match self {
288 ReviewAction::Auto if on_github => ReviewMedium::GithubPr,
289 ReviewAction::Auto => ReviewMedium::Viewer(None),
290 ReviewAction::Pr => ReviewMedium::GithubPr,
291 ReviewAction::Viewer(name) => ReviewMedium::Viewer(name.clone()),
292 }
293 }
294
295 pub fn needs_probe(&self) -> bool {
298 matches!(self, ReviewAction::Auto)
299 }
300}
301
302impl fmt::Display for ReviewAction {
303 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304 match self {
305 ReviewAction::Auto => f.pad("auto"),
306 ReviewAction::Pr => f.pad("pr"),
307 ReviewAction::Viewer(None) => f.pad("viewer"),
308 ReviewAction::Viewer(Some(name)) => f.pad(&format!("viewer:{name}")),
309 }
310 }
311}
312
313impl FromSql for ReviewAction {
314 fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
315 match value {
316 ValueRef::Null => Ok(ReviewAction::Auto),
317 _ => ReviewAction::parse(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e))),
318 }
319 }
320}
321
322impl ToSql for ReviewAction {
323 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
326 match self {
327 ReviewAction::Auto => Ok(rusqlite::types::Null.into()),
328 other => Ok(other.to_string().into()),
329 }
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
336pub enum ReviewMedium {
337 GithubPr,
339 Viewer(Option<String>),
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct Project {
346 pub id: i64,
347 pub name: String,
348 pub path: String,
349 pub weight: i64,
350 pub review_action: ReviewAction,
352}
353
354#[derive(Debug, Clone, PartialEq)]
355pub struct Task {
356 pub id: i64,
357 pub project_id: i64,
358 pub title: String,
359 pub body: String,
360 pub priority: Priority,
361 pub state: TaskState,
362 pub agent: Option<String>,
363 pub question: Option<String>,
364 pub pr_url: Option<String>,
368 pub branch: Option<String>,
372 pub state_since: String,
373 pub created_at: String,
374 pub closed_at: Option<String>,
375 pub human: bool,
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
385pub enum NextAction {
386 Triage,
388 Answer,
390 Pr,
392 ReviewPr,
394 Do,
396 Redispatch,
399 Dispatch,
401}
402
403impl NextAction {
404 pub fn as_str(self) -> &'static str {
405 match self {
406 NextAction::Triage => "triage",
407 NextAction::Answer => "answer",
408 NextAction::Pr => "pr",
409 NextAction::ReviewPr => "review PR",
410 NextAction::Do => "do",
411 NextAction::Redispatch => "redispatch",
412 NextAction::Dispatch => "dispatch",
413 }
414 }
415}
416
417impl fmt::Display for NextAction {
418 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
419 f.pad(self.as_str())
420 }
421}
422
423impl Task {
424 pub fn next_action(&self) -> Option<NextAction> {
431 match self.state {
432 TaskState::Proposed => Some(NextAction::Triage),
433 TaskState::NeedsInput => Some(NextAction::Answer),
434 TaskState::Review if self.pr_url.is_some() => Some(NextAction::ReviewPr),
435 TaskState::Review => Some(NextAction::Pr),
436 TaskState::Stalled => Some(NextAction::Redispatch),
437 TaskState::Ready if self.human => Some(NextAction::Do),
438 TaskState::Ready => Some(NextAction::Dispatch),
439 TaskState::Running
440 | TaskState::Waiting
441 | TaskState::Parked
442 | TaskState::Done
443 | TaskState::Rejected => None,
444 }
445 }
446}
447
448#[derive(Debug, Clone, PartialEq, Eq)]
449pub struct Dep {
450 pub task_id: i64,
451 pub depends_on: i64,
452 pub kind: DepKind,
453}
454
455#[derive(Debug, Clone, PartialEq, Eq)]
461pub struct DepRef {
462 pub id: i64,
463 pub title: String,
464 pub state: TaskState,
465 pub kind: DepKind,
466}
467
468impl DepRef {
469 pub fn is_open(&self) -> bool {
471 !self.state.is_terminal()
472 }
473}
474
475#[derive(Debug, Clone)]
476pub struct Event {
477 pub id: i64,
478 pub task_id: Option<i64>,
479 pub at: String,
480 pub kind: String,
481 pub detail: Option<String>,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq)]
485pub struct Session {
486 pub id: i64,
487 pub task_id: i64,
488 pub agent: String,
489 pub pid: Option<i64>,
490 pub session_ref: Option<String>,
495 pub log_path: Option<String>,
496 pub started_at: String,
497 pub ended_at: Option<String>,
498 pub outcome: Option<SessionOutcome>,
499}
500
501#[derive(Debug, Clone, PartialEq, Eq)]
507pub struct RunningRow {
508 pub session_id: Option<i64>,
509 pub task_id: i64,
510 pub task_title: String,
511 pub task_state: TaskState,
512 pub agent: Option<String>,
513 pub started_at: String,
514 pub elapsed_secs: i64,
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
522 fn task_state_display_honors_width() {
523 assert_eq!(format!("{:11}", TaskState::Ready), "ready ");
524 assert_eq!(format!("{:>6}", TaskState::Done), " done");
525 assert_eq!(format!("{:>6}", TaskState::NeedsInput), "needs-input");
526 }
527
528 #[test]
529 fn priority_display_honors_width() {
530 assert_eq!(format!("{:>6}", Priority::P0), " P0");
531 assert_eq!(format!("{:>6}", Priority::P2), " P2");
532 }
533
534 fn task_in(state: TaskState, pr_url: Option<&str>, human: bool) -> Task {
535 Task {
536 id: 1,
537 project_id: 1,
538 title: "t".into(),
539 body: String::new(),
540 priority: Priority::P2,
541 state,
542 agent: None,
543 question: None,
544 pr_url: pr_url.map(str::to_string),
545 branch: None,
546 state_since: "2026-01-01T00:00:00Z".into(),
547 created_at: "2026-01-01T00:00:00Z".into(),
548 closed_at: None,
549 human,
550 }
551 }
552
553 #[test]
554 fn next_action_derives_every_arm() {
555 for (state, pr_url, human, expected) in [
556 (TaskState::Proposed, None, false, Some(NextAction::Triage)),
557 (TaskState::NeedsInput, None, false, Some(NextAction::Answer)),
558 (TaskState::Review, None, false, Some(NextAction::Pr)),
559 (
560 TaskState::Review,
561 Some("https://github.com/o/r/pull/1"),
562 false,
563 Some(NextAction::ReviewPr),
564 ),
565 (TaskState::Ready, None, true, Some(NextAction::Do)),
566 (TaskState::Ready, None, false, Some(NextAction::Dispatch)),
567 (
568 TaskState::Stalled,
569 None,
570 false,
571 Some(NextAction::Redispatch),
572 ),
573 (TaskState::Running, None, false, None),
574 (TaskState::Waiting, None, false, None),
575 (TaskState::Parked, None, false, None),
576 (TaskState::Done, None, false, None),
577 (TaskState::Rejected, None, false, None),
578 ] {
579 assert_eq!(
580 task_in(state, pr_url, human).next_action(),
581 expected,
582 "{state} pr_url={pr_url:?} human={human}"
583 );
584 }
585 }
586
587 #[test]
588 fn next_action_ignores_fields_its_arm_does_not_read() {
589 assert_eq!(
590 task_in(TaskState::Proposed, Some("https://x"), true).next_action(),
591 Some(NextAction::Triage)
592 );
593 assert_eq!(
594 task_in(TaskState::NeedsInput, None, true).next_action(),
595 Some(NextAction::Answer)
596 );
597 assert_eq!(
598 task_in(TaskState::Ready, Some("https://x"), false).next_action(),
599 Some(NextAction::Dispatch)
600 );
601 }
602
603 #[test]
604 fn next_action_display_honors_width() {
605 assert_eq!(format!("{:10}", NextAction::Do), "do ");
606 assert_eq!(format!("{:10}", NextAction::ReviewPr), "review PR ");
607 assert_eq!(format!("{:10}", NextAction::Redispatch), "redispatch");
608 }
609
610 #[test]
611 fn review_action_parses_and_displays_every_form() {
612 for (text, action) in [
613 ("auto", ReviewAction::Auto),
614 ("pr", ReviewAction::Pr),
615 ("viewer", ReviewAction::Viewer(None)),
616 ("viewer:zed", ReviewAction::Viewer(Some("zed".into()))),
617 ] {
618 assert_eq!(ReviewAction::parse(text).unwrap(), action, "{text}");
619 assert_eq!(action.to_string(), text);
620 }
621 assert!(ReviewAction::parse("github").is_err());
622 assert!(ReviewAction::parse("viewer:").is_err());
623 assert!(ReviewAction::parse("viewer: ").is_err());
624 }
625
626 #[test]
627 fn review_action_resolves_the_medium() {
628 assert_eq!(ReviewAction::Auto.resolve(true), ReviewMedium::GithubPr);
629 assert_eq!(
630 ReviewAction::Auto.resolve(false),
631 ReviewMedium::Viewer(None)
632 );
633 assert!(ReviewAction::Auto.needs_probe());
634
635 assert_eq!(ReviewAction::Pr.resolve(false), ReviewMedium::GithubPr);
636 assert!(!ReviewAction::Pr.needs_probe());
637 assert_eq!(
638 ReviewAction::Viewer(Some("zed".into())).resolve(true),
639 ReviewMedium::Viewer(Some("zed".into()))
640 );
641 assert!(!ReviewAction::Viewer(None).needs_probe());
642 }
643}