Skip to main content

xtask_todo_lib/list/
mod.rs

1//! Facade for todo operations (create, list, complete, delete).
2
3use std::time::SystemTime;
4
5use crate::error::TodoError;
6use crate::id::TodoId;
7use crate::model::{ListOptions, ListSort, Todo, TodoPatch};
8use crate::priority::Priority;
9use crate::store::{InMemoryStore, Store};
10
11/// Validates title: after trim, must be non-empty. Returns `Err(TodoError::InvalidInput)` otherwise.
12fn validate_title(title: &str) -> Result<String, TodoError> {
13    let t = title.trim();
14    if t.is_empty() {
15        return Err(TodoError::InvalidInput);
16    }
17    Ok(t.to_string())
18}
19
20/// Facade for todo operations. Holds a store (default: in-memory).
21pub struct TodoList<S> {
22    store: S,
23}
24
25impl TodoList<InMemoryStore> {
26    /// Creates a new list with in-memory storage.
27    #[must_use]
28    pub fn new() -> Self {
29        Self {
30            store: InMemoryStore::new(),
31        }
32    }
33}
34
35impl Default for TodoList<InMemoryStore> {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41impl<S: Store> TodoList<S> {
42    /// Builds a list with the given store (e.g. for testing or custom backends).
43    #[must_use]
44    pub const fn with_store(store: S) -> Self {
45        Self { store }
46    }
47
48    /// Creates a todo with the given title. Returns its `TodoId` or an error if title is invalid.
49    ///
50    /// # Errors
51    /// Returns `TodoError::InvalidInput` if the title is empty or only whitespace after trim.
52    pub fn create(&mut self, title: impl AsRef<str>) -> Result<TodoId, TodoError> {
53        let title = validate_title(title.as_ref())?;
54        let id = self.store.next_id();
55        let todo = Todo {
56            id,
57            title,
58            completed: false,
59            created_at: SystemTime::now(),
60            completed_at: None,
61            description: None,
62            due_date: None,
63            priority: None,
64            tags: Vec::new(),
65            repeat_rule: None,
66            repeat_until: None,
67            repeat_count: None,
68        };
69        self.store.insert(todo);
70        Ok(id)
71    }
72
73    /// Inserts an existing todo's content with a new id (e.g. for import/merge). Returns the new `TodoId`.
74    pub fn add_todo(&mut self, todo: &Todo) -> TodoId {
75        let id = self.store.next_id();
76        let new_todo = Todo {
77            id,
78            title: todo.title.clone(),
79            completed: todo.completed,
80            created_at: todo.created_at,
81            completed_at: todo.completed_at,
82            description: todo.description.clone(),
83            due_date: todo.due_date.clone(),
84            priority: todo.priority,
85            tags: todo.tags.clone(),
86            repeat_rule: todo.repeat_rule.clone(),
87            repeat_until: todo.repeat_until.clone(),
88            repeat_count: todo.repeat_count,
89        };
90        self.store.insert(new_todo);
91        id
92    }
93
94    /// Returns the todo with the given id, if it exists.
95    #[must_use]
96    pub fn get(&self, id: TodoId) -> Option<Todo> {
97        self.store.get(id)
98    }
99
100    /// Returns all todos in creation order.
101    #[must_use]
102    pub fn list(&self) -> Vec<Todo> {
103        self.store.list()
104    }
105
106    /// Returns todos filtered and sorted according to `options`.
107    #[must_use]
108    pub fn list_with_options(&self, options: &ListOptions) -> Vec<Todo> {
109        let mut items = self.store.list();
110        if let Some(ref f) = options.filter {
111            items.retain(|t| {
112                if let Some(s) = f.status {
113                    if t.completed != s {
114                        return false;
115                    }
116                }
117                if let Some(p) = f.priority {
118                    if t.priority != Some(p) {
119                        return false;
120                    }
121                }
122                if let Some(ref tags) = f.tags_any {
123                    if tags.is_empty() {
124                        return true;
125                    }
126                    if !t.tags.iter().any(|tag| tags.contains(tag)) {
127                        return false;
128                    }
129                }
130                if let Some(ref d) = f.due_before {
131                    if let Some(ref due) = t.due_date {
132                        if due > d {
133                            return false;
134                        }
135                    } else {
136                        return false;
137                    }
138                }
139                if let Some(ref d) = f.due_after {
140                    if let Some(ref due) = t.due_date {
141                        if due < d {
142                            return false;
143                        }
144                    } else {
145                        return false;
146                    }
147                }
148                true
149            });
150        }
151        match options.sort {
152            ListSort::CreatedAt => items.sort_by_key(|t| t.created_at),
153            ListSort::DueDate => items.sort_by(|a, b| {
154                a.due_date
155                    .as_ref()
156                    .cmp(&b.due_date.as_ref())
157                    .then_with(|| a.id.cmp(&b.id))
158            }),
159            ListSort::Priority => items.sort_by(|a, b| {
160                let pa = a.priority.map_or(0, Priority::as_u8);
161                let pb = b.priority.map_or(0, Priority::as_u8);
162                pa.cmp(&pb).then_with(|| a.id.cmp(&b.id))
163            }),
164            ListSort::Title => {
165                items.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id)));
166            }
167        }
168        items
169    }
170
171    /// Updates the title of the todo with the given id.
172    ///
173    /// # Errors
174    /// Returns `TodoError::NotFound(id)` if no todo with that id exists.
175    /// Returns `TodoError::InvalidInput` if the new title is empty or only whitespace.
176    pub fn update_title(&mut self, id: TodoId, title: impl AsRef<str>) -> Result<(), TodoError> {
177        self.update(
178            id,
179            TodoPatch {
180                title: Some(validate_title(title.as_ref())?),
181                ..TodoPatch::default()
182            },
183        )
184    }
185
186    /// Applies a partial update to the todo with the given id. Only fields set in `patch` are updated.
187    ///
188    /// # Errors
189    /// Returns `TodoError::NotFound(id)` if no todo with that id exists.
190    /// Returns `TodoError::InvalidInput` if `patch.title` is Some and empty/whitespace.
191    pub fn update(&mut self, id: TodoId, patch: TodoPatch) -> Result<(), TodoError> {
192        let mut todo = self.store.get(id).ok_or(TodoError::NotFound(id))?;
193        if let Some(ref t) = patch.title {
194            todo.title = validate_title(t)?;
195        }
196        if patch.description.is_some() {
197            todo.description = patch.description;
198        }
199        if patch.due_date.is_some() {
200            todo.due_date = patch.due_date;
201        }
202        if patch.priority.is_some() {
203            todo.priority = patch.priority;
204        }
205        if patch.tags.is_some() {
206            todo.tags = patch.tags.unwrap_or_default();
207        }
208        if patch.repeat_rule.is_some() {
209            todo.repeat_rule = patch.repeat_rule;
210        }
211        if patch.repeat_until.is_some() {
212            todo.repeat_until = patch.repeat_until;
213        }
214        if patch.repeat_count.is_some() {
215            todo.repeat_count = patch.repeat_count;
216        }
217        if patch.repeat_rule_clear {
218            todo.repeat_rule = None;
219        }
220        self.store.update(todo);
221        Ok(())
222    }
223
224    /// Marks the todo with the given `TodoId` as completed.
225    ///
226    /// # Errors
227    /// Returns `TodoError::NotFound(id)` if no todo with that id exists.
228    /// Marks the todo as completed. If it has a repeat rule and `no_next` is false, creates the next instance.
229    pub fn complete(&mut self, id: TodoId, no_next: bool) -> Result<(), TodoError> {
230        let mut todo = self.store.get(id).ok_or(TodoError::NotFound(id))?;
231        let repeat_rule = todo.repeat_rule.clone();
232        let due_date = todo.due_date.clone();
233        let repeat_until = todo.repeat_until.clone();
234        let repeat_count = todo.repeat_count;
235        let title = todo.title.clone();
236        let description = todo.description.clone();
237        let priority = todo.priority;
238        let tags = todo.tags.clone();
239        todo.completed = true;
240        todo.completed_at = Some(SystemTime::now());
241        self.store.update(todo);
242        if !no_next {
243            if let (Some(rule), Some(ref from)) = (repeat_rule, &due_date) {
244                if repeat_count == Some(0) || repeat_count == Some(1) {
245                    // last occurrence, do not create next
246                } else if let Some(next_due) = rule.next_due_date(from) {
247                    let past_until = repeat_until
248                        .as_ref()
249                        .is_some_and(|until| next_due.as_str() > until);
250                    if past_until {
251                        // next would be after end date
252                    } else {
253                        let next_count = repeat_count.and_then(|n| n.checked_sub(1));
254                        let next_id = self.store.next_id();
255                        let next_todo = Todo {
256                            id: next_id,
257                            title,
258                            completed: false,
259                            created_at: SystemTime::now(),
260                            completed_at: None,
261                            description,
262                            due_date: Some(next_due),
263                            priority,
264                            tags,
265                            repeat_rule: Some(rule),
266                            repeat_until,
267                            repeat_count: next_count,
268                        };
269                        self.store.insert(next_todo);
270                    }
271                }
272            }
273        }
274        Ok(())
275    }
276
277    /// Removes the todo with the given `TodoId`.
278    ///
279    /// # Errors
280    /// Returns `TodoError::NotFound(id)` if no todo with that id exists.
281    pub fn delete(&mut self, id: TodoId) -> Result<(), TodoError> {
282        if self.store.get(id).is_none() {
283            return Err(TodoError::NotFound(id));
284        }
285        self.store.remove(id);
286        Ok(())
287    }
288
289    /// Search todos by keyword (matches title; optionally description and tags when present).
290    #[must_use]
291    pub fn search(&self, keyword: &str) -> Vec<Todo> {
292        let k = keyword.trim().to_lowercase();
293        if k.is_empty() {
294            return self.store.list();
295        }
296        self.store
297            .list()
298            .into_iter()
299            .filter(|t| {
300                t.title.to_lowercase().contains(&k)
301                    || t.description
302                        .as_ref()
303                        .is_some_and(|d| d.to_lowercase().contains(&k))
304                    || t.tags.iter().any(|tag| tag.to_lowercase().contains(&k))
305            })
306            .collect()
307    }
308
309    /// Returns counts: total, incomplete, complete.
310    #[must_use]
311    pub fn stats(&self) -> (usize, usize, usize) {
312        let items = self.store.list();
313        let total = items.len();
314        let complete = items.iter().filter(|t| t.completed).count();
315        let incomplete = total - complete;
316        (total, incomplete, complete)
317    }
318}
319
320#[cfg(test)]
321mod tests;