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