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
12pub mod datetime_rfc3339 {
14 use chrono::{DateTime, Utc};
15 use serde::{Deserializer, Serializer};
16
17 pub fn serialize<S>(dt: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
18 where
19 S: Serializer,
20 {
21 serializer.serialize_str(&dt.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 = serde::Deserialize::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
35pub mod 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!("{}.md", self.uuid));
378
379 if self.deleted {
380 if filepath.exists() {
382 std::fs::remove_file(&filepath)?;
383 }
384 } else {
385 let markdown_data = crate::frontmatter::task_to_markdown(self)?;
387
388 if let Some(parent) = filepath.parent() {
390 std::fs::create_dir_all(parent)?;
391 }
392
393 std::fs::write(&filepath, markdown_data)?;
394 }
395
396 for status in ALL_STATUSES {
398 if *status == self.status {
399 continue;
400 }
401
402 let other_filepath =
404 must_get_repo_path(repo_path, status, &format!("{}.md", self.uuid));
405 if other_filepath.exists() {
406 std::fs::remove_file(&other_filepath)?;
407 }
408
409 let legacy_filepath =
411 must_get_repo_path(repo_path, status, &format!("{}.yml", self.uuid));
412 if legacy_filepath.exists() {
413 std::fs::remove_file(&legacy_filepath)?;
414 }
415 }
416
417 Ok(())
418 }
419
420 pub fn delete_from_disk(&self, repo_path: &Path) -> Result<()> {
422 let yml_filepath =
424 must_get_repo_path(repo_path, &self.status, &format!("{}.yml", self.uuid));
425 if yml_filepath.exists() {
426 std::fs::remove_file(&yml_filepath)?;
427 }
428
429 let md_filepath = must_get_repo_path(repo_path, &self.status, &format!("{}.md", self.uuid));
430 if md_filepath.exists() {
431 std::fs::remove_file(&md_filepath)?;
432 }
433
434 for status in ALL_STATUSES {
436 if *status == self.status {
437 continue;
438 }
439
440 let other_yml_filepath =
441 must_get_repo_path(repo_path, status, &format!("{}.yml", self.uuid));
442 if other_yml_filepath.exists() {
443 std::fs::remove_file(&other_yml_filepath)?;
444 }
445
446 let other_md_filepath =
447 must_get_repo_path(repo_path, status, &format!("{}.md", self.uuid));
448 if other_md_filepath.exists() {
449 std::fs::remove_file(&other_md_filepath)?;
450 }
451 }
452
453 Ok(())
454 }
455
456 pub fn parse_due_date_to_str(&self) -> String {
458 match self.due {
459 Some(due) => format_due_date(due.with_timezone(&chrono::Local)),
460 None => String::new(),
461 }
462 }
463}
464
465impl std::fmt::Display for Task {
466 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467 if self.id > 0 {
468 write!(f, "{}: {}", self.id, self.summary)
469 } else {
470 write!(f, "{}", self.summary)
471 }
472 }
473}
474
475pub fn unmarshal_task(
477 path: &Path,
478 filename: &str,
479 ids: &std::collections::HashMap<String, i32>,
480 status: &str,
481) -> Result<Task> {
482 let is_markdown = filename.ends_with(".md");
484 let is_yaml = filename.ends_with(".yml");
485
486 if !is_markdown && !is_yaml {
487 return Err(RstaskError::Parse(format!(
488 "invalid filename extension: {}",
489 filename
490 )));
491 }
492
493 let expected_len = if is_markdown { 39 } else { 40 }; if filename.len() != expected_len {
495 return Err(RstaskError::Parse(format!(
496 "filename does not encode UUID {} (wrong length)",
497 filename
498 )));
499 }
500
501 let uuid = &filename[0..36];
502 if !is_valid_uuid4_string(uuid) {
503 return Err(RstaskError::Parse(format!(
504 "filename does not encode UUID {}",
505 filename
506 )));
507 }
508
509 let id = ids.get(uuid).copied().unwrap_or(0);
510 let data = std::fs::read_to_string(path)?;
511
512 let task = if is_markdown {
513 crate::frontmatter::task_from_markdown(&data, uuid, status, id)?
515 } else {
516 let mut task: Task = serde_yaml::from_str(&data)?;
518 task.uuid = uuid.to_string();
519 task.status = status.to_string();
520 task.id = id;
521 task
522 };
523
524 Ok(task)
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn test_yaml_serialization_format() {
533 let task = Task {
534 summary: "test task".to_string(),
535 tags: vec!["work".to_string()],
536 project: "myproject".to_string(),
537 priority: "P1".to_string(),
538 notes: String::new(),
539 delegated_to: String::new(),
540 subtasks: Vec::new(),
541 dependencies: Vec::new(),
542 created: Utc::now(),
543 resolved: None,
544 due: None,
545 ..Default::default()
546 };
547
548 let yaml = serde_yaml::to_string(&task).unwrap();
549 eprintln!("YAML output:\n{}", yaml);
550
551 assert!(yaml.contains("created:"));
553 assert!(
554 yaml.contains('T'),
555 "created should be in RFC3339 format with 'T'"
556 );
557 assert!(
558 yaml.contains("notes: ''") || yaml.contains("notes: \"\""),
559 "notes should be serialized as empty string"
560 );
561 assert!(
562 yaml.contains("delegatedto:"),
563 "delegatedto field should exist"
564 );
565 }
566
567 #[test]
568 fn test_parse_go_yaml_with_local_timezone() {
569 let go_yaml = r#"
570summary: go created task
571notes: ""
572tags:
573- work
574project: myproject
575priority: P1
576delegatedto: ""
577subtasks: []
578dependencies: []
579created: 2026-01-21T03:08:06.14017135+01:00
580resolved: 0001-01-01T00:00:00Z
581due: 0001-01-01T00:00:00Z
582"#;
583
584 let task: Task = serde_yaml::from_str(go_yaml).unwrap();
585 eprintln!("Parsed task: {:?}", task);
586 eprintln!("Created timestamp: {}", task.created.to_rfc3339());
587
588 assert_eq!(task.summary, "go created task");
589 assert_eq!(task.priority, "P1");
590 assert!(task.resolved.is_none());
591 assert!(task.due.is_none());
592 }
593
594 #[test]
595 fn test_task_modify_adds_note() {
596 let mut task = Task::new("Test".to_string());
597 let query = Query {
598 note: "Test Note".to_string(),
599 ..Default::default()
600 };
601 task.modify(&query);
602 assert_eq!(task.notes, "Test Note");
603 }
604
605 #[test]
606 fn test_task_modify_appends_note() {
607 let mut task = Task::new("Test".to_string());
608 task.notes = "Start Note".to_string();
609 let query = Query {
610 note: "Query Note".to_string(),
611 ..Default::default()
612 };
613 task.modify(&query);
614 assert_eq!(task.notes, "Start Note\nQuery Note");
615 }
616
617 #[test]
618 fn test_task_modify_priority() {
619 let mut task = Task::new("Test".to_string());
620 let query = Query {
621 priority: "P1".to_string(),
622 ..Default::default()
623 };
624 task.modify(&query);
625 assert_eq!(task.priority, "P1");
626 }
627
628 #[test]
629 fn test_task_modify_removes_project() {
630 let mut task = Task::new("Test".to_string());
631 task.project = "myproject".to_string();
632 let query = Query {
633 anti_projects: vec!["myproject".to_string()],
634 ..Default::default()
635 };
636 task.modify(&query);
637 assert_eq!(task.project, "");
638 }
639
640 #[test]
641 fn test_task_normalise() {
642 let mut task = Task::new("Test".to_string());
643 task.project = "MyProject".to_string();
644 task.tags = vec!["B".to_string(), "A".to_string(), "B".to_string()];
645 task.normalise();
646
647 assert_eq!(task.project, "myproject");
648 assert_eq!(task.tags, vec!["a".to_string(), "b".to_string()]);
649 }
650}