Skip to main content

fond_store/
repo.rs

1use rusqlite::params;
2use serde::Serialize;
3
4use crate::db::FondDb;
5use crate::error::StoreError;
6use fond_domain::{RecipeFilter, parse_time_minutes};
7
8/// Summary of a recipe for list views.
9#[derive(Debug, Serialize)]
10pub struct RecipeSummary {
11    pub id: i64,
12    pub slug: String,
13    pub title: String,
14    pub source: String,
15    pub tags: Vec<String>,
16    pub total_time: String,
17    pub total_time_minutes: Option<u32>,
18}
19
20/// A full recipe record from the database (indexed projection).
21#[derive(Debug, Serialize)]
22pub struct RecipeRecord {
23    pub id: i64,
24    pub file_path: String,
25    pub slug: String,
26    pub title: String,
27    pub source: String,
28    pub source_url: String,
29    pub description: String,
30    pub recipe_yield: String,
31    pub prep_time: String,
32    pub cook_time: String,
33    pub total_time: String,
34    pub servings: String,
35    pub content_hash: String,
36    pub raw_source: String,
37    pub created_at: String,
38    pub updated_at: String,
39}
40
41/// FTS5 search result.
42#[derive(Debug, Serialize)]
43pub struct SearchResult {
44    pub recipe_id: i64,
45    pub title: String,
46    pub slug: String,
47    pub source: String,
48    pub tags: Vec<String>,
49    pub rank: f64,
50}
51
52/// A tag with its recipe count.
53#[derive(Debug, Serialize)]
54pub struct TagCount {
55    pub name: String,
56    pub count: i64,
57}
58
59/// Repository for recipe persistence operations.
60pub struct RecipeRepository<'a> {
61    db: &'a FondDb,
62}
63
64impl<'a> RecipeRepository<'a> {
65    pub fn new(db: &'a FondDb) -> Self {
66        Self { db }
67    }
68
69    /// Insert or update a recipe by file_path (the stable reindex key).
70    ///
71    /// Returns the SQLite rowid of the upserted recipe.
72    pub fn upsert_recipe(
73        &self,
74        file_path: &str,
75        recipe: &fond_domain::Recipe,
76        content_hash: &str,
77    ) -> Result<i64, StoreError> {
78        let conn = self.db.conn();
79
80        let total_time_minutes = compute_total_time_minutes(recipe);
81
82        // Check if recipe already exists by file_path
83        let existing_id: Option<i64> = conn
84            .query_row(
85                "SELECT id FROM recipes WHERE file_path = ?1",
86                params![file_path],
87                |row| row.get(0),
88            )
89            .ok();
90
91        let recipe_id = if let Some(id) = existing_id {
92            // Update existing recipe
93            conn.execute(
94                "UPDATE recipes SET slug = ?1, title = ?2, source = ?3, source_url = ?4,
95                 description = ?5, recipe_yield = ?6, prep_time = ?7, cook_time = ?8,
96                 total_time = ?9, servings = ?10, content_hash = ?11, raw_source = ?12,
97                 total_time_minutes = ?13, updated_at = datetime('now')
98                 WHERE id = ?14",
99                params![
100                    recipe.slug,
101                    recipe.title,
102                    recipe.source.as_deref().unwrap_or(""),
103                    recipe.source_url.as_deref().unwrap_or(""),
104                    recipe.description.as_deref().unwrap_or(""),
105                    recipe.recipe_yield.as_deref().unwrap_or(""),
106                    recipe.prep_time.as_deref().unwrap_or(""),
107                    recipe.cook_time.as_deref().unwrap_or(""),
108                    recipe.total_time.as_deref().unwrap_or(""),
109                    recipe.servings.as_deref().unwrap_or(""),
110                    content_hash,
111                    recipe.raw_source.as_deref().unwrap_or(""),
112                    total_time_minutes,
113                    id,
114                ],
115            )?;
116
117            // Delete child rows for rebuild
118            conn.execute(
119                "DELETE FROM recipe_ingredients WHERE recipe_id = ?1",
120                params![id],
121            )?;
122            conn.execute("DELETE FROM steps WHERE recipe_id = ?1", params![id])?;
123            conn.execute("DELETE FROM cookware WHERE recipe_id = ?1", params![id])?;
124            conn.execute("DELETE FROM tags WHERE recipe_id = ?1", params![id])?;
125            conn.execute("DELETE FROM recipe_fts WHERE rowid = ?1", params![id])?;
126
127            id
128        } else {
129            // Insert new recipe
130            conn.execute(
131                "INSERT INTO recipes (file_path, slug, title, source, source_url,
132                 description, recipe_yield, prep_time, cook_time, total_time,
133                 servings, content_hash, raw_source, total_time_minutes)
134                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
135                params![
136                    file_path,
137                    recipe.slug,
138                    recipe.title,
139                    recipe.source.as_deref().unwrap_or(""),
140                    recipe.source_url.as_deref().unwrap_or(""),
141                    recipe.description.as_deref().unwrap_or(""),
142                    recipe.recipe_yield.as_deref().unwrap_or(""),
143                    recipe.prep_time.as_deref().unwrap_or(""),
144                    recipe.cook_time.as_deref().unwrap_or(""),
145                    recipe.total_time.as_deref().unwrap_or(""),
146                    recipe.servings.as_deref().unwrap_or(""),
147                    content_hash,
148                    recipe.raw_source.as_deref().unwrap_or(""),
149                    total_time_minutes,
150                ],
151            )?;
152            conn.last_insert_rowid()
153        };
154
155        // Insert child rows
156        for (i, ing) in recipe.ingredients.iter().enumerate() {
157            conn.execute(
158                "INSERT INTO recipe_ingredients (recipe_id, name, quantity, unit, note, optional, sort_order)
159                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
160                params![
161                    recipe_id,
162                    ing.name,
163                    ing.quantity.as_deref().unwrap_or(""),
164                    ing.unit.as_deref().unwrap_or(""),
165                    ing.note.as_deref().unwrap_or(""),
166                    ing.optional as i32,
167                    i as i32,
168                ],
169            )?;
170        }
171
172        for step in &recipe.steps {
173            conn.execute(
174                "INSERT INTO steps (recipe_id, section, body, sort_order)
175                 VALUES (?1, ?2, ?3, ?4)",
176                params![
177                    recipe_id,
178                    step.section.as_deref().unwrap_or(""),
179                    step.body,
180                    step.order as i32,
181                ],
182            )?;
183        }
184
185        for cw in &recipe.cookware {
186            conn.execute(
187                "INSERT INTO cookware (recipe_id, name, quantity)
188                 VALUES (?1, ?2, ?3)",
189                params![recipe_id, cw.name, cw.quantity.as_deref().unwrap_or(""),],
190            )?;
191        }
192
193        for tag in &recipe.tags {
194            conn.execute(
195                "INSERT OR IGNORE INTO tags (name, recipe_id) VALUES (?1, ?2)",
196                params![tag, recipe_id],
197            )?;
198        }
199
200        // FTS5 index
201        let ingredients_text: String = recipe
202            .ingredients
203            .iter()
204            .map(|i| i.name.as_str())
205            .collect::<Vec<_>>()
206            .join(" ");
207        let steps_text: String = recipe
208            .steps
209            .iter()
210            .map(|s| s.body.as_str())
211            .collect::<Vec<_>>()
212            .join(" ");
213        let tags_text: String = recipe.tags.join(" ");
214
215        conn.execute(
216            "INSERT INTO recipe_fts (rowid, title, ingredients_text, steps_text, tags_text)
217             VALUES (?1, ?2, ?3, ?4, ?5)",
218            params![
219                recipe_id,
220                recipe.title,
221                ingredients_text,
222                steps_text,
223                tags_text
224            ],
225        )?;
226
227        Ok(recipe_id)
228    }
229
230    /// Get a recipe record by its SQLite id.
231    pub fn get_recipe_by_id(&self, id: i64) -> Result<Option<RecipeRecord>, StoreError> {
232        let conn = self.db.conn();
233        let mut stmt = conn.prepare(
234            "SELECT id, file_path, slug, title, source, source_url, description,
235                    recipe_yield, prep_time, cook_time, total_time, servings,
236                    content_hash, raw_source, created_at, updated_at
237             FROM recipes WHERE id = ?1",
238        )?;
239
240        let record = stmt.query_row(params![id], row_to_record).ok();
241
242        Ok(record)
243    }
244
245    /// Get a recipe record by slug.
246    pub fn get_recipe_by_slug(&self, slug: &str) -> Result<Option<RecipeRecord>, StoreError> {
247        let conn = self.db.conn();
248        let mut stmt = conn.prepare(
249            "SELECT id, file_path, slug, title, source, source_url, description,
250                    recipe_yield, prep_time, cook_time, total_time, servings,
251                    content_hash, raw_source, created_at, updated_at
252             FROM recipes WHERE slug = ?1",
253        )?;
254
255        let record = stmt.query_row(params![slug], row_to_record).ok();
256
257        Ok(record)
258    }
259
260    /// Get a recipe record by file path.
261    pub fn get_recipe_by_path(&self, file_path: &str) -> Result<Option<RecipeRecord>, StoreError> {
262        let conn = self.db.conn();
263        let mut stmt = conn.prepare(
264            "SELECT id, file_path, slug, title, source, source_url, description,
265                    recipe_yield, prep_time, cook_time, total_time, servings,
266                    content_hash, raw_source, created_at, updated_at
267             FROM recipes WHERE file_path = ?1",
268        )?;
269
270        let record = stmt.query_row(params![file_path], row_to_record).ok();
271
272        Ok(record)
273    }
274
275    /// List all recipes (summary view).
276    pub fn list_recipes(&self) -> Result<Vec<RecipeSummary>, StoreError> {
277        self.list_recipes_filtered(&RecipeFilter::default())
278    }
279
280    /// List recipes filtered by tag, max time, and/or source.
281    pub fn list_recipes_filtered(
282        &self,
283        filter: &RecipeFilter,
284    ) -> Result<Vec<RecipeSummary>, StoreError> {
285        let conn = self.db.conn();
286
287        let (where_clause, bind_values) = build_filter_where(filter, 1);
288
289        let sql = format!(
290            "SELECT r.id, r.slug, r.title, r.source, r.total_time, r.total_time_minutes
291             FROM recipes r
292             {where_clause}
293             ORDER BY r.title"
294        );
295
296        let mut stmt = conn.prepare(&sql)?;
297
298        let recipes: Vec<RecipeSummary> = stmt
299            .query_map(rusqlite::params_from_iter(bind_values.iter()), |row| {
300                let id: i64 = row.get(0)?;
301                let total_time_minutes: Option<u32> = row.get(5)?;
302                Ok((
303                    id,
304                    RecipeSummary {
305                        id,
306                        slug: row.get(1)?,
307                        title: row.get(2)?,
308                        source: row.get(3)?,
309                        tags: Vec::new(),
310                        total_time: row.get(4)?,
311                        total_time_minutes,
312                    },
313                ))
314            })?
315            .filter_map(|r| r.ok())
316            .map(|(id, mut summary)| {
317                if let Ok(mut tag_stmt) =
318                    conn.prepare_cached("SELECT name FROM tags WHERE recipe_id = ?1 ORDER BY name")
319                    && let Ok(tags) = tag_stmt
320                        .query_map(params![id], |row| row.get::<_, String>(0))
321                        .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
322                {
323                    summary.tags = tags;
324                }
325                summary
326            })
327            .collect();
328
329        Ok(recipes)
330    }
331
332    /// Full-text search across recipes (unfiltered).
333    pub fn search(&self, query: &str) -> Result<Vec<SearchResult>, StoreError> {
334        self.search_filtered(query, &RecipeFilter::default())
335    }
336
337    /// Full-text search with filters.
338    pub fn search_filtered(
339        &self,
340        query: &str,
341        filter: &RecipeFilter,
342    ) -> Result<Vec<SearchResult>, StoreError> {
343        let conn = self.db.conn();
344
345        let (filter_where, filter_values) = build_filter_where(filter, 2);
346
347        // Build the WHERE clause: FTS MATCH + any filters
348        // The filter_where already starts with "WHERE ..." if non-empty,
349        // so we need to integrate it with the FTS MATCH.
350        let sql = if filter_where.is_empty() {
351            "SELECT f.rowid, r.title, r.slug, r.source, rank
352             FROM recipe_fts f
353             JOIN recipes r ON r.id = f.rowid
354             WHERE recipe_fts MATCH ?1
355             ORDER BY rank"
356                .to_string()
357        } else {
358            // Replace "WHERE" with "AND" in filter clause since we already have WHERE
359            let filter_and = filter_where.replacen("WHERE", "AND", 1);
360            format!(
361                "SELECT f.rowid, r.title, r.slug, r.source, rank
362                 FROM recipe_fts f
363                 JOIN recipes r ON r.id = f.rowid
364                 WHERE recipe_fts MATCH ?1
365                 {filter_and}
366                 ORDER BY rank"
367            )
368        };
369
370        // Build parameter list: FTS query first, then filter params
371        let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
372        all_params.push(Box::new(query.to_string()));
373        for v in &filter_values {
374            all_params.push(Box::new(v.clone()));
375        }
376
377        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
378            all_params.iter().map(|b| b.as_ref()).collect();
379
380        let mut stmt = conn.prepare(&sql)?;
381        let results: Vec<SearchResult> = stmt
382            .query_map(param_refs.as_slice(), |row| {
383                let recipe_id: i64 = row.get(0)?;
384                Ok((
385                    recipe_id,
386                    SearchResult {
387                        recipe_id,
388                        title: row.get(1)?,
389                        slug: row.get(2)?,
390                        source: row.get(3)?,
391                        tags: Vec::new(),
392                        rank: row.get(4)?,
393                    },
394                ))
395            })?
396            .filter_map(|r| r.ok())
397            .map(|(id, mut result)| {
398                if let Ok(mut tag_stmt) =
399                    conn.prepare_cached("SELECT name FROM tags WHERE recipe_id = ?1 ORDER BY name")
400                    && let Ok(tags) = tag_stmt
401                        .query_map(params![id], |row| row.get::<_, String>(0))
402                        .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
403                {
404                    result.tags = tags;
405                }
406                result
407            })
408            .collect();
409
410        Ok(results)
411    }
412
413    /// List all distinct tags with their recipe counts.
414    pub fn list_tags(&self) -> Result<Vec<TagCount>, StoreError> {
415        let conn = self.db.conn();
416        let mut stmt = conn.prepare(
417            "SELECT name, COUNT(*) as cnt FROM tags GROUP BY name ORDER BY cnt DESC, name",
418        )?;
419
420        let tags = stmt
421            .query_map([], |row| {
422                Ok(TagCount {
423                    name: row.get(0)?,
424                    count: row.get(1)?,
425                })
426            })?
427            .collect::<Result<Vec<_>, _>>()?;
428
429        Ok(tags)
430    }
431
432    /// Get the tags for a specific recipe by slug.
433    pub fn get_tags_for_slug(&self, slug: &str) -> Result<Option<(i64, Vec<String>)>, StoreError> {
434        let conn = self.db.conn();
435
436        let row: Option<i64> = conn
437            .query_row(
438                "SELECT id FROM recipes WHERE slug = ?1",
439                params![slug],
440                |row| row.get(0),
441            )
442            .ok();
443
444        let Some(recipe_id) = row else {
445            return Ok(None);
446        };
447
448        let mut stmt = conn.prepare("SELECT name FROM tags WHERE recipe_id = ?1 ORDER BY name")?;
449        let tags: Vec<String> = stmt
450            .query_map(params![recipe_id], |row| row.get(0))?
451            .collect::<Result<Vec<_>, _>>()?;
452
453        Ok(Some((recipe_id, tags)))
454    }
455
456    /// Delete all derived data (recipe index + FTS). Preserves overlay tables.
457    pub fn delete_all_derived(&self) -> Result<(), StoreError> {
458        let conn = self.db.conn();
459        conn.execute_batch(
460            "DELETE FROM recipe_fts;
461             DELETE FROM tags;
462             DELETE FROM cookware;
463             DELETE FROM steps;
464             DELETE FROM recipe_ingredients;
465             DELETE FROM recipes;",
466        )?;
467        Ok(())
468    }
469
470    /// Count the total number of recipes.
471    pub fn count_recipes(&self) -> Result<i64, StoreError> {
472        let conn = self.db.conn();
473        let count: i64 = conn.query_row("SELECT count(*) FROM recipes", [], |row| row.get(0))?;
474        Ok(count)
475    }
476
477    /// Delete a single recipe by slug.
478    ///
479    /// Returns the file_path of the deleted recipe (for callers that
480    /// need to remove the file from disk), or `None` if the slug was
481    /// not found.
482    pub fn delete_recipe_by_slug(&self, slug: &str) -> Result<Option<String>, StoreError> {
483        let conn = self.db.conn();
484
485        // Look up the recipe first to get id and file_path
486        let row: Option<(i64, String)> = conn
487            .query_row(
488                "SELECT id, file_path FROM recipes WHERE slug = ?1",
489                params![slug],
490                |row| Ok((row.get(0)?, row.get(1)?)),
491            )
492            .ok();
493
494        let Some((id, file_path)) = row else {
495            return Ok(None);
496        };
497
498        // Delete FTS entry (not covered by CASCADE)
499        conn.execute("DELETE FROM recipe_fts WHERE rowid = ?1", params![id])?;
500        // Delete recipe (CASCADE handles child tables)
501        conn.execute("DELETE FROM recipes WHERE id = ?1", params![id])?;
502
503        Ok(Some(file_path))
504    }
505}
506
507/// Compute total_time_minutes from the recipe's time fields.
508///
509/// Tries `total_time` first, then falls back to `prep_time + cook_time`.
510fn compute_total_time_minutes(recipe: &fond_domain::Recipe) -> Option<u32> {
511    // Try total_time first
512    if let Some(ref total) = recipe.total_time
513        && let Some(mins) = parse_time_minutes(total)
514    {
515        return Some(mins);
516    }
517
518    // Fall back to prep + cook sum
519    let prep = recipe
520        .prep_time
521        .as_deref()
522        .and_then(parse_time_minutes)
523        .unwrap_or(0);
524    let cook = recipe
525        .cook_time
526        .as_deref()
527        .and_then(parse_time_minutes)
528        .unwrap_or(0);
529
530    if prep > 0 || cook > 0 {
531        Some(prep + cook)
532    } else {
533        None
534    }
535}
536
537/// Build a SQL WHERE clause and parameter values from a `RecipeFilter`.
538///
539/// `param_start` is the first `?N` placeholder index to use.
540/// Returns `("WHERE ...", vec_of_string_params)` or `("", vec![])` if
541/// no filters are active.
542fn build_filter_where(filter: &RecipeFilter, param_start: usize) -> (String, Vec<String>) {
543    if filter.is_empty() {
544        return (String::new(), Vec::new());
545    }
546
547    let mut conditions: Vec<String> = Vec::new();
548    let mut values: Vec<String> = Vec::new();
549    let mut param_idx = param_start;
550
551    // Tag filter (AND semantics: recipe must have ALL listed tags)
552    if !filter.tags.is_empty() {
553        let deduped: Vec<&String> = {
554            let mut seen = std::collections::HashSet::new();
555            filter
556                .tags
557                .iter()
558                .filter(|t| seen.insert(t.as_str()))
559                .collect()
560        };
561        let placeholders: Vec<String> = deduped
562            .iter()
563            .map(|_| {
564                let p = format!("?{param_idx}");
565                param_idx += 1;
566                p
567            })
568            .collect();
569        let count = deduped.len();
570        conditions.push(format!(
571            "r.id IN (SELECT recipe_id FROM tags WHERE name IN ({}) GROUP BY recipe_id HAVING COUNT(DISTINCT name) = {})",
572            placeholders.join(", "),
573            count
574        ));
575        for tag in deduped {
576            values.push(tag.clone());
577        }
578    }
579
580    // Max time filter
581    if let Some(max) = filter.max_time_minutes {
582        conditions.push(format!(
583            "r.total_time_minutes IS NOT NULL AND r.total_time_minutes <= ?{param_idx}"
584        ));
585        values.push(max.to_string());
586        param_idx += 1;
587    }
588
589    // Source filter (case-insensitive substring match)
590    if let Some(ref source) = filter.source {
591        // Escape SQL LIKE wildcards in user input
592        let escaped = source.replace('%', "\\%").replace('_', "\\_");
593        conditions.push(format!("r.source LIKE ?{param_idx} ESCAPE '\\'"));
594        values.push(format!("%{escaped}%"));
595        let _ = param_idx; // suppress unused warning
596    }
597
598    let where_clause = format!("WHERE {}", conditions.join(" AND "));
599    (where_clause, values)
600}
601
602fn row_to_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RecipeRecord> {
603    Ok(RecipeRecord {
604        id: row.get(0)?,
605        file_path: row.get(1)?,
606        slug: row.get(2)?,
607        title: row.get(3)?,
608        source: row.get(4)?,
609        source_url: row.get(5)?,
610        description: row.get(6)?,
611        recipe_yield: row.get(7)?,
612        prep_time: row.get(8)?,
613        cook_time: row.get(9)?,
614        total_time: row.get(10)?,
615        servings: row.get(11)?,
616        content_hash: row.get(12)?,
617        raw_source: row.get(13)?,
618        created_at: row.get(14)?,
619        updated_at: row.get(15)?,
620    })
621}