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