Skip to main content

track/services/
link_service.rs

1use crate::db::row_mapping::parse_datetime;
2use crate::db::Database;
3use crate::models::{Link, Scrap, TodoStatus};
4use crate::utils::{Result, TrackError};
5use chrono::Utc;
6use rusqlite::{params, OptionalExtension};
7
8pub struct LinkService<'a> {
9    db: &'a Database,
10}
11
12impl<'a> LinkService<'a> {
13    pub fn new(db: &'a Database) -> Self {
14        Self { db }
15    }
16
17    pub fn add_link(&self, task_id: i64, url: &str, title: Option<&str>) -> Result<Link> {
18        self.validate_url(url)?;
19
20        let title = title.unwrap_or(url).to_string();
21        let url = url.to_string();
22        let now = Utc::now().to_rfc3339();
23
24        // Use transaction to make SELECT MAX + INSERT atomic
25        self.db.with_transaction(|| {
26            let conn = self.db.get_connection();
27
28            // Get the next task_index for this task
29            let next_index: i64 = conn.query_row(
30                "SELECT COALESCE(MAX(task_index), 0) + 1 FROM links WHERE task_id = ?1",
31                params![task_id],
32                |row| row.get(0),
33            )?;
34
35            conn.execute(
36                "INSERT INTO links (task_id, task_index, url, title, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
37                params![task_id, next_index, url, title, now],
38            )?;
39
40            let link_id = conn.last_insert_rowid();
41            self.db.increment_rev("links")?;
42            self.get_link(link_id)
43        })
44    }
45
46    pub fn get_link(&self, link_id: i64) -> Result<Link> {
47        let conn = self.db.get_connection();
48        let mut stmt = conn.prepare(
49            "SELECT id, task_id, task_index, url, title, created_at FROM links WHERE id = ?1",
50        )?;
51
52        let link = stmt.query_row(params![link_id], |row| {
53            Ok(Link {
54                id: row.get(0)?,
55                task_id: row.get(1)?,
56                task_index: row.get(2)?,
57                url: row.get(3)?,
58                title: row.get(4)?,
59                created_at: parse_datetime(row.get::<_, String>(5)?)?,
60            })
61        })?;
62
63        Ok(link)
64    }
65
66    pub fn list_links(&self, task_id: i64) -> Result<Vec<Link>> {
67        let conn = self.db.get_connection();
68        let mut stmt = conn.prepare(
69            "SELECT id, task_id, task_index, url, title, created_at FROM links WHERE task_id = ?1 ORDER BY task_index ASC"
70        )?;
71
72        let links = stmt
73            .query_map(params![task_id], |row| {
74                Ok(Link {
75                    id: row.get(0)?,
76                    task_id: row.get(1)?,
77                    task_index: row.get(2)?,
78                    url: row.get(3)?,
79                    title: row.get(4)?,
80                    created_at: parse_datetime(row.get::<_, String>(5)?)?,
81                })
82            })?
83            .collect::<std::result::Result<Vec<_>, _>>()?;
84
85        Ok(links)
86    }
87
88    fn validate_url(&self, url: &str) -> Result<()> {
89        if url.starts_with("http://") || url.starts_with("https://") {
90            Ok(())
91        } else {
92            Err(TrackError::InvalidUrl(url.to_string()))
93        }
94    }
95
96    /// Delete a link by its ID
97    pub fn delete_link(&self, link_id: i64) -> Result<()> {
98        let conn = self.db.get_connection();
99        let affected = conn.execute("DELETE FROM links WHERE id = ?1", params![link_id])?;
100
101        if affected == 0 {
102            return Err(TrackError::LinkNotFound(link_id));
103        }
104
105        self.db.increment_rev("links")?;
106        Ok(())
107    }
108}
109
110pub struct ScrapService<'a> {
111    db: &'a Database,
112}
113
114impl<'a> ScrapService<'a> {
115    pub fn new(db: &'a Database) -> Self {
116        Self { db }
117    }
118
119    pub fn add_scrap(&self, task_id: i64, content: &str) -> Result<Scrap> {
120        if content.trim().is_empty() {
121            return Err(TrackError::EmptyScrapContent);
122        }
123
124        let now = Utc::now().to_rfc3339();
125        let content = content.to_string();
126
127        // Use transaction to make SELECT MAX + INSERT atomic
128        self.db.with_transaction(|| {
129            let conn = self.db.get_connection();
130
131            // Get the next task_index for this task
132            let next_index: i64 = conn.query_row(
133                "SELECT COALESCE(MAX(task_index), 0) + 1 FROM scraps WHERE task_id = ?1",
134                params![task_id],
135                |row| row.get(0),
136            )?;
137
138            // Find the active todo (oldest pending todo) at the time of scrap creation
139            let active_query = format!(
140                "SELECT task_index FROM todos WHERE task_id = ?1 AND status = '{}' ORDER BY task_index ASC LIMIT 1",
141                TodoStatus::PENDING
142            );
143            let active_todo_id: Option<i64> = conn
144                .query_row(&active_query, params![task_id], |row| row.get(0))
145                .optional()?;
146
147            conn.execute(
148                "INSERT INTO scraps (task_id, task_index, content, created_at, active_todo_id) VALUES (?1, ?2, ?3, ?4, ?5)",
149                params![task_id, next_index, content, now, active_todo_id],
150            )?;
151
152            let scrap_id = conn.last_insert_rowid();
153            self.db.increment_rev("scraps")?;
154            self.get_scrap(scrap_id)
155        })
156    }
157
158    pub fn get_scrap(&self, scrap_id: i64) -> Result<Scrap> {
159        let conn = self.db.get_connection();
160        let mut stmt = conn.prepare(
161            "SELECT id, task_id, task_index, content, created_at, active_todo_id FROM scraps WHERE id = ?1",
162        )?;
163
164        let scrap = stmt.query_row(params![scrap_id], |row| {
165            Ok(Scrap {
166                id: row.get(0)?,
167                task_id: row.get(1)?,
168                scrap_id: row.get(2)?,
169                content: row.get(3)?,
170                created_at: parse_datetime(row.get::<_, String>(4)?)?,
171                active_todo_id: row.get(5)?,
172            })
173        })?;
174
175        Ok(scrap)
176    }
177
178    pub fn list_scraps(&self, task_id: i64) -> Result<Vec<Scrap>> {
179        let conn = self.db.get_connection();
180        let mut stmt = conn.prepare(
181            "SELECT id, task_id, task_index, content, created_at, active_todo_id FROM scraps WHERE task_id = ?1 ORDER BY created_at ASC"
182        )?;
183
184        let scraps = stmt
185            .query_map(params![task_id], |row| {
186                Ok(Scrap {
187                    id: row.get(0)?,
188                    task_id: row.get(1)?,
189                    scrap_id: row.get(2)?,
190                    content: row.get(3)?,
191                    created_at: parse_datetime(row.get::<_, String>(4)?)?,
192                    active_todo_id: row.get(5)?,
193                })
194            })?
195            .collect::<std::result::Result<Vec<_>, _>>()?;
196
197        Ok(scraps)
198    }
199
200    /// Copies scraps that are linked to inherited todos.
201    ///
202    /// # Arguments
203    ///
204    /// * `from_task_id` - The task ID to copy from
205    /// * `to_task_id` - The task ID to copy to
206    /// * `todo_mapping` - HashMap mapping old todo task_index to new todo task_index
207    pub fn copy_linked_scraps(
208        &self,
209        from_task_id: i64,
210        to_task_id: i64,
211        todo_mapping: &std::collections::HashMap<i64, i64>,
212    ) -> Result<()> {
213        if todo_mapping.is_empty() {
214            return Ok(());
215        }
216
217        self.db.with_transaction(|| {
218            self.copy_linked_scraps_in_tx(from_task_id, to_task_id, todo_mapping)
219        })
220    }
221
222    /// Copies linked scraps between tasks. Caller must already hold a DB transaction.
223    pub(crate) fn copy_linked_scraps_in_tx(
224        &self,
225        from_task_id: i64,
226        to_task_id: i64,
227        todo_mapping: &std::collections::HashMap<i64, i64>,
228    ) -> Result<()> {
229        let conn = self.db.get_connection();
230
231        let mut stmt = conn.prepare(
232            "SELECT content, active_todo_id FROM scraps WHERE task_id = ?1 AND active_todo_id IS NOT NULL ORDER BY created_at ASC"
233        )?;
234
235        let linked_scraps: Vec<(String, i64)> = stmt
236            .query_map(params![from_task_id], |row| Ok((row.get(0)?, row.get(1)?)))?
237            .collect::<std::result::Result<Vec<_>, _>>()?;
238
239        let has_scraps = !linked_scraps.is_empty();
240
241        for (content, old_todo_index) in linked_scraps {
242            if let Some(&new_todo_index) = todo_mapping.get(&old_todo_index) {
243                let next_index: i64 = conn.query_row(
244                    "SELECT COALESCE(MAX(task_index), 0) + 1 FROM scraps WHERE task_id = ?1",
245                    params![to_task_id],
246                    |row| row.get(0),
247                )?;
248
249                let now = Utc::now().to_rfc3339();
250
251                conn.execute(
252                    "INSERT INTO scraps (task_id, task_index, content, created_at, active_todo_id) VALUES (?1, ?2, ?3, ?4, ?5)",
253                    params![to_task_id, next_index, content, now, new_todo_index],
254                )?;
255            }
256        }
257
258        if has_scraps {
259            self.db.increment_rev("scraps")?;
260        }
261
262        Ok(())
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::db::Database;
270    use crate::services::TaskService;
271
272    fn setup_db() -> Database {
273        Database::new_in_memory().unwrap()
274    }
275
276    fn create_test_task(db: &Database) -> i64 {
277        let task_service = TaskService::new(db);
278        task_service
279            .create_task("Test Task", None, None, None)
280            .unwrap()
281            .id
282    }
283
284    // LinkService tests
285    #[test]
286    fn test_add_link_success() {
287        let db = setup_db();
288        let task_id = create_test_task(&db);
289        let service = LinkService::new(&db);
290
291        let link = service
292            .add_link(task_id, "https://example.com", Some("Example"))
293            .unwrap();
294        assert_eq!(link.url, "https://example.com");
295        assert_eq!(link.title, "Example");
296    }
297
298    #[test]
299    fn test_add_link_default_title() {
300        let db = setup_db();
301        let task_id = create_test_task(&db);
302        let service = LinkService::new(&db);
303
304        let link = service
305            .add_link(task_id, "https://example.com", None)
306            .unwrap();
307        assert_eq!(link.title, "https://example.com");
308    }
309
310    #[test]
311    fn test_add_link_invalid_url() {
312        let db = setup_db();
313        let task_id = create_test_task(&db);
314        let service = LinkService::new(&db);
315
316        let result = service.add_link(task_id, "invalid-url", None);
317        assert!(matches!(result, Err(TrackError::InvalidUrl(_))));
318    }
319
320    #[test]
321    fn test_list_links() {
322        let db = setup_db();
323        let task_id = create_test_task(&db);
324        let service = LinkService::new(&db);
325
326        service
327            .add_link(task_id, "https://example1.com", None)
328            .unwrap();
329        service
330            .add_link(task_id, "https://example2.com", None)
331            .unwrap();
332
333        let links = service.list_links(task_id).unwrap();
334        assert_eq!(links.len(), 2);
335    }
336
337    #[test]
338    fn test_validate_url_http() {
339        let db = setup_db();
340        let service = LinkService::new(&db);
341
342        assert!(service.validate_url("http://example.com").is_ok());
343    }
344
345    #[test]
346    fn test_validate_url_https() {
347        let db = setup_db();
348        let service = LinkService::new(&db);
349
350        assert!(service.validate_url("https://example.com").is_ok());
351    }
352
353    #[test]
354    fn test_validate_url_invalid() {
355        let db = setup_db();
356        let service = LinkService::new(&db);
357
358        assert!(matches!(
359            service.validate_url("ftp://example.com"),
360            Err(TrackError::InvalidUrl(_))
361        ));
362    }
363
364    #[test]
365    fn test_delete_link_success() {
366        let db = setup_db();
367        let task_id = create_test_task(&db);
368        let service = LinkService::new(&db);
369
370        let link = service
371            .add_link(task_id, "https://example.com", Some("Example"))
372            .unwrap();
373
374        // Delete the link
375        service.delete_link(link.id).unwrap();
376
377        // Verify it's deleted
378        let links = service.list_links(task_id).unwrap();
379        assert_eq!(links.len(), 0);
380    }
381
382    #[test]
383    fn test_delete_link_not_found() {
384        let db = setup_db();
385        let service = LinkService::new(&db);
386
387        // Try to delete non-existent link
388        let result = service.delete_link(999);
389        assert!(result.is_err());
390    }
391
392    // ScrapService tests
393    #[test]
394    fn test_add_scrap_empty_content() {
395        let db = setup_db();
396        let task_id = create_test_task(&db);
397        let service = ScrapService::new(&db);
398
399        let result = service.add_scrap(task_id, "  ");
400        assert!(matches!(result, Err(TrackError::EmptyScrapContent)));
401    }
402
403    #[test]
404    fn test_add_scrap_success() {
405        let db = setup_db();
406        let task_id = create_test_task(&db);
407        let service = ScrapService::new(&db);
408
409        let scrap = service.add_scrap(task_id, "Test scrap content").unwrap();
410        assert_eq!(scrap.content, "Test scrap content");
411    }
412
413    #[test]
414    fn test_get_scrap_success() {
415        let db = setup_db();
416        let task_id = create_test_task(&db);
417        let service = ScrapService::new(&db);
418
419        let created = service.add_scrap(task_id, "Test scrap").unwrap();
420        let retrieved = service.get_scrap(created.id).unwrap();
421        assert_eq!(retrieved.id, created.id);
422        assert_eq!(retrieved.content, "Test scrap");
423    }
424
425    #[test]
426    fn test_list_scraps() {
427        let db = setup_db();
428        let task_id = create_test_task(&db);
429        let service = ScrapService::new(&db);
430
431        service.add_scrap(task_id, "Scrap 1").unwrap();
432        std::thread::sleep(std::time::Duration::from_millis(10));
433        service.add_scrap(task_id, "Scrap 2").unwrap();
434
435        let scraps = service.list_scraps(task_id).unwrap();
436        assert_eq!(scraps.len(), 2);
437        // Should be in ascending order (oldest first)
438        assert_eq!(scraps[0].content, "Scrap 1");
439        assert_eq!(scraps[1].content, "Scrap 2");
440    }
441
442    #[test]
443    fn test_copy_linked_scraps_public_api() {
444        use crate::services::TodoService;
445        use std::collections::HashMap;
446
447        let db = setup_db();
448        let from_task = create_test_task(&db);
449        let to_task = create_test_task(&db);
450        let todo_service = TodoService::new(&db);
451        let scrap_service = ScrapService::new(&db);
452
453        let todo = todo_service.add_todo(from_task, "Todo", false).unwrap();
454        scrap_service.add_scrap(from_task, "Linked scrap").unwrap();
455
456        let mut mapping = HashMap::new();
457        mapping.insert(todo.task_index, 1);
458
459        scrap_service
460            .copy_linked_scraps(from_task, to_task, &mapping)
461            .unwrap();
462
463        let copied = scrap_service.list_scraps(to_task).unwrap();
464        assert_eq!(copied.len(), 1);
465        assert_eq!(copied[0].content, "Linked scrap");
466        assert_eq!(copied[0].active_todo_id, Some(1));
467    }
468}