heroforge_core/fs/
fs_interface.rs

1//! High-level filesystem interface for Heroforge repositories.
2//!
3//! `FsInterface` provides a filesystem-like API with staging directory support.
4//! All writes go to staging first, with background commits to the database.
5//!
6//! # Architecture
7//!
8//! ```text
9//! ┌─────────────────────────────────────────────────────────────────┐
10//! │                      FsInterface (sync API)                     │
11//! │  - RwLock<StagingState>                                         │
12//! │  - Author name (set at initialization)                          │
13//! │  - All read/write operations acquire appropriate locks          │
14//! └─────────────────────────────────────────────────────────────────┘
15//!                               │
16//!           ┌───────────────────┴───────────────────┐
17//!           │                                       │
18//!           ▼                                       ▼
19//! ┌─────────────────────┐                 ┌─────────────────────────┐
20//! │   Staging Directory │                 │    Commit Thread        │
21//! │                     │                 │    (background)         │
22//! └─────────────────────┘                 └─────────────────────────┘
23//! ```
24//!
25//! # Example
26//!
27//! ```no_run
28//! use heroforge_core::Repository;
29//! use heroforge_core::fs::FsInterface;
30//! use std::sync::Arc;
31//!
32//! let repo = Arc::new(Repository::open_rw("project.forge")?);
33//! let fs = FsInterface::new(repo, "developer@example.com")?;
34//!
35//! // Write goes to staging (fast)
36//! fs.write_file("config.json", b"{}")?;
37//!
38//! // Read checks staging first, then database
39//! let content = fs.read_file("config.json")?;
40//!
41//! // Partial update (read-modify-write pattern)
42//! fs.write_at("data.bin", 100, b"updated")?;
43//!
44//! // Force immediate commit (normally auto-commits every 1 minute)
45//! fs.commit()?;
46//! # Ok::<(), heroforge_core::FossilError>(())
47//! ```
48
49use std::path::PathBuf;
50use std::sync::Arc;
51
52use crate::fs::commit_thread::{CommitConfig, CommitTimer, commit_now};
53use crate::fs::errors::{FsError, FsResult};
54use crate::fs::operations::{DirectoryEntry, FileKind, FileMetadata, FilePermissions, FindResults};
55use crate::fs::staging::{MAX_FILE_SIZE, Staging};
56use crate::repo::Repository;
57
58/// Status of the FsInterface
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum FsInterfaceStatus {
61    /// Active and accepting operations
62    Active,
63    /// Commit in progress
64    Committing,
65    /// Closed/disposed
66    Closed,
67}
68
69/// High-level filesystem interface with staging support.
70///
71/// This is the recommended way to interact with Heroforge repositories for
72/// filesystem-like operations. It provides:
73///
74/// - Fast writes via staging directory
75/// - Layered reads (staging → database)
76/// - Automatic background commits
77/// - Partial file updates (read-modify-write)
78/// - Thread-safe operations via RwLock
79pub struct FsInterface {
80    /// Reference to the underlying repository
81    repo: Arc<Repository>,
82
83    /// Staging state
84    staging: Staging,
85
86    /// Background commit timer
87    commit_timer: Option<CommitTimer>,
88
89    /// Current status
90    status: FsInterfaceStatus,
91
92    /// Current branch name
93    branch: String,
94}
95
96impl FsInterface {
97    /// Create a new FsInterface for a repository.
98    ///
99    /// # Arguments
100    ///
101    /// * `repo` - The repository to operate on
102    /// * `author` - Author name for all commits from this interface
103    ///
104    /// # Example
105    ///
106    /// ```no_run
107    /// use heroforge_core::Repository;
108    /// use heroforge_core::fs::FsInterface;
109    /// use std::sync::Arc;
110    ///
111    /// let repo = Arc::new(Repository::open_rw("project.forge")?);
112    /// let fs = FsInterface::new(repo, "developer@example.com")?;
113    /// # Ok::<(), heroforge_core::FossilError>(())
114    /// ```
115    pub fn new(repo: Arc<Repository>, author: &str) -> FsResult<Self> {
116        Self::with_config(repo, author, CommitConfig::default())
117    }
118
119    /// Create a new FsInterface with custom configuration.
120    pub fn with_config(
121        repo: Arc<Repository>,
122        author: &str,
123        config: CommitConfig,
124    ) -> FsResult<Self> {
125        // Create staging directory alongside the repository
126        let staging_dir = Self::get_staging_dir(&repo)?;
127
128        let staging = Staging::new(staging_dir, author.to_string())?;
129
130        // Start background commit timer
131        let commit_timer = Some(CommitTimer::start(config));
132
133        Ok(Self {
134            repo,
135            staging,
136            commit_timer,
137            status: FsInterfaceStatus::Active,
138            branch: "trunk".to_string(),
139        })
140    }
141
142    /// Create a new FsInterface without background commit timer.
143    /// Useful for testing or manual commit control.
144    pub fn without_timer(repo: Arc<Repository>, author: &str) -> FsResult<Self> {
145        let staging_dir = Self::get_staging_dir(&repo)?;
146        let staging = Staging::new(staging_dir, author.to_string())?;
147
148        Ok(Self {
149            repo,
150            staging,
151            commit_timer: None,
152            status: FsInterfaceStatus::Active,
153            branch: "trunk".to_string(),
154        })
155    }
156
157    /// Get the staging directory path for a repository
158    fn get_staging_dir(repo: &Repository) -> FsResult<PathBuf> {
159        // Use project code to create unique staging directory
160        let project_code = repo
161            .project_code()
162            .map_err(|e| FsError::DatabaseError(format!("Failed to get project code: {}", e)))?;
163
164        let staging_dir = std::env::temp_dir()
165            .join("heroforge-staging")
166            .join(&project_code[..8.min(project_code.len())]);
167
168        Ok(staging_dir)
169    }
170
171    /// Get the current status
172    pub fn status(&self) -> FsInterfaceStatus {
173        self.status
174    }
175
176    /// Get the author name
177    pub fn author(&self) -> String {
178        self.staging.read().author().to_string()
179    }
180
181    /// Get the current branch
182    pub fn branch(&self) -> &str {
183        &self.branch
184    }
185
186    /// Check if there are uncommitted changes
187    pub fn has_changes(&self) -> bool {
188        self.staging.read().is_dirty()
189    }
190
191    // ========================================================================
192    // Read Operations (check staging first, then database)
193    // ========================================================================
194
195    /// Check if a path exists (in staging or database).
196    pub fn exists(&self, path: &str) -> FsResult<bool> {
197        self.validate_path(path)?;
198
199        let state = self.staging.read();
200
201        // Check staging first
202        if state.has_file(path) {
203            return Ok(!state.is_deleted(path));
204        }
205
206        // Check database
207        self.exists_in_db(path)
208    }
209
210    /// Check if a path is a directory.
211    pub fn is_dir(&self, path: &str) -> FsResult<bool> {
212        self.validate_path(path)?;
213
214        // Check if any file in staging starts with this path
215        let state = self.staging.read();
216        let prefix = if path.ends_with('/') {
217            path.to_string()
218        } else {
219            format!("{}/", path)
220        };
221
222        for key in state.files().keys() {
223            if key.starts_with(&prefix) && !state.is_deleted(key) {
224                return Ok(true);
225            }
226        }
227
228        // Check database
229        self.is_dir_in_db(path)
230    }
231
232    /// Check if a path is a file.
233    pub fn is_file(&self, path: &str) -> FsResult<bool> {
234        self.validate_path(path)?;
235
236        let state = self.staging.read();
237
238        if state.has_file(path) {
239            let file = state.get_file(path).unwrap();
240            return Ok(!file.is_deleted);
241        }
242
243        self.is_file_in_db(path)
244    }
245
246    /// Get file metadata.
247    pub fn stat(&self, path: &str) -> FsResult<FileMetadata> {
248        self.validate_path(path)?;
249
250        let state = self.staging.read();
251
252        // Check staging first
253        if let Some(staged) = state.get_file(path) {
254            if staged.is_deleted {
255                return Err(FsError::NotFound(path.to_string()));
256            }
257
258            return Ok(FileMetadata {
259                path: path.to_string(),
260                is_dir: false,
261                size: staged.size,
262                permissions: FilePermissions::file(),
263                is_symlink: false,
264                symlink_target: None,
265                modified: staged.staged_at.elapsed().as_secs() as i64,
266                hash: staged.original_hash.clone(),
267                kind: FileKind::File,
268            });
269        }
270
271        // Check database
272        self.stat_from_db(path)
273    }
274
275    /// Read file content as bytes.
276    ///
277    /// Checks staging directory first, then falls back to database.
278    pub fn read_file(&self, path: &str) -> FsResult<Vec<u8>> {
279        self.validate_path(path)?;
280
281        let state = self.staging.read();
282
283        // Check staging first
284        if state.has_file(path) {
285            if state.is_deleted(path) {
286                return Err(FsError::NotFound(path.to_string()));
287            }
288            return state.read_file(path);
289        }
290
291        // Read from database
292        self.read_file_from_db(path)
293    }
294
295    /// Read file content as string.
296    pub fn read_file_string(&self, path: &str) -> FsResult<String> {
297        let bytes = self.read_file(path)?;
298        String::from_utf8(bytes).map_err(|e| FsError::Encoding(e.to_string()))
299    }
300
301    /// List directory contents.
302    pub fn list_dir(&self, path: &str) -> FsResult<Vec<DirectoryEntry>> {
303        self.validate_path(path)?;
304
305        let mut entries: std::collections::HashMap<String, DirectoryEntry> =
306            std::collections::HashMap::new();
307
308        // Get entries from staging
309        let state = self.staging.read();
310        for name in state.list_dir(path) {
311            if let Some(staged) = state.get_file(&name) {
312                if !staged.is_deleted {
313                    let entry_name = name.rsplit('/').next().unwrap_or(&name).to_string();
314                    entries.insert(
315                        entry_name.clone(),
316                        DirectoryEntry {
317                            name: entry_name,
318                            is_dir: false,
319                            size: staged.size,
320                            permissions: FilePermissions::file(),
321                            modified: staged.staged_at.elapsed().as_secs() as i64,
322                        },
323                    );
324                }
325            }
326        }
327
328        // Get entries from database (if not overridden by staging)
329        if let Ok(db_entries) = self.list_dir_from_db(path) {
330            for entry in db_entries {
331                if !entries.contains_key(&entry.name) {
332                    // Check if deleted in staging
333                    let full_path = if path.is_empty() || path == "/" {
334                        entry.name.clone()
335                    } else {
336                        format!("{}/{}", path.trim_end_matches('/'), entry.name)
337                    };
338                    if !state.is_deleted(&full_path) {
339                        entries.insert(entry.name.clone(), entry);
340                    }
341                }
342            }
343        }
344
345        let mut result: Vec<_> = entries.into_values().collect();
346        result.sort_by(|a, b| a.name.cmp(&b.name));
347        Ok(result)
348    }
349
350    /// Find files matching a glob pattern.
351    pub fn find(&self, pattern: &str) -> FsResult<FindResults> {
352        if pattern.is_empty() {
353            return Err(FsError::PatternError("Pattern cannot be empty".to_string()));
354        }
355
356        let glob = glob::Pattern::new(pattern).map_err(|e| FsError::PatternError(e.to_string()))?;
357
358        let mut files: Vec<String> = Vec::new();
359        let state = self.staging.read();
360
361        // Find in staging
362        for (path, staged) in state.files() {
363            if !staged.is_deleted && glob.matches(path) {
364                files.push(path.clone());
365            }
366        }
367
368        // Find in database
369        if let Ok(db_files) = self.find_in_db(pattern) {
370            for path in db_files {
371                if !files.contains(&path) && !state.is_deleted(&path) {
372                    files.push(path);
373                }
374            }
375        }
376
377        files.sort();
378
379        Ok(FindResults {
380            count: files.len(),
381            files,
382            dirs_traversed: 0,
383        })
384    }
385
386    /// Calculate disk usage of a path.
387    pub fn disk_usage(&self, path: &str) -> FsResult<u64> {
388        self.validate_path(path)?;
389
390        let mut total: u64 = 0;
391        let state = self.staging.read();
392
393        // Sum staging files
394        let prefix = if path.is_empty() || path == "/" {
395            String::new()
396        } else {
397            format!("{}/", path.trim_end_matches('/'))
398        };
399
400        for (file_path, staged) in state.files() {
401            if (prefix.is_empty() || file_path.starts_with(&prefix)) && !staged.is_deleted {
402                total += staged.size;
403            }
404        }
405
406        // Add database files (if not in staging)
407        if let Ok(db_usage) = self.disk_usage_from_db(path) {
408            total += db_usage;
409        }
410
411        Ok(total)
412    }
413
414    /// Count files matching a pattern.
415    pub fn count_files(&self, pattern: &str) -> FsResult<usize> {
416        let results = self.find(pattern)?;
417        Ok(results.count)
418    }
419
420    // ========================================================================
421    // Write Operations (all go to staging first)
422    // ========================================================================
423
424    /// Write file content.
425    ///
426    /// The file is written to the staging directory immediately.
427    /// It will be committed to the database during the next auto-commit
428    /// or when `commit()` is called.
429    pub fn write_file(&self, path: &str, content: &[u8]) -> FsResult<()> {
430        self.validate_path(path)?;
431        self.validate_size(path, content.len() as u64)?;
432
433        let mut state = self.staging.write();
434        state.stage_file(path, content)
435    }
436
437    /// Write file content from a string.
438    pub fn write_file_string(&self, path: &str, content: &str) -> FsResult<()> {
439        self.write_file(path, content.as_bytes())
440    }
441
442    /// Write at a specific offset in a file (partial update).
443    ///
444    /// If the file exists only in the database, it will be promoted to staging first.
445    /// This implements the read-modify-write pattern described in the spec.
446    pub fn write_at(&self, path: &str, offset: u64, data: &[u8]) -> FsResult<()> {
447        self.validate_path(path)?;
448
449        let mut state = self.staging.write();
450
451        // Check if file is already in staging
452        if state.has_file(path) {
453            if state.is_deleted(path) {
454                return Err(FsError::NotFound(path.to_string()));
455            }
456            // File is in staging, write directly
457            state.write_at(path, offset, data)?;
458            state.mark_modified(path);
459            return Ok(());
460        }
461
462        // File not in staging - need to promote from database
463        drop(state); // Release lock before database read
464
465        let content = self.read_file_from_db(path)?;
466        let hash = self.get_file_hash_from_db(path)?;
467
468        let mut state = self.staging.write();
469
470        // Promote to staging
471        state.stage_promoted(path, &content, hash)?;
472
473        // Now write at offset
474        state.write_at(path, offset, data)?;
475        state.mark_modified(path);
476
477        Ok(())
478    }
479
480    /// Delete a file.
481    pub fn delete_file(&self, path: &str) -> FsResult<()> {
482        self.validate_path(path)?;
483
484        let mut state = self.staging.write();
485        state.delete_file(path)
486    }
487
488    /// Delete a directory recursively.
489    pub fn delete_dir(&self, path: &str) -> FsResult<()> {
490        self.validate_path(path)?;
491
492        let mut state = self.staging.write();
493        state.delete_dir(path)
494    }
495
496    /// Copy a file.
497    pub fn copy_file(&self, src: &str, dst: &str) -> FsResult<()> {
498        self.validate_path(src)?;
499        self.validate_path(dst)?;
500
501        // Read from wherever the file exists
502        let content = self.read_file(src)?;
503
504        let mut state = self.staging.write();
505        state.stage_file(dst, &content)
506    }
507
508    /// Move a file.
509    pub fn move_file(&self, src: &str, dst: &str) -> FsResult<()> {
510        self.copy_file(src, dst)?;
511        self.delete_file(src)
512    }
513
514    /// Copy a directory recursively.
515    pub fn copy_dir(&self, src: &str, dst: &str) -> FsResult<()> {
516        self.validate_path(src)?;
517        self.validate_path(dst)?;
518
519        // Find all files in source directory
520        let pattern = format!("{}/**/*", src.trim_end_matches('/'));
521        let files = self.find(&pattern)?;
522
523        for file_path in files.files {
524            let rel_path = file_path
525                .strip_prefix(src.trim_end_matches('/'))
526                .unwrap_or(&file_path)
527                .trim_start_matches('/');
528            let dst_path = format!("{}/{}", dst.trim_end_matches('/'), rel_path);
529
530            let content = self.read_file(&file_path)?;
531            let mut state = self.staging.write();
532            state.stage_file(&dst_path, &content)?;
533        }
534
535        Ok(())
536    }
537
538    /// Move a directory recursively.
539    pub fn move_dir(&self, src: &str, dst: &str) -> FsResult<()> {
540        self.copy_dir(src, dst)?;
541        self.delete_dir(src)
542    }
543
544    // ========================================================================
545    // Commit Operations
546    // ========================================================================
547
548    /// Force an immediate commit of all staged changes.
549    ///
550    /// Returns the commit hash, or "no-changes" if nothing was staged.
551    pub fn commit(&self) -> FsResult<String> {
552        commit_now(&self.staging, &self.repo)
553    }
554
555    /// Force a commit with a custom message.
556    pub fn commit_with_message(&self, message: &str) -> FsResult<String> {
557        let mut state = self.staging.write();
558
559        if !state.is_dirty() {
560            return Ok("no-changes".to_string());
561        }
562
563        let author = state.author().to_string();
564        let branch = state.branch().to_string();
565
566        // Collect staged changes (new/modified files and deletions)
567        let mut staged_files: Vec<(String, Vec<u8>)> = Vec::new();
568        let mut deletions: std::collections::HashSet<String> = std::collections::HashSet::new();
569
570        for (path, staged_file) in state.files() {
571            if staged_file.is_deleted {
572                deletions.insert(path.clone());
573            } else if staged_file.modified {
574                let content = state.read_file(path)?;
575                staged_files.push((path.clone(), content));
576            }
577        }
578
579        if staged_files.is_empty() && deletions.is_empty() {
580            state.mark_clean();
581            return Ok("no-changes".to_string());
582        }
583
584        // Get parent commit hash
585        let parent_hash = self
586            .repo
587            .branches()
588            .get(&branch)
589            .ok()
590            .and_then(|b| b.tip().ok())
591            .map(|c| c.hash);
592
593        // Build complete file list: parent files + staged changes - deletions
594        let mut files_to_commit: Vec<(String, Vec<u8>)> = Vec::new();
595        let staged_paths: std::collections::HashSet<String> =
596            staged_files.iter().map(|(p, _)| p.clone()).collect();
597
598        // First, add files from parent that aren't being modified or deleted
599        if let Some(ref parent) = parent_hash {
600            if let Ok(parent_files) = self.repo.list_files_internal(parent) {
601                for file_info in parent_files {
602                    // Skip if this file is being deleted
603                    if deletions.contains(&file_info.name) {
604                        continue;
605                    }
606                    // Skip if this file is being replaced with staged content
607                    if staged_paths.contains(&file_info.name) {
608                        continue;
609                    }
610                    // Read the file content from parent and include it
611                    if let Ok(content) = self.repo.read_file_internal(parent, &file_info.name) {
612                        files_to_commit.push((file_info.name, content));
613                    }
614                }
615            }
616        }
617
618        // Add all staged files (new and modified)
619        files_to_commit.extend(staged_files);
620
621        let files_refs: Vec<(&str, &[u8])> = files_to_commit
622            .iter()
623            .map(|(p, c)| (p.as_str(), c.as_slice()))
624            .collect();
625
626        let commit_hash = self
627            .repo
628            .commit_internal(
629                &files_refs,
630                message,
631                &author,
632                parent_hash.as_deref(),
633                Some(&branch),
634            )
635            .map_err(|e| FsError::DatabaseError(format!("Commit failed: {}", e)))?;
636
637        state.clear()?;
638
639        Ok(commit_hash)
640    }
641
642    // ========================================================================
643    // Branch Operations (force commit before switching)
644    // ========================================================================
645
646    /// Switch to a different branch.
647    ///
648    /// Forces a commit of any staged changes before switching.
649    pub fn switch_branch(&mut self, branch: &str) -> FsResult<()> {
650        // Force commit before branch switch
651        self.commit()?;
652
653        self.branch = branch.to_string();
654        self.staging.write().set_branch(branch.to_string());
655
656        Ok(())
657    }
658
659    // ========================================================================
660    // Helper Methods
661    // ========================================================================
662
663    /// Validate path format
664    fn validate_path(&self, path: &str) -> FsResult<()> {
665        if path.is_empty() {
666            return Err(FsError::InvalidPath("Path cannot be empty".to_string()));
667        }
668        if path.contains('\0') {
669            return Err(FsError::InvalidPath(
670                "Path cannot contain null bytes".to_string(),
671            ));
672        }
673        Ok(())
674    }
675
676    /// Validate file size
677    fn validate_size(&self, path: &str, size: u64) -> FsResult<()> {
678        if size > MAX_FILE_SIZE {
679            return Err(FsError::FileTooLarge {
680                path: path.to_string(),
681                size,
682                max: MAX_FILE_SIZE,
683            });
684        }
685        Ok(())
686    }
687
688    // Database access helpers
689
690    fn exists_in_db(&self, path: &str) -> FsResult<bool> {
691        let checkin = self.get_branch_tip()?;
692        match self.repo.read_file_internal(&checkin, path) {
693            Ok(_) => Ok(true),
694            Err(_) => Ok(false),
695        }
696    }
697
698    fn is_dir_in_db(&self, path: &str) -> FsResult<bool> {
699        let checkin = self.get_branch_tip()?;
700        match self.repo.list_directory_internal(&checkin, path) {
701            Ok(files) => Ok(!files.is_empty()),
702            Err(_) => Ok(false),
703        }
704    }
705
706    fn is_file_in_db(&self, path: &str) -> FsResult<bool> {
707        let checkin = self.get_branch_tip()?;
708        match self.repo.read_file_internal(&checkin, path) {
709            Ok(_) => Ok(true),
710            Err(_) => Ok(false),
711        }
712    }
713
714    fn stat_from_db(&self, path: &str) -> FsResult<FileMetadata> {
715        let checkin = self.get_branch_tip()?;
716        let files = self
717            .repo
718            .list_files_internal(&checkin)
719            .map_err(|e| FsError::DatabaseError(e.to_string()))?;
720
721        for file in files {
722            if file.name == path {
723                return Ok(FileMetadata {
724                    path: path.to_string(),
725                    is_dir: false,
726                    size: file.size.unwrap_or(0) as u64,
727                    permissions: FilePermissions::file(),
728                    is_symlink: false,
729                    symlink_target: None,
730                    modified: 0,
731                    hash: Some(file.hash),
732                    kind: FileKind::File,
733                });
734            }
735        }
736
737        Err(FsError::NotFound(path.to_string()))
738    }
739
740    fn read_file_from_db(&self, path: &str) -> FsResult<Vec<u8>> {
741        let checkin = self.get_branch_tip()?;
742        self.repo
743            .read_file_internal(&checkin, path)
744            .map_err(|e| FsError::NotFound(format!("{}: {}", path, e)))
745    }
746
747    fn get_file_hash_from_db(&self, path: &str) -> FsResult<String> {
748        let checkin = self.get_branch_tip()?;
749        let files = self
750            .repo
751            .list_files_internal(&checkin)
752            .map_err(|e| FsError::DatabaseError(e.to_string()))?;
753
754        for file in files {
755            if file.name == path {
756                return Ok(file.hash);
757            }
758        }
759
760        Err(FsError::NotFound(path.to_string()))
761    }
762
763    fn list_dir_from_db(&self, path: &str) -> FsResult<Vec<DirectoryEntry>> {
764        let checkin = self.get_branch_tip()?;
765        let files = self
766            .repo
767            .list_directory_internal(&checkin, path)
768            .map_err(|e| FsError::DatabaseError(e.to_string()))?;
769
770        Ok(files
771            .into_iter()
772            .map(|f| DirectoryEntry {
773                name: f.name.rsplit('/').next().unwrap_or(&f.name).to_string(),
774                is_dir: false,
775                size: f.size.unwrap_or(0) as u64,
776                permissions: FilePermissions::file(),
777                modified: 0,
778            })
779            .collect())
780    }
781
782    fn find_in_db(&self, pattern: &str) -> FsResult<Vec<String>> {
783        let checkin = self.get_branch_tip()?;
784        let files = self
785            .repo
786            .find_files_internal(&checkin, pattern)
787            .map_err(|e| FsError::DatabaseError(e.to_string()))?;
788
789        Ok(files.into_iter().map(|f| f.name).collect())
790    }
791
792    fn disk_usage_from_db(&self, path: &str) -> FsResult<u64> {
793        let checkin = self.get_branch_tip()?;
794        let files = self
795            .repo
796            .list_files_internal(&checkin)
797            .map_err(|e| FsError::DatabaseError(e.to_string()))?;
798
799        let prefix = if path.is_empty() || path == "/" {
800            String::new()
801        } else {
802            format!("{}/", path.trim_end_matches('/'))
803        };
804
805        let total: u64 = files
806            .into_iter()
807            .filter(|f| prefix.is_empty() || f.name.starts_with(&prefix))
808            .map(|f| f.size.unwrap_or(0) as u64)
809            .sum();
810
811        Ok(total)
812    }
813
814    fn get_branch_tip(&self) -> FsResult<String> {
815        let branch_ref = self
816            .repo
817            .branches()
818            .get(&self.branch)
819            .map_err(|e| FsError::DatabaseError(format!("Failed to get branch: {}", e)))?;
820
821        let tip = branch_ref
822            .tip()
823            .map_err(|e| FsError::DatabaseError(format!("Failed to get branch tip: {}", e)))?;
824
825        Ok(tip.hash)
826    }
827}
828
829impl Drop for FsInterface {
830    fn drop(&mut self) {
831        // Perform final commit and stop timer
832        let _ = self.commit();
833        if let Some(mut timer) = self.commit_timer.take() {
834            timer.stop();
835        }
836    }
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842    use tempfile::tempdir;
843
844    // Note: Full integration tests require a real repository
845    // These tests focus on unit-level behavior
846
847    #[test]
848    fn test_validate_path() {
849        // Create a minimal test - actual repo tests would go in integration tests
850    }
851}