Skip to main content

vibe_graph_ops/
store.rs

1//! Persistence layer using `.self` folder.
2
3use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5
6use serde::{Deserialize, Serialize};
7use tracing::{debug, info};
8use vibe_graph_core::SourceCodeGraph;
9use walkdir::WalkDir;
10
11use crate::error::OpsResult;
12use crate::project::Project;
13use crate::workspace::WorkspaceKind;
14
15/// Name of the persistence folder.
16pub const SELF_DIR: &str = ".self";
17
18const MANIFEST_FILE: &str = "manifest.json";
19const PROJECT_FILE: &str = "project.json";
20const GRAPH_FILE: &str = "graph.json";
21const SNAPSHOTS_DIR: &str = "snapshots";
22
23/// Workspace manifest containing metadata about the persisted state.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Manifest {
26    /// Version of the manifest format.
27    pub version: u32,
28
29    /// Name of the workspace.
30    pub name: String,
31
32    /// Root path of the workspace.
33    pub root: PathBuf,
34
35    /// Detected workspace kind.
36    pub kind: String,
37
38    /// Timestamp of last sync.
39    pub last_sync: SystemTime,
40
41    /// Number of repositories.
42    pub repo_count: usize,
43
44    /// Total number of source files.
45    pub source_count: usize,
46
47    /// Total size in bytes.
48    pub total_size: u64,
49
50    /// Remote URL or GitHub organization.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub remote: Option<String>,
53}
54
55impl Manifest {
56    /// Create a new manifest from a project.
57    pub fn from_project(
58        project: &Project,
59        root: &Path,
60        kind: &WorkspaceKind,
61        remote: Option<String>,
62    ) -> Self {
63        Self {
64            version: 1,
65            name: project.name.clone(),
66            root: root.to_path_buf(),
67            kind: kind.to_string(),
68            last_sync: SystemTime::now(),
69            repo_count: project.repositories.len(),
70            source_count: project.total_sources(),
71            total_size: project.total_size(),
72            remote,
73        }
74    }
75}
76
77/// Store manages the `.self` folder and persistence operations.
78#[derive(Debug, Clone)]
79pub struct Store {
80    /// Root path of the workspace.
81    root: PathBuf,
82
83    /// Path to the `.self` directory.
84    self_dir: PathBuf,
85}
86
87impl Store {
88    /// Create a new store for the given workspace root.
89    pub fn new(root: impl AsRef<Path>) -> Self {
90        let root = root.as_ref().to_path_buf();
91        let self_dir = root.join(SELF_DIR);
92        Self { root, self_dir }
93    }
94
95    /// Get the root path.
96    pub fn root(&self) -> &Path {
97        &self.root
98    }
99
100    /// Get the path to the `.self` directory.
101    pub fn self_dir(&self) -> &Path {
102        &self.self_dir
103    }
104
105    /// Check if the `.self` directory exists.
106    pub fn exists(&self) -> bool {
107        self.self_dir.exists()
108    }
109
110    /// Initialize the `.self` directory structure.
111    pub fn init(&self) -> OpsResult<()> {
112        if !self.self_dir.exists() {
113            std::fs::create_dir_all(&self.self_dir)?;
114            debug!(path = %self.self_dir.display(), "Created .self directory");
115        }
116
117        let snapshots_dir = self.self_dir.join(SNAPSHOTS_DIR);
118        if !snapshots_dir.exists() {
119            std::fs::create_dir_all(&snapshots_dir)?;
120        }
121
122        Ok(())
123    }
124
125    /// Save a project to the store.
126    pub fn save(
127        &self,
128        project: &Project,
129        kind: &WorkspaceKind,
130        remote: Option<String>,
131    ) -> OpsResult<()> {
132        self.init()?;
133
134        // Strip content for storage (it can be re-read from disk)
135        let storage_project = strip_content(project);
136
137        // Save project data
138        let project_path = self.self_dir.join(PROJECT_FILE);
139        let project_json = serde_json::to_string_pretty(&storage_project)?;
140        std::fs::write(&project_path, &project_json)?;
141
142        // Save manifest
143        let manifest = Manifest::from_project(project, &self.root, kind, remote);
144        self.save_manifest(&manifest)?;
145
146        info!(
147            path = %self.self_dir.display(),
148            repos = project.repositories.len(),
149            files = project.total_sources(),
150            "Saved project to .self"
151        );
152
153        Ok(())
154    }
155
156    /// Save the manifest to the store.
157    pub fn save_manifest(&self, manifest: &Manifest) -> OpsResult<()> {
158        self.init()?;
159
160        let manifest_path = self.self_dir.join(MANIFEST_FILE);
161        let manifest_json = serde_json::to_string_pretty(manifest)?;
162        std::fs::write(&manifest_path, &manifest_json)?;
163
164        debug!(path = %manifest_path.display(), "Saved manifest");
165        Ok(())
166    }
167
168    /// Create a timestamped snapshot.
169    pub fn snapshot(&self, project: &Project) -> OpsResult<PathBuf> {
170        self.init()?;
171
172        let storage_project = strip_content(project);
173        let timestamp = SystemTime::now()
174            .duration_since(SystemTime::UNIX_EPOCH)
175            .unwrap()
176            .as_secs();
177
178        let snapshot_path = self
179            .self_dir
180            .join(SNAPSHOTS_DIR)
181            .join(format!("{}.json", timestamp));
182
183        let json = serde_json::to_string_pretty(&storage_project)?;
184        std::fs::write(&snapshot_path, json)?;
185
186        info!(path = %snapshot_path.display(), "Created snapshot");
187        Ok(snapshot_path)
188    }
189
190    /// Load the project from the store.
191    pub fn load(&self) -> OpsResult<Option<Project>> {
192        let project_path = self.self_dir.join(PROJECT_FILE);
193
194        if !project_path.exists() {
195            return Ok(None);
196        }
197
198        let json = std::fs::read_to_string(&project_path)?;
199        let project: Project = serde_json::from_str(&json)?;
200
201        info!(
202            path = %project_path.display(),
203            repos = project.repositories.len(),
204            "Loaded project from .self"
205        );
206
207        Ok(Some(project))
208    }
209
210    /// Load the manifest from the store.
211    pub fn load_manifest(&self) -> OpsResult<Option<Manifest>> {
212        let manifest_path = self.self_dir.join(MANIFEST_FILE);
213
214        if !manifest_path.exists() {
215            return Ok(None);
216        }
217
218        let json = std::fs::read_to_string(&manifest_path)?;
219        let manifest: Manifest = serde_json::from_str(&json)?;
220
221        Ok(Some(manifest))
222    }
223
224    /// Save a SourceCodeGraph to the store.
225    pub fn save_graph(&self, graph: &SourceCodeGraph) -> OpsResult<PathBuf> {
226        self.init()?;
227
228        let graph_path = self.self_dir.join(GRAPH_FILE);
229        let json = serde_json::to_string_pretty(graph)?;
230        std::fs::write(&graph_path, &json)?;
231
232        info!(
233            path = %graph_path.display(),
234            nodes = graph.node_count(),
235            edges = graph.edge_count(),
236            "Saved graph to .self"
237        );
238
239        Ok(graph_path)
240    }
241
242    /// Load a SourceCodeGraph from the store.
243    pub fn load_graph(&self) -> OpsResult<Option<SourceCodeGraph>> {
244        let graph_path = self.self_dir.join(GRAPH_FILE);
245
246        if !graph_path.exists() {
247            return Ok(None);
248        }
249
250        let json = std::fs::read_to_string(&graph_path)?;
251        let graph: SourceCodeGraph = serde_json::from_str(&json)?;
252
253        info!(
254            path = %graph_path.display(),
255            nodes = graph.node_count(),
256            edges = graph.edge_count(),
257            "Loaded graph from .self"
258        );
259
260        Ok(Some(graph))
261    }
262
263    /// Check if a graph exists in the store.
264    pub fn has_graph(&self) -> bool {
265        self.self_dir.join(GRAPH_FILE).exists()
266    }
267
268    /// List available snapshots.
269    pub fn list_snapshots(&self) -> OpsResult<Vec<PathBuf>> {
270        let snapshots_dir = self.self_dir.join(SNAPSHOTS_DIR);
271
272        if !snapshots_dir.exists() {
273            return Ok(vec![]);
274        }
275
276        let mut snapshots: Vec<PathBuf> = std::fs::read_dir(&snapshots_dir)?
277            .filter_map(|e| e.ok())
278            .map(|e| e.path())
279            .filter(|p| p.extension().map(|e| e == "json").unwrap_or(false))
280            .collect();
281
282        // Sort by filename (timestamp) descending
283        snapshots.sort_by(|a, b| b.cmp(a));
284
285        Ok(snapshots)
286    }
287
288    /// Load a specific snapshot.
289    pub fn load_snapshot(&self, path: &Path) -> OpsResult<Project> {
290        let json = std::fs::read_to_string(path)?;
291        let project: Project = serde_json::from_str(&json)?;
292        Ok(project)
293    }
294
295    /// Clean up the `.self` directory.
296    pub fn clean(&self) -> OpsResult<()> {
297        if self.self_dir.exists() {
298            std::fs::remove_dir_all(&self.self_dir)?;
299            info!(path = %self.self_dir.display(), "Removed .self directory");
300        }
301        Ok(())
302    }
303
304    /// Get storage statistics.
305    pub fn stats(&self) -> OpsResult<StoreStats> {
306        if !self.exists() {
307            return Ok(StoreStats::default());
308        }
309
310        let manifest = self.load_manifest()?;
311        let snapshots = self.list_snapshots()?;
312
313        // Calculate total size of .self directory
314        let total_size = WalkDir::new(&self.self_dir)
315            .into_iter()
316            .filter_map(|e| e.ok())
317            .filter(|e| e.path().is_file())
318            .filter_map(|e| e.metadata().ok())
319            .map(|m| m.len())
320            .sum();
321
322        Ok(StoreStats {
323            exists: true,
324            manifest,
325            snapshot_count: snapshots.len(),
326            total_size,
327        })
328    }
329}
330
331/// Statistics about the store.
332#[derive(Debug, Default, Clone)]
333pub struct StoreStats {
334    /// Whether the store exists.
335    pub exists: bool,
336    /// Loaded manifest if available.
337    pub manifest: Option<Manifest>,
338    /// Number of snapshots.
339    pub snapshot_count: usize,
340    /// Total size of .self directory in bytes.
341    pub total_size: u64,
342}
343
344/// Strip content from project for storage.
345fn strip_content(project: &Project) -> Project {
346    let mut stripped = project.clone();
347    for repo in &mut stripped.repositories {
348        for source in &mut repo.sources {
349            source.content = None;
350        }
351    }
352    stripped
353}
354
355/// Check if a `.self` directory exists at the given path.
356pub fn has_store(path: &Path) -> bool {
357    path.join(SELF_DIR).exists()
358}