Skip to main content

task_graph_mcp/db/
attachments.rs

1//! Attachment storage operations.
2
3use super::{Database, now_ms};
4use crate::types::{Attachment, AttachmentMeta};
5use anyhow::{Result, anyhow};
6use rusqlite::params;
7
8impl Database {
9    /// Add an attachment to a task with auto-increment sequence per type.
10    /// Returns the sequence number of the new attachment.
11    /// If file_path is provided, content should be empty (stored externally).
12    pub fn add_attachment(
13        &self,
14        task_id: &str,
15        attachment_type: String,
16        name: String,
17        content: String,
18        mime_type: Option<String>,
19        file_path: Option<String>,
20    ) -> Result<i32> {
21        let now = now_ms();
22        let mime_type = mime_type.unwrap_or_else(|| "text/plain".to_string());
23
24        self.with_conn_mut(|conn| {
25            let tx = conn.transaction()?;
26
27            // Verify task exists
28            let exists: bool = tx
29                .query_row(
30                    "SELECT 1 FROM tasks WHERE id = ?1",
31                    params![task_id],
32                    |_| Ok(true),
33                )
34                .unwrap_or(false);
35
36            if !exists {
37                return Err(anyhow!("Task not found"));
38            }
39
40            // Get next sequence for this (task_id, attachment_type)
41            let max_seq: Option<i32> = tx.query_row(
42                "SELECT MAX(sequence) FROM attachments WHERE task_id = ?1 AND attachment_type = ?2",
43                params![task_id, attachment_type],
44                |row| row.get(0),
45            )?;
46            let sequence = max_seq.unwrap_or(-1) + 1;
47
48            tx.execute(
49                "INSERT INTO attachments (task_id, attachment_type, sequence, name, mime_type, content, file_path, created_at)
50                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
51                params![
52                    task_id,
53                    attachment_type,
54                    sequence,
55                    name,
56                    mime_type,
57                    content,
58                    file_path,
59                    now,
60                ],
61            )?;
62
63            tx.commit()?;
64            Ok(sequence)
65        })
66    }
67
68    /// Get attachments for a task, optionally including content.
69    /// Note: For file-based attachments, content is NOT loaded here - use get_attachment for that.
70    pub fn get_attachments_full(
71        &self,
72        task_id: &str,
73        include_content: bool,
74    ) -> Result<Vec<Attachment>> {
75        self.with_conn(|conn| {
76            let mut stmt = conn.prepare(
77                "SELECT task_id, attachment_type, sequence, name, mime_type, content, file_path, created_at
78                 FROM attachments WHERE task_id = ?1 ORDER BY attachment_type, sequence",
79            )?;
80
81            let attachments = stmt
82                .query_map(params![task_id], |row| {
83                    let task_id: String = row.get(0)?;
84                    let attachment_type: String = row.get(1)?;
85                    let sequence: i32 = row.get(2)?;
86                    let name: String = row.get(3)?;
87                    let mime_type: String = row.get(4)?;
88                    let content: String = row.get(5)?;
89                    let file_path: Option<String> = row.get(6)?;
90                    let created_at: i64 = row.get(7)?;
91
92                    Ok(Attachment {
93                        task_id,
94                        attachment_type,
95                        sequence,
96                        name,
97                        mime_type,
98                        content: if include_content {
99                            content
100                        } else {
101                            String::new()
102                        },
103                        file_path,
104                        created_at,
105                    })
106                })?
107                .filter_map(|r| r.ok())
108                .collect();
109
110            Ok(attachments)
111        })
112    }
113
114    /// Get attachments for a task (metadata only).
115    pub fn get_attachments(&self, task_id: &str) -> Result<Vec<AttachmentMeta>> {
116        self.get_attachments_filtered(task_id, None, None)
117    }
118
119    /// Get attachments for a task with optional filtering (metadata only).
120    /// - type_pattern: Optional glob pattern (with * wildcard) to filter by attachment_type
121    /// - mime_pattern: Optional prefix to filter by MIME type (e.g., "image/" matches "image/png")
122    pub fn get_attachments_filtered(
123        &self,
124        task_id: &str,
125        type_pattern: Option<&str>,
126        mime_pattern: Option<&str>,
127    ) -> Result<Vec<AttachmentMeta>> {
128        self.with_conn(|conn| {
129            // Build query with optional filters
130            let mut sql = String::from(
131                "SELECT task_id, attachment_type, sequence, name, mime_type, file_path, created_at
132                 FROM attachments WHERE task_id = ?1",
133            );
134
135            // For type pattern, convert glob to SQL LIKE pattern
136            let type_like = type_pattern.map(|p| {
137                // Convert glob wildcards to SQL LIKE: * -> %, ? -> _
138                p.replace('*', "%").replace('?', "_")
139            });
140
141            if type_like.is_some() {
142                sql.push_str(" AND attachment_type LIKE ?2 ESCAPE '\\'");
143            }
144
145            if mime_pattern.is_some() {
146                let idx = if type_like.is_some() { 3 } else { 2 };
147                sql.push_str(&format!(" AND mime_type LIKE ?{} ESCAPE '\\'", idx));
148            }
149
150            sql.push_str(" ORDER BY attachment_type, sequence");
151
152            let mut stmt = conn.prepare(&sql)?;
153
154            // Bind parameters based on which filters are present
155            let attachments: Vec<AttachmentMeta> = match (&type_like, mime_pattern) {
156                (Some(type_pat), Some(mime)) => {
157                    let mime_like = format!("{}%", mime);
158                    stmt.query_map(params![task_id, type_pat, mime_like], |row| {
159                        Self::map_attachment_meta(row)
160                    })?
161                    .filter_map(|r| r.ok())
162                    .collect()
163                }
164                (Some(type_pat), None) => stmt
165                    .query_map(params![task_id, type_pat], Self::map_attachment_meta)?
166                    .filter_map(|r| r.ok())
167                    .collect(),
168                (None, Some(mime)) => {
169                    let mime_like = format!("{}%", mime);
170                    stmt.query_map(params![task_id, mime_like], |row| {
171                        Self::map_attachment_meta(row)
172                    })?
173                    .filter_map(|r| r.ok())
174                    .collect()
175                }
176                (None, None) => stmt
177                    .query_map(params![task_id], Self::map_attachment_meta)?
178                    .filter_map(|r| r.ok())
179                    .collect(),
180            };
181
182            Ok(attachments)
183        })
184    }
185
186    /// Helper to map a row to AttachmentMeta.
187    fn map_attachment_meta(row: &rusqlite::Row) -> rusqlite::Result<AttachmentMeta> {
188        Ok(AttachmentMeta {
189            task_id: row.get(0)?,
190            attachment_type: row.get(1)?,
191            sequence: row.get(2)?,
192            name: row.get(3)?,
193            mime_type: row.get(4)?,
194            file_path: row.get(5)?,
195            created_at: row.get(6)?,
196        })
197    }
198
199    /// Get a full attachment by (task_id, attachment_type, sequence).
200    /// Note: For file-based attachments, content field contains the DB content (empty).
201    /// The caller should read from file_path if set.
202    pub fn get_attachment(
203        &self,
204        task_id: &str,
205        attachment_type: &str,
206        sequence: i32,
207    ) -> Result<Option<Attachment>> {
208        self.with_conn(|conn| {
209            let mut stmt = conn.prepare(
210                "SELECT task_id, attachment_type, sequence, name, mime_type, content, file_path, created_at
211                 FROM attachments WHERE task_id = ?1 AND attachment_type = ?2 AND sequence = ?3",
212            )?;
213
214            let result = stmt.query_row(params![task_id, attachment_type, sequence], |row| {
215                let task_id: String = row.get(0)?;
216                let attachment_type: String = row.get(1)?;
217                let sequence: i32 = row.get(2)?;
218                let name: String = row.get(3)?;
219                let mime_type: String = row.get(4)?;
220                let content: String = row.get(5)?;
221                let file_path: Option<String> = row.get(6)?;
222                let created_at: i64 = row.get(7)?;
223
224                Ok(Attachment {
225                    task_id,
226                    attachment_type,
227                    sequence,
228                    name,
229                    mime_type,
230                    content,
231                    file_path,
232                    created_at,
233                })
234            });
235
236            match result {
237                Ok(attachment) => Ok(Some(attachment)),
238                Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
239                Err(e) => Err(e.into()),
240            }
241        })
242    }
243
244    /// Get file_paths for all attachments of a given type (useful before deletion).
245    pub fn get_attachment_file_paths_by_type(
246        &self,
247        task_id: &str,
248        attachment_type: &str,
249    ) -> Result<Vec<String>> {
250        self.with_conn(|conn| {
251            let mut stmt = conn.prepare(
252                "SELECT file_path FROM attachments WHERE task_id = ?1 AND attachment_type = ?2 AND file_path IS NOT NULL",
253            )?;
254
255            let paths: Vec<String> = stmt
256                .query_map(params![task_id, attachment_type], |row| row.get(0))?
257                .filter_map(|r| r.ok())
258                .collect();
259
260            Ok(paths)
261        })
262    }
263
264    /// Delete an attachment by (task_id, attachment_type, sequence).
265    pub fn delete_attachment(
266        &self,
267        task_id: &str,
268        attachment_type: &str,
269        sequence: i32,
270    ) -> Result<bool> {
271        self.with_conn(|conn| {
272            let deleted = conn.execute(
273                "DELETE FROM attachments WHERE task_id = ?1 AND attachment_type = ?2 AND sequence = ?3",
274                params![task_id, attachment_type, sequence],
275            )?;
276
277            Ok(deleted > 0)
278        })
279    }
280
281    /// Delete all attachments of a given type (for replace behavior).
282    /// Returns the file_paths of deleted attachments (for cleanup).
283    pub fn delete_attachments_by_type(
284        &self,
285        task_id: &str,
286        attachment_type: &str,
287    ) -> Result<Vec<String>> {
288        self.with_conn(|conn| {
289            // First get all file_paths
290            let file_paths = {
291                let mut stmt = conn.prepare(
292                    "SELECT file_path FROM attachments WHERE task_id = ?1 AND attachment_type = ?2 AND file_path IS NOT NULL",
293                )?;
294                stmt.query_map(params![task_id, attachment_type], |row| row.get(0))?
295                    .filter_map(|r| r.ok())
296                    .collect::<Vec<String>>()
297            };
298
299            // Delete all attachments of this type
300            conn.execute(
301                "DELETE FROM attachments WHERE task_id = ?1 AND attachment_type = ?2",
302                params![task_id, attachment_type],
303            )?;
304
305            Ok(file_paths)
306        })
307    }
308
309    /// Delete all attachments of a given type and return count plus file_paths.
310    /// Returns (deleted_count, file_paths).
311    pub fn delete_attachments_by_type_ex(
312        &self,
313        task_id: &str,
314        attachment_type: &str,
315    ) -> Result<(usize, Vec<String>)> {
316        self.with_conn(|conn| {
317            // First get all file_paths
318            let file_paths = {
319                let mut stmt = conn.prepare(
320                    "SELECT file_path FROM attachments WHERE task_id = ?1 AND attachment_type = ?2 AND file_path IS NOT NULL",
321                )?;
322                stmt.query_map(params![task_id, attachment_type], |row| row.get(0))?
323                    .filter_map(|r| r.ok())
324                    .collect::<Vec<String>>()
325            };
326
327            // Delete all attachments of this type
328            let deleted = conn.execute(
329                "DELETE FROM attachments WHERE task_id = ?1 AND attachment_type = ?2",
330                params![task_id, attachment_type],
331            )?;
332
333            Ok((deleted, file_paths))
334        })
335    }
336}