1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4use uuid::Uuid;
5
6use crate::constants::*;
7use crate::date_util::format_due_date;
8use crate::query::Query;
9use crate::util::{is_valid_uuid4_string, must_get_repo_path};
10use crate::{Result, RstaskError};
11
12mod datetime_rfc3339 {
14 use chrono::{DateTime, Utc};
15 use serde::{Deserialize, Deserializer, Serializer};
16
17 pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
18 where
19 S: Serializer,
20 {
21 serializer.serialize_str(&date.to_rfc3339())
22 }
23
24 pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
25 where
26 D: Deserializer<'de>,
27 {
28 let s = String::deserialize(deserializer)?;
29 DateTime::parse_from_rfc3339(&s)
30 .map(|dt| dt.with_timezone(&Utc))
31 .map_err(serde::de::Error::custom)
32 }
33}
34
35mod optional_datetime_rfc3339 {
37 use chrono::{DateTime, Utc};
38 use serde::{Deserialize, Deserializer, Serializer};
39
40 const ZERO_DATE_STR: &str = "0001-01-01T00:00:00Z";
42
43 pub fn serialize<S>(date: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
44 where
45 S: Serializer,
46 {
47 match date {
48 Some(dt) => serializer.serialize_str(&dt.to_rfc3339()),
49 None => serializer.serialize_str(ZERO_DATE_STR),
50 }
51 }
52
53 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
54 where
55 D: Deserializer<'de>,
56 {
57 let s = String::deserialize(deserializer)?;
58 if s == ZERO_DATE_STR || s.starts_with("0001-01-01") {
59 Ok(None)
60 } else {
61 DateTime::parse_from_rfc3339(&s)
62 .map(|dt| Some(dt.with_timezone(&Utc)))
63 .map_err(serde::de::Error::custom)
64 }
65 }
66}
67
68#[derive(Debug, Clone, Serialize)]
70pub struct TaskJson {
71 pub uuid: String,
72 pub status: String,
73 pub id: i32,
74 pub summary: String,
75 pub notes: String,
76 pub tags: Vec<String>,
77 pub project: String,
78 pub priority: String,
79 pub created: String,
80 pub resolved: String,
81 pub due: String,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85pub struct SubTask {
86 pub summary: String,
87 pub resolved: bool,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, Default)]
91pub struct Task {
92 #[serde(skip)]
93 pub uuid: String,
94
95 #[serde(skip_serializing_if = "String::is_empty", default)]
96 pub status: String,
97
98 #[serde(skip)]
99 pub write_pending: bool,
100
101 #[serde(skip_serializing, default)]
102 pub id: i32,
103
104 #[serde(skip)]
105 pub deleted: bool,
106
107 pub summary: String,
108
109 #[serde(default)]
110 pub notes: String,
111
112 #[serde(default)]
113 pub tags: Vec<String>,
114
115 #[serde(default)]
116 pub project: String,
117
118 #[serde(default)]
119 pub priority: String,
120
121 #[serde(default, rename = "delegatedto")]
122 pub delegated_to: String,
123
124 #[serde(default)]
125 pub subtasks: Vec<SubTask>,
126
127 #[serde(default)]
128 pub dependencies: Vec<String>,
129
130 #[serde(with = "datetime_rfc3339")]
131 pub created: DateTime<Utc>,
132
133 #[serde(with = "optional_datetime_rfc3339", default)]
134 pub resolved: Option<DateTime<Utc>>,
135
136 #[serde(with = "optional_datetime_rfc3339", default)]
137 pub due: Option<DateTime<Utc>>,
138
139 #[serde(skip)]
140 pub filtered: bool,
141}
142
143impl Task {
144 pub fn new(summary: String) -> Self {
146 Task {
147 uuid: Uuid::new_v4().to_string(),
148 status: STATUS_PENDING.to_string(),
149 write_pending: true,
150 id: 0,
151 deleted: false,
152 summary,
153 notes: String::new(),
154 tags: Vec::new(),
155 project: String::new(),
156 priority: PRIORITY_NORMAL.to_string(),
157 delegated_to: String::new(),
158 subtasks: Vec::new(),
159 dependencies: Vec::new(),
160 created: Utc::now(),
161 resolved: None,
162 due: None,
163 filtered: false,
164 }
165 }
166
167 pub fn to_json(&self) -> TaskJson {
169 TaskJson {
170 uuid: self.uuid.clone(),
171 status: self.status.clone(),
172 id: self.id,
173 summary: self.summary.clone(),
174 notes: self.notes.clone(),
175 tags: self.tags.clone(),
176 project: self.project.clone(),
177 priority: self.priority.clone(),
178 created: self.created.to_rfc3339(),
179 resolved: self
180 .resolved
181 .map(|r| r.to_rfc3339())
182 .unwrap_or_else(|| "0001-01-01T00:00:00Z".to_string()),
183 due: self
184 .due
185 .map(|d| d.to_rfc3339())
186 .unwrap_or_else(|| "0001-01-01T00:00:00Z".to_string()),
187 }
188 }
189
190 pub fn equals(&self, other: &Task) -> bool {
192 self.uuid == other.uuid
193 && self.status == other.status
194 && self.summary == other.summary
195 && self.notes == other.notes
196 && self.tags == other.tags
197 && self.project == other.project
198 && self.priority == other.priority
199 && self.delegated_to == other.delegated_to
200 && self.subtasks == other.subtasks
201 && self.dependencies == other.dependencies
202 && self.created == other.created
203 && self.resolved == other.resolved
204 && self.due == other.due
205 }
206
207 pub fn matches_filter(&self, query: &Query) -> bool {
209 if !query.ids.is_empty() && !query.ids.contains(&self.id) {
211 return false;
212 }
213
214 for tag in &query.tags {
216 if !self.tags.contains(tag) {
217 return false;
218 }
219 }
220
221 for tag in &query.anti_tags {
223 if self.tags.contains(tag) {
224 return false;
225 }
226 }
227
228 if query.anti_projects.contains(&self.project) {
230 return false;
231 }
232
233 if !query.project.is_empty() && self.project != query.project {
235 return false;
236 }
237
238 if let Some(query_due) = &query.due {
240 match self.due {
241 None => return false,
242 Some(task_due) => match query.date_filter.as_str() {
243 "after" if task_due < *query_due => return false,
244 "before" if task_due > *query_due => return false,
245 "on" | "in" if task_due.date_naive() != query_due.date_naive() => return false,
246 "" if task_due.date_naive() != query_due.date_naive() => return false,
247 _ => {}
248 },
249 }
250 }
251
252 if !query.priority.is_empty() && self.priority != query.priority {
254 return false;
255 }
256
257 if !query.text.is_empty() {
259 let search_text = query.text.to_lowercase();
260 let summary_lower = self.summary.to_lowercase();
261 let notes_lower = self.notes.to_lowercase();
262 if !summary_lower.contains(&search_text) && !notes_lower.contains(&search_text) {
263 return false;
264 }
265 }
266
267 true
268 }
269
270 pub fn normalise(&mut self) {
272 self.project = self.project.to_lowercase();
273
274 for tag in &mut self.tags {
276 *tag = tag.to_lowercase();
277 }
278
279 self.tags.sort();
281
282 self.tags.dedup();
284
285 if self.status == STATUS_RESOLVED {
287 self.id = 0;
288 }
289
290 if self.priority.is_empty() {
292 self.priority = PRIORITY_NORMAL.to_string();
293 }
294 }
295
296 pub fn validate(&self) -> Result<()> {
298 if !is_valid_uuid4_string(&self.uuid) {
299 return Err(RstaskError::InvalidUuid(self.uuid.clone()));
300 }
301
302 if !is_valid_status(&self.status) {
303 return Err(RstaskError::InvalidStatus(self.status.clone()));
304 }
305
306 if !is_valid_priority(&self.priority) {
307 return Err(RstaskError::InvalidPriority(self.priority.clone()));
308 }
309
310 for dep_uuid in &self.dependencies {
311 if !is_valid_uuid4_string(dep_uuid) {
312 return Err(RstaskError::InvalidUuid(dep_uuid.clone()));
313 }
314 }
315
316 Ok(())
317 }
318
319 pub fn long_summary(&self) -> String {
321 let notes = self.notes.trim();
322 if let Some(last_note) = notes.lines().last()
323 && !last_note.is_empty()
324 {
325 return format!("{} {} {}", self.summary, NOTE_MODE_KEYWORD, last_note);
326 }
327 self.summary.clone()
328 }
329
330 pub fn modify(&mut self, query: &Query) {
332 for tag in &query.tags {
334 if !self.tags.contains(tag) {
335 self.tags.push(tag.clone());
336 }
337 }
338
339 self.tags.retain(|tag| !query.anti_tags.contains(tag));
341
342 if !query.project.is_empty() {
344 self.project = query.project.clone();
345 }
346
347 if query.anti_projects.contains(&self.project) {
349 self.project.clear();
350 }
351
352 if !query.priority.is_empty() {
354 self.priority = query.priority.clone();
355 }
356
357 if let Some(due) = query.due {
359 self.due = Some(due);
360 }
361
362 if !query.note.is_empty() {
364 if !self.notes.is_empty() {
365 self.notes.push('\n');
366 }
367 self.notes.push_str(&query.note);
368 }
369
370 self.write_pending = true;
371 }
372
373 pub fn save_to_disk(&mut self, repo_path: &Path) -> Result<()> {
375 self.write_pending = false;
376
377 let filepath = must_get_repo_path(repo_path, &self.status, &format!("{}.yml", self.uuid));
378
379 if self.deleted {
380 if filepath.exists() {
382 std::fs::remove_file(&filepath)?;
383 }
384 } else {
385 let mut task_copy = self.clone();
388 task_copy.status.clear();
389
390 let yaml_data = serde_yaml::to_string(&task_copy)?;
391
392 if let Some(parent) = filepath.parent() {
394 std::fs::create_dir_all(parent)?;
395 }
396
397 std::fs::write(&filepath, yaml_data)?;
398 }
399
400 for status in ALL_STATUSES {
402 if *status == self.status {
403 continue;
404 }
405
406 let other_filepath =
407 must_get_repo_path(repo_path, status, &format!("{}.yml", self.uuid));
408 if other_filepath.exists() {
409 std::fs::remove_file(&other_filepath)?;
410 }
411 }
412
413 Ok(())
414 }
415
416 pub fn delete_from_disk(&self, repo_path: &Path) -> Result<()> {
418 let filepath = must_get_repo_path(repo_path, &self.status, &format!("{}.yml", self.uuid));
420 if filepath.exists() {
421 std::fs::remove_file(&filepath)?;
422 }
423
424 for status in ALL_STATUSES {
426 if *status == self.status {
427 continue;
428 }
429 let other_filepath =
430 must_get_repo_path(repo_path, status, &format!("{}.yml", self.uuid));
431 if other_filepath.exists() {
432 std::fs::remove_file(&other_filepath)?;
433 }
434 }
435
436 Ok(())
437 }
438
439 pub fn parse_due_date_to_str(&self) -> String {
441 match self.due {
442 Some(due) => format_due_date(due.with_timezone(&chrono::Local)),
443 None => String::new(),
444 }
445 }
446}
447
448impl std::fmt::Display for Task {
449 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450 if self.id > 0 {
451 write!(f, "{}: {}", self.id, self.summary)
452 } else {
453 write!(f, "{}", self.summary)
454 }
455 }
456}
457
458pub fn unmarshal_task(
460 path: &Path,
461 filename: &str,
462 ids: &std::collections::HashMap<String, i32>,
463 status: &str,
464) -> Result<Task> {
465 if filename.len() != TASK_FILENAME_LEN {
466 return Err(RstaskError::Parse(format!(
467 "filename does not encode UUID {} (wrong length)",
468 filename
469 )));
470 }
471
472 let uuid = &filename[0..36];
473 if !is_valid_uuid4_string(uuid) {
474 return Err(RstaskError::Parse(format!(
475 "filename does not encode UUID {}",
476 filename
477 )));
478 }
479
480 let id = ids.get(uuid).copied().unwrap_or(0);
481
482 let data = std::fs::read_to_string(path)?;
483 let mut task: Task = serde_yaml::from_str(&data)?;
484
485 task.uuid = uuid.to_string();
486 task.status = status.to_string();
487 task.id = id;
488
489 Ok(task)
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn test_yaml_serialization_format() {
498 let task = Task {
499 summary: "test task".to_string(),
500 tags: vec!["work".to_string()],
501 project: "myproject".to_string(),
502 priority: "P1".to_string(),
503 notes: String::new(),
504 delegated_to: String::new(),
505 subtasks: Vec::new(),
506 dependencies: Vec::new(),
507 created: Utc::now(),
508 resolved: None,
509 due: None,
510 ..Default::default()
511 };
512
513 let yaml = serde_yaml::to_string(&task).unwrap();
514 eprintln!("YAML output:\n{}", yaml);
515
516 assert!(yaml.contains("created:"));
518 assert!(
519 yaml.contains('T'),
520 "created should be in RFC3339 format with 'T'"
521 );
522 assert!(
523 yaml.contains("notes: ''") || yaml.contains("notes: \"\""),
524 "notes should be serialized as empty string"
525 );
526 assert!(
527 yaml.contains("delegatedto:"),
528 "delegatedto field should exist"
529 );
530 }
531
532 #[test]
533 fn test_parse_go_yaml_with_local_timezone() {
534 let go_yaml = r#"
535summary: go created task
536notes: ""
537tags:
538- work
539project: myproject
540priority: P1
541delegatedto: ""
542subtasks: []
543dependencies: []
544created: 2026-01-21T03:08:06.14017135+01:00
545resolved: 0001-01-01T00:00:00Z
546due: 0001-01-01T00:00:00Z
547"#;
548
549 let task: Task = serde_yaml::from_str(go_yaml).unwrap();
550 eprintln!("Parsed task: {:?}", task);
551 eprintln!("Created timestamp: {}", task.created.to_rfc3339());
552
553 assert_eq!(task.summary, "go created task");
554 assert_eq!(task.priority, "P1");
555 assert!(task.resolved.is_none());
556 assert!(task.due.is_none());
557 }
558
559 #[test]
560 fn test_task_modify_adds_note() {
561 let mut task = Task::new("Test".to_string());
562 let query = Query {
563 note: "Test Note".to_string(),
564 ..Default::default()
565 };
566 task.modify(&query);
567 assert_eq!(task.notes, "Test Note");
568 }
569
570 #[test]
571 fn test_task_modify_appends_note() {
572 let mut task = Task::new("Test".to_string());
573 task.notes = "Start Note".to_string();
574 let query = Query {
575 note: "Query Note".to_string(),
576 ..Default::default()
577 };
578 task.modify(&query);
579 assert_eq!(task.notes, "Start Note\nQuery Note");
580 }
581
582 #[test]
583 fn test_task_modify_priority() {
584 let mut task = Task::new("Test".to_string());
585 let query = Query {
586 priority: "P1".to_string(),
587 ..Default::default()
588 };
589 task.modify(&query);
590 assert_eq!(task.priority, "P1");
591 }
592
593 #[test]
594 fn test_task_modify_removes_project() {
595 let mut task = Task::new("Test".to_string());
596 task.project = "myproject".to_string();
597 let query = Query {
598 anti_projects: vec!["myproject".to_string()],
599 ..Default::default()
600 };
601 task.modify(&query);
602 assert_eq!(task.project, "");
603 }
604
605 #[test]
606 fn test_task_normalise() {
607 let mut task = Task::new("Test".to_string());
608 task.project = "MyProject".to_string();
609 task.tags = vec!["B".to_string(), "A".to_string(), "B".to_string()];
610 task.normalise();
611
612 assert_eq!(task.project, "myproject");
613 assert_eq!(task.tags, vec!["a".to_string(), "b".to_string()]);
614 }
615}