Skip to main content

suture_core/metadata/
mod.rs

1//! Metadata — persistent storage and global configuration.
2//!
3//! Uses SQLite in WAL mode for concurrent read access. The metadata store
4//! is the persistent backing for the in-memory PatchDag.
5
6#[doc(hidden)]
7pub(crate) mod global_config;
8#[doc(hidden)]
9pub(crate) mod repo_config;
10
11use crate::engine::tree::FileTree;
12use crate::patch::types::{Patch, PatchId, TouchSet};
13use rusqlite::{Connection, params};
14use std::collections::BTreeMap;
15use std::path::Path;
16use suture_common::{BranchName, FileStatus, Hash, RepoPath};
17use thiserror::Error;
18
19/// Metadata store errors.
20#[derive(Error, Debug)]
21pub enum MetaError {
22    #[error("database error: {0}")]
23    Database(#[from] rusqlite::Error),
24
25    #[error("I/O error: {0}")]
26    Io(#[from] std::io::Error),
27
28    #[error("patch not found: {0}")]
29    PatchNotFound(String),
30
31    #[error("branch not found: {0}")]
32    BranchNotFound(String),
33
34    #[error("corrupt metadata: {0}")]
35    Corrupt(String),
36
37    #[error("migration failed: {0}")]
38    MigrationFailed(String),
39
40    #[error("{0}")]
41    Custom(String),
42}
43
44/// The SQLite metadata store.
45pub struct MetadataStore {
46    conn: Connection,
47}
48
49/// Current schema version.
50#[allow(dead_code)]
51const SCHEMA_VERSION: i32 = 2;
52
53impl MetadataStore {
54    /// Open or create a metadata database at the given path.
55    pub fn open(path: &Path) -> Result<Self, MetaError> {
56        let conn = Connection::open(path)?;
57
58        // Enable WAL mode for better concurrency
59        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
60
61        let mut store = Self { conn };
62        store.migrate()?;
63        Ok(store)
64    }
65
66    /// Open an in-memory metadata database (for testing).
67    pub fn open_in_memory() -> Result<Self, MetaError> {
68        let conn = Connection::open_in_memory()?;
69        conn.execute_batch("PRAGMA journal_mode=WAL;")?;
70        let mut store = Self { conn };
71        store.migrate()?;
72        Ok(store)
73    }
74
75    /// Get a reference to the underlying SQLite connection.
76    ///
77    /// Used internally for direct queries that don't have dedicated methods.
78    pub fn conn(&self) -> &Connection {
79        &self.conn
80    }
81
82    /// Run schema migrations.
83    fn migrate(&mut self) -> Result<(), MetaError> {
84        // Create schema_version table
85        self.conn.execute_batch(
86            "CREATE TABLE IF NOT EXISTS schema_version (
87                version INTEGER PRIMARY KEY,
88                applied_at TEXT NOT NULL DEFAULT (datetime('now'))
89            );",
90        )?;
91
92        let current_version: i32 = self
93            .conn
94            .query_row(
95                "SELECT COALESCE(MAX(version), 0) FROM schema_version",
96                [],
97                |row| row.get(0),
98            )
99            .unwrap_or(0);
100
101        if current_version < 1 {
102            self.conn.execute_batch(
103                "CREATE TABLE IF NOT EXISTS patches (
104                    id TEXT PRIMARY KEY,
105                    parent_ids TEXT NOT NULL,
106                    operation_type TEXT NOT NULL,
107                    touch_set TEXT NOT NULL,
108                    target_path TEXT,
109                    payload BLOB,
110                    timestamp INTEGER NOT NULL,
111                    author TEXT NOT NULL,
112                    message TEXT NOT NULL
113                );
114
115                CREATE TABLE IF NOT EXISTS edges (
116                    parent_id TEXT NOT NULL,
117                    child_id TEXT NOT NULL,
118                    PRIMARY KEY (parent_id, child_id)
119                );
120
121                CREATE TABLE IF NOT EXISTS branches (
122                    name TEXT PRIMARY KEY,
123                    target_patch_id TEXT NOT NULL,
124                    created_at TEXT NOT NULL DEFAULT (datetime('now'))
125                );
126
127                CREATE TABLE IF NOT EXISTS working_set (
128                    path TEXT PRIMARY KEY,
129                    patch_id TEXT,
130                    status TEXT NOT NULL
131                );
132
133                CREATE TABLE IF NOT EXISTS config (
134                    key TEXT PRIMARY KEY,
135                    value TEXT NOT NULL
136                );
137
138                CREATE INDEX IF NOT EXISTS idx_edges_parent ON edges(parent_id);
139                CREATE INDEX IF NOT EXISTS idx_edges_child ON edges(child_id);
140                CREATE INDEX IF NOT EXISTS idx_branches_target ON branches(target_patch_id);
141
142                CREATE TABLE IF NOT EXISTS public_keys (
143                    author TEXT PRIMARY KEY,
144                    public_key BLOB NOT NULL
145                );
146
147                CREATE TABLE IF NOT EXISTS signatures (
148                    patch_id TEXT PRIMARY KEY,
149                    signature BLOB NOT NULL
150                );
151                ",
152            )?;
153
154            self.conn.execute(
155                "INSERT INTO schema_version (version) VALUES (?)",
156                params![1],
157            )?;
158        }
159
160        if current_version < 2 {
161            self.conn.execute_batch(
162                "CREATE TABLE IF NOT EXISTS file_trees (
163                    patch_id TEXT NOT NULL,
164                    path TEXT NOT NULL,
165                    blob_hash TEXT NOT NULL,
166                    PRIMARY KEY (patch_id, path)
167                );
168
169                CREATE INDEX IF NOT EXISTS idx_file_trees_patch ON file_trees(patch_id);
170                CREATE INDEX IF NOT EXISTS idx_file_trees_path ON file_trees(path);
171
172                CREATE TABLE IF NOT EXISTS reflog (
173                    id INTEGER PRIMARY KEY AUTOINCREMENT,
174                    old_head TEXT NOT NULL,
175                    new_head TEXT NOT NULL,
176                    message TEXT NOT NULL,
177                    timestamp INTEGER NOT NULL
178                );
179
180                CREATE INDEX IF NOT EXISTS idx_reflog_timestamp ON reflog(timestamp);
181                ",
182            )?;
183
184            self.conn.execute(
185                "INSERT INTO schema_version (version) VALUES (?)",
186                params![2],
187            )?;
188        }
189
190        Ok(())
191    }
192
193    /// Store a patch in the metadata database.
194    pub fn store_patch(&self, patch: &Patch) -> Result<(), MetaError> {
195        let parent_ids_json = serde_json::to_string(&patch.parent_ids)
196            .map_err(|e| MetaError::Corrupt(e.to_string()))?;
197        let touch_set_json = serde_json::to_string(&patch.touch_set.iter().collect::<Vec<_>>())
198            .map_err(|e| MetaError::Corrupt(e.to_string()))?;
199
200        self.conn.execute(
201            "INSERT OR REPLACE INTO patches (id, parent_ids, operation_type, touch_set, target_path, payload, timestamp, author, message)
202             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
203            params![
204                patch.id.to_hex(),
205                parent_ids_json,
206                patch.operation_type.to_string(),
207                touch_set_json,
208                patch.target_path.as_deref(),
209                &patch.payload,
210                patch.timestamp as i64,
211                &patch.author,
212                &patch.message,
213            ],
214        )?;
215
216        // Store edges
217        for parent_id in &patch.parent_ids {
218            self.conn.execute(
219                "INSERT OR IGNORE INTO edges (parent_id, child_id) VALUES (?1, ?2)",
220                params![parent_id.to_hex(), patch.id.to_hex()],
221            )?;
222        }
223
224        Ok(())
225    }
226
227    /// Retrieve a patch by ID.
228    pub fn get_patch(&self, id: &PatchId) -> Result<Patch, MetaError> {
229        let hex = id.to_hex();
230        self.conn
231            .query_row(
232                "SELECT id, parent_ids, operation_type, touch_set, target_path, payload, timestamp, author, message
233                 FROM patches WHERE id = ?1",
234                params![hex],
235                |row| {
236                    let parent_ids_json: String = row.get(1)?;
237                    let op_type_str: String = row.get(2)?;
238                    let touch_set_json: String = row.get(3)?;
239                    let target_path: Option<String> = row.get(4)?;
240                    let payload: Vec<u8> = row.get(5)?;
241                    let timestamp: i64 = row.get(6)?;
242                    let author: String = row.get(7)?;
243                    let message: String = row.get(8)?;
244
245                    let parent_ids: Vec<PatchId> = serde_json::from_str(&parent_ids_json)
246                        .unwrap_or_default();
247                    let touch_addrs: Vec<String> = serde_json::from_str(&touch_set_json)
248                        .unwrap_or_default();
249                    let touch_set = TouchSet::from_addrs(touch_addrs);
250
251                    let op_type = match op_type_str.as_str() {
252                        "create" => crate::patch::types::OperationType::Create,
253                        "delete" => crate::patch::types::OperationType::Delete,
254                        "modify" => crate::patch::types::OperationType::Modify,
255                        "move" => crate::patch::types::OperationType::Move,
256                        "metadata" => crate::patch::types::OperationType::Metadata,
257                        "merge" => crate::patch::types::OperationType::Merge,
258                        "identity" => crate::patch::types::OperationType::Identity,
259                        "batch" => crate::patch::types::OperationType::Batch,
260                        _ => crate::patch::types::OperationType::Modify,
261                    };
262
263                    Ok(Patch {
264                        id: *id,
265                        parent_ids,
266                        operation_type: op_type,
267                        touch_set,
268                        target_path,
269                        payload,
270                        timestamp: timestamp as u64,
271                        author,
272                        message,
273                    })
274                },
275            )
276            .map_err(|_| MetaError::PatchNotFound(hex))
277    }
278
279    /// Store a branch pointer.
280    pub fn set_branch(&self, name: &BranchName, target: &PatchId) -> Result<(), MetaError> {
281        self.conn.execute(
282            "INSERT OR REPLACE INTO branches (name, target_patch_id) VALUES (?1, ?2)",
283            params![name.as_str(), target.to_hex()],
284        )?;
285        Ok(())
286    }
287
288    /// Get a branch target.
289    pub fn get_branch(&self, name: &BranchName) -> Result<PatchId, MetaError> {
290        let hex: String = self
291            .conn
292            .query_row(
293                "SELECT target_patch_id FROM branches WHERE name = ?1",
294                params![name.as_str()],
295                |row| row.get(0),
296            )
297            .map_err(|_| MetaError::BranchNotFound(name.as_str().to_string()))?;
298
299        PatchId::from_hex(&hex).map_err(|e| MetaError::Corrupt(e.to_string()))
300    }
301
302    /// List all branches.
303    pub fn list_branches(&self) -> Result<Vec<(String, PatchId)>, MetaError> {
304        let mut stmt = self
305            .conn
306            .prepare("SELECT name, target_patch_id FROM branches ORDER BY name")?;
307
308        let branches = stmt
309            .query_map([], |row| {
310                let name: String = row.get(0)?;
311                let target_hex: String = row.get(1)?;
312                Ok((name, target_hex))
313            })?
314            .filter_map(|r| {
315                r.ok()
316                    .and_then(|(name, hex)| Hash::from_hex(&hex).ok().map(|id| (name, id)))
317            })
318            .collect();
319
320        Ok(branches)
321    }
322
323    /// Store a DAG edge.
324    pub fn store_edge(&self, parent: &PatchId, child: &PatchId) -> Result<(), MetaError> {
325        self.conn.execute(
326            "INSERT OR IGNORE INTO edges (parent_id, child_id) VALUES (?1, ?2)",
327            params![parent.to_hex(), child.to_hex()],
328        )?;
329        Ok(())
330    }
331
332    /// Get parent and child IDs for a patch.
333    pub fn get_edges(&self, patch_id: &PatchId) -> Result<(Vec<PatchId>, Vec<PatchId>), MetaError> {
334        let hex = patch_id.to_hex();
335
336        let parents: Vec<PatchId> = {
337            let mut stmt = self
338                .conn
339                .prepare("SELECT parent_id FROM edges WHERE child_id = ?1")?;
340            let rows = stmt.query_map(params![hex], |row| row.get::<_, String>(0))?;
341            rows.filter_map(|r| r.ok().and_then(|h| Hash::from_hex(&h).ok()))
342                .collect()
343        };
344
345        let children: Vec<PatchId> = {
346            let mut stmt = self
347                .conn
348                .prepare("SELECT child_id FROM edges WHERE parent_id = ?1")?;
349            let rows = stmt.query_map(params![hex], |row| row.get::<_, String>(0))?;
350            rows.filter_map(|r| r.ok().and_then(|h| Hash::from_hex(&h).ok()))
351                .collect()
352        };
353
354        Ok((parents, children))
355    }
356
357    /// Add a file to the working set.
358    pub fn working_set_add(&self, path: &RepoPath, status: FileStatus) -> Result<(), MetaError> {
359        self.conn.execute(
360            "INSERT OR REPLACE INTO working_set (path, status) VALUES (?1, ?2)",
361            params![path.as_str(), format!("{:?}", status).to_lowercase()],
362        )?;
363        Ok(())
364    }
365
366    /// Remove a file from the working set.
367    pub fn working_set_remove(&self, path: &RepoPath) -> Result<(), MetaError> {
368        self.conn.execute(
369            "DELETE FROM working_set WHERE path = ?1",
370            params![path.as_str()],
371        )?;
372        Ok(())
373    }
374
375    /// Get the working set.
376    pub fn working_set(&self) -> Result<Vec<(String, FileStatus)>, MetaError> {
377        let mut stmt = self
378            .conn
379            .prepare("SELECT path, status FROM working_set ORDER BY path")?;
380
381        let entries = stmt
382            .query_map([], |row| {
383                let path: String = row.get(0)?;
384                let status_str: String = row.get(1)?;
385                let status = match status_str.as_str() {
386                    "added" => FileStatus::Added,
387                    "modified" => FileStatus::Modified,
388                    "deleted" => FileStatus::Deleted,
389                    "clean" => FileStatus::Clean,
390                    _ => FileStatus::Untracked,
391                };
392                Ok((path, status))
393            })?
394            .filter_map(|r| r.ok())
395            .collect();
396
397        Ok(entries)
398    }
399
400    /// Store a configuration value.
401    pub fn set_config(&self, key: &str, value: &str) -> Result<(), MetaError> {
402        self.conn.execute(
403            "INSERT OR REPLACE INTO config (key, value) VALUES (?1, ?2)",
404            params![key, value],
405        )?;
406        Ok(())
407    }
408
409    /// List all config key-value pairs.
410    pub fn list_config(&self) -> Result<Vec<(String, String)>, MetaError> {
411        let mut stmt = self
412            .conn
413            .prepare("SELECT key, value FROM config ORDER BY key")?;
414        let rows = stmt.query_map([], |row| {
415            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
416        })?;
417        let mut result = Vec::new();
418        for row in rows {
419            let Ok(pair) = row else { continue };
420            result.push(pair);
421        }
422        Ok(result)
423    }
424
425    /// Delete a config key.
426    pub fn delete_config(&self, key: &str) -> Result<(), MetaError> {
427        self.conn
428            .execute("DELETE FROM config WHERE key = ?", [key])?;
429        Ok(())
430    }
431
432    /// Get a configuration value.
433    pub fn get_config(&self, key: &str) -> Result<Option<String>, MetaError> {
434        let result = self.conn.query_row(
435            "SELECT value FROM config WHERE key = ?1",
436            params![key],
437            |row| row.get::<_, String>(0),
438        );
439
440        match result {
441            Ok(value) => Ok(Some(value)),
442            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
443            Err(e) => Err(MetaError::Database(e)),
444        }
445    }
446
447    /// Get the number of patches stored.
448    pub fn patch_count(&self) -> Result<i64, MetaError> {
449        let count: i64 = self
450            .conn
451            .query_row("SELECT COUNT(*) FROM patches", [], |row| row.get(0))?;
452        Ok(count)
453    }
454
455    pub fn store_public_key(&self, author: &str, public_key_bytes: &[u8]) -> Result<(), MetaError> {
456        self.conn
457            .execute(
458                "INSERT OR REPLACE INTO public_keys (author, public_key) VALUES (?1, ?2)",
459                params![author, public_key_bytes],
460            )
461            .map_err(|e| MetaError::Custom(e.to_string()))?;
462        Ok(())
463    }
464
465    pub fn get_public_key(&self, author: &str) -> Result<Option<Vec<u8>>, MetaError> {
466        let mut stmt = self
467            .conn
468            .prepare("SELECT public_key FROM public_keys WHERE author = ?1")
469            .map_err(|e| MetaError::Custom(e.to_string()))?;
470        let result = stmt.query_row(params![author], |row| row.get::<_, Vec<u8>>(0));
471        match result {
472            Ok(bytes) => Ok(Some(bytes)),
473            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
474            Err(e) => Err(MetaError::Custom(e.to_string())),
475        }
476    }
477
478    pub fn store_signature(&self, patch_id: &str, signature_bytes: &[u8]) -> Result<(), MetaError> {
479        self.conn
480            .execute(
481                "INSERT OR REPLACE INTO signatures (patch_id, signature) VALUES (?1, ?2)",
482                params![patch_id, signature_bytes],
483            )
484            .map_err(|e| MetaError::Custom(e.to_string()))?;
485        Ok(())
486    }
487
488    pub fn get_signature(&self, patch_id: &str) -> Result<Option<Vec<u8>>, MetaError> {
489        let mut stmt = self
490            .conn
491            .prepare("SELECT signature FROM signatures WHERE patch_id = ?1")
492            .map_err(|e| MetaError::Custom(e.to_string()))?;
493        let result = stmt.query_row(params![patch_id], |row| row.get::<_, Vec<u8>>(0));
494        match result {
495            Ok(bytes) => Ok(Some(bytes)),
496            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
497            Err(e) => Err(MetaError::Custom(e.to_string())),
498        }
499    }
500
501    // =========================================================================
502    // File Trees (persistent snapshot storage)
503    // =========================================================================
504
505    /// Store a FileTree for a given patch ID.
506    ///
507    /// Replaces all existing entries for that patch_id (DELETE + INSERT in transaction).
508    pub fn store_file_tree(&self, patch_id: &PatchId, tree: &FileTree) -> Result<(), MetaError> {
509        let hex = patch_id.to_hex();
510        self.conn
511            .execute("DELETE FROM file_trees WHERE patch_id = ?1", params![hex])?;
512
513        let tx = self.conn.unchecked_transaction()?;
514        for (path, hash) in tree.iter() {
515            tx.execute(
516                "INSERT INTO file_trees (patch_id, path, blob_hash) VALUES (?1, ?2, ?3)",
517                params![hex, path.as_str(), hash.to_hex()],
518            )?;
519        }
520        tx.commit()?;
521        Ok(())
522    }
523
524    /// Load a FileTree for a given patch ID from the database.
525    ///
526    /// Returns `None` if no entries exist for that patch_id.
527    pub fn load_file_tree(&self, patch_id: &PatchId) -> Result<Option<FileTree>, MetaError> {
528        let hex = patch_id.to_hex();
529        let mut stmt = self
530            .conn
531            .prepare("SELECT path, blob_hash FROM file_trees WHERE patch_id = ?1 ORDER BY path")?;
532
533        let entries: BTreeMap<String, Hash> = stmt
534            .query_map(params![hex], |row| {
535                let path: String = row.get(0)?;
536                let hash_hex: String = row.get(1)?;
537                Ok((path, hash_hex))
538            })?
539            .filter_map(|r| {
540                r.ok().and_then(|(path, hash_hex)| {
541                    Hash::from_hex(&hash_hex).ok().map(|hash| (path, hash))
542                })
543            })
544            .collect();
545
546        if entries.is_empty() {
547            Ok(None)
548        } else {
549            Ok(Some(FileTree::from_map(entries)))
550        }
551    }
552
553    /// Check if a path exists in the file tree for a given patch ID.
554    pub fn file_tree_contains(&self, patch_id: &PatchId, path: &str) -> Result<bool, MetaError> {
555        let hex = patch_id.to_hex();
556        let result: i64 = self.conn.query_row(
557            "SELECT COUNT(*) FROM file_trees WHERE patch_id = ?1 AND path = ?2",
558            params![hex, path],
559            |row| row.get(0),
560        )?;
561        Ok(result > 0)
562    }
563
564    // =========================================================================
565    // Reflog (persistent operation log)
566    // =========================================================================
567
568    /// Append an entry to the reflog.
569    pub fn reflog_push(
570        &self,
571        old_head: &PatchId,
572        new_head: &PatchId,
573        message: &str,
574    ) -> Result<(), MetaError> {
575        self.conn.execute(
576            "INSERT INTO reflog (old_head, new_head, message, timestamp) VALUES (?1, ?2, ?3, ?4)",
577            params![
578                old_head.to_hex(),
579                new_head.to_hex(),
580                message,
581                std::time::SystemTime::now()
582                    .duration_since(std::time::UNIX_EPOCH)
583                    .map(|d| d.as_secs() as i64)
584                    .unwrap_or(0),
585            ],
586        )?;
587        Ok(())
588    }
589
590    /// Get the full reflog (newest first).
591    pub fn reflog_list(&self) -> Result<Vec<(String, String, String)>, MetaError> {
592        let mut stmt = self
593            .conn
594            .prepare("SELECT old_head, new_head, message FROM reflog ORDER BY id DESC")?;
595
596        let entries = stmt
597            .query_map([], |row| {
598                Ok((
599                    row.get::<_, String>(0)?,
600                    row.get::<_, String>(1)?,
601                    row.get::<_, String>(2)?,
602                ))
603            })?
604            .filter_map(|r| r.ok())
605            .collect();
606
607        Ok(entries)
608    }
609
610    /// Clear the entire reflog.
611    pub fn reflog_clear(&self) -> Result<usize, MetaError> {
612        let deleted = self.conn.execute("DELETE FROM reflog", [])?;
613        Ok(deleted)
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use crate::patch::types::{OperationType, Patch, TouchSet};
621
622    fn make_test_patch(addr: &str) -> Patch {
623        Patch::new(
624            OperationType::Modify,
625            TouchSet::single(addr),
626            Some(format!("file_{}", addr)),
627            vec![1, 2, 3],
628            vec![],
629            "test".to_string(),
630            format!("edit {}", addr),
631        )
632    }
633
634    #[test]
635    fn test_open_in_memory() {
636        let store = MetadataStore::open_in_memory().unwrap();
637        assert_eq!(store.patch_count().unwrap(), 0);
638    }
639
640    #[test]
641    fn test_store_and_get_patch() {
642        let store = MetadataStore::open_in_memory().unwrap();
643        let patch = make_test_patch("A1");
644        let id = patch.id;
645
646        store.store_patch(&patch).unwrap();
647        let retrieved = store.get_patch(&id).unwrap();
648
649        assert_eq!(retrieved.id, id);
650        assert_eq!(retrieved.author, "test");
651        assert_eq!(retrieved.payload, vec![1, 2, 3]);
652    }
653
654    #[test]
655    fn test_store_and_get_branch() {
656        let store = MetadataStore::open_in_memory().unwrap();
657        let patch = make_test_patch("root");
658        store.store_patch(&patch).unwrap();
659
660        let main = BranchName::new("main").unwrap();
661        store.set_branch(&main, &patch.id).unwrap();
662
663        let target = store.get_branch(&main).unwrap();
664        assert_eq!(target, patch.id);
665    }
666
667    #[test]
668    fn test_list_branches() {
669        let store = MetadataStore::open_in_memory().unwrap();
670        let patch = make_test_patch("root");
671        store.store_patch(&patch).unwrap();
672
673        store
674            .set_branch(&BranchName::new("main").unwrap(), &patch.id)
675            .unwrap();
676        store
677            .set_branch(&BranchName::new("dev").unwrap(), &patch.id)
678            .unwrap();
679
680        let branches = store.list_branches().unwrap();
681        assert_eq!(branches.len(), 2);
682    }
683
684    #[test]
685    fn test_working_set() {
686        let store = MetadataStore::open_in_memory().unwrap();
687
688        let path = RepoPath::new("src/main.rs").unwrap();
689        store.working_set_add(&path, FileStatus::Added).unwrap();
690
691        let ws = store.working_set().unwrap();
692        assert_eq!(ws.len(), 1);
693        assert_eq!(ws[0].0, "src/main.rs");
694        assert_eq!(ws[0].1, FileStatus::Added);
695
696        store.working_set_remove(&path).unwrap();
697        let ws = store.working_set().unwrap();
698        assert!(ws.is_empty());
699    }
700
701    #[test]
702    fn test_config() {
703        let store = MetadataStore::open_in_memory().unwrap();
704
705        assert!(store.get_config("key").unwrap().is_none());
706
707        store.set_config("key", "value").unwrap();
708        assert_eq!(store.get_config("key").unwrap(), Some("value".to_string()));
709
710        store.set_config("key", "updated").unwrap();
711        assert_eq!(
712            store.get_config("key").unwrap(),
713            Some("updated".to_string())
714        );
715    }
716
717    #[test]
718    fn test_edges() {
719        let store = MetadataStore::open_in_memory().unwrap();
720        let parent = make_test_patch("parent");
721        let child = make_test_patch("child");
722        store.store_patch(&parent).unwrap();
723        store.store_patch(&child).unwrap();
724
725        store.store_edge(&parent.id, &child.id).unwrap();
726
727        let (parents, _children) = store.get_edges(&child.id).unwrap();
728        assert_eq!(parents.len(), 1);
729        assert_eq!(parents[0], parent.id);
730
731        let (_, children) = store.get_edges(&parent.id).unwrap();
732        assert_eq!(children.len(), 1);
733        assert_eq!(children[0], child.id);
734    }
735
736    #[test]
737    fn test_store_and_load_file_tree() {
738        let store = MetadataStore::open_in_memory().unwrap();
739        let patch = make_test_patch("root");
740        let patch_id = patch.id;
741
742        let mut tree = FileTree::empty();
743        tree.insert("src/main.rs".to_string(), Hash::from_data(b"main"));
744        tree.insert("src/lib.rs".to_string(), Hash::from_data(b"lib"));
745
746        store.store_file_tree(&patch_id, &tree).unwrap();
747
748        let loaded = store.load_file_tree(&patch_id).unwrap().unwrap();
749        assert_eq!(loaded.len(), 2);
750        assert!(loaded.contains("src/main.rs"));
751        assert!(loaded.contains("src/lib.rs"));
752        assert_eq!(loaded.get("src/main.rs"), Some(&Hash::from_data(b"main")));
753    }
754
755    #[test]
756    fn test_file_tree_replace() {
757        let store = MetadataStore::open_in_memory().unwrap();
758        let patch = make_test_patch("root");
759        let patch_id = patch.id;
760
761        let mut tree1 = FileTree::empty();
762        tree1.insert("a.txt".to_string(), Hash::from_data(b"a"));
763
764        store.store_file_tree(&patch_id, &tree1).unwrap();
765
766        let mut tree2 = FileTree::empty();
767        tree2.insert("b.txt".to_string(), Hash::from_data(b"b"));
768
769        // Replacing should remove old entries
770        store.store_file_tree(&patch_id, &tree2).unwrap();
771
772        let loaded = store.load_file_tree(&patch_id).unwrap().unwrap();
773        assert_eq!(loaded.len(), 1);
774        assert!(!loaded.contains("a.txt"));
775        assert!(loaded.contains("b.txt"));
776    }
777
778    #[test]
779    fn test_file_tree_contains() {
780        let store = MetadataStore::open_in_memory().unwrap();
781        let patch = make_test_patch("root");
782        let patch_id = patch.id;
783
784        let mut tree = FileTree::empty();
785        tree.insert("tracked.txt".to_string(), Hash::from_data(b"data"));
786
787        store.store_file_tree(&patch_id, &tree).unwrap();
788
789        assert!(store.file_tree_contains(&patch_id, "tracked.txt").unwrap());
790        assert!(!store.file_tree_contains(&patch_id, "missing.txt").unwrap());
791    }
792
793    #[test]
794    fn test_load_file_tree_empty() {
795        let store = MetadataStore::open_in_memory().unwrap();
796        let patch = make_test_patch("root");
797        let patch_id = patch.id;
798
799        let result = store.load_file_tree(&patch_id).unwrap();
800        assert!(result.is_none());
801    }
802
803    #[test]
804    fn test_reflog_push_and_list() {
805        let store = MetadataStore::open_in_memory().unwrap();
806        let old = Hash::from_data(b"old");
807        let new = Hash::from_data(b"new");
808
809        store.reflog_push(&old, &new, "commit: test").unwrap();
810        store
811            .reflog_push(&new, &Hash::from_data(b"newer"), "checkout: feature")
812            .unwrap();
813
814        let log = store.reflog_list().unwrap();
815        assert_eq!(log.len(), 2);
816        // Newest first
817        assert!(log[0].2.contains("checkout"));
818        assert!(log[1].2.contains("commit"));
819    }
820
821    #[test]
822    fn test_reflog_clear() {
823        let store = MetadataStore::open_in_memory().unwrap();
824        let old = Hash::from_data(b"old");
825        let new = Hash::from_data(b"new");
826
827        store.reflog_push(&old, &new, "test").unwrap();
828        assert_eq!(store.reflog_list().unwrap().len(), 1);
829
830        let deleted = store.reflog_clear().unwrap();
831        assert_eq!(deleted, 1);
832        assert!(store.reflog_list().unwrap().is_empty());
833    }
834}