Skip to main content

roder_api/
task_ledger.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
4#[serde(rename_all = "snake_case")]
5pub enum TaskLedgerStatus {
6    Pending,
7    InProgress,
8    Completed,
9    Blocked,
10}
11
12impl TaskLedgerStatus {
13    pub fn as_str(&self) -> &'static str {
14        match self {
15            Self::Pending => "pending",
16            Self::InProgress => "in_progress",
17            Self::Completed => "completed",
18            Self::Blocked => "blocked",
19        }
20    }
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "camelCase")]
25pub struct TaskLedgerItem {
26    pub id: String,
27    pub content: String,
28    pub status: TaskLedgerStatus,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub evidence: Option<String>,
31}
32
33impl TaskLedgerItem {
34    pub fn validate(&self, require_completion_evidence: bool) -> Result<(), TaskLedgerError> {
35        validate_nonempty(&self.id, "id")?;
36        validate_nonempty(&self.content, "content")?;
37        if matches!(self.status, TaskLedgerStatus::Completed)
38            && require_completion_evidence
39            && self
40                .evidence
41                .as_deref()
42                .is_none_or(|evidence| evidence.trim().is_empty())
43        {
44            return Err(TaskLedgerError::MissingEvidence {
45                id: self.id.clone(),
46            });
47        }
48        Ok(())
49    }
50}
51
52#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
53#[serde(rename_all = "camelCase")]
54pub struct TaskLedgerSnapshot {
55    pub tasks: Vec<TaskLedgerItem>,
56}
57
58impl TaskLedgerSnapshot {
59    pub fn validate(&self, require_completion_evidence: bool) -> Result<(), TaskLedgerError> {
60        let mut ids = std::collections::HashSet::new();
61        let mut in_progress = 0usize;
62        for task in &self.tasks {
63            task.validate(require_completion_evidence)?;
64            if !ids.insert(task.id.clone()) {
65                return Err(TaskLedgerError::DuplicateId {
66                    id: task.id.clone(),
67                });
68            }
69            if matches!(task.status, TaskLedgerStatus::InProgress) {
70                in_progress += 1;
71            }
72        }
73        if in_progress > 1 {
74            return Err(TaskLedgerError::MultipleInProgress);
75        }
76        Ok(())
77    }
78
79    pub fn completed_count(&self) -> usize {
80        self.tasks
81            .iter()
82            .filter(|task| matches!(task.status, TaskLedgerStatus::Completed))
83            .count()
84    }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum TaskLedgerError {
89    EmptyField { field: &'static str },
90    DuplicateId { id: String },
91    MultipleInProgress,
92    MissingEvidence { id: String },
93}
94
95impl std::fmt::Display for TaskLedgerError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            Self::EmptyField { field } => write!(f, "task ledger {field} must not be empty"),
99            Self::DuplicateId { id } => write!(f, "duplicate task ledger id {id:?}"),
100            Self::MultipleInProgress => {
101                write!(f, "task ledger accepts at most one in_progress task")
102            }
103            Self::MissingEvidence { id } => {
104                write!(f, "completed task ledger item {id:?} requires evidence")
105            }
106        }
107    }
108}
109
110impl std::error::Error for TaskLedgerError {}
111
112fn validate_nonempty(value: &str, field: &'static str) -> Result<(), TaskLedgerError> {
113    if value.trim().is_empty() {
114        Err(TaskLedgerError::EmptyField { field })
115    } else {
116        Ok(())
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn task_ledger_rejects_duplicate_ids_and_multiple_in_progress() {
126        let snapshot = TaskLedgerSnapshot {
127            tasks: vec![
128                TaskLedgerItem {
129                    id: "a".to_string(),
130                    content: "First".to_string(),
131                    status: TaskLedgerStatus::InProgress,
132                    evidence: None,
133                },
134                TaskLedgerItem {
135                    id: "a".to_string(),
136                    content: "Second".to_string(),
137                    status: TaskLedgerStatus::InProgress,
138                    evidence: None,
139                },
140            ],
141        };
142
143        assert_eq!(
144            snapshot.validate(false).unwrap_err(),
145            TaskLedgerError::DuplicateId {
146                id: "a".to_string()
147            }
148        );
149
150        let snapshot = TaskLedgerSnapshot {
151            tasks: vec![
152                TaskLedgerItem {
153                    id: "a".to_string(),
154                    content: "First".to_string(),
155                    status: TaskLedgerStatus::InProgress,
156                    evidence: None,
157                },
158                TaskLedgerItem {
159                    id: "b".to_string(),
160                    content: "Second".to_string(),
161                    status: TaskLedgerStatus::InProgress,
162                    evidence: None,
163                },
164            ],
165        };
166
167        assert_eq!(
168            snapshot.validate(false).unwrap_err(),
169            TaskLedgerError::MultipleInProgress
170        );
171    }
172
173    #[test]
174    fn task_ledger_can_require_completion_evidence() {
175        let snapshot = TaskLedgerSnapshot {
176            tasks: vec![TaskLedgerItem {
177                id: "done".to_string(),
178                content: "Verify".to_string(),
179                status: TaskLedgerStatus::Completed,
180                evidence: None,
181            }],
182        };
183
184        assert_eq!(
185            snapshot.validate(true).unwrap_err(),
186            TaskLedgerError::MissingEvidence {
187                id: "done".to_string()
188            }
189        );
190    }
191}