Skip to main content

xtask_todo_lib/devshell/
todo_io.rs

1//! File I/O for todo list (`.todo.json`). Same format as xtask so `dev_shell` and `cargo xtask todo` share data.
2//!
3//! **Mode P / guest-primary:** paths stay on the **host** current directory (design ยง11 **A**); they are
4//! **not** mapped into the guest project tree.
5
6use std::path::PathBuf;
7use std::str::FromStr;
8use std::time::{Duration, UNIX_EPOCH};
9
10use crate::{InMemoryStore, Priority, RepeatRule, Todo, TodoId, TodoList};
11
12#[derive(serde::Serialize, serde::Deserialize)]
13pub struct TodoDto {
14    pub id: u64,
15    pub title: String,
16    pub completed: bool,
17    pub created_at_secs: u64,
18    #[serde(default)]
19    pub completed_at_secs: Option<u64>,
20    #[serde(default)]
21    pub description: Option<String>,
22    #[serde(default)]
23    pub due_date: Option<String>,
24    #[serde(default)]
25    pub priority: Option<String>,
26    #[serde(default)]
27    pub tags: Vec<String>,
28    #[serde(default)]
29    pub repeat_rule: Option<String>,
30    #[serde(default)]
31    pub repeat_until: Option<String>,
32    #[serde(default)]
33    pub repeat_count: Option<u32>,
34}
35
36/// Path to `.todo.json` in the current directory.
37///
38/// # Errors
39/// Returns error if `current_dir()` fails.
40pub fn todo_file() -> Result<PathBuf, Box<dyn std::error::Error>> {
41    let cwd = std::env::current_dir()?;
42    Ok(cwd.join(".todo.json"))
43}
44
45fn dto_to_todo(d: TodoDto) -> Option<Todo> {
46    let id = TodoId::from_raw(d.id)?;
47    let created_at = UNIX_EPOCH + Duration::from_secs(d.created_at_secs);
48    let completed_at = d
49        .completed_at_secs
50        .filter(|&s| s > 0)
51        .map(|s| UNIX_EPOCH + Duration::from_secs(s));
52    let priority = d
53        .priority
54        .as_deref()
55        .and_then(|s| Priority::from_str(s).ok());
56    let repeat_rule = d
57        .repeat_rule
58        .as_deref()
59        .and_then(|s| RepeatRule::from_str(s).ok());
60    Some(Todo {
61        id,
62        title: d.title,
63        completed: d.completed,
64        created_at,
65        completed_at,
66        description: d.description,
67        due_date: d.due_date,
68        priority,
69        tags: d.tags,
70        repeat_rule,
71        repeat_until: d.repeat_until,
72        repeat_count: d.repeat_count,
73    })
74}
75
76/// Load todos from `.todo.json` in the current directory.
77///
78/// # Errors
79/// Returns error on I/O or invalid JSON (invalid entries are skipped).
80pub fn load_todos() -> Result<Vec<Todo>, Box<dyn std::error::Error>> {
81    let path = todo_file()?;
82    if !path.exists() {
83        return Ok(Vec::new());
84    }
85    let s = super::host_text::read_host_text(&path)?;
86    let dtos: Vec<TodoDto> = serde_json::from_str(&s).unwrap_or_default();
87    let todos = dtos.into_iter().filter_map(dto_to_todo).collect();
88    Ok(todos)
89}
90
91/// Build a `TodoList` from loaded todos (call after `load_todos`).
92#[must_use]
93pub fn list_from_todos(todos: Vec<Todo>) -> TodoList<InMemoryStore> {
94    TodoList::with_store(InMemoryStore::from_todos(todos))
95}
96
97/// Save todos to `.todo.json` in the current directory.
98///
99/// # Errors
100/// Returns error on I/O or serialization failure.
101pub fn save_todos(list: &TodoList<InMemoryStore>) -> Result<(), Box<dyn std::error::Error>> {
102    let path = todo_file()?;
103    let dtos: Vec<TodoDto> = list
104        .list()
105        .iter()
106        .map(|t| TodoDto {
107            id: t.id.as_u64(),
108            title: t.title.clone(),
109            completed: t.completed,
110            created_at_secs: t
111                .created_at
112                .duration_since(UNIX_EPOCH)
113                .unwrap_or(Duration::ZERO)
114                .as_secs(),
115            completed_at_secs: t
116                .completed_at
117                .and_then(|ct| ct.duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs())),
118            description: t.description.clone(),
119            due_date: t.due_date.clone(),
120            priority: t.priority.map(|p| p.to_string()),
121            tags: t.tags.clone(),
122            repeat_rule: t.repeat_rule.as_ref().map(ToString::to_string),
123            repeat_until: t.repeat_until.clone(),
124            repeat_count: t.repeat_count,
125        })
126        .collect();
127    let s = serde_json::to_string_pretty(&dtos)?;
128    std::fs::write(path, s)?;
129    Ok(())
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn load_todos_when_file_missing_returns_empty() {
138        let _g = crate::test_support::cwd_mutex();
139        let dir = std::env::temp_dir().join(format!(
140            "todo_io_nojson_{}_{}",
141            std::process::id(),
142            std::time::SystemTime::now()
143                .duration_since(std::time::UNIX_EPOCH)
144                .unwrap()
145                .as_nanos()
146        ));
147        std::fs::create_dir_all(&dir).unwrap();
148        let todo_json = dir.join(".todo.json");
149        let _ = std::fs::remove_file(&todo_json);
150        let cwd = std::env::current_dir().unwrap();
151        std::env::set_current_dir(&dir).unwrap();
152        let result = load_todos();
153        std::env::set_current_dir(&cwd).unwrap();
154        let _ = std::fs::remove_dir_all(&dir);
155        let todos = result.unwrap();
156        assert!(todos.is_empty());
157    }
158}