Skip to main content

void_graph/
void_backend.rs

1//! Void repository backend - replaces Serie's git.rs
2//!
3//! This module provides the interface between the TUI and void-core,
4//! translating void's commit/ref model into the types Serie expects.
5
6use std::collections::VecDeque;
7use std::sync::Arc;
8
9use camino::{Utf8Path, Utf8PathBuf};
10use thiserror::Error;
11use void_core::{
12    cid as void_cid,
13    crypto::KeyVault,
14    metadata::Commit,
15    refs::{self, HeadRef},
16    store::{FsStore, ObjectStoreExt},
17    support::ToVoidCid,
18};
19
20/// Error type for void backend operations
21#[derive(Debug, Error)]
22pub enum VoidBackendError {
23    #[error("repository not found at {0}")]
24    NotFound(String),
25
26    #[error("invalid key: {0}")]
27    InvalidKey(String),
28
29    #[error("invalid CID: {0}")]
30    InvalidCid(String),
31
32    #[error("decryption failed: {0}")]
33    Decryption(String),
34
35    #[error("io error: {0}")]
36    Io(#[from] std::io::Error),
37
38    #[error("void error: {0}")]
39    Void(#[from] void_core::VoidError),
40}
41
42/// Result type for void backend operations
43pub type Result<T> = std::result::Result<T, VoidBackendError>;
44
45/// Content identifier for commits (wraps CID string)
46#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47pub struct CommitCid(pub String);
48
49impl CommitCid {
50    /// Return first 8 chars for display
51    pub fn short(&self) -> &str {
52        &self.0[..self.0.len().min(8)]
53    }
54}
55
56/// HEAD reference state
57#[derive(Debug, Clone)]
58pub enum VoidHead {
59    /// Symbolic ref pointing to a branch name
60    Branch(String),
61    /// Detached HEAD pointing directly to a commit CID
62    Detached(CommitCid),
63}
64
65/// Reference (branch or tag)
66#[derive(Debug, Clone)]
67pub struct VoidRef {
68    /// Reference name
69    pub name: String,
70    /// Reference kind (branch or tag)
71    pub kind: RefKind,
72    /// Target commit CID
73    pub target: CommitCid,
74}
75
76/// Kind of reference
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum RefKind {
79    /// Branch reference
80    Branch,
81    /// Tag reference
82    Tag,
83}
84
85/// Commit with all displayable metadata
86#[derive(Debug, Clone)]
87pub struct VoidCommit {
88    /// Commit CID
89    pub cid: CommitCid,
90    /// Parent commit CIDs
91    pub parents: Vec<CommitCid>,
92    /// Commit message
93    pub message: String,
94    /// Unix timestamp in milliseconds
95    pub timestamp_ms: u64,
96    /// Whether the commit is signed
97    pub is_signed: bool,
98    /// Signature validity (None if unsigned)
99    pub signature_valid: Option<bool>,
100    /// Author public key (hex-encoded, None if unsigned)
101    pub author: Option<String>,
102}
103
104/// Sort order for commit walking
105#[derive(Debug, Clone, Copy, Default)]
106pub enum SortOrder {
107    /// Sort by timestamp (newest first)
108    #[default]
109    Chronological,
110    /// Topological sort (parents before children)
111    Topological,
112}
113
114/// Void repository handle for TUI access
115pub struct VoidRepository {
116    /// Path to the .void directory
117    void_dir: Utf8PathBuf,
118    /// Repository key vault
119    vault: Arc<KeyVault>,
120    /// Object store
121    store: FsStore,
122}
123
124impl VoidRepository {
125    /// Open repository at path with a pre-loaded vault.
126    ///
127    /// The caller (CLI) is responsible for loading the key via identity + manifest
128    /// and constructing the vault. This avoids the TUI crate needing to know about
129    /// identity/PIN infrastructure.
130    pub fn open(path: &Utf8Path, vault: Arc<KeyVault>) -> Result<Self> {
131        let void_dir = find_void_dir(path)?;
132        let store = FsStore::new(void_dir.join("objects"))?;
133        Ok(Self {
134            void_dir,
135            vault,
136            store,
137        })
138    }
139
140    /// Get current HEAD
141    pub fn head(&self) -> Result<Option<VoidHead>> {
142        match refs::read_head(&self.void_dir)? {
143            None => Ok(None),
144            Some(HeadRef::Symbolic(branch)) => Ok(Some(VoidHead::Branch(branch))),
145            Some(HeadRef::Detached(commit_cid)) => {
146                let cid = void_cid::from_bytes(commit_cid.as_bytes())?;
147                Ok(Some(VoidHead::Detached(CommitCid(cid.to_string()))))
148            }
149        }
150    }
151
152    /// List all refs (branches + tags)
153    pub fn refs(&self) -> Result<Vec<VoidRef>> {
154        let mut result = Vec::new();
155
156        for name in refs::list_branches(&self.void_dir)? {
157            if let Some(commit_cid) = refs::read_branch(&self.void_dir, &name)? {
158                let cid = void_cid::from_bytes(commit_cid.as_bytes())?;
159                result.push(VoidRef {
160                    name,
161                    kind: RefKind::Branch,
162                    target: CommitCid(cid.to_string()),
163                });
164            }
165        }
166
167        for name in refs::list_tags(&self.void_dir)? {
168            if let Some(commit_cid) = refs::read_tag(&self.void_dir, &name)? {
169                let cid = void_cid::from_bytes(commit_cid.as_bytes())?;
170                result.push(VoidRef {
171                    name,
172                    kind: RefKind::Tag,
173                    target: CommitCid(cid.to_string()),
174                });
175            }
176        }
177
178        Ok(result)
179    }
180
181    /// Resolve HEAD to a commit CID
182    pub fn resolve_head(&self) -> Result<Option<CommitCid>> {
183        match refs::resolve_head(&self.void_dir)? {
184            None => Ok(None),
185            Some(commit_cid) => {
186                let cid = void_cid::from_bytes(commit_cid.as_bytes())?;
187                Ok(Some(CommitCid(cid.to_string())))
188            }
189        }
190    }
191
192    /// Load a commit by CID
193    pub fn load_commit(&self, cid: &CommitCid) -> Result<VoidCommit> {
194        let cid_obj = void_cid::parse(&cid.0)?;
195        let encrypted: void_core::crypto::EncryptedCommit = self.store.get_blob(&cid_obj)?;
196
197        // Decrypt via vault (auto-detects envelope vs legacy format)
198        let (decrypted, _reader) = void_core::crypto::CommitReader::open_with_vault(&self.vault, &encrypted)
199            .map_err(|e| VoidBackendError::Decryption(e.to_string()))?;
200
201        // Parse with backwards compatibility
202        let commit: Commit = decrypted.parse()?;
203
204        // Check signature before moving fields
205        let is_signed = commit.is_signed();
206        let signature_valid = if is_signed {
207            Some(commit.verify().unwrap_or(false))
208        } else {
209            None
210        };
211
212        // Convert parent CIDs
213        let parents = commit
214            .parents
215            .iter()
216            .map(|p| {
217                let parent_cid = p.to_void_cid()?;
218                Ok(CommitCid(parent_cid.to_string()))
219            })
220            .collect::<Result<Vec<_>>>()?;
221
222        // Extract author public key as hex string
223        let author = commit.author.map(|a| a.to_hex());
224
225        Ok(VoidCommit {
226            cid: cid.clone(),
227            parents,
228            timestamp_ms: commit.timestamp,
229            message: commit.message,
230            is_signed,
231            signature_valid,
232            author,
233        })
234    }
235
236    /// Walk commits starting from the given CIDs
237    ///
238    /// Returns an iterator over commits in the specified order.
239    pub fn walk_commits(
240        &self,
241        starts: &[CommitCid],
242        order: SortOrder,
243        limit: Option<usize>,
244    ) -> Result<Vec<VoidCommit>> {
245        let mut visited = rustc_hash::FxHashSet::default();
246        let mut commits = Vec::new();
247        let mut queue: VecDeque<CommitCid> = starts.iter().cloned().collect();
248
249        while let Some(cid) = queue.pop_front() {
250            if visited.contains(&cid) {
251                continue;
252            }
253            visited.insert(cid.clone());
254
255            let commit = self.load_commit(&cid)?;
256
257            // Add parents to queue
258            for parent in &commit.parents {
259                if !visited.contains(parent) {
260                    queue.push_back(parent.clone());
261                }
262            }
263
264            commits.push(commit);
265
266            // Check limit
267            if let Some(max) = limit {
268                if commits.len() >= max {
269                    break;
270                }
271            }
272        }
273
274        // Sort based on order
275        match order {
276            SortOrder::Chronological => {
277                commits.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms));
278            }
279            SortOrder::Topological => {
280                // Already in topological order from BFS
281            }
282        }
283
284        Ok(commits)
285    }
286}
287
288/// Find the .void directory by walking up from the start path
289fn find_void_dir(start: &Utf8Path) -> Result<Utf8PathBuf> {
290    let mut current = start.to_path_buf();
291    loop {
292        let void_dir = current.join(".void");
293        if void_dir.exists() {
294            return Ok(void_dir);
295        }
296        if !current.pop() {
297            return Err(VoidBackendError::NotFound(start.to_string()));
298        }
299    }
300}