use std::fmt;
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TaskState {
Proposed,
Parked,
Ready,
Running,
NeedsInput,
Review,
Waiting,
Stalled,
Done,
Rejected,
}
impl TaskState {
pub const ALL: [TaskState; 10] = [
TaskState::Proposed,
TaskState::Parked,
TaskState::Ready,
TaskState::Running,
TaskState::NeedsInput,
TaskState::Review,
TaskState::Waiting,
TaskState::Stalled,
TaskState::Done,
TaskState::Rejected,
];
pub fn as_str(self) -> &'static str {
match self {
TaskState::Proposed => "proposed",
TaskState::Parked => "parked",
TaskState::Ready => "ready",
TaskState::Running => "running",
TaskState::NeedsInput => "needs-input",
TaskState::Review => "review",
TaskState::Waiting => "waiting",
TaskState::Stalled => "stalled",
TaskState::Done => "done",
TaskState::Rejected => "rejected",
}
}
pub fn parse(s: &str) -> Result<TaskState> {
Self::ALL
.into_iter()
.find(|state| state.as_str() == s)
.ok_or_else(|| Error::Invalid(format!("unknown task state '{s}'")))
}
pub fn is_terminal(self) -> bool {
matches!(self, TaskState::Done | TaskState::Rejected)
}
}
impl fmt::Display for TaskState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(self.as_str())
}
}
impl FromSql for TaskState {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
let s = value.as_str()?;
TaskState::parse(s).map_err(|e| FromSqlError::Other(Box::new(e)))
}
}
impl ToSql for TaskState {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(self.as_str().into())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Priority {
P0,
P1,
P2,
P3,
}
impl Priority {
pub fn from_int(n: i64) -> Result<Priority> {
match n {
0 => Ok(Priority::P0),
1 => Ok(Priority::P1),
2 => Ok(Priority::P2),
3 => Ok(Priority::P3),
_ => Err(Error::Invalid(format!("priority {n} out of range 0-3"))),
}
}
pub fn as_int(self) -> i64 {
match self {
Priority::P0 => 0,
Priority::P1 => 1,
Priority::P2 => 2,
Priority::P3 => 3,
}
}
pub fn value(self) -> f64 {
match self {
Priority::P0 => 8.0,
Priority::P1 => 4.0,
Priority::P2 => 2.0,
Priority::P3 => 1.0,
}
}
}
impl fmt::Display for Priority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Priority::P0 => "P0",
Priority::P1 => "P1",
Priority::P2 => "P2",
Priority::P3 => "P3",
};
f.pad(s)
}
}
impl FromSql for Priority {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
Priority::from_int(value.as_i64()?).map_err(|e| FromSqlError::Other(Box::new(e)))
}
}
impl ToSql for Priority {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(self.as_int().into())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DepKind {
Blocks,
DiscoveredFrom,
Parent,
Related,
}
impl DepKind {
pub const ALL: [DepKind; 4] = [
DepKind::Blocks,
DepKind::DiscoveredFrom,
DepKind::Parent,
DepKind::Related,
];
pub fn as_str(self) -> &'static str {
match self {
DepKind::Blocks => "blocks",
DepKind::DiscoveredFrom => "discovered-from",
DepKind::Parent => "parent",
DepKind::Related => "related",
}
}
pub fn parse(s: &str) -> Result<DepKind> {
Self::ALL
.into_iter()
.find(|kind| kind.as_str() == s)
.ok_or_else(|| Error::Invalid(format!("unknown dep kind '{s}'")))
}
}
impl fmt::Display for DepKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromSql for DepKind {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
DepKind::parse(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e)))
}
}
impl ToSql for DepKind {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(self.as_str().into())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SessionOutcome {
Completed,
Asked,
Failed,
Capped,
Aborted,
}
impl SessionOutcome {
pub const ALL: [SessionOutcome; 5] = [
SessionOutcome::Completed,
SessionOutcome::Asked,
SessionOutcome::Failed,
SessionOutcome::Capped,
SessionOutcome::Aborted,
];
pub fn as_str(self) -> &'static str {
match self {
SessionOutcome::Completed => "completed",
SessionOutcome::Asked => "asked",
SessionOutcome::Failed => "failed",
SessionOutcome::Capped => "capped",
SessionOutcome::Aborted => "aborted",
}
}
pub fn parse(s: &str) -> Result<SessionOutcome> {
Self::ALL
.into_iter()
.find(|outcome| outcome.as_str() == s)
.ok_or_else(|| Error::Invalid(format!("unknown session outcome '{s}'")))
}
}
impl fmt::Display for SessionOutcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromSql for SessionOutcome {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
SessionOutcome::parse(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e)))
}
}
impl ToSql for SessionOutcome {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(self.as_str().into())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ReviewAction {
#[default]
Auto,
Pr,
Viewer(Option<String>),
}
impl ReviewAction {
pub fn parse(s: &str) -> Result<ReviewAction> {
match s {
"auto" => Ok(ReviewAction::Auto),
"pr" => Ok(ReviewAction::Pr),
"viewer" => Ok(ReviewAction::Viewer(None)),
other => match other.strip_prefix("viewer:") {
Some(name) if !name.trim().is_empty() => {
Ok(ReviewAction::Viewer(Some(name.trim().to_string())))
}
_ => Err(Error::Invalid(format!(
"unknown review action '{s}' — expected auto, pr, viewer, or viewer:<name>"
))),
},
}
}
pub fn resolve(&self, on_github: bool) -> ReviewMedium {
match self {
ReviewAction::Auto if on_github => ReviewMedium::GithubPr,
ReviewAction::Auto => ReviewMedium::Viewer(None),
ReviewAction::Pr => ReviewMedium::GithubPr,
ReviewAction::Viewer(name) => ReviewMedium::Viewer(name.clone()),
}
}
pub fn needs_probe(&self) -> bool {
matches!(self, ReviewAction::Auto)
}
}
impl fmt::Display for ReviewAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ReviewAction::Auto => f.pad("auto"),
ReviewAction::Pr => f.pad("pr"),
ReviewAction::Viewer(None) => f.pad("viewer"),
ReviewAction::Viewer(Some(name)) => f.pad(&format!("viewer:{name}")),
}
}
}
impl FromSql for ReviewAction {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
match value {
ValueRef::Null => Ok(ReviewAction::Auto),
_ => ReviewAction::parse(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e))),
}
}
}
impl ToSql for ReviewAction {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
match self {
ReviewAction::Auto => Ok(rusqlite::types::Null.into()),
other => Ok(other.to_string().into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewMedium {
GithubPr,
Viewer(Option<String>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Project {
pub id: i64,
pub name: String,
pub path: String,
pub weight: i64,
pub review_action: ReviewAction,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Task {
pub id: i64,
pub project_id: i64,
pub title: String,
pub body: String,
pub priority: Priority,
pub state: TaskState,
pub agent: Option<String>,
pub question: Option<String>,
pub pr_url: Option<String>,
pub branch: Option<String>,
pub state_since: String,
pub created_at: String,
pub closed_at: Option<String>,
pub human: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NextAction {
Triage,
Answer,
Pr,
ReviewPr,
Do,
Redispatch,
Dispatch,
}
impl NextAction {
pub fn as_str(self) -> &'static str {
match self {
NextAction::Triage => "triage",
NextAction::Answer => "answer",
NextAction::Pr => "pr",
NextAction::ReviewPr => "review PR",
NextAction::Do => "do",
NextAction::Redispatch => "redispatch",
NextAction::Dispatch => "dispatch",
}
}
}
impl fmt::Display for NextAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(self.as_str())
}
}
impl Task {
pub fn next_action(&self) -> Option<NextAction> {
match self.state {
TaskState::Proposed => Some(NextAction::Triage),
TaskState::NeedsInput => Some(NextAction::Answer),
TaskState::Review if self.pr_url.is_some() => Some(NextAction::ReviewPr),
TaskState::Review => Some(NextAction::Pr),
TaskState::Stalled => Some(NextAction::Redispatch),
TaskState::Ready if self.human => Some(NextAction::Do),
TaskState::Ready => Some(NextAction::Dispatch),
TaskState::Running
| TaskState::Waiting
| TaskState::Parked
| TaskState::Done
| TaskState::Rejected => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dep {
pub task_id: i64,
pub depends_on: i64,
pub kind: DepKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DepRef {
pub id: i64,
pub title: String,
pub state: TaskState,
pub kind: DepKind,
}
impl DepRef {
pub fn is_open(&self) -> bool {
!self.state.is_terminal()
}
}
#[derive(Debug, Clone)]
pub struct Event {
pub id: i64,
pub task_id: Option<i64>,
pub at: String,
pub kind: String,
pub detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
pub id: i64,
pub task_id: i64,
pub agent: String,
pub pid: Option<i64>,
pub session_ref: Option<String>,
pub log_path: Option<String>,
pub started_at: String,
pub ended_at: Option<String>,
pub outcome: Option<SessionOutcome>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunningRow {
pub session_id: Option<i64>,
pub task_id: i64,
pub task_title: String,
pub task_state: TaskState,
pub agent: Option<String>,
pub started_at: String,
pub elapsed_secs: i64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn task_state_display_honors_width() {
assert_eq!(format!("{:11}", TaskState::Ready), "ready ");
assert_eq!(format!("{:>6}", TaskState::Done), " done");
assert_eq!(format!("{:>6}", TaskState::NeedsInput), "needs-input");
}
#[test]
fn priority_display_honors_width() {
assert_eq!(format!("{:>6}", Priority::P0), " P0");
assert_eq!(format!("{:>6}", Priority::P2), " P2");
}
fn task_in(state: TaskState, pr_url: Option<&str>, human: bool) -> Task {
Task {
id: 1,
project_id: 1,
title: "t".into(),
body: String::new(),
priority: Priority::P2,
state,
agent: None,
question: None,
pr_url: pr_url.map(str::to_string),
branch: None,
state_since: "2026-01-01T00:00:00Z".into(),
created_at: "2026-01-01T00:00:00Z".into(),
closed_at: None,
human,
}
}
#[test]
fn next_action_derives_every_arm() {
for (state, pr_url, human, expected) in [
(TaskState::Proposed, None, false, Some(NextAction::Triage)),
(TaskState::NeedsInput, None, false, Some(NextAction::Answer)),
(TaskState::Review, None, false, Some(NextAction::Pr)),
(
TaskState::Review,
Some("https://github.com/o/r/pull/1"),
false,
Some(NextAction::ReviewPr),
),
(TaskState::Ready, None, true, Some(NextAction::Do)),
(TaskState::Ready, None, false, Some(NextAction::Dispatch)),
(
TaskState::Stalled,
None,
false,
Some(NextAction::Redispatch),
),
(TaskState::Running, None, false, None),
(TaskState::Waiting, None, false, None),
(TaskState::Parked, None, false, None),
(TaskState::Done, None, false, None),
(TaskState::Rejected, None, false, None),
] {
assert_eq!(
task_in(state, pr_url, human).next_action(),
expected,
"{state} pr_url={pr_url:?} human={human}"
);
}
}
#[test]
fn next_action_ignores_fields_its_arm_does_not_read() {
assert_eq!(
task_in(TaskState::Proposed, Some("https://x"), true).next_action(),
Some(NextAction::Triage)
);
assert_eq!(
task_in(TaskState::NeedsInput, None, true).next_action(),
Some(NextAction::Answer)
);
assert_eq!(
task_in(TaskState::Ready, Some("https://x"), false).next_action(),
Some(NextAction::Dispatch)
);
}
#[test]
fn next_action_display_honors_width() {
assert_eq!(format!("{:10}", NextAction::Do), "do ");
assert_eq!(format!("{:10}", NextAction::ReviewPr), "review PR ");
assert_eq!(format!("{:10}", NextAction::Redispatch), "redispatch");
}
#[test]
fn review_action_parses_and_displays_every_form() {
for (text, action) in [
("auto", ReviewAction::Auto),
("pr", ReviewAction::Pr),
("viewer", ReviewAction::Viewer(None)),
("viewer:zed", ReviewAction::Viewer(Some("zed".into()))),
] {
assert_eq!(ReviewAction::parse(text).unwrap(), action, "{text}");
assert_eq!(action.to_string(), text);
}
assert!(ReviewAction::parse("github").is_err());
assert!(ReviewAction::parse("viewer:").is_err());
assert!(ReviewAction::parse("viewer: ").is_err());
}
#[test]
fn review_action_resolves_the_medium() {
assert_eq!(ReviewAction::Auto.resolve(true), ReviewMedium::GithubPr);
assert_eq!(
ReviewAction::Auto.resolve(false),
ReviewMedium::Viewer(None)
);
assert!(ReviewAction::Auto.needs_probe());
assert_eq!(ReviewAction::Pr.resolve(false), ReviewMedium::GithubPr);
assert!(!ReviewAction::Pr.needs_probe());
assert_eq!(
ReviewAction::Viewer(Some("zed".into())).resolve(true),
ReviewMedium::Viewer(Some("zed".into()))
);
assert!(!ReviewAction::Viewer(None).needs_probe());
}
}