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}