heroforge_core/repo/
mod.rs

1//! Repository access and manipulation.
2//!
3//! This module provides the main [`Repository`] type for interacting with Heroforge repositories.
4//!
5//! # Examples
6//!
7//! ## Opening an existing repository
8//!
9//! ```no_run
10//! use heroforge_core::Repository;
11//!
12//! let repo = Repository::open("project.forge")?;
13//! let files = repo.files().on_trunk().list()?;
14//! for file in files {
15//!     println!("{}", file.name);
16//! }
17//! # Ok::<(), heroforge_core::FossilError>(())
18//! ```
19//!
20//! ## Creating a new repository
21//!
22//! ```no_run
23//! use heroforge_core::Repository;
24//!
25//! let repo = Repository::init("new.forge")?;
26//! let hash = repo.commit_builder()
27//!     .message("Initial commit")
28//!     .author("admin")
29//!     .initial()
30//!     .execute()?;
31//! # Ok::<(), heroforge_core::FossilError>(())
32//! ```
33
34mod builders;
35mod database;
36
37pub use builders::{
38    BranchBuilder, BranchesBuilder, CommitBuilder, FileEntry, FileQuery, FileType, FilesBuilder,
39    FindBuilder, FindResult, FsBuilder, FsOperation, FsOpsBuilder, FsPreview, HistoryBuilder,
40    Permissions, TagBuilder, TagsBuilder, UserBuilder, UsersBuilder,
41};
42pub use database::Database;
43
44use crate::artifact::{blob, manifest};
45use crate::error::{FossilError, Result};
46use crate::hash;
47use crate::sync::SyncBuilder;
48use chrono::Utc;
49use std::path::Path;
50
51/// A Heroforge repository handle.
52///
53/// This is the main entry point for interacting with Heroforge repositories.
54/// It provides a fluent builder API for all operations.
55///
56/// # Opening Repositories
57///
58/// - [`Repository::open`] - Open read-only
59/// - [`Repository::open_rw`] - Open read-write
60/// - [`Repository::init`] - Create new repository
61///
62/// # Builder API
63///
64/// - [`Repository::files`] - File operations
65/// - [`Repository::fs`] - Filesystem operations (copy, move, delete, chmod, find, symlinks)
66/// - [`Repository::branches`] - Branch operations
67/// - [`Repository::tags`] - Tag operations
68/// - [`Repository::history`] - Browse history
69/// - [`Repository::users`] - User management
70/// - [`Repository::sync`] - Sync operations
71pub struct Repository {
72    db: Database,
73}
74
75/// A check-in (commit) in the repository.
76#[derive(Debug, Clone)]
77pub struct CheckIn {
78    /// Internal row ID in the database
79    pub rid: i64,
80    /// SHA3-256 hash of the check-in manifest
81    pub hash: String,
82    /// ISO 8601 timestamp
83    pub timestamp: String,
84    /// Username who created the check-in
85    pub user: String,
86    /// Commit message
87    pub comment: String,
88    /// Parent check-in hashes
89    pub parents: Vec<String>,
90    /// Branch name (if available)
91    pub branch: Option<String>,
92}
93
94/// Information about a file in a check-in.
95#[derive(Debug, Clone)]
96pub struct FileInfo {
97    /// File path relative to repository root
98    pub name: String,
99    /// SHA3-256 hash of file content
100    pub hash: String,
101    /// Unix permissions (if set)
102    pub permissions: Option<String>,
103    /// File size in bytes (if known)
104    pub size: Option<usize>,
105}
106
107impl Repository {
108    // ========================================================================
109    // Constructors
110    // ========================================================================
111
112    /// Open a repository in read-only mode.
113    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
114        let db = Database::open(path)?;
115        Ok(Self { db })
116    }
117
118    /// Open a repository in read-write mode.
119    pub fn open_rw<P: AsRef<Path>>(path: P) -> Result<Self> {
120        let db = Database::open_rw(path)?;
121        Ok(Self { db })
122    }
123
124    /// Create a new repository.
125    pub fn init<P: AsRef<Path>>(path: P) -> Result<Self> {
126        let db = Database::init(path)?;
127        Ok(Self { db })
128    }
129
130    // ========================================================================
131    // Builder Entry Points
132    // ========================================================================
133
134    /// Access file operations.
135    pub fn files(&self) -> FilesBuilder<'_> {
136        FilesBuilder::new(self)
137    }
138
139    /// Start building a commit using the builder pattern.
140    pub fn commit_builder(&self) -> CommitBuilder<'_> {
141        CommitBuilder::new(self)
142    }
143
144    /// Access branch operations.
145    pub fn branches(&self) -> BranchesBuilder<'_> {
146        BranchesBuilder::new(self)
147    }
148
149    /// Access tag operations.
150    pub fn tags(&self) -> TagsBuilder<'_> {
151        TagsBuilder::new(self)
152    }
153
154    /// Access history/commit browsing.
155    pub fn history(&self) -> HistoryBuilder<'_> {
156        HistoryBuilder::new(self)
157    }
158
159    /// Access user operations.
160    pub fn users(&self) -> UsersBuilder<'_> {
161        UsersBuilder::new(self)
162    }
163
164    /// Access sync operations.
165    pub fn sync(&self) -> SyncBuilder<'_> {
166        SyncBuilder::new(self)
167    }
168
169    /// Access filesystem operations (copy, move, delete, chmod, find, symlinks).
170    pub fn fs(&self) -> FsOpsBuilder<'_> {
171        FsOpsBuilder::new(self)
172    }
173
174    /// Import a git repository (requires `git-import` feature).
175    ///
176    /// Clones a git repository and imports its contents (without history)
177    /// into this heroforge repository as a single commit.
178    ///
179    /// # Examples
180    ///
181    /// ```no_run
182    /// use heroforge_core::Repository;
183    ///
184    /// let repo = Repository::init("project.fossil")?;
185    ///
186    /// repo.git_import()
187    ///     .url("https://github.com/user/project.git")
188    ///     .branch("main")
189    ///     .message("Import from git")
190    ///     .author("developer")
191    ///     .execute()?;
192    /// # Ok::<(), heroforge_core::FossilError>(())
193    /// ```
194    #[cfg(feature = "git-import")]
195    pub fn git_import(&self) -> crate::tools::GitImportBuilder<'_> {
196        crate::tools::GitImportBuilder::new(self)
197    }
198
199    // ========================================================================
200    // Direct Access
201    // ========================================================================
202
203    /// Get the project code.
204    pub fn project_code(&self) -> Result<String> {
205        self.db.get_project_code()
206    }
207
208    /// Get the project name.
209    pub fn project_name(&self) -> Result<Option<String>> {
210        self.db.get_project_name()
211    }
212
213    /// Get the underlying database handle.
214    pub fn database(&self) -> &Database {
215        &self.db
216    }
217
218    /// Rebuild repository metadata.
219    pub fn rebuild(&self) -> Result<()> {
220        self.db.connection().execute("DELETE FROM leaf", [])?;
221        self.db.connection().execute(
222            "INSERT INTO leaf SELECT rid FROM blob WHERE rid NOT IN (SELECT pid FROM plink)
223             AND rid IN (SELECT objid FROM event WHERE type='ci')",
224            [],
225        )?;
226        Ok(())
227    }
228
229    // ========================================================================
230    // Internal Implementation Methods
231    // ========================================================================
232
233    pub(crate) fn get_checkin_internal(&self, hash: &str) -> Result<CheckIn> {
234        let rid = self.db.get_rid_by_hash(hash)?;
235        let full_hash = self.db.get_hash_by_rid(rid)?;
236
237        let content = blob::get_artifact_content(&self.db, rid)?;
238        let manifest = manifest::parse_manifest(&content)?;
239
240        Ok(CheckIn {
241            rid,
242            hash: full_hash,
243            timestamp: manifest.timestamp,
244            user: manifest.user,
245            comment: manifest.comment,
246            parents: manifest.parents,
247            branch: None,
248        })
249    }
250
251    pub(crate) fn branch_tip_internal(&self, branch: &str) -> Result<CheckIn> {
252        let rid = if branch == "trunk" {
253            self.db.get_trunk_tip()?
254        } else {
255            self.db.get_branch_tip(branch)?
256        };
257        let hash = self.db.get_hash_by_rid(rid)?;
258        self.get_checkin_internal(&hash)
259    }
260
261    pub(crate) fn recent_checkins_internal(&self, limit: usize) -> Result<Vec<CheckIn>> {
262        let raw = self.db.get_recent_checkins(limit)?;
263        let mut result = Vec::with_capacity(raw.len());
264
265        for (rid, hash, _mtime, user, comment) in raw {
266            let content = blob::get_artifact_content(&self.db, rid)?;
267            let manifest = manifest::parse_manifest(&content)?;
268
269            result.push(CheckIn {
270                rid,
271                hash,
272                timestamp: manifest.timestamp,
273                user,
274                comment,
275                parents: manifest.parents,
276                branch: None,
277            });
278        }
279
280        Ok(result)
281    }
282
283    pub(crate) fn list_files_internal(&self, checkin_hash: &str) -> Result<Vec<FileInfo>> {
284        let rid = self.db.get_rid_by_hash(checkin_hash)?;
285        let files = self.db.get_files_for_manifest(rid)?;
286
287        Ok(files
288            .into_iter()
289            .map(|(name, hash)| FileInfo {
290                name,
291                hash,
292                permissions: None,
293                size: None,
294            })
295            .collect())
296    }
297
298    pub(crate) fn read_file_internal(&self, checkin_hash: &str, path: &str) -> Result<Vec<u8>> {
299        let rid = self.db.get_rid_by_hash(checkin_hash)?;
300        let file_hash = self.db.get_file_hash_from_manifest(rid, path)?;
301        blob::get_artifact_by_hash(&self.db, &file_hash)
302    }
303
304    pub(crate) fn find_files_internal(
305        &self,
306        checkin_hash: &str,
307        pattern: &str,
308    ) -> Result<Vec<FileInfo>> {
309        let all_files = self.list_files_internal(checkin_hash)?;
310        let glob_pattern =
311            glob::Pattern::new(pattern).map_err(|e| FossilError::InvalidArtifact(e.to_string()))?;
312
313        Ok(all_files
314            .into_iter()
315            .filter(|f| glob_pattern.matches(&f.name))
316            .collect())
317    }
318
319    pub(crate) fn list_directory_internal(
320        &self,
321        checkin_hash: &str,
322        dir: &str,
323    ) -> Result<Vec<FileInfo>> {
324        let all_files = self.list_files_internal(checkin_hash)?;
325        let dir = dir.trim_end_matches('/');
326
327        Ok(all_files
328            .into_iter()
329            .filter(|f| {
330                if dir.is_empty() {
331                    !f.name.contains('/')
332                } else {
333                    f.name.starts_with(&format!("{}/", dir))
334                        && !f.name[dir.len() + 1..].contains('/')
335                }
336            })
337            .collect())
338    }
339
340    pub(crate) fn list_subdirs_internal(
341        &self,
342        checkin_hash: &str,
343        dir: &str,
344    ) -> Result<Vec<String>> {
345        let all_files = self.list_files_internal(checkin_hash)?;
346        let dir = dir.trim_end_matches('/');
347        let prefix = if dir.is_empty() {
348            String::new()
349        } else {
350            format!("{}/", dir)
351        };
352
353        let mut subdirs: Vec<String> = all_files
354            .into_iter()
355            .filter_map(|f| {
356                if f.name.starts_with(&prefix) {
357                    let rest = &f.name[prefix.len()..];
358                    if let Some(idx) = rest.find('/') {
359                        return Some(rest[..idx].to_string());
360                    }
361                }
362                None
363            })
364            .collect();
365
366        subdirs.sort();
367        subdirs.dedup();
368        Ok(subdirs)
369    }
370
371    pub(crate) fn list_branches_internal(&self) -> Result<Vec<String>> {
372        self.db.list_branches()
373    }
374
375    pub(crate) fn list_tags_internal(&self) -> Result<Vec<String>> {
376        let mut stmt = self.db.connection().prepare(
377            "SELECT DISTINCT substr(tagname, 5) FROM tag
378             WHERE tagname LIKE 'sym-%'
379             AND substr(tagname, 5) NOT IN (SELECT value FROM tagxref WHERE tagid IN
380                 (SELECT tagid FROM tag WHERE tagname = 'branch'))",
381        )?;
382
383        let tags: Vec<String> = stmt
384            .query_map([], |row| row.get(0))?
385            .filter_map(|r| r.ok())
386            .collect();
387
388        Ok(tags)
389    }
390
391    pub(crate) fn get_tag_checkin_internal(&self, tag_name: &str) -> Result<String> {
392        let tag_full = format!("sym-{}", tag_name);
393        let hash: String = self.db.connection().query_row(
394            "SELECT b.uuid FROM blob b
395             JOIN tagxref x ON x.rid = b.rid
396             JOIN tag t ON t.tagid = x.tagid
397             WHERE t.tagname = ?1
398             ORDER BY x.mtime DESC LIMIT 1",
399            rusqlite::params![tag_full],
400            |row| row.get(0),
401        )?;
402        Ok(hash)
403    }
404
405    pub(crate) fn commit_internal(
406        &self,
407        files: &[(&str, &[u8])],
408        comment: &str,
409        user: &str,
410        parent_hash: Option<&str>,
411        branch: Option<&str>,
412    ) -> Result<String> {
413        self.db.begin_transaction()?;
414
415        let result = self.commit_inner(files, comment, user, parent_hash, branch);
416
417        match result {
418            Ok(hash) => {
419                self.db.commit_transaction()?;
420                Ok(hash)
421            }
422            Err(e) => {
423                self.db.rollback_transaction()?;
424                Err(e)
425            }
426        }
427    }
428
429    fn commit_inner(
430        &self,
431        files: &[(&str, &[u8])],
432        comment: &str,
433        user: &str,
434        parent_hash: Option<&str>,
435        branch: Option<&str>,
436    ) -> Result<String> {
437        let now = Utc::now();
438        let timestamp = now.format("%Y-%m-%dT%H:%M:%S%.3f").to_string();
439        let mtime = now.timestamp() as f64 / 86400.0 + 2440587.5;
440
441        let mut sorted_files: Vec<(&str, &[u8])> = files.to_vec();
442        sorted_files.sort_by(|a, b| a.0.cmp(&b.0));
443
444        let mut blobs_to_insert: Vec<(Vec<u8>, String, i64)> =
445            Vec::with_capacity(sorted_files.len());
446        let mut file_entries: Vec<(String, String)> = Vec::with_capacity(sorted_files.len());
447        let mut r_hasher = md5::Context::new();
448
449        for (name, content) in &sorted_files {
450            let file_hash = hash::sha3_256_hex(content);
451            let compressed = blob::compress(content)?;
452            blobs_to_insert.push((compressed, file_hash.clone(), content.len() as i64));
453            file_entries.push((name.to_string(), file_hash));
454            r_hasher.consume(content);
455        }
456
457        let blob_refs: Vec<(&[u8], &str, i64)> = blobs_to_insert
458            .iter()
459            .map(|(c, h, s)| (c.as_slice(), h.as_str(), *s))
460            .collect();
461        let file_rids = self.db.insert_blobs(&blob_refs)?;
462
463        let r_hash = format!("{:x}", r_hasher.compute());
464
465        let mut manifest_lines: Vec<String> = Vec::new();
466
467        let escaped_comment = manifest::encode_fossil_string(comment);
468        manifest_lines.push(format!("C {}", escaped_comment));
469        manifest_lines.push(format!("D {}", timestamp));
470
471        for (name, file_hash) in &file_entries {
472            let escaped_name = manifest::encode_fossil_string(name);
473            manifest_lines.push(format!("F {} {}", escaped_name, file_hash));
474        }
475
476        if let Some(parent) = parent_hash {
477            manifest_lines.push(format!("P {}", parent));
478        }
479
480        manifest_lines.push(format!("R {}", r_hash));
481
482        let branch_name = branch.unwrap_or("trunk");
483        if parent_hash.is_none() || branch.is_some() {
484            manifest_lines.push(format!("T *branch * {}", branch_name));
485            manifest_lines.push(format!("T *sym-{} *", branch_name));
486        }
487
488        manifest_lines.push(format!("U {}", user));
489
490        let manifest_without_z = manifest_lines.join("\n") + "\n";
491        let z_hash = format!("{:x}", md5::compute(manifest_without_z.as_bytes()));
492        manifest_lines.push(format!("Z {}", z_hash));
493
494        let manifest_content = manifest_lines.join("\n") + "\n";
495        let manifest_bytes = manifest_content.as_bytes();
496        let manifest_hash = hash::sha3_256_hex(manifest_bytes);
497        let manifest_compressed = blob::compress(manifest_bytes)?;
498        let manifest_rid = self.db.insert_blob(
499            &manifest_compressed,
500            &manifest_hash,
501            manifest_bytes.len() as i64,
502        )?;
503
504        self.db
505            .insert_event("ci", manifest_rid, mtime, user, comment)?;
506
507        if let Some(parent) = parent_hash {
508            let parent_rid = self.db.get_rid_by_hash(parent)?;
509            self.db.insert_plink(parent_rid, manifest_rid, mtime)?;
510        } else {
511            self.db.insert_leaf(manifest_rid)?;
512        }
513
514        let branch_tag_id = self.db.get_or_create_tag("branch")?;
515        self.db
516            .insert_tagxref(branch_tag_id, 2, manifest_rid, mtime, Some(branch_name))?;
517
518        let sym_tag_id = self.db.get_or_create_tag(&format!("sym-{}", branch_name))?;
519        self.db
520            .insert_tagxref(sym_tag_id, 2, manifest_rid, mtime, None)?;
521
522        if branch.is_some() && parent_hash.is_some() {
523            let parent_rid = self.db.get_rid_by_hash(parent_hash.unwrap())?;
524            if let Ok(parent_branch) = self.get_checkin_branch(parent_rid) {
525                if parent_branch != branch_name {
526                    let old_sym_tag_id = self
527                        .db
528                        .get_or_create_tag(&format!("sym-{}", parent_branch))?;
529                    self.db
530                        .insert_tagxref(old_sym_tag_id, 0, manifest_rid, mtime, None)?;
531                }
532            }
533        }
534
535        let names: Vec<&str> = file_entries.iter().map(|(n, _)| n.as_str()).collect();
536        let fnid_map = self.db.get_or_create_filenames(&names)?;
537
538        let mlink_entries: Vec<(i64, i64)> = file_entries
539            .iter()
540            .map(|(name, file_hash)| {
541                let fnid = fnid_map.get(name).copied().unwrap_or(0);
542                let frid = file_rids.get(file_hash).copied().unwrap_or(0);
543                (frid, fnid)
544            })
545            .collect();
546        self.db.insert_mlinks(manifest_rid, &mlink_entries)?;
547
548        Ok(manifest_hash)
549    }
550
551    fn get_checkin_branch(&self, rid: i64) -> Result<String> {
552        let branch: String = self.db.connection().query_row(
553            "SELECT value FROM tagxref WHERE rid = ?1 AND tagid = (SELECT tagid FROM tag WHERE tagname = 'branch')",
554            rusqlite::params![rid],
555            |row| row.get(0),
556        )?;
557        Ok(branch)
558    }
559
560    pub(crate) fn create_branch_internal(
561        &self,
562        branch_name: &str,
563        parent_hash: &str,
564        user: &str,
565    ) -> Result<String> {
566        let comment = format!("Create new branch named \"{}\"", branch_name);
567
568        let parent_files = self.list_files_internal(parent_hash)?;
569        let mut files_content: Vec<(String, Vec<u8>)> = Vec::new();
570
571        for file in &parent_files {
572            let content = self.read_file_internal(parent_hash, &file.name)?;
573            files_content.push((file.name.clone(), content));
574        }
575
576        let files: Vec<(&str, &[u8])> = files_content
577            .iter()
578            .map(|(n, c)| (n.as_str(), c.as_slice()))
579            .collect();
580
581        self.commit_internal(&files, &comment, user, Some(parent_hash), Some(branch_name))
582    }
583
584    pub(crate) fn add_tag_internal(
585        &self,
586        tag_name: &str,
587        checkin_hash: &str,
588        user: &str,
589    ) -> Result<String> {
590        let rid = self.db.get_rid_by_hash(checkin_hash)?;
591        let full_hash = self.db.get_hash_by_rid(rid)?;
592        let now = Utc::now();
593        let timestamp = now.format("%Y-%m-%dT%H:%M:%S").to_string();
594        let mtime = now.timestamp() as f64 / 86400.0 + 2440587.5;
595
596        let mut lines: Vec<String> = Vec::new();
597        lines.push(format!("D {}", timestamp));
598        lines.push(format!("T +sym-{} {}", tag_name, full_hash));
599        lines.push(format!("U {}", user));
600
601        let content_without_z = lines.join("\n") + "\n";
602        let z_hash = format!("{:x}", md5::compute(content_without_z.as_bytes()));
603        lines.push(format!("Z {}", z_hash));
604
605        let control_content = lines.join("\n") + "\n";
606        let control_bytes = control_content.as_bytes();
607        let control_hash = hash::sha3_256_hex(control_bytes);
608        let control_compressed = blob::compress(control_bytes)?;
609
610        let control_rid = self.db.insert_blob(
611            &control_compressed,
612            &control_hash,
613            control_bytes.len() as i64,
614        )?;
615
616        self.db.insert_event(
617            "g",
618            control_rid,
619            mtime,
620            user,
621            &format!("Add tag {}", tag_name),
622        )?;
623
624        let tag_id = self.db.get_or_create_tag(&format!("sym-{}", tag_name))?;
625        self.db.insert_tagxref(tag_id, 1, rid, mtime, None)?;
626
627        Ok(control_hash)
628    }
629
630    pub(crate) fn create_user_internal(
631        &self,
632        login: &str,
633        password: &str,
634        capabilities: &str,
635    ) -> Result<()> {
636        self.db.create_user(login, password, capabilities)
637    }
638
639    pub(crate) fn set_user_capabilities_internal(
640        &self,
641        login: &str,
642        capabilities: &str,
643    ) -> Result<()> {
644        self.db.set_user_capabilities(login, capabilities)
645    }
646
647    pub(crate) fn get_user_capabilities_internal(&self, login: &str) -> Result<Option<String>> {
648        self.db.get_user_capabilities(login)
649    }
650
651    pub(crate) fn list_users_internal(&self) -> Result<Vec<(String, String)>> {
652        self.db.list_users()
653    }
654}