Skip to main content

task_graph_mcp/db/
attachments.rs

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