bl4_idb/
sqlite.rs

1//! SQLite implementation using rusqlite (synchronous).
2//!
3//! This implementation is used by the CLI tool.
4
5use crate::repository::*;
6use crate::types::*;
7use rusqlite::{params, Connection, OptionalExtension};
8use std::collections::HashMap;
9use std::path::Path;
10
11/// Default database location
12pub const DEFAULT_DB_PATH: &str = "share/items.db";
13
14/// SQLite-backed items database
15pub struct SqliteDb {
16    conn: Connection,
17}
18
19impl SqliteDb {
20    /// Open or create the database
21    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, rusqlite::Error> {
22        let conn = Connection::open(path.as_ref())?;
23        Ok(Self { conn })
24    }
25
26    /// Open an in-memory database (for testing)
27    pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
28        let conn = Connection::open_in_memory()?;
29        Ok(Self { conn })
30    }
31
32    /// Check if migration from old schema is needed and perform it
33    fn migrate_to_serial_pk(&self) -> RepoResult<()> {
34        println!("Migrating database to use serial as primary key...");
35
36        self.conn
37            .execute_batch(
38                r#"
39            ALTER TABLE weapons RENAME TO weapons_old;
40            ALTER TABLE weapon_parts RENAME TO weapon_parts_old;
41            ALTER TABLE attachments RENAME TO attachments_old;
42
43            CREATE TABLE weapons (
44                serial TEXT PRIMARY KEY NOT NULL,
45                name TEXT,
46                prefix TEXT,
47                manufacturer TEXT,
48                weapon_type TEXT,
49                item_type TEXT,
50                rarity TEXT,
51                level INTEGER,
52                element TEXT,
53                dps INTEGER,
54                damage INTEGER,
55                accuracy INTEGER,
56                fire_rate REAL,
57                reload_time REAL,
58                mag_size INTEGER,
59                value INTEGER,
60                red_text TEXT,
61                notes TEXT,
62                verification_status TEXT DEFAULT 'unverified',
63                verification_notes TEXT,
64                verified_at TIMESTAMP,
65                legal BOOLEAN DEFAULT FALSE,
66                source TEXT,
67                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
68            );
69
70            CREATE TABLE weapon_parts (
71                id INTEGER PRIMARY KEY AUTOINCREMENT,
72                item_serial TEXT NOT NULL REFERENCES weapons(serial) ON DELETE CASCADE,
73                slot TEXT NOT NULL,
74                part_index INTEGER,
75                part_name TEXT,
76                manufacturer TEXT,
77                effect TEXT,
78                verified BOOLEAN DEFAULT FALSE,
79                verification_method TEXT,
80                verification_notes TEXT,
81                verified_at TIMESTAMP
82            );
83
84            CREATE TABLE attachments (
85                id INTEGER PRIMARY KEY AUTOINCREMENT,
86                item_serial TEXT NOT NULL REFERENCES weapons(serial) ON DELETE CASCADE,
87                name TEXT NOT NULL,
88                mime_type TEXT NOT NULL,
89                data BLOB NOT NULL,
90                view TEXT DEFAULT 'OTHER'
91            );
92
93            INSERT INTO weapons (serial, name, prefix, manufacturer, weapon_type, item_type, rarity,
94                level, element, dps, damage, accuracy, fire_rate, reload_time, mag_size, value,
95                red_text, notes, verification_status, verification_notes, verified_at, legal,
96                source, created_at)
97            SELECT serial, name, prefix, manufacturer, weapon_type, item_type, rarity,
98                level, element, dps, damage, accuracy, fire_rate, reload_time, mag_size, value,
99                red_text, notes, verification_status, verification_notes, verified_at, legal,
100                source, created_at
101            FROM weapons_old;
102
103            INSERT INTO weapon_parts (item_serial, slot, part_index, part_name, manufacturer,
104                effect, verified, verification_method, verification_notes, verified_at)
105            SELECT w.serial, wp.slot, wp.part_index, wp.part_name, wp.manufacturer,
106                wp.effect, wp.verified, wp.verification_method, wp.verification_notes, wp.verified_at
107            FROM weapon_parts_old wp
108            JOIN weapons_old w ON wp.weapon_id = w.id;
109
110            INSERT INTO attachments (item_serial, name, mime_type, data)
111            SELECT w.serial, a.name, a.mime_type, a.data
112            FROM attachments_old a
113            JOIN weapons_old w ON a.weapon_id = w.id;
114
115            DROP TABLE attachments_old;
116            DROP TABLE weapon_parts_old;
117            DROP TABLE weapons_old;
118
119            DROP INDEX IF EXISTS idx_weapons_serial;
120            DROP INDEX IF EXISTS idx_weapon_parts_weapon_id;
121            DROP INDEX IF EXISTS idx_attachments_weapon_id;
122            "#,
123            )
124            .map_err(|e| RepoError::Database(e.to_string()))?;
125
126        println!("Migration complete.");
127        Ok(())
128    }
129}
130
131impl ItemsRepository for SqliteDb {
132    fn init(&self) -> RepoResult<()> {
133        // Check if we need to migrate from old schema
134        let needs_migration = self
135            .conn
136            .query_row(
137                "SELECT 1 FROM sqlite_master WHERE type='table' AND name='weapons'
138                 AND sql LIKE '%id INTEGER PRIMARY KEY%'",
139                [],
140                |_| Ok(true),
141            )
142            .unwrap_or(false);
143
144        if needs_migration {
145            self.migrate_to_serial_pk()?;
146        }
147
148        self.conn
149            .execute_batch(
150                r#"
151            CREATE TABLE IF NOT EXISTS weapons (
152                serial TEXT PRIMARY KEY NOT NULL,
153                name TEXT,
154                prefix TEXT,
155                manufacturer TEXT,
156                weapon_type TEXT,
157                item_type TEXT,
158                rarity TEXT,
159                level INTEGER,
160                element TEXT,
161                dps INTEGER,
162                damage INTEGER,
163                accuracy INTEGER,
164                fire_rate REAL,
165                reload_time REAL,
166                mag_size INTEGER,
167                value INTEGER,
168                red_text TEXT,
169                notes TEXT,
170                verification_status TEXT DEFAULT 'unverified',
171                verification_notes TEXT,
172                verified_at TIMESTAMP,
173                legal BOOLEAN DEFAULT FALSE,
174                source TEXT,
175                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
176            );
177
178            CREATE TABLE IF NOT EXISTS weapon_parts (
179                id INTEGER PRIMARY KEY AUTOINCREMENT,
180                item_serial TEXT NOT NULL REFERENCES weapons(serial) ON DELETE CASCADE,
181                slot TEXT NOT NULL,
182                part_index INTEGER,
183                part_name TEXT,
184                manufacturer TEXT,
185                effect TEXT,
186                verified BOOLEAN DEFAULT FALSE,
187                verification_method TEXT,
188                verification_notes TEXT,
189                verified_at TIMESTAMP
190            );
191
192            CREATE TABLE IF NOT EXISTS attachments (
193                id INTEGER PRIMARY KEY AUTOINCREMENT,
194                item_serial TEXT NOT NULL REFERENCES weapons(serial) ON DELETE CASCADE,
195                name TEXT NOT NULL,
196                mime_type TEXT NOT NULL,
197                data BLOB NOT NULL,
198                view TEXT DEFAULT 'OTHER'
199            );
200
201            CREATE TABLE IF NOT EXISTS item_values (
202                id INTEGER PRIMARY KEY AUTOINCREMENT,
203                item_serial TEXT NOT NULL REFERENCES weapons(serial) ON DELETE CASCADE,
204                field TEXT NOT NULL,
205                value TEXT NOT NULL,
206                source TEXT NOT NULL,
207                source_detail TEXT,
208                confidence TEXT NOT NULL DEFAULT 'inferred',
209                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
210                UNIQUE(item_serial, field, source)
211            );
212
213            CREATE INDEX IF NOT EXISTS idx_weapons_name ON weapons(name);
214            CREATE INDEX IF NOT EXISTS idx_weapons_manufacturer ON weapons(manufacturer);
215            CREATE INDEX IF NOT EXISTS idx_weapon_parts_item_serial ON weapon_parts(item_serial);
216            CREATE INDEX IF NOT EXISTS idx_item_values_serial ON item_values(item_serial);
217            CREATE INDEX IF NOT EXISTS idx_item_values_field ON item_values(item_serial, field);
218            CREATE INDEX IF NOT EXISTS idx_attachments_item_serial ON attachments(item_serial);
219            "#,
220            )
221            .map_err(|e| RepoError::Database(e.to_string()))?;
222
223        Ok(())
224    }
225
226    fn add_item(&self, serial: &str) -> RepoResult<()> {
227        self.conn
228            .execute("INSERT INTO weapons (serial) VALUES (?1)", params![serial])
229            .map_err(|e| RepoError::Database(e.to_string()))?;
230        Ok(())
231    }
232
233    fn get_item(&self, serial: &str) -> RepoResult<Option<Item>> {
234        let mut stmt = self
235            .conn
236            .prepare(
237                "SELECT serial, name, prefix, manufacturer, weapon_type, item_type, rarity, level, element,
238                    dps, damage, accuracy, fire_rate, reload_time, mag_size, value, red_text,
239                    notes, verification_status, verification_notes, verified_at, legal, source, created_at
240             FROM weapons WHERE serial = ?1",
241            )
242            .map_err(|e| RepoError::Database(e.to_string()))?;
243
244        let item = stmt
245            .query_row(params![serial], |row| {
246                let status_str: String = row
247                    .get::<_, Option<String>>(18)?
248                    .unwrap_or_else(|| "unverified".to_string());
249                Ok(Item {
250                    serial: row.get(0)?,
251                    name: row.get(1)?,
252                    prefix: row.get(2)?,
253                    manufacturer: row.get(3)?,
254                    weapon_type: row.get(4)?,
255                    item_type: row.get(5)?,
256                    rarity: row.get(6)?,
257                    level: row.get(7)?,
258                    element: row.get(8)?,
259                    dps: row.get(9)?,
260                    damage: row.get(10)?,
261                    accuracy: row.get(11)?,
262                    fire_rate: row.get(12)?,
263                    reload_time: row.get(13)?,
264                    mag_size: row.get(14)?,
265                    value: row.get(15)?,
266                    red_text: row.get(16)?,
267                    notes: row.get(17)?,
268                    verification_status: status_str
269                        .parse()
270                        .unwrap_or(VerificationStatus::Unverified),
271                    verification_notes: row.get(19)?,
272                    verified_at: row.get(20)?,
273                    legal: row.get::<_, Option<bool>>(21)?.unwrap_or(false),
274                    source: row.get(22)?,
275                    created_at: row.get::<_, Option<String>>(23)?.unwrap_or_default(),
276                })
277            })
278            .optional()
279            .map_err(|e| RepoError::Database(e.to_string()))?;
280
281        Ok(item)
282    }
283
284    fn update_item(&self, serial: &str, update: &ItemUpdate) -> RepoResult<()> {
285        self.conn
286            .execute(
287                r#"UPDATE weapons SET
288                name = COALESCE(?2, name),
289                prefix = COALESCE(?3, prefix),
290                manufacturer = COALESCE(?4, manufacturer),
291                weapon_type = COALESCE(?5, weapon_type),
292                rarity = COALESCE(?6, rarity),
293                level = COALESCE(?7, level),
294                element = COALESCE(?8, element),
295                dps = COALESCE(?9, dps),
296                damage = COALESCE(?10, damage),
297                accuracy = COALESCE(?11, accuracy),
298                fire_rate = COALESCE(?12, fire_rate),
299                reload_time = COALESCE(?13, reload_time),
300                mag_size = COALESCE(?14, mag_size),
301                value = COALESCE(?15, value),
302                red_text = COALESCE(?16, red_text),
303                notes = COALESCE(?17, notes)
304            WHERE serial = ?1"#,
305                params![
306                    serial,
307                    update.name,
308                    update.prefix,
309                    update.manufacturer,
310                    update.weapon_type,
311                    update.rarity,
312                    update.level,
313                    update.element,
314                    update.dps,
315                    update.damage,
316                    update.accuracy,
317                    update.fire_rate,
318                    update.reload_time,
319                    update.mag_size,
320                    update.value,
321                    update.red_text,
322                    update.notes
323                ],
324            )
325            .map_err(|e| RepoError::Database(e.to_string()))?;
326        Ok(())
327    }
328
329    fn list_items(&self, filter: &ItemFilter) -> RepoResult<Vec<Item>> {
330        let mut sql = String::from(
331            "SELECT serial, name, prefix, manufacturer, weapon_type, item_type, rarity, level, element,
332                    dps, damage, accuracy, fire_rate, reload_time, mag_size, value, red_text,
333                    notes, verification_status, verification_notes, verified_at, legal, source, created_at
334             FROM weapons WHERE 1=1",
335        );
336
337        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
338
339        if let Some(m) = &filter.manufacturer {
340            sql.push_str(" AND manufacturer = ?");
341            params_vec.push(Box::new(m.clone()));
342        }
343        if let Some(w) = &filter.weapon_type {
344            sql.push_str(" AND weapon_type = ?");
345            params_vec.push(Box::new(w.clone()));
346        }
347        if let Some(e) = &filter.element {
348            sql.push_str(" AND element = ?");
349            params_vec.push(Box::new(e.clone()));
350        }
351        if let Some(r) = &filter.rarity {
352            sql.push_str(" AND rarity = ?");
353            params_vec.push(Box::new(r.clone()));
354        }
355
356        sql.push_str(" ORDER BY created_at DESC");
357
358        if let Some(limit) = filter.limit {
359            sql.push_str(&format!(" LIMIT {}", limit));
360        }
361        if let Some(offset) = filter.offset {
362            sql.push_str(&format!(" OFFSET {}", offset));
363        }
364
365        let mut stmt = self
366            .conn
367            .prepare(&sql)
368            .map_err(|e| RepoError::Database(e.to_string()))?;
369
370        let params_refs: Vec<&dyn rusqlite::ToSql> =
371            params_vec.iter().map(|p| p.as_ref()).collect();
372
373        let items = stmt
374            .query_map(params_refs.as_slice(), |row| {
375                let status_str: String = row
376                    .get::<_, Option<String>>(18)?
377                    .unwrap_or_else(|| "unverified".to_string());
378                Ok(Item {
379                    serial: row.get(0)?,
380                    name: row.get(1)?,
381                    prefix: row.get(2)?,
382                    manufacturer: row.get(3)?,
383                    weapon_type: row.get(4)?,
384                    item_type: row.get(5)?,
385                    rarity: row.get(6)?,
386                    level: row.get(7)?,
387                    element: row.get(8)?,
388                    dps: row.get(9)?,
389                    damage: row.get(10)?,
390                    accuracy: row.get(11)?,
391                    fire_rate: row.get(12)?,
392                    reload_time: row.get(13)?,
393                    mag_size: row.get(14)?,
394                    value: row.get(15)?,
395                    red_text: row.get(16)?,
396                    notes: row.get(17)?,
397                    verification_status: status_str
398                        .parse()
399                        .unwrap_or(VerificationStatus::Unverified),
400                    verification_notes: row.get(19)?,
401                    verified_at: row.get(20)?,
402                    legal: row.get::<_, Option<bool>>(21)?.unwrap_or(false),
403                    source: row.get(22)?,
404                    created_at: row.get::<_, Option<String>>(23)?.unwrap_or_default(),
405                })
406            })
407            .map_err(|e| RepoError::Database(e.to_string()))?
408            .collect::<Result<Vec<_>, _>>()
409            .map_err(|e| RepoError::Database(e.to_string()))?;
410
411        Ok(items)
412    }
413
414    fn delete_item(&self, serial: &str) -> RepoResult<bool> {
415        let rows = self
416            .conn
417            .execute("DELETE FROM weapons WHERE serial = ?1", params![serial])
418            .map_err(|e| RepoError::Database(e.to_string()))?;
419        Ok(rows > 0)
420    }
421
422    fn set_verification_status(
423        &self,
424        serial: &str,
425        status: VerificationStatus,
426        notes: Option<&str>,
427    ) -> RepoResult<()> {
428        self.conn
429            .execute(
430                r#"UPDATE weapons SET
431                verification_status = ?2,
432                verification_notes = COALESCE(?3, verification_notes),
433                verified_at = CASE WHEN ?2 != 'unverified' THEN CURRENT_TIMESTAMP ELSE verified_at END
434            WHERE serial = ?1"#,
435                params![serial, status.to_string(), notes],
436            )
437            .map_err(|e| RepoError::Database(e.to_string()))?;
438        Ok(())
439    }
440
441    fn set_legal(&self, serial: &str, legal: bool) -> RepoResult<()> {
442        self.conn
443            .execute(
444                "UPDATE weapons SET legal = ?2 WHERE serial = ?1",
445                params![serial, legal],
446            )
447            .map_err(|e| RepoError::Database(e.to_string()))?;
448        Ok(())
449    }
450
451    fn set_all_legal(&self, legal: bool) -> RepoResult<usize> {
452        let rows = self
453            .conn
454            .execute("UPDATE weapons SET legal = ?1", params![legal])
455            .map_err(|e| RepoError::Database(e.to_string()))?;
456        Ok(rows)
457    }
458
459    fn set_item_type(&self, serial: &str, item_type: &str) -> RepoResult<()> {
460        self.conn
461            .execute(
462                "UPDATE weapons SET item_type = ?2 WHERE serial = ?1",
463                params![serial, item_type],
464            )
465            .map_err(|e| RepoError::Database(e.to_string()))?;
466        Ok(())
467    }
468
469    fn set_source(&self, serial: &str, source: &str) -> RepoResult<()> {
470        self.conn
471            .execute(
472                "UPDATE weapons SET source = ?2 WHERE serial = ?1",
473                params![serial, source],
474            )
475            .map_err(|e| RepoError::Database(e.to_string()))?;
476        Ok(())
477    }
478
479    fn set_source_for_null(&self, source: &str) -> RepoResult<usize> {
480        let rows = self
481            .conn
482            .execute(
483                "UPDATE weapons SET source = ?1 WHERE source IS NULL",
484                params![source],
485            )
486            .map_err(|e| RepoError::Database(e.to_string()))?;
487        Ok(rows)
488    }
489
490    fn set_source_where(&self, source: &str, condition: &str) -> RepoResult<usize> {
491        let sql = format!("UPDATE weapons SET source = ?1 WHERE {}", condition);
492        let rows = self
493            .conn
494            .execute(&sql, params![source])
495            .map_err(|e| RepoError::Database(e.to_string()))?;
496        Ok(rows)
497    }
498
499    fn get_parts(&self, serial: &str) -> RepoResult<Vec<ItemPart>> {
500        let mut stmt = self
501            .conn
502            .prepare(
503                "SELECT id, item_serial, slot, part_index, part_name, manufacturer, effect,
504                    verified, verification_method, verification_notes, verified_at
505             FROM weapon_parts WHERE item_serial = ?1",
506            )
507            .map_err(|e| RepoError::Database(e.to_string()))?;
508
509        let parts = stmt
510            .query_map(params![serial], |row| {
511                Ok(ItemPart {
512                    id: row.get(0)?,
513                    item_serial: row.get(1)?,
514                    slot: row.get(2)?,
515                    part_index: row.get(3)?,
516                    part_name: row.get(4)?,
517                    manufacturer: row.get(5)?,
518                    effect: row.get(6)?,
519                    verified: row.get::<_, Option<bool>>(7)?.unwrap_or(false),
520                    verification_method: row.get(8)?,
521                    verification_notes: row.get(9)?,
522                    verified_at: row.get(10)?,
523                })
524            })
525            .map_err(|e| RepoError::Database(e.to_string()))?
526            .collect::<Result<Vec<_>, _>>()
527            .map_err(|e| RepoError::Database(e.to_string()))?;
528
529        Ok(parts)
530    }
531
532    fn set_value(
533        &self,
534        serial: &str,
535        field: &str,
536        value: &str,
537        source: ValueSource,
538        source_detail: Option<&str>,
539        confidence: Confidence,
540    ) -> RepoResult<()> {
541        self.conn
542            .execute(
543                r#"INSERT OR REPLACE INTO item_values
544               (item_serial, field, value, source, source_detail, confidence)
545               VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
546                params![
547                    serial,
548                    field,
549                    value,
550                    source.to_string(),
551                    source_detail,
552                    confidence.to_string()
553                ],
554            )
555            .map_err(|e| RepoError::Database(e.to_string()))?;
556        Ok(())
557    }
558
559    fn get_values(&self, serial: &str, field: &str) -> RepoResult<Vec<ItemValue>> {
560        let mut stmt = self
561            .conn
562            .prepare(
563                "SELECT id, item_serial, field, value, source, source_detail, confidence, created_at
564             FROM item_values
565             WHERE item_serial = ?1 AND field = ?2
566             ORDER BY source DESC, confidence DESC",
567            )
568            .map_err(|e| RepoError::Database(e.to_string()))?;
569
570        let values = stmt
571            .query_map(params![serial, field], |row| {
572                let source_str: String = row.get(4)?;
573                let confidence_str: String = row.get(6)?;
574                Ok(ItemValue {
575                    id: row.get(0)?,
576                    item_serial: row.get(1)?,
577                    field: row.get(2)?,
578                    value: row.get(3)?,
579                    source: source_str.parse().unwrap_or(ValueSource::CommunityTool),
580                    source_detail: row.get(5)?,
581                    confidence: confidence_str.parse().unwrap_or(Confidence::Uncertain),
582                    created_at: row.get(7)?,
583                })
584            })
585            .map_err(|e| RepoError::Database(e.to_string()))?
586            .collect::<Result<Vec<_>, _>>()
587            .map_err(|e| RepoError::Database(e.to_string()))?;
588
589        Ok(values)
590    }
591
592    fn get_best_value(&self, serial: &str, field: &str) -> RepoResult<Option<ItemValue>> {
593        let values = self.get_values(serial, field)?;
594        Ok(pick_best_value(values))
595    }
596
597    fn get_all_values(&self, serial: &str) -> RepoResult<Vec<ItemValue>> {
598        let mut stmt = self
599            .conn
600            .prepare(
601                "SELECT id, item_serial, field, value, source, source_detail, confidence, created_at
602             FROM item_values
603             WHERE item_serial = ?1
604             ORDER BY field, source DESC, confidence DESC",
605            )
606            .map_err(|e| RepoError::Database(e.to_string()))?;
607
608        let values = stmt
609            .query_map(params![serial], |row| {
610                let source_str: String = row.get(4)?;
611                let confidence_str: String = row.get(6)?;
612                Ok(ItemValue {
613                    id: row.get(0)?,
614                    item_serial: row.get(1)?,
615                    field: row.get(2)?,
616                    value: row.get(3)?,
617                    source: source_str.parse().unwrap_or(ValueSource::CommunityTool),
618                    source_detail: row.get(5)?,
619                    confidence: confidence_str.parse().unwrap_or(Confidence::Uncertain),
620                    created_at: row.get(7)?,
621                })
622            })
623            .map_err(|e| RepoError::Database(e.to_string()))?
624            .collect::<Result<Vec<_>, _>>()
625            .map_err(|e| RepoError::Database(e.to_string()))?;
626
627        Ok(values)
628    }
629
630    fn get_best_values(&self, serial: &str) -> RepoResult<HashMap<String, String>> {
631        let all_values = self.get_all_values(serial)?;
632        Ok(best_values_by_field(all_values))
633    }
634
635    fn get_all_items_best_values(&self) -> RepoResult<HashMap<String, HashMap<String, String>>> {
636        let mut stmt = self
637            .conn
638            .prepare(
639                "SELECT item_serial, field, value, source, confidence
640             FROM item_values
641             ORDER BY item_serial, field, source DESC, confidence DESC",
642            )
643            .map_err(|e| RepoError::Database(e.to_string()))?;
644
645        let values: Vec<(String, String, String, String, String)> = stmt
646            .query_map([], |row| {
647                Ok((
648                    row.get::<_, String>(0)?,
649                    row.get::<_, String>(1)?,
650                    row.get::<_, String>(2)?,
651                    row.get::<_, String>(3)?,
652                    row.get::<_, String>(4)?,
653                ))
654            })
655            .map_err(|e| RepoError::Database(e.to_string()))?
656            .filter_map(|r| r.ok())
657            .collect();
658
659        let mut result: HashMap<String, HashMap<String, (String, u8, u8)>> = HashMap::new();
660
661        for (serial, field, value, source_str, confidence_str) in values {
662            let source: ValueSource = source_str.parse().unwrap_or(ValueSource::CommunityTool);
663            let confidence: Confidence = confidence_str.parse().unwrap_or(Confidence::Uncertain);
664
665            let entry = result.entry(serial).or_default();
666            let current = entry.get(&field);
667
668            let should_replace = current
669                .map(
670                    |(_, src_prio, conf_prio)| match source.priority().cmp(src_prio) {
671                        std::cmp::Ordering::Greater => true,
672                        std::cmp::Ordering::Equal => confidence.priority() > *conf_prio,
673                        std::cmp::Ordering::Less => false,
674                    },
675                )
676                .unwrap_or(true);
677
678            if should_replace {
679                entry.insert(field, (value, source.priority(), confidence.priority()));
680            }
681        }
682
683        Ok(result
684            .into_iter()
685            .map(|(serial, fields)| {
686                (
687                    serial,
688                    fields.into_iter().map(|(f, (v, _, _))| (f, v)).collect(),
689                )
690            })
691            .collect())
692    }
693
694    fn stats(&self) -> RepoResult<DbStats> {
695        let item_count: i64 = self
696            .conn
697            .query_row("SELECT COUNT(*) FROM weapons", [], |row| row.get(0))
698            .map_err(|e| RepoError::Database(e.to_string()))?;
699
700        let part_count: i64 = self
701            .conn
702            .query_row("SELECT COUNT(*) FROM weapon_parts", [], |row| row.get(0))
703            .map_err(|e| RepoError::Database(e.to_string()))?;
704
705        let attachment_count: i64 = self
706            .conn
707            .query_row("SELECT COUNT(*) FROM attachments", [], |row| row.get(0))
708            .map_err(|e| RepoError::Database(e.to_string()))?;
709
710        let value_count: i64 = self
711            .conn
712            .query_row("SELECT COUNT(*) FROM item_values", [], |row| row.get(0))
713            .map_err(|e| RepoError::Database(e.to_string()))?;
714
715        Ok(DbStats {
716            item_count,
717            part_count,
718            attachment_count,
719            value_count,
720        })
721    }
722
723    fn migrate_column_values(&self, dry_run: bool) -> RepoResult<MigrationStats> {
724        let mut stats = MigrationStats::default();
725
726        let fields_to_migrate = [
727            ("name", "name"),
728            ("prefix", "prefix"),
729            ("manufacturer", "manufacturer"),
730            ("weapon_type", "weapon_type"),
731            ("item_type", "item_type"),
732            ("rarity", "rarity"),
733            ("level", "level"),
734            ("element", "element"),
735            ("dps", "dps"),
736            ("damage", "damage"),
737            ("accuracy", "accuracy"),
738            ("fire_rate", "fire_rate"),
739            ("reload_time", "reload_time"),
740            ("mag_size", "mag_size"),
741            ("value", "value"),
742            ("red_text", "red_text"),
743        ];
744
745        let mut stmt = self
746            .conn
747            .prepare(
748                "SELECT serial, name, prefix, manufacturer, weapon_type, item_type, rarity,
749                    level, element, dps, damage, accuracy, fire_rate, reload_time,
750                    mag_size, value, red_text
751             FROM weapons",
752            )
753            .map_err(|e| RepoError::Database(e.to_string()))?;
754
755        let items: Vec<(String, Vec<Option<String>>)> = stmt
756            .query_map([], |row| {
757                let serial: String = row.get(0)?;
758                let values: Vec<Option<String>> = (1..=16)
759                    .map(|i| {
760                        row.get::<_, Option<String>>(i)
761                            .or_else(|_| {
762                                row.get::<_, Option<i32>>(i)
763                                    .map(|v| v.map(|n| n.to_string()))
764                            })
765                            .or_else(|_| {
766                                row.get::<_, Option<f64>>(i)
767                                    .map(|v| v.map(|n| n.to_string()))
768                            })
769                            .unwrap_or(None)
770                    })
771                    .collect();
772                Ok((serial, values))
773            })
774            .map_err(|e| RepoError::Database(e.to_string()))?
775            .filter_map(|r| r.ok())
776            .collect();
777
778        for (serial, values) in items {
779            stats.items_processed += 1;
780
781            for (i, (_, field_name)) in fields_to_migrate.iter().enumerate() {
782                if let Some(value) = &values[i] {
783                    if value.is_empty() {
784                        continue;
785                    }
786
787                    let existing: Option<i64> = self
788                        .conn
789                        .query_row(
790                            "SELECT 1 FROM item_values WHERE item_serial = ?1 AND field = ?2",
791                            params![&serial, field_name],
792                            |row| row.get(0),
793                        )
794                        .optional()
795                        .map_err(|e| RepoError::Database(e.to_string()))?;
796
797                    if existing.is_some() {
798                        stats.values_skipped += 1;
799                        continue;
800                    }
801
802                    if dry_run {
803                        println!("Would migrate: {}.{} = {}", serial, field_name, value);
804                    } else {
805                        self.set_value(
806                            &serial,
807                            field_name,
808                            value,
809                            ValueSource::Decoder,
810                            None,
811                            Confidence::Inferred,
812                        )?;
813                    }
814                    stats.values_migrated += 1;
815                }
816            }
817        }
818
819        Ok(stats)
820    }
821}
822
823#[cfg(feature = "attachments")]
824impl AttachmentsRepository for SqliteDb {
825    fn add_attachment(
826        &self,
827        serial: &str,
828        name: &str,
829        mime_type: &str,
830        data: &[u8],
831        view: &str,
832    ) -> RepoResult<i64> {
833        self.conn
834            .execute(
835                "INSERT INTO attachments (item_serial, name, mime_type, data, view) VALUES (?1, ?2, ?3, ?4, ?5)",
836                params![serial, name, mime_type, data, view],
837            )
838            .map_err(|e| RepoError::Database(e.to_string()))?;
839        Ok(self.conn.last_insert_rowid())
840    }
841
842    fn get_attachments(&self, serial: &str) -> RepoResult<Vec<Attachment>> {
843        let mut stmt = self
844            .conn
845            .prepare(
846                "SELECT id, item_serial, name, mime_type, COALESCE(view, 'OTHER') FROM attachments WHERE item_serial = ?1",
847            )
848            .map_err(|e| RepoError::Database(e.to_string()))?;
849
850        let attachments = stmt
851            .query_map(params![serial], |row| {
852                Ok(Attachment {
853                    id: row.get(0)?,
854                    item_serial: row.get(1)?,
855                    name: row.get(2)?,
856                    mime_type: row.get(3)?,
857                    view: row.get(4)?,
858                })
859            })
860            .map_err(|e| RepoError::Database(e.to_string()))?
861            .collect::<Result<Vec<_>, _>>()
862            .map_err(|e| RepoError::Database(e.to_string()))?;
863
864        Ok(attachments)
865    }
866
867    fn get_attachment_data(&self, id: i64) -> RepoResult<Option<Vec<u8>>> {
868        let mut stmt = self
869            .conn
870            .prepare("SELECT data FROM attachments WHERE id = ?1")
871            .map_err(|e| RepoError::Database(e.to_string()))?;
872
873        let data = stmt
874            .query_row(params![id], |row| row.get(0))
875            .optional()
876            .map_err(|e| RepoError::Database(e.to_string()))?;
877
878        Ok(data)
879    }
880
881    fn delete_attachment(&self, id: i64) -> RepoResult<bool> {
882        let rows = self
883            .conn
884            .execute("DELETE FROM attachments WHERE id = ?1", params![id])
885            .map_err(|e| RepoError::Database(e.to_string()))?;
886        Ok(rows > 0)
887    }
888}
889
890// Bulk attachment methods (not part of trait - SqliteDb specific)
891#[cfg(feature = "attachments")]
892impl SqliteDb {
893    /// Get all attachments for multiple serials (bulk fetch)
894    pub fn get_attachments_bulk(&self, serials: &[&str]) -> RepoResult<Vec<Attachment>> {
895        if serials.is_empty() {
896            return Ok(vec![]);
897        }
898
899        // Build placeholders for IN clause
900        let placeholders: Vec<String> = (1..=serials.len()).map(|i| format!("?{}", i)).collect();
901        let sql = format!(
902            "SELECT id, item_serial, name, mime_type, COALESCE(view, 'OTHER') FROM attachments WHERE item_serial IN ({})",
903            placeholders.join(", ")
904        );
905
906        let mut stmt = self
907            .conn
908            .prepare(&sql)
909            .map_err(|e| RepoError::Database(e.to_string()))?;
910
911        let attachments = stmt
912            .query_map(rusqlite::params_from_iter(serials.iter()), |row| {
913                Ok(Attachment {
914                    id: row.get(0)?,
915                    item_serial: row.get(1)?,
916                    name: row.get(2)?,
917                    mime_type: row.get(3)?,
918                    view: row.get(4)?,
919                })
920            })
921            .map_err(|e| RepoError::Database(e.to_string()))?
922            .collect::<Result<Vec<_>, _>>()
923            .map_err(|e| RepoError::Database(e.to_string()))?;
924
925        Ok(attachments)
926    }
927
928    /// Get attachment data for multiple IDs (bulk fetch)
929    pub fn get_attachment_data_bulk(&self, ids: &[i64]) -> RepoResult<Vec<(i64, Vec<u8>)>> {
930        if ids.is_empty() {
931            return Ok(vec![]);
932        }
933
934        let placeholders: Vec<String> = (1..=ids.len()).map(|i| format!("?{}", i)).collect();
935        let sql = format!(
936            "SELECT id, data FROM attachments WHERE id IN ({})",
937            placeholders.join(", ")
938        );
939
940        let mut stmt = self
941            .conn
942            .prepare(&sql)
943            .map_err(|e| RepoError::Database(e.to_string()))?;
944
945        let data = stmt
946            .query_map(rusqlite::params_from_iter(ids.iter()), |row| {
947                Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
948            })
949            .map_err(|e| RepoError::Database(e.to_string()))?
950            .collect::<Result<Vec<_>, _>>()
951            .map_err(|e| RepoError::Database(e.to_string()))?;
952
953        Ok(data)
954    }
955}
956
957// Bulk values method (not part of trait - SqliteDb specific)
958impl SqliteDb {
959    /// Get all item_values for multiple serials (bulk fetch)
960    pub fn get_all_values_bulk(&self, serials: &[&str]) -> RepoResult<Vec<ItemValue>> {
961        if serials.is_empty() {
962            return Ok(vec![]);
963        }
964
965        let placeholders: Vec<String> = (1..=serials.len()).map(|i| format!("?{}", i)).collect();
966        let sql = format!(
967            "SELECT id, item_serial, field, value, source, source_detail, confidence, created_at
968             FROM item_values WHERE item_serial IN ({})
969             ORDER BY item_serial, field, source DESC, confidence DESC",
970            placeholders.join(", ")
971        );
972
973        let mut stmt = self
974            .conn
975            .prepare(&sql)
976            .map_err(|e| RepoError::Database(e.to_string()))?;
977
978        let values = stmt
979            .query_map(rusqlite::params_from_iter(serials.iter()), |row| {
980                Ok(ItemValue {
981                    id: row.get(0)?,
982                    item_serial: row.get(1)?,
983                    field: row.get(2)?,
984                    value: row.get(3)?,
985                    source: row
986                        .get::<_, String>(4)?
987                        .parse()
988                        .unwrap_or(ValueSource::Decoder),
989                    source_detail: row.get(5)?,
990                    confidence: row
991                        .get::<_, String>(6)?
992                        .parse()
993                        .unwrap_or(Confidence::Uncertain),
994                    created_at: row.get(7)?,
995                })
996            })
997            .map_err(|e| RepoError::Database(e.to_string()))?
998            .collect::<Result<Vec<_>, _>>()
999            .map_err(|e| RepoError::Database(e.to_string()))?;
1000
1001        Ok(values)
1002    }
1003}
1004
1005impl ImportExportRepository for SqliteDb {
1006    fn import_from_dir(&self, dir: &Path) -> RepoResult<String> {
1007        let serial_path = dir.join("serial.txt");
1008        let serial = std::fs::read_to_string(&serial_path)?.trim().to_string();
1009
1010        if self.get_item(&serial)?.is_some() {
1011            println!("Item already exists: {}", serial);
1012            return Ok(serial);
1013        }
1014
1015        self.add_item(&serial)?;
1016
1017        let dir_name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
1018        let parts: Vec<&str> = dir_name.split('_').collect();
1019
1020        if parts.len() >= 2 {
1021            let update = ItemUpdate {
1022                manufacturer: Some(parts[0].to_string()),
1023                weapon_type: Some(parts[1].to_string()),
1024                name: if parts.len() > 2 {
1025                    Some(parts[2..].join("_").replace('_', " "))
1026                } else {
1027                    None
1028                },
1029                ..Default::default()
1030            };
1031            self.update_item(&serial, &update)?;
1032        }
1033
1034        #[cfg(feature = "attachments")]
1035        {
1036            for entry in std::fs::read_dir(dir)? {
1037                let entry = entry?;
1038                let path = entry.path();
1039
1040                if path.extension().map(|e| e == "png").unwrap_or(false) {
1041                    let name = path
1042                        .file_stem()
1043                        .and_then(|n| n.to_str())
1044                        .unwrap_or("unknown");
1045                    let view = match name {
1046                        "inventory" | "stats" => "POPUP",
1047                        "inspect" => "DETAIL",
1048                        _ => "OTHER",
1049                    };
1050                    let data = std::fs::read(&path)?;
1051                    self.add_attachment(&serial, name, "image/png", &data, view)?;
1052                }
1053            }
1054        }
1055
1056        Ok(serial)
1057    }
1058
1059    fn export_to_dir(&self, serial: &str, dir: &Path) -> RepoResult<()> {
1060        std::fs::create_dir_all(dir)?;
1061
1062        let item = self
1063            .get_item(serial)?
1064            .ok_or_else(|| RepoError::NotFound(serial.to_string()))?;
1065
1066        std::fs::write(dir.join("serial.txt"), &item.serial)?;
1067
1068        let metadata =
1069            serde_json::to_string_pretty(&item).map_err(|e| RepoError::Database(e.to_string()))?;
1070        std::fs::write(dir.join("metadata.json"), metadata)?;
1071
1072        #[cfg(feature = "attachments")]
1073        {
1074            let attachments = self.get_attachments(serial)?;
1075            for attachment in attachments {
1076                if let Some(data) = self.get_attachment_data(attachment.id)? {
1077                    let ext = match attachment.mime_type.as_str() {
1078                        "image/png" => "png",
1079                        "image/jpeg" => "jpg",
1080                        _ => "bin",
1081                    };
1082                    let filename = format!("{}.{}", attachment.name, ext);
1083                    std::fs::write(dir.join(filename), data)?;
1084                }
1085            }
1086        }
1087
1088        Ok(())
1089    }
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::*;
1095
1096    fn setup_db() -> SqliteDb {
1097        let db = SqliteDb::open_in_memory().unwrap();
1098        db.init().unwrap();
1099        db
1100    }
1101
1102    #[test]
1103    fn test_init_creates_tables() {
1104        let db = setup_db();
1105        // Should be able to query the tables
1106        let count: i64 = db
1107            .conn
1108            .query_row("SELECT COUNT(*) FROM weapons", [], |row| row.get(0))
1109            .unwrap();
1110        assert_eq!(count, 0);
1111    }
1112
1113    #[test]
1114    fn test_add_and_get_item() {
1115        let db = setup_db();
1116        let serial = "BL4(AwAAAACo4A==)";
1117
1118        db.add_item(serial).unwrap();
1119
1120        let item = db.get_item(serial).unwrap().unwrap();
1121        assert_eq!(item.serial, serial);
1122    }
1123
1124    #[test]
1125    fn test_add_duplicate_item_errors() {
1126        let db = setup_db();
1127        let serial = "BL4(AwAAAACo4A==)";
1128
1129        db.add_item(serial).unwrap();
1130        // Adding again should error (UNIQUE constraint)
1131        let result = db.add_item(serial);
1132        assert!(result.is_err());
1133
1134        // Should still only have one item
1135        let filter = ItemFilter::default();
1136        let items = db.list_items(&filter).unwrap();
1137        assert_eq!(items.len(), 1);
1138    }
1139
1140    #[test]
1141    fn test_update_item() {
1142        let db = setup_db();
1143        let serial = "BL4(AwAAAACo4A==)";
1144        db.add_item(serial).unwrap();
1145
1146        let update = ItemUpdate {
1147            name: Some("Test Weapon".to_string()),
1148            level: Some(50),
1149            rarity: Some("legendary".to_string()),
1150            ..Default::default()
1151        };
1152        db.update_item(serial, &update).unwrap();
1153
1154        let item = db.get_item(serial).unwrap().unwrap();
1155        assert_eq!(item.name, Some("Test Weapon".to_string()));
1156        assert_eq!(item.level, Some(50));
1157        assert_eq!(item.rarity, Some("legendary".to_string()));
1158    }
1159
1160    #[test]
1161    fn test_set_and_get_value() {
1162        let db = setup_db();
1163        let serial = "BL4(AwAAAACo4A==)";
1164        db.add_item(serial).unwrap();
1165
1166        db.set_value(
1167            serial,
1168            "name",
1169            "Test Name",
1170            ValueSource::Decoder,
1171            None,
1172            Confidence::Inferred,
1173        )
1174        .unwrap();
1175
1176        let values = db.get_values(serial, "name").unwrap();
1177        assert_eq!(values.len(), 1);
1178        assert_eq!(values[0].value, "Test Name");
1179        assert_eq!(values[0].source, ValueSource::Decoder);
1180        assert_eq!(values[0].confidence, Confidence::Inferred);
1181    }
1182
1183    #[test]
1184    fn test_get_best_values() {
1185        let db = setup_db();
1186        let serial = "BL4(AwAAAACo4A==)";
1187        db.add_item(serial).unwrap();
1188
1189        // Add community value first
1190        db.set_value(
1191            serial,
1192            "name",
1193            "Community Name",
1194            ValueSource::CommunityTool,
1195            Some("test-tool"),
1196            Confidence::Uncertain,
1197        )
1198        .unwrap();
1199
1200        // Add decoder value
1201        db.set_value(
1202            serial,
1203            "name",
1204            "Decoder Name",
1205            ValueSource::Decoder,
1206            None,
1207            Confidence::Inferred,
1208        )
1209        .unwrap();
1210
1211        let best = db.get_best_values(serial).unwrap();
1212        // Decoder should win over CommunityTool
1213        assert_eq!(best.get("name"), Some(&"Decoder Name".to_string()));
1214    }
1215
1216    #[test]
1217    fn test_list_items_with_filter() {
1218        let db = setup_db();
1219
1220        db.add_item("BL4(item1)").unwrap();
1221        db.add_item("BL4(item2)").unwrap();
1222        db.add_item("BL4(item3)").unwrap();
1223
1224        db.update_item(
1225            "BL4(item1)",
1226            &ItemUpdate {
1227                rarity: Some("legendary".to_string()),
1228                ..Default::default()
1229            },
1230        )
1231        .unwrap();
1232        db.update_item(
1233            "BL4(item2)",
1234            &ItemUpdate {
1235                rarity: Some("epic".to_string()),
1236                ..Default::default()
1237            },
1238        )
1239        .unwrap();
1240        db.update_item(
1241            "BL4(item3)",
1242            &ItemUpdate {
1243                rarity: Some("legendary".to_string()),
1244                ..Default::default()
1245            },
1246        )
1247        .unwrap();
1248
1249        let filter = ItemFilter {
1250            rarity: Some("legendary".to_string()),
1251            ..Default::default()
1252        };
1253        let items = db.list_items(&filter).unwrap();
1254        assert_eq!(items.len(), 2);
1255    }
1256
1257    #[test]
1258    fn test_list_items_count() {
1259        let db = setup_db();
1260
1261        db.add_item("BL4(item1)").unwrap();
1262        db.add_item("BL4(item2)").unwrap();
1263        db.add_item("BL4(item3)").unwrap();
1264
1265        let items = db.list_items(&ItemFilter::default()).unwrap();
1266        assert_eq!(items.len(), 3);
1267    }
1268
1269    #[test]
1270    fn test_stats() {
1271        let db = setup_db();
1272
1273        db.add_item("BL4(item1)").unwrap();
1274        db.add_item("BL4(item2)").unwrap();
1275
1276        db.set_value(
1277            "BL4(item1)",
1278            "name",
1279            "Test",
1280            ValueSource::Decoder,
1281            None,
1282            Confidence::Inferred,
1283        )
1284        .unwrap();
1285
1286        let stats = db.stats().unwrap();
1287        assert_eq!(stats.item_count, 2);
1288        assert_eq!(stats.value_count, 1);
1289    }
1290
1291    #[test]
1292    fn test_set_item_type() {
1293        let db = setup_db();
1294        let serial = "BL4(AwAAAACo4A==)";
1295        db.add_item(serial).unwrap();
1296
1297        db.set_item_type(serial, "weapon").unwrap();
1298
1299        let item = db.get_item(serial).unwrap().unwrap();
1300        assert_eq!(item.item_type, Some("weapon".to_string()));
1301    }
1302
1303    #[test]
1304    fn test_set_source() {
1305        let db = setup_db();
1306        let serial = "BL4(AwAAAACo4A==)";
1307        db.add_item(serial).unwrap();
1308
1309        db.set_source(serial, "community-pull").unwrap();
1310
1311        let item = db.get_item(serial).unwrap().unwrap();
1312        assert_eq!(item.source, Some("community-pull".to_string()));
1313    }
1314}