Skip to main content

forgekit_core/storage/
mod.rs

1//! Storage abstraction layer supporting dual backends.
2//!
3//! This module provides graph-based storage for ForgeKit with support for both
4//! SQLite and Native V3 backends. Users choose the backend based on their needs.
5//!
6//! # Backend Selection
7//!
8//! | Feature | SQLite Backend | Native V3 Backend |
9//! |---------|----------------|-------------------|
10//! | ACID Transactions | ✅ Full | ✅ WAL-based |
11//! | Raw SQL Access | ✅ Yes | ❌ No |
12//! | Dependencies | libsqlite3 | Pure Rust |
13//! | Startup Time | Fast | Faster |
14//! | Tool Compatibility | magellan, llmgrep, mirage, splice (current) | Updated tools |
15//!
16//! # Examples
17//!
18//! ```rust,no_run
19//! use forgekit_core::storage::{UnifiedGraphStore, BackendKind};
20//!
21//! # #[tokio::main]
22//! # async fn main() -> anyhow::Result<()> {
23//! // Use SQLite backend (default, stable)
24//! let store = UnifiedGraphStore::open("./codebase", BackendKind::SQLite).await?;
25//!
26//! // Or use Native V3 backend (updated tools required)
27//! let store = UnifiedGraphStore::open("./codebase", BackendKind::NativeV3).await?;
28//! # Ok(())
29//! # }
30//! ```
31
32mod ops;
33mod store;
34#[cfg(test)]
35mod tests;
36
37pub use sqlitegraph::backend::{EdgeSpec, NodeSpec};
38pub use sqlitegraph::config::{open_graph, BackendKind as SqliteGraphBackendKind, GraphConfig};
39pub use sqlitegraph::graph::{GraphEntity, SqliteGraph};
40
41pub use store::UnifiedGraphStore;
42
43use std::path::{Path, PathBuf};
44
45/// Resolve the database path for a project by consulting the magellan registry,
46/// falling back to the `~/.magellan/<stem>/<stem>.db` convention.
47///
48/// The magellan registry at `~/.config/magellan/registry.toml` maps project roots
49/// to database paths. When a project root matches (or is a parent of) the given
50/// `project_root`, the registered DB path is returned.
51///
52/// For workspace monorepos (e.g. forge with forgekit-core, forgekit-agent), each crate
53/// is registered separately with its own `src/` root and DB path.
54pub fn default_db_path(project_root: &Path) -> PathBuf {
55    if let Some(db) = lookup_registry(project_root) {
56        return db;
57    }
58
59    fallback_db_path(project_root)
60}
61
62fn fallback_db_path(project_root: &Path) -> PathBuf {
63    let stem = project_root
64        .file_name()
65        .and_then(|n| n.to_str())
66        .unwrap_or("graph");
67    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
68    PathBuf::from(home)
69        .join(".magellan")
70        .join(stem)
71        .join(format!("{}.db", stem))
72}
73
74fn lookup_registry(project_root: &Path) -> Option<PathBuf> {
75    let home = std::env::var("HOME").ok()?;
76    let registry_path = PathBuf::from(&home)
77        .join(".config")
78        .join("magellan")
79        .join("registry.toml");
80
81    let content = std::fs::read_to_string(&registry_path).ok()?;
82
83    let canonical_root = project_root
84        .canonicalize()
85        .ok()
86        .unwrap_or_else(|| project_root.to_path_buf());
87
88    for block in content.split("[[project]]") {
89        let mut name = None;
90        let mut root = None;
91        let mut db = None;
92
93        for line in block.lines() {
94            let trimmed = line.trim();
95            if let Some(rest) = trimmed.strip_prefix("name = ") {
96                name = parse_toml_string(rest);
97            } else if let Some(rest) = trimmed.strip_prefix("root = ") {
98                root = parse_toml_string(rest);
99            } else if let Some(rest) = trimmed.strip_prefix("db = ") {
100                db = parse_toml_string(rest);
101            }
102        }
103
104        if let (Some(proj_root), Some(proj_db)) = (root, db) {
105            let proj_root_path = Path::new(&proj_root);
106            if canonical_root.starts_with(proj_root_path)
107                || proj_root_path.starts_with(&canonical_root)
108                || paths_equal_after_src_strip(&canonical_root, proj_root_path)
109            {
110                return Some(PathBuf::from(proj_db));
111            }
112        }
113
114        let _ = name;
115    }
116
117    None
118}
119
120fn paths_equal_after_src_strip(a: &Path, b: &Path) -> bool {
121    let a_str = a.to_string_lossy();
122    let b_str = b.to_string_lossy();
123
124    if let Some(a_stripped) = a_str.strip_suffix("/src") {
125        if a_stripped == b_str {
126            return true;
127        }
128    }
129    if let Some(b_stripped) = b_str.strip_suffix("/src") {
130        if b_stripped == a_str {
131            return true;
132        }
133    }
134    false
135}
136
137fn parse_toml_string(s: &str) -> Option<String> {
138    s.trim()
139        .strip_prefix('"')
140        .and_then(|s| s.strip_suffix('"'))
141        .map(|s| s.to_string())
142}
143
144#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
145pub enum BackendKind {
146    #[default]
147    SQLite,
148    NativeV3,
149}
150
151impl std::fmt::Display for BackendKind {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        match self {
154            Self::SQLite => write!(f, "SQLite"),
155            Self::NativeV3 => write!(f, "NativeV3"),
156        }
157    }
158}
159
160impl BackendKind {
161    #[cfg(test)]
162    fn to_sqlitegraph_kind(self) -> SqliteGraphBackendKind {
163        match self {
164            Self::SQLite => SqliteGraphBackendKind::SQLite,
165            Self::NativeV3 => SqliteGraphBackendKind::Native,
166        }
167    }
168
169    pub fn file_extension(&self) -> &str {
170        match self {
171            Self::SQLite => "db",
172            Self::NativeV3 => "v3",
173        }
174    }
175
176    pub fn default_filename(&self) -> &str {
177        match self {
178            Self::SQLite => "graph.db",
179            Self::NativeV3 => "graph.v3",
180        }
181    }
182}