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
13mod 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
35// Custom serialization for optional DateTime fields
36mod 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!("{}.yml", 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
386            // Create a copy and clear status for serialization
387            let mut task_copy = self.clone();
388            task_copy.status.clear();
389
390            let yaml_data = serde_yaml::to_string(&task_copy)?;
391
392            // Ensure directory exists
393            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        // Delete task from other status directories
401        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    /// Deletes task from disk
417    pub fn delete_from_disk(&self, repo_path: &Path) -> Result<()> {
418        // Delete from current status directory
419        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        // Also check other status directories
425        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    /// Parses due date to a display string
440    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
458/// Unmarshals a task from disk
459pub 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        // Check that created is in RFC3339 format (contains 'T' and timezone)
517        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}