Skip to main content

rstask_core/
task.rs

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
12// Custom serialization module for DateTime fields to match Go's RFC3339 format
13pub 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
35// Custom serialization module for Option<DateTime> fields
36pub mod optional_datetime_rfc3339 {
37    use chrono::{DateTime, Utc};
38    use serde::{Deserialize, Deserializer, Serializer};
39
40    // Zero date constant matching Go's "0001-01-01T00:00:00Z"
41    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/// JSON representation of a task (matches Go version output)
69#[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    /// Creates a new task with default values
145    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    /// Converts task to JSON representation (matches Go version)
168    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    /// Checks equality of core properties (ignores ephemeral fields)
191    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    /// Checks if task matches a filter query
208    pub fn matches_filter(&self, query: &Query) -> bool {
209        // IDs were specified but none match
210        if !query.ids.is_empty() && !query.ids.contains(&self.id) {
211            return false;
212        }
213
214        // Must have all specified tags
215        for tag in &query.tags {
216            if !self.tags.contains(tag) {
217                return false;
218            }
219        }
220
221        // Must not have any anti-tags
222        for tag in &query.anti_tags {
223            if self.tags.contains(tag) {
224                return false;
225            }
226        }
227
228        // Must not be in anti-projects
229        if query.anti_projects.contains(&self.project) {
230            return false;
231        }
232
233        // Must match project if specified
234        if !query.project.is_empty() && self.project != query.project {
235            return false;
236        }
237
238        // Check due date filter
239        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        // Check priority
253        if !query.priority.is_empty() && self.priority != query.priority {
254            return false;
255        }
256
257        // Check text search
258        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    /// Normalizes task data (lowercase tags/project, sort, deduplicate)
271    pub fn normalise(&mut self) {
272        self.project = self.project.to_lowercase();
273
274        // Lowercase all tags
275        for tag in &mut self.tags {
276            *tag = tag.to_lowercase();
277        }
278
279        // Sort tags
280        self.tags.sort();
281
282        // Deduplicate tags
283        self.tags.dedup();
284
285        // Resolved tasks should not have IDs
286        if self.status == STATUS_RESOLVED {
287            self.id = 0;
288        }
289
290        // Default priority
291        if self.priority.is_empty() {
292            self.priority = PRIORITY_NORMAL.to_string();
293        }
294    }
295
296    /// Validates task data
297    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    /// Returns summary with last note if available
320    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    /// Modifies task based on query
331    pub fn modify(&mut self, query: &Query) {
332        // Add tags
333        for tag in &query.tags {
334            if !self.tags.contains(tag) {
335                self.tags.push(tag.clone());
336            }
337        }
338
339        // Remove anti-tags
340        self.tags.retain(|tag| !query.anti_tags.contains(tag));
341
342        // Set project
343        if !query.project.is_empty() {
344            self.project = query.project.clone();
345        }
346
347        // Remove anti-projects
348        if query.anti_projects.contains(&self.project) {
349            self.project.clear();
350        }
351
352        // Set priority
353        if !query.priority.is_empty() {
354            self.priority = query.priority.clone();
355        }
356
357        // Set due date
358        if let Some(due) = query.due {
359            self.due = Some(due);
360        }
361
362        // Append note
363        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    /// Saves task to disk
374    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            // Delete the task file
381            if filepath.exists() {
382                std::fs::remove_file(&filepath)?;
383            }
384        } else {
385            // Save task to disk using markdown with frontmatter
386            let markdown_data = crate::frontmatter::task_to_markdown(self)?;
387
388            // Ensure directory exists
389            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        // Delete task from other status directories (both .md and legacy .yml)
397        for status in ALL_STATUSES {
398            if *status == self.status {
399                continue;
400            }
401
402            // Delete .md file
403            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            // Delete legacy .yml file if it exists
410            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    /// Deletes task from disk
421    pub fn delete_from_disk(&self, repo_path: &Path) -> Result<()> {
422        // Delete both .yml and .md files from current status directory
423        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        // Also check other status directories
435        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    /// Parses due date to a display string
457    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
475/// Unmarshals a task from disk
476pub fn unmarshal_task(
477    path: &Path,
478    filename: &str,
479    ids: &std::collections::HashMap<String, i32>,
480    status: &str,
481) -> Result<Task> {
482    // Support both .md (new format) and .yml (legacy format)
483    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 }; // UUID(36) + ".md"(3) or ".yml"(4)
494    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        // Parse markdown with frontmatter
514        crate::frontmatter::task_from_markdown(&data, uuid, status, id)?
515    } else {
516        // Parse legacy YAML format
517        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        // Check that created is in RFC3339 format (contains 'T' and timezone)
552        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}