heroforge_core/repo/
database.rs

1use rusqlite::{Connection, OpenFlags, params};
2use std::collections::HashMap;
3use std::path::Path;
4
5use crate::error::{FossilError, Result};
6
7pub struct Database {
8    conn: Connection,
9}
10
11impl Database {
12    /// Open a Heroforge repository database (read-only)
13    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
14        let conn = Connection::open_with_flags(
15            path,
16            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
17        )?;
18
19        // Verify this is a Heroforge repository
20        let is_fossil: bool = conn.query_row(
21            "SELECT COUNT(*) > 0 FROM sqlite_master WHERE name='blob'",
22            [],
23            |row| row.get(0),
24        )?;
25
26        if !is_fossil {
27            return Err(FossilError::NotARepository);
28        }
29
30        Ok(Self { conn })
31    }
32
33    /// Open for read-write
34    pub fn open_rw<P: AsRef<Path>>(path: P) -> Result<Self> {
35        let conn = Connection::open_with_flags(
36            path,
37            OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX,
38        )?;
39        Ok(Self { conn })
40    }
41
42    /// Get raw blob content by hash (still compressed)
43    pub fn get_blob_raw(&self, hash: &str) -> Result<(i64, Vec<u8>)> {
44        // Try exact match first
45        let result: std::result::Result<(i64, Vec<u8>), _> = self.conn.query_row(
46            "SELECT rid, content FROM blob WHERE uuid = ?1",
47            params![hash],
48            |row| Ok((row.get(0)?, row.get(1)?)),
49        );
50
51        if let Ok(r) = result {
52            return Ok(r);
53        }
54
55        // Try prefix match
56        let mut stmt = self
57            .conn
58            .prepare("SELECT rid, content, uuid FROM blob WHERE uuid LIKE ?1 || '%'")?;
59        let mut rows = stmt.query(params![hash])?;
60
61        let first = rows
62            .next()?
63            .ok_or_else(|| FossilError::ArtifactNotFound(hash.to_string()))?;
64        let rid: i64 = first.get(0)?;
65        let content: Vec<u8> = first.get(1)?;
66
67        // Check for ambiguity
68        if rows.next()?.is_some() {
69            return Err(FossilError::AmbiguousHash(hash.to_string()));
70        }
71
72        Ok((rid, content))
73    }
74
75    /// Get raw blob with delta source info for sync
76    /// Returns (raw_content, size, delta_source_hash)
77    pub fn get_blob_for_sync(&self, hash: &str) -> Result<(Vec<u8>, i64, Option<String>)> {
78        let (rid, content) = self.get_blob_raw(hash)?;
79
80        // Get the size from the blob table
81        let size: i64 = self.conn.query_row(
82            "SELECT size FROM blob WHERE rid = ?1",
83            params![rid],
84            |row| row.get(0),
85        )?;
86
87        // Check if this is a delta and get source hash
88        let delta_source: Option<String> = if let Some(src_rid) = self.get_delta_source(rid)? {
89            Some(self.get_hash_by_rid(src_rid)?)
90        } else {
91            None
92        };
93
94        Ok((content, size, delta_source))
95    }
96
97    /// Insert a raw blob directly (for sync - content already compressed)
98    pub fn insert_raw_blob(&self, content: &[u8], hash: &str, size: i64) -> Result<i64> {
99        // Check if blob already exists
100        if let Ok(rid) = self.get_rid_by_hash(hash) {
101            return Ok(rid);
102        }
103
104        self.conn.execute(
105            "INSERT INTO blob(uuid, size, content) VALUES (?1, ?2, ?3)",
106            params![hash, size, content],
107        )?;
108
109        Ok(self.conn.last_insert_rowid())
110    }
111
112    /// Insert delta reference
113    pub fn insert_delta(&self, rid: i64, src_rid: i64) -> Result<()> {
114        self.conn.execute(
115            "INSERT OR REPLACE INTO delta(rid, srcid) VALUES (?1, ?2)",
116            params![rid, src_rid],
117        )?;
118        Ok(())
119    }
120
121    /// Get blob content by rid
122    pub fn get_blob_by_rid(&self, rid: i64) -> Result<Vec<u8>> {
123        let content: Vec<u8> = self
124            .conn
125            .query_row(
126                "SELECT content FROM blob WHERE rid = ?1",
127                params![rid],
128                |row| row.get(0),
129            )
130            .map_err(|_| FossilError::ArtifactNotFound(rid.to_string()))?;
131
132        Ok(content)
133    }
134
135    /// Check if blob is a delta, return source rid if so
136    pub fn get_delta_source(&self, rid: i64) -> Result<Option<i64>> {
137        let result: std::result::Result<i64, _> = self.conn.query_row(
138            "SELECT srcid FROM delta WHERE rid = ?1",
139            params![rid],
140            |row| row.get(0),
141        );
142
143        Ok(result.ok())
144    }
145
146    /// Get hash by rid
147    pub fn get_hash_by_rid(&self, rid: i64) -> Result<String> {
148        let hash: String = self
149            .conn
150            .query_row(
151                "SELECT uuid FROM blob WHERE rid = ?1",
152                params![rid],
153                |row| row.get(0),
154            )
155            .map_err(|_| FossilError::ArtifactNotFound(rid.to_string()))?;
156
157        Ok(hash)
158    }
159
160    /// Get rid by hash (supports prefix matching)
161    pub fn get_rid_by_hash(&self, hash: &str) -> Result<i64> {
162        // Try exact match first
163        let result: std::result::Result<i64, _> = self.conn.query_row(
164            "SELECT rid FROM blob WHERE uuid = ?1",
165            params![hash],
166            |row| row.get(0),
167        );
168
169        if let Ok(rid) = result {
170            return Ok(rid);
171        }
172
173        // Try prefix match
174        let mut stmt = self
175            .conn
176            .prepare("SELECT rid FROM blob WHERE uuid LIKE ?1 || '%'")?;
177        let mut rows = stmt.query(params![hash])?;
178
179        let first = rows
180            .next()?
181            .ok_or_else(|| FossilError::ArtifactNotFound(hash.to_string()))?;
182        let rid: i64 = first.get(0)?;
183
184        // Check for ambiguity
185        if rows.next()?.is_some() {
186            return Err(FossilError::AmbiguousHash(hash.to_string()));
187        }
188
189        Ok(rid)
190    }
191
192    /// Get project code
193    pub fn get_project_code(&self) -> Result<String> {
194        let code: String = self.conn.query_row(
195            "SELECT value FROM config WHERE name = 'project-code'",
196            [],
197            |row| row.get(0),
198        )?;
199        Ok(code)
200    }
201
202    /// Get project name
203    pub fn get_project_name(&self) -> Result<Option<String>> {
204        let result: std::result::Result<String, _> = self.conn.query_row(
205            "SELECT value FROM config WHERE name = 'project-name'",
206            [],
207            |row| row.get(0),
208        );
209        Ok(result.ok())
210    }
211
212    /// Get latest check-in on trunk
213    pub fn get_trunk_tip(&self) -> Result<i64> {
214        // Find the 'trunk' branch tag
215        // Order by mtime DESC, then by rid DESC as tiebreaker for commits in the same second
216        let rid: i64 = self.conn.query_row(
217            r#"SELECT event.objid FROM event
218               JOIN tagxref ON tagxref.rid = event.objid
219               JOIN tag ON tag.tagid = tagxref.tagid
220               WHERE tag.tagname = 'sym-trunk'
221               AND event.type = 'ci'
222               ORDER BY event.mtime DESC, event.objid DESC
223               LIMIT 1"#,
224            [],
225            |row| row.get(0),
226        )?;
227        Ok(rid)
228    }
229
230    /// Get check-in by branch name
231    pub fn get_branch_tip(&self, branch: &str) -> Result<i64> {
232        let tag_name = format!("sym-{}", branch);
233        // Order by mtime DESC, then by rid DESC as tiebreaker for commits in the same second
234        let rid: i64 = self.conn.query_row(
235            r#"SELECT event.objid FROM event
236               JOIN tagxref ON tagxref.rid = event.objid
237               JOIN tag ON tag.tagid = tagxref.tagid
238               WHERE tag.tagname = ?1
239               AND event.type = 'ci'
240               ORDER BY event.mtime DESC, event.objid DESC
241               LIMIT 1"#,
242            params![tag_name],
243            |row| row.get(0),
244        )?;
245        Ok(rid)
246    }
247
248    /// List all branches
249    pub fn list_branches(&self) -> Result<Vec<String>> {
250        let mut stmt = self.conn.prepare(
251            r#"SELECT DISTINCT substr(tagname, 5) FROM tag
252               WHERE tagname LIKE 'sym-%'
253               ORDER BY tagname"#,
254        )?;
255
256        let branches = stmt
257            .query_map([], |row| row.get(0))?
258            .filter_map(|r| r.ok())
259            .collect();
260
261        Ok(branches)
262    }
263
264    /// Get recent check-ins
265    pub fn get_recent_checkins(
266        &self,
267        limit: usize,
268    ) -> Result<Vec<(i64, String, f64, String, String)>> {
269        let mut stmt = self.conn.prepare(
270            r#"SELECT objid,
271                      (SELECT uuid FROM blob WHERE rid = objid),
272                      mtime, user, comment
273               FROM event
274               WHERE type = 'ci'
275               ORDER BY mtime DESC
276               LIMIT ?1"#,
277        )?;
278
279        let checkins = stmt
280            .query_map(params![limit as i64], |row| {
281                Ok((
282                    row.get::<_, i64>(0)?,
283                    row.get::<_, String>(1)?,
284                    row.get::<_, f64>(2)?,
285                    row.get::<_, String>(3)?,
286                    row.get::<_, String>(4)?,
287                ))
288            })?
289            .filter_map(|r| r.ok())
290            .collect();
291
292        Ok(checkins)
293    }
294
295    pub fn connection(&self) -> &Connection {
296        &self.conn
297    }
298
299    /// Create a new empty Heroforge repository
300    pub fn init<P: AsRef<Path>>(path: P) -> Result<Self> {
301        let conn = Connection::open(&path)?;
302
303        // Create the core tables
304        conn.execute_batch(
305            r#"
306            CREATE TABLE IF NOT EXISTS blob(
307                rid INTEGER PRIMARY KEY,
308                rcvid INTEGER,
309                size INTEGER,
310                uuid TEXT UNIQUE NOT NULL,
311                content BLOB,
312                CHECK( length(uuid)>=40 AND rid>0 )
313            );
314            CREATE TABLE IF NOT EXISTS delta(
315                rid INTEGER PRIMARY KEY,
316                srcid INTEGER NOT NULL REFERENCES blob
317            );
318            CREATE TABLE IF NOT EXISTS config(
319                name TEXT PRIMARY KEY NOT NULL,
320                value CLOB,
321                mtime DATE
322            );
323            CREATE TABLE IF NOT EXISTS event(
324                type TEXT,
325                mtime DATETIME,
326                objid INTEGER PRIMARY KEY,
327                tagid INTEGER,
328                uid INTEGER,
329                bgcolor TEXT,
330                euser TEXT,
331                user TEXT,
332                ecomment TEXT,
333                comment TEXT,
334                brief TEXT,
335                omtime DATETIME
336            );
337            CREATE INDEX IF NOT EXISTS event_i1 ON event(mtime);
338            CREATE TABLE IF NOT EXISTS tag(
339                tagid INTEGER PRIMARY KEY,
340                tagname TEXT UNIQUE
341            );
342            CREATE TABLE IF NOT EXISTS tagxref(
343                tagid INTEGER REFERENCES tag,
344                tagtype INTEGER,
345                srcid INTEGER REFERENCES blob,
346                origid INTEGER REFERENCES blob,
347                value TEXT,
348                mtime TIMESTAMP,
349                rid INTEGER REFERENCES blob,
350                UNIQUE(rid, tagid)
351            );
352            CREATE INDEX IF NOT EXISTS tagxref_i1 ON tagxref(tagid, mtime);
353            CREATE TABLE IF NOT EXISTS plink(
354                pid INTEGER REFERENCES blob,
355                cid INTEGER REFERENCES blob,
356                isprim BOOLEAN,
357                mtime DATETIME,
358                baseid INTEGER REFERENCES blob,
359                UNIQUE(pid, cid)
360            );
361            CREATE INDEX IF NOT EXISTS plink_i2 ON plink(cid,pid);
362            CREATE TABLE IF NOT EXISTS filename(
363                fnid INTEGER PRIMARY KEY,
364                name TEXT UNIQUE
365            );
366            CREATE TABLE IF NOT EXISTS mlink(
367                mid INTEGER,
368                fid INTEGER,
369                pmid INTEGER,
370                pid INTEGER,
371                fnid INTEGER REFERENCES filename,
372                pfnid INTEGER,
373                mperm INTEGER,
374                isaux BOOLEAN DEFAULT 0
375            );
376            CREATE INDEX IF NOT EXISTS mlink_i1 ON mlink(mid);
377            CREATE INDEX IF NOT EXISTS mlink_i2 ON mlink(fnid);
378            CREATE INDEX IF NOT EXISTS mlink_i3 ON mlink(fid);
379            CREATE INDEX IF NOT EXISTS mlink_i4 ON mlink(pid);
380            CREATE INDEX IF NOT EXISTS blob_uuid ON blob(uuid);
381            CREATE TABLE IF NOT EXISTS rcvfrom(
382                rcvid INTEGER PRIMARY KEY,
383                uid INTEGER,
384                mtime DATETIME,
385                nonce TEXT UNIQUE,
386                ipaddr TEXT
387            );
388            "#,
389        )?;
390
391        // Generate project and server codes
392        let project_code = Self::generate_hash(uuid::Uuid::new_v4().to_string().as_bytes());
393        let server_code = Self::generate_hash(uuid::Uuid::new_v4().to_string().as_bytes());
394
395        // Insert required config entries
396        conn.execute(
397            "INSERT INTO config(name, value, mtime) VALUES ('project-code', ?1, julianday('now'))",
398            params![project_code],
399        )?;
400        conn.execute(
401            "INSERT INTO config(name, value, mtime) VALUES ('server-code', ?1, julianday('now'))",
402            params![server_code],
403        )?;
404        conn.execute(
405            "INSERT INTO config(name, value, mtime) VALUES ('content-schema', '2', julianday('now'))",
406            [],
407        )?;
408        conn.execute(
409            "INSERT INTO config(name, value, mtime) VALUES ('aux-schema', '2015-01-24', julianday('now'))",
410            [],
411        )?;
412        conn.execute(
413            "INSERT INTO config(name, value, mtime) VALUES ('hash-policy', '2', julianday('now'))",
414            [],
415        )?;
416
417        // Create additional tables that heroforge expects
418        conn.execute_batch(
419            r#"
420            CREATE TABLE IF NOT EXISTS user(
421                uid INTEGER PRIMARY KEY,
422                login TEXT UNIQUE,
423                pw TEXT,
424                cap TEXT,
425                cookie TEXT,
426                ipaddr TEXT,
427                cexpire DATETIME,
428                info TEXT,
429                mtime DATE,
430                photo BLOB
431            );
432            CREATE TABLE IF NOT EXISTS leaf(
433                rid INTEGER PRIMARY KEY
434            );
435            CREATE TABLE IF NOT EXISTS phantom(
436                rid INTEGER PRIMARY KEY
437            );
438            CREATE TABLE IF NOT EXISTS orphan(
439                rid INTEGER PRIMARY KEY,
440                baseline INTEGER
441            );
442            CREATE TABLE IF NOT EXISTS unclustered(
443                rid INTEGER PRIMARY KEY
444            );
445            CREATE TABLE IF NOT EXISTS unsent(
446                rid INTEGER PRIMARY KEY
447            );
448            CREATE TABLE IF NOT EXISTS private(
449                rid INTEGER PRIMARY KEY
450            );
451            CREATE TABLE IF NOT EXISTS shun(
452                uuid UNIQUE,
453                mtime DATE,
454                scom TEXT
455            );
456            CREATE TABLE IF NOT EXISTS reportfmt(
457                rn INTEGER PRIMARY KEY,
458                owner TEXT,
459                title TEXT UNIQUE,
460                mtime DATE,
461                cols TEXT,
462                sqlcode TEXT
463            );
464            CREATE TABLE IF NOT EXISTS concealed(
465                hash TEXT PRIMARY KEY,
466                content TEXT
467            );
468            CREATE TABLE IF NOT EXISTS cherrypick(
469                parentid INT,
470                childid INT,
471                isExclude BOOLEAN DEFAULT false,
472                PRIMARY KEY(parentid, childid)
473            ) WITHOUT ROWID;
474            CREATE TABLE IF NOT EXISTS backlink(
475                target TEXT,
476                srctype INT,
477                srcid INT,
478                mtime TIMESTAMP,
479                UNIQUE(target, srctype, srcid)
480            );
481            CREATE TABLE IF NOT EXISTS attachment(
482                attachid INTEGER PRIMARY KEY,
483                isLatest BOOLEAN DEFAULT 0,
484                mtime TIMESTAMP,
485                src TEXT,
486                target TEXT,
487                filename TEXT,
488                comment TEXT,
489                user TEXT
490            );
491            CREATE TABLE IF NOT EXISTS ticket(tkt_id INTEGER PRIMARY KEY, tkt_uuid TEXT UNIQUE, tkt_mtime DATE, tkt_ctime DATE);
492            CREATE TABLE IF NOT EXISTS ticketchng(tkt_id INTEGER PRIMARY KEY, tkt_rid INT, tkt_mtime DATE, tkt_user TEXT);
493            "#,
494        )?;
495
496        let db = Self { conn };
497
498        // Create default users with appropriate capabilities
499        // Capabilities: a=admin, d=delete, e=email, g=clone, i=check-in, j=read-wiki,
500        // k=write-wiki, m=moderate, n=new-wiki, o=check-out, p=password, r=read,
501        // s=setup, t=ticket, u=reader, v=developer, w=write-ticket, x=private, z=download
502
503        // anonymous - public read access
504        db.create_user("anonymous", "", "cgjorz")?;
505
506        // nobody - minimal access
507        db.create_user("nobody", "", "")?;
508
509        // developer - full developer access (will be used for commits)
510        db.create_user("developer", "", "deghijkmnorstvwz")?;
511
512        Ok(db)
513    }
514
515    /// Generate SHA3-256 hash
516    pub fn generate_hash(data: &[u8]) -> String {
517        use sha3::{Digest, Sha3_256};
518        let mut hasher = Sha3_256::new();
519        hasher.update(data);
520        hex::encode(hasher.finalize())
521    }
522
523    /// Insert a blob and return its rid (uses INSERT OR IGNORE for efficiency)
524    pub fn insert_blob(&self, content: &[u8], hash: &str, size: i64) -> Result<i64> {
525        // Use INSERT OR IGNORE to avoid separate existence check
526        self.conn.execute(
527            "INSERT OR IGNORE INTO blob(uuid, size, content) VALUES (?1, ?2, ?3)",
528            params![hash, size, content],
529        )?;
530
531        // Get the rid (either just inserted or existing)
532        let rid: i64 = self.conn.query_row(
533            "SELECT rid FROM blob WHERE uuid = ?1",
534            params![hash],
535            |row| row.get(0),
536        )?;
537
538        Ok(rid)
539    }
540
541    /// Batch insert blobs and return a map of hash -> rid
542    /// Uses cached prepared statements for efficiency
543    pub fn insert_blobs(&self, blobs: &[(&[u8], &str, i64)]) -> Result<HashMap<String, i64>> {
544        // Insert all blobs using cached statement
545        {
546            let mut insert_stmt = self.conn.prepare_cached(
547                "INSERT OR IGNORE INTO blob(uuid, size, content) VALUES (?1, ?2, ?3)",
548            )?;
549            for (content, hash, size) in blobs {
550                insert_stmt.execute(params![hash, size, content])?;
551            }
552        }
553
554        // Get all rids using cached statement
555        let mut result = HashMap::new();
556        {
557            let mut select_stmt = self
558                .conn
559                .prepare_cached("SELECT rid FROM blob WHERE uuid = ?1")?;
560            for (_, hash, _) in blobs {
561                let rid: i64 = select_stmt.query_row(params![hash], |row| row.get(0))?;
562                result.insert(hash.to_string(), rid);
563            }
564        }
565
566        Ok(result)
567    }
568
569    /// Get or create filename id
570    pub fn get_or_create_filename(&self, name: &str) -> Result<i64> {
571        // Insert if not exists, then get the id
572        self.conn.execute(
573            "INSERT OR IGNORE INTO filename(name) VALUES (?1)",
574            params![name],
575        )?;
576
577        let fnid: i64 = self.conn.query_row(
578            "SELECT fnid FROM filename WHERE name = ?1",
579            params![name],
580            |row| row.get(0),
581        )?;
582
583        Ok(fnid)
584    }
585
586    /// Batch insert filenames and return a map of name -> fnid
587    /// Uses cached prepared statements for efficiency
588    pub fn get_or_create_filenames(&self, names: &[&str]) -> Result<HashMap<String, i64>> {
589        // Use cached prepared statements for batch operations
590        {
591            let mut insert_stmt = self
592                .conn
593                .prepare_cached("INSERT OR IGNORE INTO filename(name) VALUES (?1)")?;
594            for name in names {
595                insert_stmt.execute(params![name])?;
596            }
597        }
598
599        // Fetch all fnids using cached statement
600        let mut result = HashMap::new();
601        {
602            let mut select_stmt = self
603                .conn
604                .prepare_cached("SELECT fnid FROM filename WHERE name = ?1")?;
605            for name in names {
606                let fnid: i64 = select_stmt.query_row(params![name], |row| row.get(0))?;
607                result.insert(name.to_string(), fnid);
608            }
609        }
610
611        Ok(result)
612    }
613
614    /// Get or create tag id
615    pub fn get_or_create_tag(&self, tagname: &str) -> Result<i64> {
616        let existing: std::result::Result<i64, _> = self.conn.query_row(
617            "SELECT tagid FROM tag WHERE tagname = ?1",
618            params![tagname],
619            |row| row.get(0),
620        );
621
622        if let Ok(tagid) = existing {
623            return Ok(tagid);
624        }
625
626        self.conn
627            .execute("INSERT INTO tag(tagname) VALUES (?1)", params![tagname])?;
628
629        Ok(self.conn.last_insert_rowid())
630    }
631
632    /// Insert event record
633    pub fn insert_event(
634        &self,
635        event_type: &str,
636        objid: i64,
637        mtime: f64,
638        user: &str,
639        comment: &str,
640    ) -> Result<()> {
641        self.conn.execute(
642            "INSERT INTO event(type, objid, mtime, user, comment) VALUES (?1, ?2, ?3, ?4, ?5)",
643            params![event_type, objid, mtime, user, comment],
644        )?;
645        Ok(())
646    }
647
648    /// Insert tagxref record
649    pub fn insert_tagxref(
650        &self,
651        tagid: i64,
652        tagtype: i64,
653        rid: i64,
654        mtime: f64,
655        value: Option<&str>,
656    ) -> Result<()> {
657        self.conn.execute(
658            "INSERT OR REPLACE INTO tagxref(tagid, tagtype, rid, mtime, value, srcid, origid)
659             VALUES (?1, ?2, ?3, ?4, ?5, ?3, ?3)",
660            params![tagid, tagtype, rid, mtime, value],
661        )?;
662        Ok(())
663    }
664
665    /// Insert a leaf node
666    pub fn insert_leaf(&self, rid: i64) -> Result<()> {
667        self.conn
668            .execute("INSERT OR IGNORE INTO leaf(rid) VALUES (?1)", params![rid])?;
669        Ok(())
670    }
671
672    /// Insert plink (parent link) record and update leaf table
673    pub fn insert_plink(&self, parent_rid: i64, child_rid: i64, mtime: f64) -> Result<()> {
674        self.conn.execute(
675            "INSERT OR IGNORE INTO plink(pid, cid, isprim, mtime) VALUES (?1, ?2, 1, ?3)",
676            params![parent_rid, child_rid, mtime],
677        )?;
678
679        // Parent is no longer a leaf (it has a child now)
680        self.conn
681            .execute("DELETE FROM leaf WHERE rid = ?1", params![parent_rid])?;
682
683        // Child is a leaf (until it gets its own child)
684        self.conn.execute(
685            "INSERT OR IGNORE INTO leaf(rid) VALUES (?1)",
686            params![child_rid],
687        )?;
688
689        Ok(())
690    }
691
692    /// Get all files for a manifest (checkin) directly from mlink table
693    /// Returns (filename, file_hash) pairs
694    pub fn get_files_for_manifest(&self, manifest_rid: i64) -> Result<Vec<(String, String)>> {
695        let mut stmt = self.conn.prepare(
696            r#"SELECT f.name, b.uuid
697               FROM mlink m
698               JOIN filename f ON f.fnid = m.fnid
699               JOIN blob b ON b.rid = m.fid
700               WHERE m.mid = ?1 AND m.fid > 0
701               ORDER BY f.name"#,
702        )?;
703
704        let files = stmt
705            .query_map(params![manifest_rid], |row| {
706                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
707            })?
708            .filter_map(|r| r.ok())
709            .collect();
710
711        Ok(files)
712    }
713
714    /// Get a single file's hash from a manifest by filename
715    pub fn get_file_hash_from_manifest(&self, manifest_rid: i64, filename: &str) -> Result<String> {
716        let hash: String = self
717            .conn
718            .query_row(
719                r#"SELECT b.uuid
720               FROM mlink m
721               JOIN filename f ON f.fnid = m.fnid
722               JOIN blob b ON b.rid = m.fid
723               WHERE m.mid = ?1 AND f.name = ?2 AND m.fid > 0"#,
724                params![manifest_rid, filename],
725                |row| row.get(0),
726            )
727            .map_err(|_| FossilError::ArtifactNotFound(format!("file: {}", filename)))?;
728
729        Ok(hash)
730    }
731
732    /// Insert mlink (manifest link) record
733    pub fn insert_mlink(
734        &self,
735        manifest_rid: i64,
736        file_rid: i64,
737        filename_id: i64,
738        parent_file_rid: Option<i64>,
739    ) -> Result<()> {
740        self.conn.execute(
741            "INSERT INTO mlink(mid, fid, fnid, pid) VALUES (?1, ?2, ?3, ?4)",
742            params![manifest_rid, file_rid, filename_id, parent_file_rid],
743        )?;
744        Ok(())
745    }
746
747    /// Batch insert mlink records using cached prepared statement
748    pub fn insert_mlinks(
749        &self,
750        manifest_rid: i64,
751        entries: &[(i64, i64)], // (file_rid, filename_id)
752    ) -> Result<()> {
753        let mut stmt = self
754            .conn
755            .prepare_cached("INSERT INTO mlink(mid, fid, fnid) VALUES (?1, ?2, ?3)")?;
756        for (file_rid, filename_id) in entries {
757            stmt.execute(params![manifest_rid, file_rid, filename_id])?;
758        }
759        Ok(())
760    }
761
762    /// Create a user
763    pub fn create_user(&self, login: &str, password: &str, capabilities: &str) -> Result<()> {
764        self.conn.execute(
765            "INSERT OR REPLACE INTO user(login, pw, cap, mtime) VALUES (?1, ?2, ?3, julianday('now'))",
766            params![login, password, capabilities],
767        )?;
768        Ok(())
769    }
770
771    /// Set user capabilities
772    pub fn set_user_capabilities(&self, login: &str, capabilities: &str) -> Result<()> {
773        self.conn.execute(
774            "UPDATE user SET cap = ?2, mtime = julianday('now') WHERE login = ?1",
775            params![login, capabilities],
776        )?;
777        Ok(())
778    }
779
780    /// Get user capabilities
781    pub fn get_user_capabilities(&self, login: &str) -> Result<Option<String>> {
782        let result: std::result::Result<String, _> = self.conn.query_row(
783            "SELECT cap FROM user WHERE login = ?1",
784            params![login],
785            |row| row.get(0),
786        );
787        Ok(result.ok())
788    }
789
790    /// List all users
791    pub fn list_users(&self) -> Result<Vec<(String, String)>> {
792        let mut stmt = self
793            .conn
794            .prepare("SELECT login, cap FROM user ORDER BY login")?;
795        let users = stmt
796            .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
797            .filter_map(|r| r.ok())
798            .collect();
799        Ok(users)
800    }
801
802    /// Begin transaction
803    pub fn begin_transaction(&self) -> Result<()> {
804        self.conn.execute("BEGIN IMMEDIATE", [])?;
805        Ok(())
806    }
807
808    /// Commit transaction
809    pub fn commit_transaction(&self) -> Result<()> {
810        self.conn.execute("COMMIT", [])?;
811        Ok(())
812    }
813
814    /// Rollback transaction
815    pub fn rollback_transaction(&self) -> Result<()> {
816        self.conn.execute("ROLLBACK", [])?;
817        Ok(())
818    }
819}