Skip to main content

fond_store/
repo.rs

1use rusqlite::params;
2use serde::Serialize;
3
4use crate::db::FondDb;
5use crate::error::StoreError;
6
7/// Summary of a recipe for list views.
8#[derive(Debug, Serialize)]
9pub struct RecipeSummary {
10    pub id: i64,
11    pub slug: String,
12    pub title: String,
13    pub source: String,
14    pub tags: Vec<String>,
15}
16
17/// A full recipe record from the database (indexed projection).
18#[derive(Debug, Serialize)]
19pub struct RecipeRecord {
20    pub id: i64,
21    pub file_path: String,
22    pub slug: String,
23    pub title: String,
24    pub source: String,
25    pub source_url: String,
26    pub description: String,
27    pub recipe_yield: String,
28    pub prep_time: String,
29    pub cook_time: String,
30    pub total_time: String,
31    pub servings: String,
32    pub content_hash: String,
33    pub raw_source: String,
34    pub created_at: String,
35    pub updated_at: String,
36}
37
38/// FTS5 search result.
39#[derive(Debug, Serialize)]
40pub struct SearchResult {
41    pub recipe_id: i64,
42    pub title: String,
43    pub slug: String,
44    pub rank: f64,
45}
46
47/// Repository for recipe persistence operations.
48pub struct RecipeRepository<'a> {
49    db: &'a FondDb,
50}
51
52impl<'a> RecipeRepository<'a> {
53    pub fn new(db: &'a FondDb) -> Self {
54        Self { db }
55    }
56
57    /// Insert or update a recipe by file_path (the stable reindex key).
58    ///
59    /// Returns the SQLite rowid of the upserted recipe.
60    pub fn upsert_recipe(
61        &self,
62        file_path: &str,
63        recipe: &fond_domain::Recipe,
64        content_hash: &str,
65    ) -> Result<i64, StoreError> {
66        let conn = self.db.conn();
67
68        // Check if recipe already exists by file_path
69        let existing_id: Option<i64> = conn
70            .query_row(
71                "SELECT id FROM recipes WHERE file_path = ?1",
72                params![file_path],
73                |row| row.get(0),
74            )
75            .ok();
76
77        let recipe_id = if let Some(id) = existing_id {
78            // Update existing recipe
79            conn.execute(
80                "UPDATE recipes SET slug = ?1, title = ?2, source = ?3, source_url = ?4,
81                 description = ?5, recipe_yield = ?6, prep_time = ?7, cook_time = ?8,
82                 total_time = ?9, servings = ?10, content_hash = ?11, raw_source = ?12,
83                 updated_at = datetime('now')
84                 WHERE id = ?13",
85                params![
86                    recipe.slug,
87                    recipe.title,
88                    recipe.source.as_deref().unwrap_or(""),
89                    recipe.source_url.as_deref().unwrap_or(""),
90                    recipe.description.as_deref().unwrap_or(""),
91                    recipe.recipe_yield.as_deref().unwrap_or(""),
92                    recipe.prep_time.as_deref().unwrap_or(""),
93                    recipe.cook_time.as_deref().unwrap_or(""),
94                    recipe.total_time.as_deref().unwrap_or(""),
95                    recipe.servings.as_deref().unwrap_or(""),
96                    content_hash,
97                    recipe.raw_source.as_deref().unwrap_or(""),
98                    id,
99                ],
100            )?;
101
102            // Delete child rows for rebuild
103            conn.execute(
104                "DELETE FROM recipe_ingredients WHERE recipe_id = ?1",
105                params![id],
106            )?;
107            conn.execute("DELETE FROM steps WHERE recipe_id = ?1", params![id])?;
108            conn.execute("DELETE FROM cookware WHERE recipe_id = ?1", params![id])?;
109            conn.execute("DELETE FROM tags WHERE recipe_id = ?1", params![id])?;
110            conn.execute("DELETE FROM recipe_fts WHERE rowid = ?1", params![id])?;
111
112            id
113        } else {
114            // Insert new recipe
115            conn.execute(
116                "INSERT INTO recipes (file_path, slug, title, source, source_url,
117                 description, recipe_yield, prep_time, cook_time, total_time,
118                 servings, content_hash, raw_source)
119                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
120                params![
121                    file_path,
122                    recipe.slug,
123                    recipe.title,
124                    recipe.source.as_deref().unwrap_or(""),
125                    recipe.source_url.as_deref().unwrap_or(""),
126                    recipe.description.as_deref().unwrap_or(""),
127                    recipe.recipe_yield.as_deref().unwrap_or(""),
128                    recipe.prep_time.as_deref().unwrap_or(""),
129                    recipe.cook_time.as_deref().unwrap_or(""),
130                    recipe.total_time.as_deref().unwrap_or(""),
131                    recipe.servings.as_deref().unwrap_or(""),
132                    content_hash,
133                    recipe.raw_source.as_deref().unwrap_or(""),
134                ],
135            )?;
136            conn.last_insert_rowid()
137        };
138
139        // Insert child rows
140        for (i, ing) in recipe.ingredients.iter().enumerate() {
141            conn.execute(
142                "INSERT INTO recipe_ingredients (recipe_id, name, quantity, unit, note, optional, sort_order)
143                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
144                params![
145                    recipe_id,
146                    ing.name,
147                    ing.quantity.as_deref().unwrap_or(""),
148                    ing.unit.as_deref().unwrap_or(""),
149                    ing.note.as_deref().unwrap_or(""),
150                    ing.optional as i32,
151                    i as i32,
152                ],
153            )?;
154        }
155
156        for step in &recipe.steps {
157            conn.execute(
158                "INSERT INTO steps (recipe_id, section, body, sort_order)
159                 VALUES (?1, ?2, ?3, ?4)",
160                params![
161                    recipe_id,
162                    step.section.as_deref().unwrap_or(""),
163                    step.body,
164                    step.order as i32,
165                ],
166            )?;
167        }
168
169        for cw in &recipe.cookware {
170            conn.execute(
171                "INSERT INTO cookware (recipe_id, name, quantity)
172                 VALUES (?1, ?2, ?3)",
173                params![recipe_id, cw.name, cw.quantity.as_deref().unwrap_or(""),],
174            )?;
175        }
176
177        for tag in &recipe.tags {
178            conn.execute(
179                "INSERT OR IGNORE INTO tags (name, recipe_id) VALUES (?1, ?2)",
180                params![tag, recipe_id],
181            )?;
182        }
183
184        // FTS5 index
185        let ingredients_text: String = recipe
186            .ingredients
187            .iter()
188            .map(|i| i.name.as_str())
189            .collect::<Vec<_>>()
190            .join(" ");
191        let steps_text: String = recipe
192            .steps
193            .iter()
194            .map(|s| s.body.as_str())
195            .collect::<Vec<_>>()
196            .join(" ");
197        let tags_text: String = recipe.tags.join(" ");
198
199        conn.execute(
200            "INSERT INTO recipe_fts (rowid, title, ingredients_text, steps_text, tags_text)
201             VALUES (?1, ?2, ?3, ?4, ?5)",
202            params![
203                recipe_id,
204                recipe.title,
205                ingredients_text,
206                steps_text,
207                tags_text
208            ],
209        )?;
210
211        Ok(recipe_id)
212    }
213
214    /// Get a recipe record by its SQLite id.
215    pub fn get_recipe_by_id(&self, id: i64) -> Result<Option<RecipeRecord>, StoreError> {
216        let conn = self.db.conn();
217        let mut stmt = conn.prepare(
218            "SELECT id, file_path, slug, title, source, source_url, description,
219                    recipe_yield, prep_time, cook_time, total_time, servings,
220                    content_hash, raw_source, created_at, updated_at
221             FROM recipes WHERE id = ?1",
222        )?;
223
224        let record = stmt.query_row(params![id], row_to_record).ok();
225
226        Ok(record)
227    }
228
229    /// Get a recipe record by slug.
230    pub fn get_recipe_by_slug(&self, slug: &str) -> Result<Option<RecipeRecord>, StoreError> {
231        let conn = self.db.conn();
232        let mut stmt = conn.prepare(
233            "SELECT id, file_path, slug, title, source, source_url, description,
234                    recipe_yield, prep_time, cook_time, total_time, servings,
235                    content_hash, raw_source, created_at, updated_at
236             FROM recipes WHERE slug = ?1",
237        )?;
238
239        let record = stmt.query_row(params![slug], row_to_record).ok();
240
241        Ok(record)
242    }
243
244    /// Get a recipe record by file path.
245    pub fn get_recipe_by_path(&self, file_path: &str) -> Result<Option<RecipeRecord>, StoreError> {
246        let conn = self.db.conn();
247        let mut stmt = conn.prepare(
248            "SELECT id, file_path, slug, title, source, source_url, description,
249                    recipe_yield, prep_time, cook_time, total_time, servings,
250                    content_hash, raw_source, created_at, updated_at
251             FROM recipes WHERE file_path = ?1",
252        )?;
253
254        let record = stmt.query_row(params![file_path], row_to_record).ok();
255
256        Ok(record)
257    }
258
259    /// List all recipes (summary view).
260    pub fn list_recipes(&self) -> Result<Vec<RecipeSummary>, StoreError> {
261        let conn = self.db.conn();
262        let mut stmt = conn.prepare(
263            "SELECT r.id, r.slug, r.title, r.source
264             FROM recipes r ORDER BY r.title",
265        )?;
266
267        let recipes: Vec<RecipeSummary> = stmt
268            .query_map([], |row| {
269                let id: i64 = row.get(0)?;
270                Ok((
271                    id,
272                    RecipeSummary {
273                        id,
274                        slug: row.get(1)?,
275                        title: row.get(2)?,
276                        source: row.get(3)?,
277                        tags: Vec::new(),
278                    },
279                ))
280            })?
281            .filter_map(|r| r.ok())
282            .map(|(id, mut summary)| {
283                // Fetch tags for each recipe
284                if let Ok(mut tag_stmt) =
285                    conn.prepare_cached("SELECT name FROM tags WHERE recipe_id = ?1 ORDER BY name")
286                    && let Ok(tags) = tag_stmt
287                        .query_map(params![id], |row| row.get::<_, String>(0))
288                        .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
289                {
290                    summary.tags = tags;
291                }
292                summary
293            })
294            .collect();
295
296        Ok(recipes)
297    }
298
299    /// Full-text search across recipes.
300    pub fn search(&self, query: &str) -> Result<Vec<SearchResult>, StoreError> {
301        let conn = self.db.conn();
302        let mut stmt = conn.prepare(
303            "SELECT f.rowid, r.title, r.slug, rank
304             FROM recipe_fts f
305             JOIN recipes r ON r.id = f.rowid
306             WHERE recipe_fts MATCH ?1
307             ORDER BY rank",
308        )?;
309
310        let results = stmt
311            .query_map(params![query], |row| {
312                Ok(SearchResult {
313                    recipe_id: row.get(0)?,
314                    title: row.get(1)?,
315                    slug: row.get(2)?,
316                    rank: row.get(3)?,
317                })
318            })?
319            .collect::<Result<Vec<_>, _>>()?;
320
321        Ok(results)
322    }
323
324    /// Delete all derived data (recipe index + FTS). Preserves overlay tables.
325    pub fn delete_all_derived(&self) -> Result<(), StoreError> {
326        let conn = self.db.conn();
327        conn.execute_batch(
328            "DELETE FROM recipe_fts;
329             DELETE FROM tags;
330             DELETE FROM cookware;
331             DELETE FROM steps;
332             DELETE FROM recipe_ingredients;
333             DELETE FROM recipes;",
334        )?;
335        Ok(())
336    }
337
338    /// Count the total number of recipes.
339    pub fn count_recipes(&self) -> Result<i64, StoreError> {
340        let conn = self.db.conn();
341        let count: i64 = conn.query_row("SELECT count(*) FROM recipes", [], |row| row.get(0))?;
342        Ok(count)
343    }
344
345    /// Delete a single recipe by slug.
346    ///
347    /// Returns the file_path of the deleted recipe (for callers that
348    /// need to remove the file from disk), or `None` if the slug was
349    /// not found.
350    pub fn delete_recipe_by_slug(&self, slug: &str) -> Result<Option<String>, StoreError> {
351        let conn = self.db.conn();
352
353        // Look up the recipe first to get id and file_path
354        let row: Option<(i64, String)> = conn
355            .query_row(
356                "SELECT id, file_path FROM recipes WHERE slug = ?1",
357                params![slug],
358                |row| Ok((row.get(0)?, row.get(1)?)),
359            )
360            .ok();
361
362        let Some((id, file_path)) = row else {
363            return Ok(None);
364        };
365
366        // Delete FTS entry (not covered by CASCADE)
367        conn.execute("DELETE FROM recipe_fts WHERE rowid = ?1", params![id])?;
368        // Delete recipe (CASCADE handles child tables)
369        conn.execute("DELETE FROM recipes WHERE id = ?1", params![id])?;
370
371        Ok(Some(file_path))
372    }
373}
374
375fn row_to_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RecipeRecord> {
376    Ok(RecipeRecord {
377        id: row.get(0)?,
378        file_path: row.get(1)?,
379        slug: row.get(2)?,
380        title: row.get(3)?,
381        source: row.get(4)?,
382        source_url: row.get(5)?,
383        description: row.get(6)?,
384        recipe_yield: row.get(7)?,
385        prep_time: row.get(8)?,
386        cook_time: row.get(9)?,
387        total_time: row.get(10)?,
388        servings: row.get(11)?,
389        content_hash: row.get(12)?,
390        raw_source: row.get(13)?,
391        created_at: row.get(14)?,
392        updated_at: row.get(15)?,
393    })
394}