Skip to main content

smriti/db/
burst_repo.rs

1//! Burst groups database operations
2
3use std::collections::HashMap;
4
5use rusqlite::{params, params_from_iter, types::ToSql, Connection, Result as SqliteResult};
6
7use super::MAX_ROWS_PER_INSERT;
8
9/// Burst group record
10#[derive(Debug, Clone)]
11pub struct BurstGroupRecord {
12    pub id: i64,
13    pub start_time: String,
14    pub end_time: String,
15    pub photo_count: i64,
16    /// Cover thumbnails (suggested-best first, then by date) — the
17    /// listing card renders these as a horizontal filmstrip the user
18    /// can click directly to open any photo in the viewer.
19    pub cover_thumbnail_paths: Vec<String>,
20    /// Photo IDs aligned 1:1 with `cover_thumbnail_paths`. Lets the
21    /// frontend wire each filmstrip thumb to a specific photo route
22    /// without an extra IPC roundtrip.
23    pub cover_photo_ids: Vec<i64>,
24    /// Every member's photo_id, in display order. Used to scope
25    /// PhotoDetail's prev/next arrows to this burst when the user
26    /// clicks a thumb in the listing — they navigate within the burst,
27    /// not the whole library.
28    pub member_photo_ids: Vec<i64>,
29}
30
31/// Burst group member record
32#[derive(Debug, Clone)]
33pub struct BurstGroupMemberRecord {
34    pub photo_id: i64,
35    pub sharpness_score: Option<f32>,
36    pub blur_score: Option<f32>,
37    pub is_suggested_best: bool,
38}
39
40/// Burst repository
41pub struct BurstRepo<'a> {
42    conn: &'a Connection,
43}
44
45impl<'a> BurstRepo<'a> {
46    pub fn new(conn: &'a Connection) -> Self {
47        Self { conn }
48    }
49
50    /// Sync burst groups from detection results, preserving user decisions.
51    ///
52    /// Uses merge-based approach: existing groups whose photo sets match are kept
53    /// intact (preserving `is_suggested_best`), new groups are created, and groups
54    /// that no longer match any detection result are removed.
55    pub fn sync_burst_groups(
56        &self,
57        groups: &[(String, String, Vec<i64>)], // (start, end, photo_ids)
58    ) -> SqliteResult<()> {
59        use std::collections::{BTreeSet, HashMap, HashSet};
60
61        let tx = self.conn.unchecked_transaction()?;
62
63        // Load existing groups with their photo_id sets
64        let mut existing_sets: HashMap<BTreeSet<i64>, (i64, bool)> = HashMap::new();
65        {
66            let mut grp_stmt = self.conn.prepare("SELECT id, resolved FROM burst_groups")?;
67            let group_ids: Vec<(i64, bool)> = grp_stmt
68                .query_map([], |row| {
69                    Ok((row.get::<_, i64>(0)?, row.get::<_, bool>(1)?))
70                })?
71                .collect::<SqliteResult<Vec<_>>>()?;
72
73            for (gid, resolved) in group_ids {
74                let mut mem_stmt = self
75                    .conn
76                    .prepare("SELECT photo_id FROM burst_group_members WHERE group_id = ?1")?;
77                let members: BTreeSet<i64> = mem_stmt
78                    .query_map(params![gid], |row| row.get::<_, i64>(0))?
79                    .collect::<SqliteResult<BTreeSet<_>>>()?;
80                existing_sets.insert(members, (gid, resolved));
81            }
82        }
83
84        let mut seen_sets: HashSet<BTreeSet<i64>> = HashSet::new();
85
86        for (start_time, end_time, photo_ids) in groups {
87            let set: BTreeSet<i64> = photo_ids.iter().copied().collect();
88            seen_sets.insert(set.clone());
89
90            if existing_sets.contains_key(&set) {
91                continue; // Group exists — preserve user decisions
92            }
93            // Call the free helper directly — nested unchecked_transaction
94            // would conflict with the outer `tx` we opened above.
95            create_group_in_conn(&tx, start_time, end_time, photo_ids)?;
96        }
97
98        // Remove groups that no longer match any detection
99        for (set, (group_id, _)) in &existing_sets {
100            if !seen_sets.contains(set) {
101                delete_group_in_conn(&tx, *group_id)?;
102            }
103        }
104
105        tx.commit()
106    }
107
108    /// Insert any supplied burst groups that do not already exist by
109    /// member set. Does not delete old groups. Used for live streaming
110    /// during detection; a full completed run should still call
111    /// `sync_burst_groups` to prune stale results.
112    pub fn upsert_burst_groups(
113        &self,
114        groups: &[(String, String, Vec<i64>)],
115    ) -> SqliteResult<usize> {
116        Ok(self.upsert_burst_groups_collecting_inserted(groups)?.len())
117    }
118
119    pub fn upsert_burst_groups_collecting_inserted(
120        &self,
121        groups: &[(String, String, Vec<i64>)],
122    ) -> SqliteResult<Vec<Vec<i64>>> {
123        use std::collections::{BTreeSet, HashMap};
124
125        let mut existing_sets: HashMap<BTreeSet<i64>, (i64, bool)> = HashMap::new();
126        {
127            let mut grp_stmt = self.conn.prepare("SELECT id, resolved FROM burst_groups")?;
128            let group_ids: Vec<(i64, bool)> = grp_stmt
129                .query_map([], |row| {
130                    Ok((row.get::<_, i64>(0)?, row.get::<_, bool>(1)?))
131                })?
132                .collect::<SqliteResult<Vec<_>>>()?;
133
134            for (gid, resolved) in group_ids {
135                let mut mem_stmt = self
136                    .conn
137                    .prepare("SELECT photo_id FROM burst_group_members WHERE group_id = ?1")?;
138                let members: BTreeSet<i64> = mem_stmt
139                    .query_map(params![gid], |row| row.get::<_, i64>(0))?
140                    .collect::<SqliteResult<BTreeSet<_>>>()?;
141                existing_sets.insert(members, (gid, resolved));
142            }
143        }
144
145        let tx = self.conn.unchecked_transaction()?;
146        let mut inserted_sets = Vec::new();
147        for (start_time, end_time, photo_ids) in groups {
148            let set: BTreeSet<i64> = photo_ids.iter().copied().collect();
149            if existing_sets.contains_key(&set) {
150                continue;
151            }
152            let group_id = create_group_in_conn(&tx, start_time, end_time, photo_ids)?;
153            inserted_sets.push(set.iter().copied().collect());
154            existing_sets.insert(set, (group_id, false));
155        }
156        tx.commit()?;
157        Ok(inserted_sets)
158    }
159
160    pub fn delete_unresolved_groups_by_member_sets(
161        &self,
162        groups: &[Vec<i64>],
163    ) -> SqliteResult<usize> {
164        use std::collections::{BTreeSet, HashMap, HashSet};
165
166        if groups.is_empty() {
167            return Ok(0);
168        }
169        let targets: HashSet<BTreeSet<i64>> = groups
170            .iter()
171            .map(|ids| ids.iter().copied().collect())
172            .collect();
173        let mut existing_sets: HashMap<BTreeSet<i64>, i64> = HashMap::new();
174        {
175            let mut grp_stmt = self
176                .conn
177                .prepare("SELECT id FROM burst_groups WHERE resolved = FALSE")?;
178            let group_ids: Vec<i64> = grp_stmt
179                .query_map([], |row| row.get::<_, i64>(0))?
180                .collect::<SqliteResult<Vec<_>>>()?;
181
182            for gid in group_ids {
183                let mut mem_stmt = self
184                    .conn
185                    .prepare("SELECT photo_id FROM burst_group_members WHERE group_id = ?1")?;
186                let members: BTreeSet<i64> = mem_stmt
187                    .query_map(params![gid], |row| row.get::<_, i64>(0))?
188                    .collect::<SqliteResult<BTreeSet<_>>>()?;
189                existing_sets.insert(members, gid);
190            }
191        }
192
193        let tx = self.conn.unchecked_transaction()?;
194        let mut deleted = 0usize;
195        for set in targets {
196            if let Some(group_id) = existing_sets.get(&set) {
197                delete_group_in_conn(&tx, *group_id)?;
198                deleted += 1;
199            }
200        }
201        tx.commit()?;
202        Ok(deleted)
203    }
204
205    /// If the group has no `is_suggested_best = TRUE` member, pick one.
206    /// Targets the earliest member (by photos.date_taken, falling back to
207    /// `bgm.rowid`) so the choice is stable across calls. Idempotent —
208    /// callers can run it on every read.
209    pub fn ensure_suggested_best(&self, group_id: i64) -> SqliteResult<()> {
210        let tx = self.conn.unchecked_transaction()?;
211        let has_best: i64 = tx.query_row(
212            "SELECT COUNT(*) FROM burst_group_members bgm \
213             JOIN photos p ON p.id = bgm.photo_id \
214             WHERE bgm.group_id = ?1 \
215               AND bgm.is_suggested_best = TRUE \
216               AND p.is_trashed = FALSE",
217            params![group_id],
218            |r| r.get(0),
219        )?;
220        if has_best > 0 {
221            tx.commit()?;
222            return Ok(());
223        }
224        tx.execute(
225            "UPDATE burst_group_members SET is_suggested_best = FALSE WHERE group_id = ?1",
226            params![group_id],
227        )?;
228        tx.execute(
229            r#"
230            UPDATE burst_group_members
231               SET is_suggested_best = TRUE
232             WHERE group_id = ?1
233               AND photo_id = (
234                   SELECT bgm.photo_id
235                     FROM burst_group_members bgm
236                     JOIN photos p ON p.id = bgm.photo_id
237                    WHERE bgm.group_id = ?1
238                      AND p.is_trashed = FALSE
239                    ORDER BY p.date_taken ASC, bgm.rowid ASC
240                    LIMIT 1
241               )
242            "#,
243            params![group_id],
244        )?;
245        tx.commit()
246    }
247
248    /// Set the suggested best photo for a group (atomic)
249    pub fn set_suggested_best(&self, group_id: i64, photo_id: i64) -> SqliteResult<()> {
250        let tx = self.conn.unchecked_transaction()?;
251
252        let exists: i64 = tx.query_row(
253            "SELECT COUNT(*)
254               FROM burst_group_members bgm
255               JOIN burst_groups bg ON bg.id = bgm.group_id
256              WHERE bgm.group_id = ?1
257                AND bgm.photo_id = ?2
258                AND bg.resolved = FALSE",
259            params![group_id, photo_id],
260            |row| row.get(0),
261        )?;
262        if exists == 0 {
263            return Err(rusqlite::Error::QueryReturnedNoRows);
264        }
265
266        tx.execute(
267            "UPDATE burst_group_members SET is_suggested_best = FALSE WHERE group_id = ?1",
268            params![group_id],
269        )?;
270
271        tx.execute(
272            "UPDATE burst_group_members SET is_suggested_best = TRUE WHERE group_id = ?1 AND photo_id = ?2",
273            params![group_id, photo_id],
274        )?;
275
276        tx.commit()
277    }
278
279    /// Get all burst groups + the first 4 thumbnails per group for the
280    /// listing card's 2×2 stack. Two queries (one for groups, one for
281    /// thumbs) is fine here — the listing is small and we keep the
282    /// SQL plain rather than wrestling a window function.
283    pub fn get_all_groups(&self) -> SqliteResult<Vec<BurstGroupRecord>> {
284        self.get_groups(i64::MAX, 0)
285    }
286
287    pub fn get_groups(&self, limit: i64, offset: i64) -> SqliteResult<Vec<BurstGroupRecord>> {
288        let mut stmt = self.conn.prepare(
289            r#"
290            SELECT bg.id, bg.start_time, bg.end_time, COUNT(p.id) AS live_photo_count
291              FROM burst_groups bg
292              JOIN burst_group_members bgm ON bgm.group_id = bg.id
293              JOIN photos p ON p.id = bgm.photo_id
294             WHERE bg.resolved = FALSE
295               AND p.is_trashed = FALSE
296          GROUP BY bg.id
297            HAVING COUNT(p.id) > 1
298          ORDER BY bg.start_time DESC
299            LIMIT ?1 OFFSET ?2
300            "#,
301        )?;
302
303        let rows = stmt.query_map(params![limit.max(0), offset.max(0)], |row| {
304            Ok(BurstGroupRecord {
305                id: row.get(0)?,
306                start_time: row.get(1)?,
307                end_time: row.get(2)?,
308                photo_count: row.get(3)?,
309                cover_thumbnail_paths: Vec::new(),
310                cover_photo_ids: Vec::new(),
311                member_photo_ids: Vec::new(),
312            })
313        })?;
314
315        let mut groups = Vec::new();
316        for row in rows {
317            groups.push(row?);
318        }
319
320        // Cover thumbnails (up to 6 for the filmstrip) — suggested-best
321        // first, then by date. Photo ids paired 1:1 so the frontend
322        // can route directly to PhotoDetail on click.
323        let placeholders = vec!["?"; groups.len()].join(",");
324        // Every member's photo_id in the same order — used as the
325        // browseContext scope when opening a photo from the listing
326        // card. Caps at the natural group size; bursts rarely exceed
327        // tens of photos.
328        if !groups.is_empty() {
329            let sql = format!(
330                "SELECT m.group_id, m.photo_id, p.thumbnail_path
331                   FROM burst_group_members m
332                   JOIN photos p ON p.id = m.photo_id
333                  WHERE m.group_id IN ({placeholders}) AND p.is_trashed = FALSE
334               ORDER BY m.group_id, m.is_suggested_best DESC, p.date_taken ASC, m.photo_id ASC"
335            );
336            let group_ids: Vec<i64> = groups.iter().map(|g| g.id).collect();
337            let mut members: HashMap<i64, Vec<i64>> = HashMap::with_capacity(groups.len());
338            let mut covers: HashMap<i64, Vec<(i64, String)>> = HashMap::with_capacity(groups.len());
339            let mut detail_stmt = self.conn.prepare(&sql)?;
340            let rows = detail_stmt.query_map(params_from_iter(group_ids), |row| {
341                Ok((
342                    row.get::<_, i64>(0)?,
343                    row.get::<_, i64>(1)?,
344                    row.get::<_, Option<String>>(2)?,
345                ))
346            })?;
347            for row in rows {
348                let (group_id, photo_id, thumbnail) = row?;
349                members.entry(group_id).or_default().push(photo_id);
350                if let Some(path) = thumbnail {
351                    let group_covers = covers.entry(group_id).or_default();
352                    if group_covers.len() < 6 {
353                        group_covers.push((photo_id, path));
354                    }
355                }
356            }
357            for group in &mut groups {
358                group.member_photo_ids = members.remove(&group.id).unwrap_or_default();
359                let group_covers = covers.remove(&group.id).unwrap_or_default();
360                group.cover_photo_ids = group_covers.iter().map(|(id, _)| *id).collect();
361                group.cover_thumbnail_paths = group_covers.into_iter().map(|(_, p)| p).collect();
362            }
363        }
364
365        Ok(groups)
366    }
367
368    /// Get members of a burst group
369    pub fn get_group_members(&self, group_id: i64) -> SqliteResult<Vec<BurstGroupMemberRecord>> {
370        let mut stmt = self.conn.prepare(
371            r#"
372            SELECT 
373                bgm.photo_id,
374                bgm.sharpness_score,
375                bgm.blur_score,
376                bgm.face_count,
377                bgm.is_suggested_best,
378                p.file_path
379            FROM burst_group_members bgm
380            JOIN burst_groups bg ON bg.id = bgm.group_id
381            JOIN photos p ON bgm.photo_id = p.id
382            WHERE bgm.group_id = ?1
383              AND bg.resolved = FALSE
384              AND p.is_trashed = FALSE
385            ORDER BY bgm.is_suggested_best DESC, p.date_taken ASC
386            "#,
387        )?;
388
389        let rows = stmt.query_map(params![group_id], |row| {
390            Ok(BurstGroupMemberRecord {
391                photo_id: row.get(0)?,
392                sharpness_score: row.get(1)?,
393                blur_score: row.get(2)?,
394                is_suggested_best: row.get(4)?,
395            })
396        })?;
397
398        let mut members = Vec::new();
399        for row in rows {
400            members.push(row?);
401        }
402
403        Ok(members)
404    }
405
406    /// Get non-best photos to potentially trash
407    pub fn get_photos_to_trash(&self, group_id: i64) -> SqliteResult<Vec<i64>> {
408        let mut stmt = self.conn.prepare(
409            r#"
410            SELECT bgm.photo_id
411            FROM burst_group_members bgm
412            JOIN burst_groups bg ON bg.id = bgm.group_id
413            JOIN photos p ON p.id = bgm.photo_id
414            WHERE bgm.group_id = ?1
415              AND bgm.is_suggested_best = FALSE
416              AND bg.resolved = FALSE
417              AND p.is_trashed = FALSE
418            "#,
419        )?;
420
421        let rows = stmt.query_map(params![group_id], |row| row.get(0))?;
422
423        let mut photo_ids = Vec::new();
424        for row in rows {
425            photo_ids.push(row?);
426        }
427
428        Ok(photo_ids)
429    }
430
431    /// Delete a burst group
432    pub fn delete_group(&self, group_id: i64) -> SqliteResult<()> {
433        delete_group_in_conn(self.conn, group_id)
434    }
435
436    /// Mark a burst group as handled without deleting its member set.
437    pub fn dismiss_group(&self, group_id: i64) -> SqliteResult<()> {
438        let updated = self.conn.execute(
439            "UPDATE burst_groups SET resolved = TRUE WHERE id = ?1",
440            params![group_id],
441        )?;
442        if updated == 0 {
443            return Err(rusqlite::Error::QueryReturnedNoRows);
444        }
445        Ok(())
446    }
447}
448
449fn delete_group_in_conn(conn: &Connection, group_id: i64) -> SqliteResult<()> {
450    conn.execute(
451        "DELETE FROM burst_group_members WHERE group_id = ?1",
452        params![group_id],
453    )?;
454    conn.execute("DELETE FROM burst_groups WHERE id = ?1", params![group_id])?;
455    Ok(())
456}
457
458/// Insert a burst group header + its members using the given connection.
459/// Caller owns the transaction. Used by both the public `create_group`
460/// (which wraps in its own tx) and `sync_burst_groups` (already in a tx).
461fn create_group_in_conn(
462    conn: &Connection,
463    start_time: &str,
464    end_time: &str,
465    photo_ids: &[i64],
466) -> SqliteResult<i64> {
467    conn.execute(
468        r#"
469        INSERT INTO burst_groups (start_time, end_time, photo_count)
470        VALUES (?1, ?2, ?3)
471        "#,
472        params![start_time, end_time, photo_ids.len() as i64],
473    )?;
474    let group_id = conn.last_insert_rowid();
475    insert_group_members(conn, group_id, photo_ids)?;
476    // Default-suggest the first member as best so the UI shows a
477    // bordered "pick" without forcing the user to choose. The detail
478    // view's "Pick this" buttons let them change it.
479    if let Some(first) = photo_ids.first() {
480        conn.execute(
481            "UPDATE burst_group_members SET is_suggested_best = TRUE WHERE group_id = ?1 AND photo_id = ?2",
482            params![group_id, first],
483        )?;
484    }
485    Ok(group_id)
486}
487
488/// Batch-insert members for a burst group via multi-row VALUES inside
489/// whatever transaction the caller is holding. ~3× faster than one
490/// INSERT per row for large groups and keeps the whole write atomic.
491fn insert_group_members(conn: &Connection, group_id: i64, photo_ids: &[i64]) -> SqliteResult<()> {
492    if photo_ids.is_empty() {
493        return Ok(());
494    }
495    for chunk in photo_ids.chunks(MAX_ROWS_PER_INSERT) {
496        let placeholders: String = (0..chunk.len())
497            .map(|_| "(?, ?)")
498            .collect::<Vec<_>>()
499            .join(", ");
500        let sql = format!(
501            "INSERT INTO burst_group_members (group_id, photo_id) VALUES {}",
502            placeholders
503        );
504        let mut params_vec: Vec<Box<dyn ToSql>> = Vec::with_capacity(chunk.len() * 2);
505        for pid in chunk {
506            params_vec.push(Box::new(group_id));
507            params_vec.push(Box::new(*pid));
508        }
509        let params_refs: Vec<&dyn ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
510        conn.execute(&sql, params_refs.as_slice())?;
511    }
512    Ok(())
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::db::create_schema;
519
520    #[test]
521    fn invalid_best_photo_does_not_clear_existing_best() {
522        let conn = Connection::open_in_memory().unwrap();
523        create_schema(&conn).unwrap();
524        conn.execute(
525            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken)
526             VALUES (1, 'a.jpg', 'a.jpg', 'a', 10, '2026-01-01T00:00:00Z'),
527                    (2, 'b.jpg', 'b.jpg', 'b', 10, '2026-01-01T00:00:01Z')",
528            [],
529        )
530        .unwrap();
531
532        let repo = BurstRepo::new(&conn);
533        repo.upsert_burst_groups(&[(
534            "2026-01-01T00:00:00Z".into(),
535            "2026-01-01T00:00:01Z".into(),
536            vec![1, 2],
537        )])
538        .unwrap();
539        let group_id = repo.get_all_groups().unwrap()[0].id;
540
541        assert!(repo.set_suggested_best(group_id, 999).is_err());
542
543        let best_id: i64 = conn
544            .query_row(
545                "SELECT photo_id FROM burst_group_members
546                 WHERE group_id = ?1 AND is_suggested_best = TRUE",
547                params![group_id],
548                |row| row.get(0),
549            )
550            .unwrap();
551        assert_eq!(best_id, 1);
552    }
553
554    #[test]
555    fn groups_ignore_trashed_members() {
556        let conn = Connection::open_in_memory().unwrap();
557        create_schema(&conn).unwrap();
558        conn.execute(
559            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, is_trashed)
560             VALUES (1, 'a.jpg', 'a.jpg', 'a', 10, '2026-01-01T00:00:00Z', FALSE),
561                    (2, 'b.jpg', 'b.jpg', 'b', 10, '2026-01-01T00:00:01Z', FALSE),
562                    (3, 'c.jpg', 'c.jpg', 'c', 10, '2026-01-01T00:00:02Z', TRUE)",
563            [],
564        )
565        .unwrap();
566
567        let repo = BurstRepo::new(&conn);
568        repo.upsert_burst_groups(&[(
569            "2026-01-01T00:00:00Z".into(),
570            "2026-01-01T00:00:02Z".into(),
571            vec![1, 2, 3],
572        )])
573        .unwrap();
574
575        let group = repo.get_all_groups().unwrap().pop().unwrap();
576        assert_eq!(group.photo_count, 2);
577        assert_eq!(group.member_photo_ids, vec![1, 2]);
578        assert_eq!(
579            repo.get_group_members(group.id)
580                .unwrap()
581                .into_iter()
582                .map(|m| m.photo_id)
583                .collect::<Vec<_>>(),
584            vec![1, 2]
585        );
586    }
587
588    #[test]
589    fn ensure_suggested_best_replaces_trashed_best() {
590        let conn = Connection::open_in_memory().unwrap();
591        create_schema(&conn).unwrap();
592        conn.execute(
593            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken, is_trashed)
594             VALUES (1, 'a.jpg', 'a.jpg', 'a', 10, '2026-01-01T00:00:00Z', TRUE),
595                    (2, 'b.jpg', 'b.jpg', 'b', 10, '2026-01-01T00:00:01Z', FALSE),
596                    (3, 'c.jpg', 'c.jpg', 'c', 10, '2026-01-01T00:00:02Z', FALSE)",
597            [],
598        )
599        .unwrap();
600
601        let repo = BurstRepo::new(&conn);
602        repo.upsert_burst_groups(&[(
603            "2026-01-01T00:00:00Z".into(),
604            "2026-01-01T00:00:02Z".into(),
605            vec![1, 2, 3],
606        )])
607        .unwrap();
608        let group_id = conn
609            .query_row("SELECT id FROM burst_groups", [], |row| row.get(0))
610            .unwrap();
611
612        repo.ensure_suggested_best(group_id).unwrap();
613
614        assert_eq!(repo.get_photos_to_trash(group_id).unwrap(), vec![3]);
615        let best_ids = conn
616            .prepare(
617                "SELECT photo_id FROM burst_group_members
618                 WHERE group_id = ?1 AND is_suggested_best = TRUE
619                 ORDER BY photo_id",
620            )
621            .unwrap()
622            .query_map(params![group_id], |row| row.get::<_, i64>(0))
623            .unwrap()
624            .collect::<SqliteResult<Vec<_>>>()
625            .unwrap();
626        assert_eq!(best_ids, vec![2]);
627    }
628
629    #[test]
630    fn dismissed_group_does_not_reappear_on_next_detection() {
631        let conn = Connection::open_in_memory().unwrap();
632        create_schema(&conn).unwrap();
633        conn.execute(
634            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken)
635             VALUES (1, 'a.jpg', 'a.jpg', 'a', 10, '2026-01-01T00:00:00Z'),
636                    (2, 'b.jpg', 'b.jpg', 'b', 10, '2026-01-01T00:00:01Z')",
637            [],
638        )
639        .unwrap();
640
641        let repo = BurstRepo::new(&conn);
642        let detected = [(
643            "2026-01-01T00:00:00Z".into(),
644            "2026-01-01T00:00:01Z".into(),
645            vec![1, 2],
646        )];
647        repo.sync_burst_groups(&detected).unwrap();
648        let group_id = repo.get_all_groups().unwrap()[0].id;
649
650        repo.dismiss_group(group_id).unwrap();
651        assert!(repo.get_all_groups().unwrap().is_empty());
652
653        assert_eq!(repo.upsert_burst_groups(&detected).unwrap(), 0);
654        assert!(repo.get_all_groups().unwrap().is_empty());
655
656        repo.sync_burst_groups(&detected).unwrap();
657        assert!(repo.get_all_groups().unwrap().is_empty());
658    }
659
660    #[test]
661    fn sync_prunes_stale_groups_inside_transaction() {
662        let conn = Connection::open_in_memory().unwrap();
663        create_schema(&conn).unwrap();
664        conn.execute(
665            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken)
666             VALUES (1, 'a.jpg', 'a.jpg', 'a', 10, '2026-01-01T00:00:00Z'),
667                    (2, 'b.jpg', 'b.jpg', 'b', 10, '2026-01-01T00:00:01Z'),
668                    (3, 'c.jpg', 'c.jpg', 'c', 10, '2026-01-01T00:00:02Z'),
669                    (4, 'd.jpg', 'd.jpg', 'd', 10, '2026-01-01T00:00:03Z')",
670            [],
671        )
672        .unwrap();
673
674        let repo = BurstRepo::new(&conn);
675        repo.upsert_burst_groups(&[
676            (
677                "2026-01-01T00:00:00Z".into(),
678                "2026-01-01T00:00:01Z".into(),
679                vec![1, 2],
680            ),
681            (
682                "2026-01-01T00:00:02Z".into(),
683                "2026-01-01T00:00:03Z".into(),
684                vec![3, 4],
685            ),
686        ])
687        .unwrap();
688
689        repo.sync_burst_groups(&[(
690            "2026-01-01T00:00:00Z".into(),
691            "2026-01-01T00:00:01Z".into(),
692            vec![1, 2],
693        )])
694        .unwrap();
695
696        let group_count: i64 = conn
697            .query_row("SELECT COUNT(*) FROM burst_groups", [], |row| row.get(0))
698            .unwrap();
699        let member_count: i64 = conn
700            .query_row("SELECT COUNT(*) FROM burst_group_members", [], |row| {
701                row.get(0)
702            })
703            .unwrap();
704
705        assert_eq!(group_count, 1);
706        assert_eq!(member_count, 2);
707    }
708
709    #[test]
710    fn live_insert_cleanup_removes_only_new_unresolved_groups() {
711        let conn = Connection::open_in_memory().unwrap();
712        create_schema(&conn).unwrap();
713        conn.execute(
714            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken)
715             VALUES (1, 'a.jpg', 'a.jpg', 'a', 10, '2026-01-01T00:00:00Z'),
716                    (2, 'b.jpg', 'b.jpg', 'b', 10, '2026-01-01T00:00:01Z'),
717                    (3, 'c.jpg', 'c.jpg', 'c', 10, '2026-01-01T00:00:02Z'),
718                    (4, 'd.jpg', 'd.jpg', 'd', 10, '2026-01-01T00:00:03Z')",
719            [],
720        )
721        .unwrap();
722
723        let repo = BurstRepo::new(&conn);
724        repo.upsert_burst_groups(&[(
725            "2026-01-01T00:00:00Z".into(),
726            "2026-01-01T00:00:01Z".into(),
727            vec![1, 2],
728        )])
729        .unwrap();
730
731        let inserted = repo
732            .upsert_burst_groups_collecting_inserted(&[
733                (
734                    "2026-01-01T00:00:00Z".into(),
735                    "2026-01-01T00:00:01Z".into(),
736                    vec![1, 2],
737                ),
738                (
739                    "2026-01-01T00:00:02Z".into(),
740                    "2026-01-01T00:00:03Z".into(),
741                    vec![3, 4],
742                ),
743            ])
744            .unwrap();
745
746        assert_eq!(inserted, vec![vec![3, 4]]);
747        assert_eq!(
748            repo.delete_unresolved_groups_by_member_sets(&inserted)
749                .unwrap(),
750            1
751        );
752        let remaining = repo.get_all_groups().unwrap();
753        assert_eq!(remaining.len(), 1);
754        assert_eq!(remaining[0].member_photo_ids, vec![1, 2]);
755    }
756
757    #[test]
758    fn dismissed_group_cannot_be_mutated_or_trashed_from_stale_detail() {
759        let conn = Connection::open_in_memory().unwrap();
760        create_schema(&conn).unwrap();
761        conn.execute(
762            "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, date_taken)
763             VALUES (1, 'a.jpg', 'a.jpg', 'a', 10, '2026-01-01T00:00:00Z'),
764                    (2, 'b.jpg', 'b.jpg', 'b', 10, '2026-01-01T00:00:01Z')",
765            [],
766        )
767        .unwrap();
768
769        let repo = BurstRepo::new(&conn);
770        repo.sync_burst_groups(&[(
771            "2026-01-01T00:00:00Z".into(),
772            "2026-01-01T00:00:01Z".into(),
773            vec![1, 2],
774        )])
775        .unwrap();
776        let group_id = repo.get_all_groups().unwrap()[0].id;
777
778        repo.dismiss_group(group_id).unwrap();
779
780        assert!(repo.set_suggested_best(group_id, 2).is_err());
781        assert!(repo.get_photos_to_trash(group_id).unwrap().is_empty());
782    }
783}