Skip to main content

oflow/
models.rs

1// Copyright (c) 2026
2//
3// This Source Code Form is subject to the terms of the Mozilla Public
4// License, v. 2.0. If a copy of the MPL was not distributed with this
5// file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
7use chrono::prelude::*;
8use serde::{Deserialize, Serialize};
9use sqlx::FromRow;
10use sqlx::SqlitePool;
11use std::collections::BTreeMap;
12use std::fmt::{self, Display};
13use uuid::Uuid;
14
15/// A single todo item.
16///
17/// Represents one task with a unique identifier, content, timestamps,
18/// and completion status.
19#[derive(Deserialize, Serialize, Debug, FromRow, Clone)]
20pub struct Todo {
21    pub id: uuid::Uuid,
22    pub content: String,
23    pub created_at: DateTime<Utc>,
24    pub finished_at: Option<DateTime<Utc>>,
25    pub finished: bool,
26}
27
28impl Todo {
29    /// Create a new unfinished todo with the given content.
30    pub fn new(content: String) -> Todo {
31        Todo {
32            id: Uuid::new_v4(),
33            content,
34            created_at: Utc::now(),
35            finished_at: None,
36            finished: false,
37        }
38    }
39
40    /// Mark this todo as finished and return its content.
41    pub fn mark_finished(&mut self) -> &str {
42        self.finished = true;
43        self.finished_at = Some(Utc::now());
44        &self.content
45    }
46
47    /// Replace the content and return the new value.
48    pub fn edit(&mut self, new_content: String) -> &str {
49        self.content = new_content;
50        &self.content
51    }
52
53    pub fn get_content(&self) -> &str {
54        &self.content
55    }
56}
57
58impl Display for Todo {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        let status = if self.finished { " ✓ " } else { "   " };
61        write!(f, "[{}] {}", status, self.content)
62    }
63}
64
65/// An ordered collection of [`Todo`] items keyed by their UUID.
66///
67/// Supports file-based I/O (TOML) and database-backed operations
68/// via the [`database`](crate::database) module.
69#[derive(Deserialize, Serialize, Debug)]
70pub struct TodoList {
71    #[serde(flatten)]
72    pub todos: BTreeMap<String, Todo>,
73}
74
75impl TodoList {
76    /// Create an empty todo list.
77    pub fn new() -> TodoList {
78        TodoList {
79            todos: BTreeMap::new(),
80        }
81    }
82
83    /// Read a todo list from a TOML file.
84    ///
85    /// Returns an empty list if the file does not exist or cannot be parsed.
86    pub fn read_from_file(file_path: &str) -> color_eyre::Result<TodoList> {
87        let tdlist = if std::path::Path::new(file_path).exists() {
88            let content = std::fs::read_to_string(file_path)?;
89            toml::from_str(&content).unwrap_or_else(|_| TodoList::new())
90        } else {
91            TodoList::new()
92        };
93
94        Ok(tdlist)
95    }
96
97    /// Write the todo list to a TOML file.
98    pub fn write_to_file(&self, file_path: &str) -> color_eyre::Result<()> {
99        let toml_str = toml::to_string_pretty(self)?;
100        std::fs::write(file_path, toml_str)?;
101        Ok(())
102    }
103
104    /// Add a new todo via the database, skipping if content already exists.
105    pub async fn add_todo_db(
106        &mut self,
107        content: String,
108        pool: &SqlitePool,
109    ) -> color_eyre::Result<()> {
110        if let Some(task) = TodoList::find_by_content(pool, &content).await? {
111            println!("Task already exists: {}", task.get_content());
112        } else {
113            println!("Created task: {}", content);
114            let new_todo = Todo::new(content.clone());
115            self.todos.insert(new_todo.id.to_string(), new_todo);
116        }
117        Ok(())
118    }
119
120    /// Mark a todo as finished by searching its content in the database.
121    pub async fn finish_todo_db(
122        &mut self,
123        content: String,
124        pool: &SqlitePool,
125    ) -> color_eyre::Result<()> {
126        if let Some(task_from_db) = TodoList::find_by_content(pool, &content).await? {
127            let id_str = task_from_db.id.to_string();
128            if let Some(todo) = self.todos.get_mut(&id_str) {
129                todo.mark_finished();
130                println!("Finished task: {}", todo.content);
131            }
132        } else {
133            println!("Task not found: {}", content);
134        }
135        Ok(())
136    }
137
138    /// Edit a todo's content by searching for the old value in the database.
139    pub async fn edit_todo_db(
140        &mut self,
141        before: String,
142        after: String,
143        pool: &SqlitePool,
144    ) -> color_eyre::Result<()> {
145        if let Some(task_from_db) = TodoList::find_by_content(pool, &before).await? {
146            let id_str = task_from_db.id.to_string();
147            if let Some(todo) = self.todos.get_mut(&id_str) {
148                todo.edit(after);
149                println!("Updated task: {}", todo.content);
150            }
151        } else {
152            println!("Task not found: {}", before);
153        }
154        Ok(())
155    }
156
157    /// Remove all finished todos from both the database and in-memory map.
158    pub async fn clean_todo_db(
159        &mut self,
160        pool: &sqlx::SqlitePool,
161    ) -> color_eyre::Result<()> {
162        use sqlx::Row;
163
164        // Delete finished rows and return their IDs
165        let rows = sqlx::query("DELETE FROM todos WHERE finished = 1 RETURNING id")
166            .fetch_all(pool)
167            .await?;
168
169        if rows.is_empty() {
170            println!("Nothing to delete!");
171            return Ok(());
172        }
173
174        for row in rows {
175            // Decode the UUID (sqlx handles BLOB automatically)
176            let id: Uuid = row.try_get(0)?;
177
178            // Convert to String to match BTreeMap key
179            let id_str = id.to_string();
180            if let Some(removed_todo) = self.todos.remove(&id_str) {
181                println!(
182                    "Successfully cleaned task: [{}] \"{}\" (Finished at: {:?})",
183                    id_str, removed_todo.content, removed_todo.finished_at
184                );
185            } else {
186                // DB row deleted but not found in memory (should not happen unless state is out of sync)
187                println!(
188                    "Warning: Task ID {} was deleted from DB but not found in memory map.",
189                    id_str
190                );
191            }
192        }
193
194        Ok(())
195    }
196
197    /// Print all todos to stdout.
198    pub fn list_todos(&self) {
199        println!("{}", self);
200    }
201}
202
203impl Default for TodoList {
204    fn default() -> Self {
205        Self::new()
206    }
207}
208
209impl Display for TodoList {
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        if self.todos.is_empty() {
212            return write!(f, "No todos");
213        }
214        for (i, (_, todo)) in self.todos.iter().enumerate() {
215            writeln!(f, "{}. {}", i + 1, todo)?;
216        }
217
218        Ok(())
219    }
220}
221
222#[cfg(test)]
223mod performance_test {
224    use super::*;
225    use sqlx::SqlitePool;
226    use sqlx::sqlite::SqlitePoolOptions;
227    use std::time::Instant;
228
229    async fn create_test_pool() -> SqlitePool {
230        let pool = SqlitePoolOptions::new()
231            .max_connections(1)
232            .connect("sqlite::memory:")
233            .await
234            .unwrap();
235        sqlx::query(
236            "CREATE TABLE IF NOT EXISTS todos (
237                id TEXT PRIMARY KEY,
238                content TEXT NOT NULL,
239                created_at TEXT NOT NULL,
240                finished_at TEXT,
241                finished INTEGER NOT NULL DEFAULT 0
242            )",
243        )
244        .execute(&pool)
245        .await
246        .unwrap();
247        pool
248    }
249
250    fn create_large_todo_list(count: usize) -> TodoList {
251        let mut tdlist = TodoList::new();
252        for i in 0..count {
253            let todo = Todo::new(format!("Test task {}", i));
254            tdlist.todos.insert(todo.id.to_string(), todo);
255        }
256        tdlist
257    }
258
259    #[tokio::test]
260    async fn test_add_10000_todos_performance() {
261        let pool = create_test_pool().await;
262        let mut tdlist = TodoList::new();
263        let start = Instant::now();
264
265        for i in 0..10000 {
266            let content = format!("Performance test task {}", i);
267            let new_todo = Todo::new(content);
268            tdlist.todos.insert(new_todo.id.to_string(), new_todo);
269        }
270
271        tdlist.sync_to_db(&pool).await.unwrap();
272
273        let elapsed = start.elapsed();
274        println!("Adding 10000 todos took: {:?}", elapsed);
275        assert!(
276            elapsed.as_secs() < 30,
277            "Adding 10000 todos took too long: {:?}",
278            elapsed
279        );
280    }
281
282    #[tokio::test]
283    async fn test_finish_10000_todos_performance() {
284        let pool = create_test_pool().await;
285        let mut tdlist = create_large_todo_list(10000);
286
287        tdlist.sync_to_db(&pool).await.unwrap();
288
289        let start = Instant::now();
290
291        for (_, todo) in tdlist.todos.iter_mut() {
292            todo.mark_finished();
293        }
294
295        tdlist.sync_to_db(&pool).await.unwrap();
296
297        let elapsed = start.elapsed();
298        println!("Finishing 10000 todos took: {:?}", elapsed);
299        assert!(
300            elapsed.as_secs() < 30,
301            "Finishing 10000 todos took too long: {:?}",
302            elapsed
303        );
304    }
305
306    #[tokio::test]
307    async fn test_clean_10000_completed_todos_performance() {
308        let pool = create_test_pool().await;
309        let mut tdlist = TodoList::new();
310
311        for i in 0..10000 {
312            let mut todo = Todo::new(format!("Task to clean {}", i));
313            todo.mark_finished();
314            tdlist.todos.insert(todo.id.to_string(), todo);
315        }
316
317        tdlist.sync_to_db(&pool).await.unwrap();
318
319        let start = Instant::now();
320
321        tdlist.clean_todo_db(&pool).await.unwrap();
322
323        let elapsed = start.elapsed();
324        println!("Cleaning 10000 completed todos took: {:?}", elapsed);
325        assert!(
326            elapsed.as_secs() < 30,
327            "Cleaning 10000 todos took too long: {:?}",
328            elapsed
329        );
330    }
331
332    #[tokio::test]
333    async fn test_mixed_operations_10000_todos() {
334        let pool = create_test_pool().await;
335        let mut tdlist = create_large_todo_list(10000);
336
337        tdlist.sync_to_db(&pool).await.unwrap();
338
339        let start_add = Instant::now();
340        for i in 0..1000 {
341            let content = format!("Additional task {}", i);
342            let new_todo = Todo::new(content);
343            tdlist.todos.insert(new_todo.id.to_string(), new_todo);
344        }
345        tdlist.sync_to_db(&pool).await.unwrap();
346        println!("Adding 1000 more todos took: {:?}", start_add.elapsed());
347
348        let start_finish = Instant::now();
349        for (_, todo) in tdlist.todos.iter_mut().take(5000) {
350            todo.mark_finished();
351        }
352        tdlist.sync_to_db(&pool).await.unwrap();
353        println!("Finishing 5000 todos took: {:?}", start_finish.elapsed());
354
355        let start_clean = Instant::now();
356        tdlist.clean_todo_db(&pool).await.unwrap();
357        println!(
358            "Cleaning 5000 completed todos took: {:?}",
359            start_clean.elapsed()
360        );
361
362        let remaining = tdlist.todos.len();
363        println!("Remaining todos after operations: {}", remaining);
364        assert_eq!(remaining, 6000, "Expected 6000 remaining todos");
365    }
366}