heroforge_core/repo/builders/
fs.rs

1//! Filesystem operations builder for high-level file manipulation.
2//!
3//! This module provides a fluent API for performing filesystem-like operations
4//! on repository contents. All modifications are staged and committed atomically.
5//!
6//! # Overview
7//!
8//! Access filesystem operations through [`Repository::fs()`](crate::Repository::fs):
9//!
10//! ```no_run
11//! use heroforge_core::Repository;
12//!
13//! let repo = Repository::open_rw("project.forge")?;
14//!
15//! // Modify files (copy, move, delete, etc.)
16//! repo.fs().modify()
17//!     .message("Reorganize project")
18//!     .author("developer")
19//!     .copy_file("README.md", "docs/README.md")
20//!     .move_dir("scripts", "tools")
21//!     .delete_file("old.txt")
22//!     .execute()?;
23//!
24//! // Find files with patterns
25//! let rust_files = repo.fs().find()
26//!     .pattern("**/*.rs")
27//!     .ignore("target/**")
28//!     .paths()?;
29//!
30//! // Utility functions
31//! let exists = repo.fs().exists("README.md")?;
32//! let size = repo.fs().du("**/*.rs")?;
33//! # Ok::<(), heroforge_core::FossilError>(())
34//! ```
35//!
36//! # Modification Operations
37//!
38//! Use [`FsOpsBuilder::modify()`] to start a modification chain:
39//!
40//! ## Copy Operations
41//! - [`FsBuilder::copy_file()`] - Copy a single file
42//! - [`FsBuilder::copy_dir()`] - Copy a directory recursively
43//!
44//! ## Move/Rename Operations
45//! - [`FsBuilder::move_file()`] - Move or rename a file
46//! - [`FsBuilder::move_dir()`] - Move or rename a directory
47//! - [`FsBuilder::rename()`] - Alias for move_file
48//!
49//! ## Delete Operations
50//! - [`FsBuilder::delete_file()`] - Delete a file (silent if missing)
51//! - [`FsBuilder::delete_dir()`] - Delete a directory recursively
52//! - [`FsBuilder::delete_matching()`] - Delete files matching a glob pattern
53//!
54//! ## Permission Operations
55//! - [`FsBuilder::chmod()`] - Change file permissions (octal)
56//! - [`FsBuilder::chmod_dir()`] - Change directory permissions recursively
57//! - [`FsBuilder::make_executable()`] - Make a file executable (755)
58//!
59//! ## Symlink Operations
60//! - [`FsBuilder::symlink()`] - Create a symbolic link
61//!
62//! ## Write Operations
63//! - [`FsBuilder::write()`] - Write bytes to a file
64//! - [`FsBuilder::write_str()`] - Write a string to a file
65//! - [`FsBuilder::touch()`] - Create an empty file
66//!
67//! # Find Operations
68//!
69//! Use [`FsOpsBuilder::find()`] to search for files:
70//!
71//! ```no_run
72//! # use heroforge_core::Repository;
73//! # let repo = Repository::open("project.forge")?;
74//! // Find all Rust files, excluding target directory
75//! let files = repo.fs().find()
76//!     .pattern("**/*.rs")
77//!     .ignore("target/**")
78//!     .ignore_hidden()
79//!     .max_depth(5)
80//!     .paths()?;
81//!
82//! // Use gitignore-style defaults
83//! let clean = repo.fs().find()
84//!     .pattern("**/*")
85//!     .use_gitignore()
86//!     .execute()?;
87//! # Ok::<(), heroforge_core::FossilError>(())
88//! ```
89//!
90//! # Utility Functions
91//!
92//! Quick checks without building operations:
93//!
94//! - [`FsOpsBuilder::exists()`] - Check if a file exists
95//! - [`FsOpsBuilder::is_dir()`] - Check if a path is a directory
96//! - [`FsOpsBuilder::stat()`] - Get file metadata
97//! - [`FsOpsBuilder::du()`] - Calculate total size of matching files
98//! - [`FsOpsBuilder::count()`] - Count matching files
99//! - [`FsOpsBuilder::list_symlinks()`] - List all symbolic links
100
101use crate::error::{FossilError, Result};
102use crate::repo::Repository;
103use std::collections::HashMap;
104
105/// Unix-style file permissions.
106///
107/// # Examples
108///
109/// ```
110/// use heroforge_core::Permissions;
111///
112/// // Create from octal
113/// let perms = Permissions::from_octal(0o755);
114/// assert_eq!(perms.to_string_repr(), "rwxr-xr-x");
115///
116/// // Use preset
117/// let exec = Permissions::executable();
118/// assert_eq!(exec.to_octal(), 0o755);
119///
120/// let readonly = Permissions::readonly();
121/// assert_eq!(readonly.to_octal(), 0o444);
122/// ```
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub struct Permissions {
125    /// Read permission for owner
126    pub owner_read: bool,
127    /// Write permission for owner
128    pub owner_write: bool,
129    /// Execute permission for owner
130    pub owner_exec: bool,
131    /// Read permission for group
132    pub group_read: bool,
133    /// Write permission for group
134    pub group_write: bool,
135    /// Execute permission for group
136    pub group_exec: bool,
137    /// Read permission for others
138    pub other_read: bool,
139    /// Write permission for others
140    pub other_write: bool,
141    /// Execute permission for others
142    pub other_exec: bool,
143}
144
145impl Permissions {
146    /// Create permissions from octal (e.g., 0o755).
147    ///
148    /// # Examples
149    ///
150    /// ```
151    /// use heroforge_core::Permissions;
152    ///
153    /// let perms = Permissions::from_octal(0o644);
154    /// assert!(perms.owner_read);
155    /// assert!(perms.owner_write);
156    /// assert!(!perms.owner_exec);
157    /// ```
158    pub fn from_octal(mode: u32) -> Self {
159        Self {
160            owner_read: mode & 0o400 != 0,
161            owner_write: mode & 0o200 != 0,
162            owner_exec: mode & 0o100 != 0,
163            group_read: mode & 0o040 != 0,
164            group_write: mode & 0o020 != 0,
165            group_exec: mode & 0o010 != 0,
166            other_read: mode & 0o004 != 0,
167            other_write: mode & 0o002 != 0,
168            other_exec: mode & 0o001 != 0,
169        }
170    }
171
172    /// Convert to octal representation.
173    ///
174    /// # Examples
175    ///
176    /// ```
177    /// use heroforge_core::Permissions;
178    ///
179    /// let perms = Permissions::executable();
180    /// assert_eq!(perms.to_octal(), 0o755);
181    /// ```
182    pub fn to_octal(&self) -> u32 {
183        let mut mode = 0u32;
184        if self.owner_read {
185            mode |= 0o400;
186        }
187        if self.owner_write {
188            mode |= 0o200;
189        }
190        if self.owner_exec {
191            mode |= 0o100;
192        }
193        if self.group_read {
194            mode |= 0o040;
195        }
196        if self.group_write {
197            mode |= 0o020;
198        }
199        if self.group_exec {
200            mode |= 0o010;
201        }
202        if self.other_read {
203            mode |= 0o004;
204        }
205        if self.other_write {
206            mode |= 0o002;
207        }
208        if self.other_exec {
209            mode |= 0o001;
210        }
211        mode
212    }
213
214    /// Convert to string representation (e.g., "rwxr-xr-x").
215    ///
216    /// # Examples
217    ///
218    /// ```
219    /// use heroforge_core::Permissions;
220    ///
221    /// assert_eq!(Permissions::from_octal(0o755).to_string_repr(), "rwxr-xr-x");
222    /// assert_eq!(Permissions::from_octal(0o644).to_string_repr(), "rw-r--r--");
223    /// ```
224    pub fn to_string_repr(&self) -> String {
225        format!(
226            "{}{}{}{}{}{}{}{}{}",
227            if self.owner_read { 'r' } else { '-' },
228            if self.owner_write { 'w' } else { '-' },
229            if self.owner_exec { 'x' } else { '-' },
230            if self.group_read { 'r' } else { '-' },
231            if self.group_write { 'w' } else { '-' },
232            if self.group_exec { 'x' } else { '-' },
233            if self.other_read { 'r' } else { '-' },
234            if self.other_write { 'w' } else { '-' },
235            if self.other_exec { 'x' } else { '-' },
236        )
237    }
238
239    /// Create permissions for a regular file (644 = rw-r--r--).
240    pub fn file() -> Self {
241        Self::from_octal(0o644)
242    }
243
244    /// Create permissions for an executable file (755 = rwxr-xr-x).
245    pub fn executable() -> Self {
246        Self::from_octal(0o755)
247    }
248
249    /// Create permissions for a read-only file (444 = r--r--r--).
250    pub fn readonly() -> Self {
251        Self::from_octal(0o444)
252    }
253}
254
255/// Type of file entry in the repository.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum FileType {
258    /// Regular file
259    Regular,
260    /// Executable file
261    Executable,
262    /// Symbolic link with target path
263    Symlink(String),
264}
265
266/// Extended file information including permissions and type.
267///
268/// Returned by [`FsOpsBuilder::stat()`] and [`FindBuilder::execute()`].
269#[derive(Debug, Clone)]
270pub struct FileEntry {
271    /// File path relative to repository root
272    pub path: String,
273    /// SHA3-256 hash of file content
274    pub hash: String,
275    /// Type of file (regular, executable, symlink)
276    pub file_type: FileType,
277    /// Unix permissions (if tracked)
278    pub permissions: Option<Permissions>,
279    /// File size in bytes
280    pub size: Option<usize>,
281}
282
283/// A staged filesystem operation.
284///
285/// These are created by [`FsBuilder`] methods and applied atomically
286/// when [`FsBuilder::execute()`] is called.
287#[derive(Debug, Clone)]
288pub enum FsOperation {
289    /// Copy file or directory
290    Copy {
291        /// Source path
292        src: String,
293        /// Destination path
294        dst: String,
295    },
296    /// Move/rename file or directory
297    Move {
298        /// Source path
299        src: String,
300        /// Destination path
301        dst: String,
302    },
303    /// Delete file or directory
304    Delete {
305        /// Path to delete
306        path: String,
307        /// Whether to delete recursively
308        recursive: bool,
309    },
310    /// Change permissions
311    Chmod {
312        /// Path to modify
313        path: String,
314        /// New permissions
315        permissions: Permissions,
316        /// Whether to apply recursively
317        recursive: bool,
318    },
319    /// Create symlink
320    Symlink {
321        /// Path where symlink will be created
322        link_path: String,
323        /// Target the symlink points to
324        target: String,
325    },
326    /// Write file content
327    Write {
328        /// File path
329        path: String,
330        /// Content to write
331        content: Vec<u8>,
332    },
333    /// Make file executable
334    MakeExecutable {
335        /// File path
336        path: String,
337    },
338}
339
340/// Result of a find operation with metadata.
341///
342/// # Examples
343///
344/// ```no_run
345/// # use heroforge_core::Repository;
346/// # let repo = Repository::open("project.forge")?;
347/// let result = repo.fs().find()
348///     .pattern("**/*.rs")
349///     .execute()?;
350///
351/// println!("Found {} files in {} directories", result.count, result.dirs_traversed);
352/// for file in result.files {
353///     println!("  {} ({} bytes)", file.path, file.size.unwrap_or(0));
354/// }
355/// # Ok::<(), heroforge_core::FossilError>(())
356/// ```
357#[derive(Debug, Clone)]
358pub struct FindResult {
359    /// Matched files with metadata
360    pub files: Vec<FileEntry>,
361    /// Total number of matches
362    pub count: usize,
363    /// Number of directories traversed during search
364    pub dirs_traversed: usize,
365}
366
367/// Builder for advanced file search operations.
368///
369/// Create via [`FsOpsBuilder::find()`]. Supports glob patterns, ignore patterns,
370/// depth limits, and various filters.
371///
372/// # Examples
373///
374/// ## Basic Pattern Matching
375///
376/// ```no_run
377/// # use heroforge_core::Repository;
378/// # let repo = Repository::open("project.forge")?;
379/// // Find all Rust source files
380/// let rust_files = repo.fs().find()
381///     .pattern("**/*.rs")
382///     .paths()?;
383///
384/// // Find multiple patterns
385/// let source_files = repo.fs().find()
386///     .patterns(&["**/*.rs", "**/*.toml", "**/*.md"])
387///     .paths()?;
388/// # Ok::<(), heroforge_core::FossilError>(())
389/// ```
390///
391/// ## Ignore Patterns
392///
393/// ```no_run
394/// # use heroforge_core::Repository;
395/// # let repo = Repository::open("project.forge")?;
396/// // Exclude build artifacts
397/// let files = repo.fs().find()
398///     .pattern("**/*.rs")
399///     .ignore("target/**")
400///     .ignore("**/generated/**")
401///     .paths()?;
402///
403/// // Use common gitignore patterns
404/// let clean = repo.fs().find()
405///     .pattern("**/*")
406///     .use_gitignore()
407///     .ignore_hidden()
408///     .paths()?;
409/// # Ok::<(), heroforge_core::FossilError>(())
410/// ```
411///
412/// ## Directory and Depth Limits
413///
414/// ```no_run
415/// # use heroforge_core::Repository;
416/// # let repo = Repository::open("project.forge")?;
417/// // Search only in src directory
418/// let src_files = repo.fs().find()
419///     .in_dir("src")
420///     .pattern("**/*.rs")
421///     .paths()?;
422///
423/// // Limit depth to 2 levels
424/// let shallow = repo.fs().find()
425///     .pattern("**/*")
426///     .max_depth(2)
427///     .paths()?;
428/// # Ok::<(), heroforge_core::FossilError>(())
429/// ```
430pub struct FindBuilder<'a> {
431    repo: &'a Repository,
432    base_commit: Option<String>,
433    patterns: Vec<String>,
434    ignore_patterns: Vec<String>,
435    ignore_hidden: bool,
436    ignore_case: bool,
437    max_depth: Option<usize>,
438    file_type_filter: Option<FileType>,
439    min_size: Option<usize>,
440    max_size: Option<usize>,
441    base_dir: Option<String>,
442}
443
444impl<'a> FindBuilder<'a> {
445    fn new(repo: &'a Repository) -> Self {
446        Self {
447            repo,
448            base_commit: None,
449            patterns: Vec::new(),
450            ignore_patterns: Vec::new(),
451            ignore_hidden: false,
452            ignore_case: false,
453            max_depth: None,
454            file_type_filter: None,
455            min_size: None,
456            max_size: None,
457            base_dir: None,
458        }
459    }
460
461    /// Search at a specific commit hash.
462    ///
463    /// By default, searches at the trunk tip.
464    pub fn at_commit(mut self, hash: &str) -> Self {
465        self.base_commit = Some(hash.to_string());
466        self
467    }
468
469    /// Search on trunk (default).
470    pub fn on_trunk(mut self) -> Self {
471        self.base_commit = None;
472        self
473    }
474
475    /// Search on a specific branch.
476    pub fn on_branch(mut self, branch: &str) -> Result<Self> {
477        let tip = self.repo.branch_tip_internal(branch)?;
478        self.base_commit = Some(tip.hash);
479        Ok(self)
480    }
481
482    /// Add a glob pattern to match files.
483    ///
484    /// Supports standard glob syntax:
485    /// - `*` matches any sequence of characters in a path segment
486    /// - `**` matches any sequence of path segments
487    /// - `?` matches any single character
488    /// - `[abc]` matches any character in the brackets
489    ///
490    /// # Examples
491    ///
492    /// ```no_run
493    /// # use heroforge_core::Repository;
494    /// # let repo = Repository::open("project.forge")?;
495    /// let files = repo.fs().find()
496    ///     .pattern("**/*.rs")      // All Rust files
497    ///     .pattern("src/**/*.rs")  // Rust files in src/
498    ///     .paths()?;
499    /// # Ok::<(), heroforge_core::FossilError>(())
500    /// ```
501    pub fn pattern(mut self, pattern: &str) -> Self {
502        self.patterns.push(pattern.to_string());
503        self
504    }
505
506    /// Add multiple glob patterns at once.
507    pub fn patterns(mut self, patterns: &[&str]) -> Self {
508        for p in patterns {
509            self.patterns.push(p.to_string());
510        }
511        self
512    }
513
514    /// Exclude files matching this pattern.
515    ///
516    /// Files matching any ignore pattern will be excluded from results.
517    ///
518    /// # Examples
519    ///
520    /// ```no_run
521    /// # use heroforge_core::Repository;
522    /// # let repo = Repository::open("project.forge")?;
523    /// let files = repo.fs().find()
524    ///     .pattern("**/*")
525    ///     .ignore("target/**")
526    ///     .ignore("node_modules/**")
527    ///     .ignore("*.log")
528    ///     .paths()?;
529    /// # Ok::<(), heroforge_core::FossilError>(())
530    /// ```
531    pub fn ignore(mut self, pattern: &str) -> Self {
532        self.ignore_patterns.push(pattern.to_string());
533        self
534    }
535
536    /// Add multiple ignore patterns at once.
537    pub fn ignore_patterns(mut self, patterns: &[&str]) -> Self {
538        for p in patterns {
539            self.ignore_patterns.push(p.to_string());
540        }
541        self
542    }
543
544    /// Exclude hidden files (files starting with `.`).
545    ///
546    /// # Examples
547    ///
548    /// ```no_run
549    /// # use heroforge_core::Repository;
550    /// # let repo = Repository::open("project.forge")?;
551    /// let visible = repo.fs().find()
552    ///     .pattern("**/*")
553    ///     .ignore_hidden()
554    ///     .paths()?;
555    /// # Ok::<(), heroforge_core::FossilError>(())
556    /// ```
557    pub fn ignore_hidden(mut self) -> Self {
558        self.ignore_hidden = true;
559        self
560    }
561
562    /// Apply common gitignore-style patterns.
563    ///
564    /// Automatically excludes:
565    /// - `.git/**`, `node_modules/**`, `target/**`
566    /// - `__pycache__/**`, `*.pyc`
567    /// - `.DS_Store`, `*.swp`, `*.swo`, `*~`
568    pub fn use_gitignore(mut self) -> Self {
569        self.ignore_patterns.extend(vec![
570            ".git/**".to_string(),
571            ".gitignore".to_string(),
572            "node_modules/**".to_string(),
573            "target/**".to_string(),
574            "*.pyc".to_string(),
575            "__pycache__/**".to_string(),
576            ".DS_Store".to_string(),
577            "*.swp".to_string(),
578            "*.swo".to_string(),
579            "*~".to_string(),
580        ]);
581        self
582    }
583
584    /// Enable case-insensitive pattern matching.
585    pub fn ignore_case(mut self) -> Self {
586        self.ignore_case = true;
587        self
588    }
589
590    /// Limit search to a maximum directory depth.
591    ///
592    /// A depth of 0 matches only files in the base directory.
593    /// A depth of 1 includes one level of subdirectories, etc.
594    pub fn max_depth(mut self, depth: usize) -> Self {
595        self.max_depth = Some(depth);
596        self
597    }
598
599    /// Only match regular files (exclude executables and symlinks).
600    pub fn files_only(mut self) -> Self {
601        self.file_type_filter = Some(FileType::Regular);
602        self
603    }
604
605    /// Only match executable files.
606    pub fn executables_only(mut self) -> Self {
607        self.file_type_filter = Some(FileType::Executable);
608        self
609    }
610
611    /// Only match symbolic links.
612    pub fn symlinks_only(mut self) -> Self {
613        self.file_type_filter = Some(FileType::Symlink(String::new()));
614        self
615    }
616
617    /// Filter to files at least this size (in bytes).
618    pub fn min_size(mut self, bytes: usize) -> Self {
619        self.min_size = Some(bytes);
620        self
621    }
622
623    /// Filter to files at most this size (in bytes).
624    pub fn max_size(mut self, bytes: usize) -> Self {
625        self.max_size = Some(bytes);
626        self
627    }
628
629    /// Restrict search to a specific directory.
630    ///
631    /// # Examples
632    ///
633    /// ```no_run
634    /// # use heroforge_core::Repository;
635    /// # let repo = Repository::open("project.forge")?;
636    /// let src_files = repo.fs().find()
637    ///     .in_dir("src")
638    ///     .pattern("**/*.rs")
639    ///     .paths()?;
640    /// # Ok::<(), heroforge_core::FossilError>(())
641    /// ```
642    pub fn in_dir(mut self, dir: &str) -> Self {
643        self.base_dir = Some(dir.trim_end_matches('/').to_string());
644        self
645    }
646
647    /// Execute the search and return full results with metadata.
648    ///
649    /// Use [`paths()`](Self::paths) for just file paths, or
650    /// [`count()`](Self::count) for just the count.
651    pub fn execute(&self) -> Result<FindResult> {
652        let commit_hash = if let Some(ref hash) = self.base_commit {
653            hash.clone()
654        } else {
655            let tip = self.repo.branch_tip_internal("trunk")?;
656            tip.hash
657        };
658
659        let all_files = self.repo.list_files_internal(&commit_hash)?;
660        let mut matched_files = Vec::new();
661        let mut dirs_seen = std::collections::HashSet::new();
662
663        let match_patterns: Vec<glob::Pattern> = self
664            .patterns
665            .iter()
666            .filter_map(|p| {
667                let p = if self.ignore_case {
668                    p.to_lowercase()
669                } else {
670                    p.clone()
671                };
672                glob::Pattern::new(&p).ok()
673            })
674            .collect();
675
676        let ignore_patterns: Vec<glob::Pattern> = self
677            .ignore_patterns
678            .iter()
679            .filter_map(|p| {
680                let p = if self.ignore_case {
681                    p.to_lowercase()
682                } else {
683                    p.clone()
684                };
685                glob::Pattern::new(&p).ok()
686            })
687            .collect();
688
689        for file in all_files {
690            let file_path = if self.ignore_case {
691                file.name.to_lowercase()
692            } else {
693                file.name.clone()
694            };
695
696            if let Some(idx) = file.name.rfind('/') {
697                dirs_seen.insert(file.name[..idx].to_string());
698            }
699
700            if let Some(ref base) = self.base_dir {
701                if !file.name.starts_with(base) && !file.name.starts_with(&format!("{}/", base)) {
702                    continue;
703                }
704            }
705
706            if let Some(max_depth) = self.max_depth {
707                let depth = file.name.matches('/').count();
708                let base_depth = self
709                    .base_dir
710                    .as_ref()
711                    .map(|b| b.matches('/').count())
712                    .unwrap_or(0);
713                if depth - base_depth > max_depth {
714                    continue;
715                }
716            }
717
718            if self.ignore_hidden {
719                let file_name = file.name.rsplit('/').next().unwrap_or(&file.name);
720                if file_name.starts_with('.') {
721                    continue;
722                }
723            }
724
725            let ignored = ignore_patterns.iter().any(|p| p.matches(&file_path));
726            if ignored {
727                continue;
728            }
729
730            let matches = if match_patterns.is_empty() {
731                true
732            } else {
733                match_patterns.iter().any(|p| p.matches(&file_path))
734            };
735
736            if matches {
737                matched_files.push(FileEntry {
738                    path: file.name.clone(),
739                    hash: file.hash.clone(),
740                    file_type: FileType::Regular,
741                    permissions: file.permissions.as_ref().map(|p| {
742                        Permissions::from_octal(u32::from_str_radix(p, 8).unwrap_or(0o644))
743                    }),
744                    size: file.size,
745                });
746            }
747        }
748
749        Ok(FindResult {
750            count: matched_files.len(),
751            files: matched_files,
752            dirs_traversed: dirs_seen.len(),
753        })
754    }
755
756    /// Execute and return just the file paths.
757    ///
758    /// More efficient than [`execute()`](Self::execute) when you only need paths.
759    pub fn paths(&self) -> Result<Vec<String>> {
760        Ok(self.execute()?.files.into_iter().map(|f| f.path).collect())
761    }
762
763    /// Execute and return only the count of matching files.
764    pub fn count(&self) -> Result<usize> {
765        Ok(self.execute()?.count)
766    }
767}
768
769/// Builder for filesystem modification operations.
770///
771/// All operations are staged and then committed atomically when
772/// [`execute()`](Self::execute) is called.
773///
774/// # Examples
775///
776/// ## Copy and Move Files
777///
778/// ```no_run
779/// # use heroforge_core::Repository;
780/// # let repo = Repository::open_rw("project.forge")?;
781/// let hash = repo.fs().modify()
782///     .message("Reorganize project structure")
783///     .author("developer")
784///     .copy_file("README.md", "docs/README.md")
785///     .copy_dir("src", "src_backup")
786///     .move_file("old_name.rs", "new_name.rs")
787///     .move_dir("scripts", "tools")
788///     .execute()?;
789/// # Ok::<(), heroforge_core::FossilError>(())
790/// ```
791///
792/// ## Delete Files
793///
794/// ```no_run
795/// # use heroforge_core::Repository;
796/// # let repo = Repository::open_rw("project.forge")?;
797/// let hash = repo.fs().modify()
798///     .message("Clean up old files")
799///     .author("developer")
800///     .delete_file("deprecated.rs")          // Silent if missing
801///     .delete_dir("old_module")              // Recursive delete
802///     .delete_matching("**/*.bak")           // Glob pattern
803///     .execute()?;
804/// # Ok::<(), heroforge_core::FossilError>(())
805/// ```
806///
807/// ## Change Permissions
808///
809/// ```no_run
810/// # use heroforge_core::Repository;
811/// # let repo = Repository::open_rw("project.forge")?;
812/// let hash = repo.fs().modify()
813///     .message("Fix permissions")
814///     .author("developer")
815///     .make_executable("scripts/build.sh")
816///     .chmod("config.toml", 0o644)
817///     .chmod_dir("bin", 0o755)
818///     .execute()?;
819/// # Ok::<(), heroforge_core::FossilError>(())
820/// ```
821///
822/// ## Create Symlinks
823///
824/// ```no_run
825/// # use heroforge_core::Repository;
826/// # let repo = Repository::open_rw("project.forge")?;
827/// let hash = repo.fs().modify()
828///     .message("Add convenience symlinks")
829///     .author("developer")
830///     .symlink("build", "scripts/build.sh")
831///     .symlink("latest", "releases/v1.0.0")
832///     .execute()?;
833/// # Ok::<(), heroforge_core::FossilError>(())
834/// ```
835///
836/// ## Write Files
837///
838/// ```no_run
839/// # use heroforge_core::Repository;
840/// # let repo = Repository::open_rw("project.forge")?;
841/// let hash = repo.fs().modify()
842///     .message("Update configuration")
843///     .author("developer")
844///     .write_str("VERSION", "1.0.0\n")
845///     .write("data.bin", &[0x00, 0x01, 0x02])
846///     .touch(".gitkeep")
847///     .execute()?;
848/// # Ok::<(), heroforge_core::FossilError>(())
849/// ```
850///
851/// ## Preview Without Committing
852///
853/// ```no_run
854/// # use heroforge_core::Repository;
855/// # let repo = Repository::open("project.forge")?;
856/// let preview = repo.fs().modify()
857///     .copy_file("a.txt", "b.txt")
858///     .delete_dir("old")
859///     .preview()?;
860///
861/// println!("Base: {}", preview.base_commit);
862/// for desc in preview.describe() {
863///     println!("  {}", desc);
864/// }
865/// # Ok::<(), heroforge_core::FossilError>(())
866/// ```
867pub struct FsBuilder<'a> {
868    repo: &'a Repository,
869    base_commit: Option<String>,
870    operations: Vec<FsOperation>,
871    commit_message: Option<String>,
872    author: Option<String>,
873    branch: Option<String>,
874}
875
876impl<'a> FsBuilder<'a> {
877    pub(crate) fn new(repo: &'a Repository) -> Self {
878        Self {
879            repo,
880            base_commit: None,
881            operations: Vec::new(),
882            commit_message: None,
883            author: None,
884            branch: None,
885        }
886    }
887
888    /// Apply operations starting from a specific commit.
889    pub fn at_commit(mut self, hash: &str) -> Self {
890        self.base_commit = Some(hash.to_string());
891        self
892    }
893
894    /// Apply operations to trunk tip (default).
895    pub fn on_trunk(self) -> Self {
896        self
897    }
898
899    /// Apply operations to a branch tip.
900    pub fn on_branch(mut self, branch: &str) -> Result<Self> {
901        let tip = self.repo.branch_tip_internal(branch)?;
902        self.base_commit = Some(tip.hash);
903        self.branch = Some(branch.to_string());
904        Ok(self)
905    }
906
907    /// Set the commit message (required).
908    pub fn message(mut self, msg: &str) -> Self {
909        self.commit_message = Some(msg.to_string());
910        self
911    }
912
913    /// Set the commit author (required).
914    pub fn author(mut self, author: &str) -> Self {
915        self.author = Some(author.to_string());
916        self
917    }
918
919    // ========================================================================
920    // Copy Operations
921    // ========================================================================
922
923    /// Copy a file to a new location.
924    ///
925    /// The source file remains unchanged.
926    pub fn copy_file(mut self, src: &str, dst: &str) -> Self {
927        self.operations.push(FsOperation::Copy {
928            src: src.to_string(),
929            dst: dst.to_string(),
930        });
931        self
932    }
933
934    /// Copy a directory and all its contents recursively.
935    pub fn copy_dir(mut self, src: &str, dst: &str) -> Self {
936        self.operations.push(FsOperation::Copy {
937            src: format!("{}/", src.trim_end_matches('/')),
938            dst: format!("{}/", dst.trim_end_matches('/')),
939        });
940        self
941    }
942
943    // ========================================================================
944    // Move/Rename Operations
945    // ========================================================================
946
947    /// Move or rename a file.
948    ///
949    /// The source file is removed after copying.
950    pub fn move_file(mut self, src: &str, dst: &str) -> Self {
951        self.operations.push(FsOperation::Move {
952            src: src.to_string(),
953            dst: dst.to_string(),
954        });
955        self
956    }
957
958    /// Move or rename a directory.
959    pub fn move_dir(mut self, src: &str, dst: &str) -> Self {
960        self.operations.push(FsOperation::Move {
961            src: format!("{}/", src.trim_end_matches('/')),
962            dst: format!("{}/", dst.trim_end_matches('/')),
963        });
964        self
965    }
966
967    /// Rename a file (alias for [`move_file`](Self::move_file)).
968    pub fn rename(self, old_path: &str, new_path: &str) -> Self {
969        self.move_file(old_path, new_path)
970    }
971
972    // ========================================================================
973    // Delete Operations
974    // ========================================================================
975
976    /// Delete a file.
977    ///
978    /// Silently succeeds if the file doesn't exist.
979    pub fn delete_file(mut self, path: &str) -> Self {
980        self.operations.push(FsOperation::Delete {
981            path: path.to_string(),
982            recursive: false,
983        });
984        self
985    }
986
987    /// Delete a directory and all its contents.
988    ///
989    /// Silently succeeds if the directory doesn't exist.
990    pub fn delete_dir(mut self, path: &str) -> Self {
991        self.operations.push(FsOperation::Delete {
992            path: format!("{}/", path.trim_end_matches('/')),
993            recursive: true,
994        });
995        self
996    }
997
998    /// Delete all files matching a glob pattern.
999    ///
1000    /// # Examples
1001    ///
1002    /// ```no_run
1003    /// # use heroforge_core::Repository;
1004    /// # let repo = Repository::open_rw("project.forge")?;
1005    /// repo.fs().modify()
1006    ///     .message("Clean backup files")
1007    ///     .author("dev")
1008    ///     .delete_matching("**/*.bak")
1009    ///     .delete_matching("**/*.tmp")
1010    ///     .execute()?;
1011    /// # Ok::<(), heroforge_core::FossilError>(())
1012    /// ```
1013    pub fn delete_matching(mut self, pattern: &str) -> Self {
1014        self.operations.push(FsOperation::Delete {
1015            path: format!("glob:{}", pattern),
1016            recursive: false,
1017        });
1018        self
1019    }
1020
1021    // ========================================================================
1022    // Permission Operations
1023    // ========================================================================
1024
1025    /// Change file permissions using octal notation.
1026    ///
1027    /// # Examples
1028    ///
1029    /// ```no_run
1030    /// # use heroforge_core::Repository;
1031    /// # let repo = Repository::open_rw("project.forge")?;
1032    /// repo.fs().modify()
1033    ///     .message("Fix permissions")
1034    ///     .author("dev")
1035    ///     .chmod("script.sh", 0o755)   // rwxr-xr-x
1036    ///     .chmod("config.toml", 0o644) // rw-r--r--
1037    ///     .chmod("secret.key", 0o600)  // rw-------
1038    ///     .execute()?;
1039    /// # Ok::<(), heroforge_core::FossilError>(())
1040    /// ```
1041    pub fn chmod(mut self, path: &str, mode: u32) -> Self {
1042        self.operations.push(FsOperation::Chmod {
1043            path: path.to_string(),
1044            permissions: Permissions::from_octal(mode),
1045            recursive: false,
1046        });
1047        self
1048    }
1049
1050    /// Change permissions using a [`Permissions`] struct.
1051    pub fn chmod_permissions(mut self, path: &str, perms: Permissions) -> Self {
1052        self.operations.push(FsOperation::Chmod {
1053            path: path.to_string(),
1054            permissions: perms,
1055            recursive: false,
1056        });
1057        self
1058    }
1059
1060    /// Change permissions for all files in a directory recursively.
1061    pub fn chmod_dir(mut self, path: &str, mode: u32) -> Self {
1062        self.operations.push(FsOperation::Chmod {
1063            path: format!("{}/", path.trim_end_matches('/')),
1064            permissions: Permissions::from_octal(mode),
1065            recursive: true,
1066        });
1067        self
1068    }
1069
1070    /// Make a file executable (sets mode to 755).
1071    pub fn make_executable(mut self, path: &str) -> Self {
1072        self.operations.push(FsOperation::MakeExecutable {
1073            path: path.to_string(),
1074        });
1075        self
1076    }
1077
1078    // ========================================================================
1079    // Symlink Operations
1080    // ========================================================================
1081
1082    /// Create a symbolic link.
1083    ///
1084    /// # Arguments
1085    ///
1086    /// * `link_path` - Path where the symlink will be created
1087    /// * `target` - Path the symlink points to
1088    ///
1089    /// # Examples
1090    ///
1091    /// ```no_run
1092    /// # use heroforge_core::Repository;
1093    /// # let repo = Repository::open_rw("project.forge")?;
1094    /// repo.fs().modify()
1095    ///     .message("Add symlinks")
1096    ///     .author("dev")
1097    ///     .symlink("current", "releases/v2.0.0")
1098    ///     .symlink("build", "scripts/build.sh")
1099    ///     .execute()?;
1100    /// # Ok::<(), heroforge_core::FossilError>(())
1101    /// ```
1102    pub fn symlink(mut self, link_path: &str, target: &str) -> Self {
1103        self.operations.push(FsOperation::Symlink {
1104            link_path: link_path.to_string(),
1105            target: target.to_string(),
1106        });
1107        self
1108    }
1109
1110    /// Create a symbolic link to a file (alias for [`symlink`](Self::symlink)).
1111    pub fn symlink_file(self, link_path: &str, target_file: &str) -> Self {
1112        self.symlink(link_path, target_file)
1113    }
1114
1115    /// Create a symbolic link to a directory (alias for [`symlink`](Self::symlink)).
1116    pub fn symlink_dir(self, link_path: &str, target_dir: &str) -> Self {
1117        self.symlink(link_path, target_dir)
1118    }
1119
1120    // ========================================================================
1121    // Write Operations
1122    // ========================================================================
1123
1124    /// Write binary content to a file.
1125    ///
1126    /// Creates the file if it doesn't exist, overwrites if it does.
1127    pub fn write(mut self, path: &str, content: &[u8]) -> Self {
1128        self.operations.push(FsOperation::Write {
1129            path: path.to_string(),
1130            content: content.to_vec(),
1131        });
1132        self
1133    }
1134
1135    /// Write a string to a file.
1136    ///
1137    /// Creates the file if it doesn't exist, overwrites if it does.
1138    pub fn write_str(self, path: &str, content: &str) -> Self {
1139        self.write(path, content.as_bytes())
1140    }
1141
1142    /// Create an empty file or update its timestamp.
1143    pub fn touch(self, path: &str) -> Self {
1144        self.write(path, &[])
1145    }
1146
1147    // ========================================================================
1148    // Execute
1149    // ========================================================================
1150
1151    /// Execute all staged operations and create a commit.
1152    ///
1153    /// Returns the hash of the new commit.
1154    ///
1155    /// # Errors
1156    ///
1157    /// Returns an error if:
1158    /// - No commit message was set
1159    /// - No author was set
1160    /// - Any file operation fails
1161    pub fn execute(self) -> Result<String> {
1162        let message = self.commit_message.ok_or_else(|| {
1163            FossilError::InvalidArtifact("commit message required for fs operations".to_string())
1164        })?;
1165        let author = self.author.ok_or_else(|| {
1166            FossilError::InvalidArtifact("author required for fs operations".to_string())
1167        })?;
1168
1169        let base_hash = if let Some(hash) = self.base_commit {
1170            hash
1171        } else {
1172            let tip = self.repo.branch_tip_internal("trunk")?;
1173            tip.hash
1174        };
1175
1176        let base_files = self.repo.list_files_internal(&base_hash)?;
1177        let mut file_contents: HashMap<String, Vec<u8>> = HashMap::new();
1178        let mut file_permissions: HashMap<String, String> = HashMap::new();
1179        let mut symlinks: HashMap<String, String> = HashMap::new();
1180
1181        for file in &base_files {
1182            let content = self.repo.read_file_internal(&base_hash, &file.name)?;
1183            file_contents.insert(file.name.clone(), content);
1184            if let Some(ref perms) = file.permissions {
1185                file_permissions.insert(file.name.clone(), perms.clone());
1186            }
1187        }
1188
1189        for op in &self.operations {
1190            match op {
1191                FsOperation::Copy { src, dst } => {
1192                    if src.ends_with('/') {
1193                        let src_prefix = src.trim_end_matches('/');
1194                        let dst_prefix = dst.trim_end_matches('/');
1195                        let to_copy: Vec<_> = file_contents
1196                            .keys()
1197                            .filter(|k| k.starts_with(src_prefix))
1198                            .cloned()
1199                            .collect();
1200                        for path in to_copy {
1201                            let new_path = path.replacen(src_prefix, dst_prefix, 1);
1202                            if let Some(content) = file_contents.get(&path).cloned() {
1203                                file_contents.insert(new_path.clone(), content);
1204                            }
1205                            if let Some(perms) = file_permissions.get(&path).cloned() {
1206                                file_permissions.insert(new_path, perms);
1207                            }
1208                        }
1209                    } else {
1210                        if let Some(content) = file_contents.get(src).cloned() {
1211                            file_contents.insert(dst.clone(), content);
1212                        }
1213                        if let Some(perms) = file_permissions.get(src).cloned() {
1214                            file_permissions.insert(dst.clone(), perms);
1215                        }
1216                    }
1217                }
1218
1219                FsOperation::Move { src, dst } => {
1220                    if src.ends_with('/') {
1221                        let src_prefix = src.trim_end_matches('/');
1222                        let dst_prefix = dst.trim_end_matches('/');
1223                        let to_move: Vec<_> = file_contents
1224                            .keys()
1225                            .filter(|k| k.starts_with(src_prefix))
1226                            .cloned()
1227                            .collect();
1228                        for path in to_move {
1229                            let new_path = path.replacen(src_prefix, dst_prefix, 1);
1230                            if let Some(content) = file_contents.remove(&path) {
1231                                file_contents.insert(new_path.clone(), content);
1232                            }
1233                            if let Some(perms) = file_permissions.remove(&path) {
1234                                file_permissions.insert(new_path, perms);
1235                            }
1236                        }
1237                    } else {
1238                        if let Some(content) = file_contents.remove(src) {
1239                            file_contents.insert(dst.clone(), content);
1240                        }
1241                        if let Some(perms) = file_permissions.remove(src) {
1242                            file_permissions.insert(dst.clone(), perms);
1243                        }
1244                    }
1245                }
1246
1247                FsOperation::Delete { path, recursive } => {
1248                    if path.starts_with("glob:") {
1249                        let pattern = &path[5..];
1250                        if let Ok(glob_pattern) = glob::Pattern::new(pattern) {
1251                            let to_delete: Vec<_> = file_contents
1252                                .keys()
1253                                .filter(|k| glob_pattern.matches(k))
1254                                .cloned()
1255                                .collect();
1256                            for p in to_delete {
1257                                file_contents.remove(&p);
1258                                file_permissions.remove(&p);
1259                            }
1260                        }
1261                    } else if *recursive || path.ends_with('/') {
1262                        let prefix = path.trim_end_matches('/');
1263                        let to_delete: Vec<_> = file_contents
1264                            .keys()
1265                            .filter(|k| k.starts_with(prefix) || *k == prefix)
1266                            .cloned()
1267                            .collect();
1268                        for p in to_delete {
1269                            file_contents.remove(&p);
1270                            file_permissions.remove(&p);
1271                        }
1272                    } else {
1273                        file_contents.remove(path);
1274                        file_permissions.remove(path);
1275                    }
1276                }
1277
1278                FsOperation::Chmod {
1279                    path,
1280                    permissions,
1281                    recursive,
1282                } => {
1283                    let perm_str = format!("{:o}", permissions.to_octal());
1284                    if *recursive || path.ends_with('/') {
1285                        let prefix = path.trim_end_matches('/');
1286                        let to_chmod: Vec<_> = file_contents
1287                            .keys()
1288                            .filter(|k| k.starts_with(prefix))
1289                            .cloned()
1290                            .collect();
1291                        for p in to_chmod {
1292                            file_permissions.insert(p, perm_str.clone());
1293                        }
1294                    } else if file_contents.contains_key(path) {
1295                        file_permissions.insert(path.clone(), perm_str);
1296                    }
1297                }
1298
1299                FsOperation::MakeExecutable { path } => {
1300                    if file_contents.contains_key(path) {
1301                        file_permissions.insert(path.clone(), "755".to_string());
1302                    }
1303                }
1304
1305                FsOperation::Symlink { link_path, target } => {
1306                    let symlink_content = format!("link {}", target);
1307                    file_contents.insert(link_path.clone(), symlink_content.into_bytes());
1308                    symlinks.insert(link_path.clone(), target.clone());
1309                }
1310
1311                FsOperation::Write { path, content } => {
1312                    file_contents.insert(path.clone(), content.clone());
1313                }
1314            }
1315        }
1316
1317        let files: Vec<(&str, &[u8])> = file_contents
1318            .iter()
1319            .map(|(k, v)| (k.as_str(), v.as_slice()))
1320            .collect();
1321
1322        self.repo.commit_internal(
1323            &files,
1324            &message,
1325            &author,
1326            Some(&base_hash),
1327            self.branch.as_deref(),
1328        )
1329    }
1330
1331    /// Preview the operations without committing.
1332    ///
1333    /// Returns information about what would happen if [`execute()`](Self::execute)
1334    /// were called.
1335    pub fn preview(&self) -> Result<FsPreview> {
1336        let base_hash = if let Some(ref hash) = self.base_commit {
1337            hash.clone()
1338        } else {
1339            let tip = self.repo.branch_tip_internal("trunk")?;
1340            tip.hash
1341        };
1342
1343        let base_files = self.repo.list_files_internal(&base_hash)?;
1344
1345        Ok(FsPreview {
1346            base_commit: base_hash,
1347            base_file_count: base_files.len(),
1348            operations: self.operations.clone(),
1349        })
1350    }
1351}
1352
1353/// Preview of staged filesystem operations.
1354///
1355/// Created by [`FsBuilder::preview()`] to inspect what operations
1356/// would be performed without actually committing them.
1357#[derive(Debug)]
1358pub struct FsPreview {
1359    /// Hash of the commit operations would be applied to
1360    pub base_commit: String,
1361    /// Number of files in the base commit
1362    pub base_file_count: usize,
1363    /// List of operations that would be performed
1364    pub operations: Vec<FsOperation>,
1365}
1366
1367impl FsPreview {
1368    /// Get human-readable descriptions of all operations.
1369    ///
1370    /// # Examples
1371    ///
1372    /// ```no_run
1373    /// # use heroforge_core::Repository;
1374    /// # let repo = Repository::open("project.forge")?;
1375    /// let preview = repo.fs().modify()
1376    ///     .copy_file("a.txt", "b.txt")
1377    ///     .delete_dir("old")
1378    ///     .preview()?;
1379    ///
1380    /// for desc in preview.describe() {
1381    ///     println!("{}", desc);
1382    /// }
1383    /// // Output:
1384    /// // COPY a.txt -> b.txt
1385    /// // DELETE old/ (recursive)
1386    /// # Ok::<(), heroforge_core::FossilError>(())
1387    /// ```
1388    pub fn describe(&self) -> Vec<String> {
1389        self.operations
1390            .iter()
1391            .map(|op| match op {
1392                FsOperation::Copy { src, dst } => format!("COPY {} -> {}", src, dst),
1393                FsOperation::Move { src, dst } => format!("MOVE {} -> {}", src, dst),
1394                FsOperation::Delete { path, recursive } => {
1395                    if *recursive {
1396                        format!("DELETE {} (recursive)", path)
1397                    } else if path.starts_with("glob:") {
1398                        format!("DELETE matching {}", &path[5..])
1399                    } else {
1400                        format!("DELETE {}", path)
1401                    }
1402                }
1403                FsOperation::Chmod {
1404                    path,
1405                    permissions,
1406                    recursive,
1407                } => {
1408                    if *recursive {
1409                        format!("CHMOD {} {:o} (recursive)", path, permissions.to_octal())
1410                    } else {
1411                        format!("CHMOD {} {:o}", path, permissions.to_octal())
1412                    }
1413                }
1414                FsOperation::Symlink { link_path, target } => {
1415                    format!("SYMLINK {} -> {}", link_path, target)
1416                }
1417                FsOperation::Write { path, content } => {
1418                    format!("WRITE {} ({} bytes)", path, content.len())
1419                }
1420                FsOperation::MakeExecutable { path } => format!("MAKE_EXECUTABLE {}", path),
1421            })
1422            .collect()
1423    }
1424}
1425
1426/// Entry point for filesystem operations.
1427///
1428/// Access via [`Repository::fs()`](crate::Repository::fs).
1429///
1430/// # Examples
1431///
1432/// ```no_run
1433/// use heroforge_core::Repository;
1434///
1435/// let repo = Repository::open_rw("project.forge")?;
1436///
1437/// // Modify files
1438/// repo.fs().modify()
1439///     .message("Update files")
1440///     .author("dev")
1441///     .copy_file("a.txt", "b.txt")
1442///     .execute()?;
1443///
1444/// // Find files
1445/// let files = repo.fs().find()
1446///     .pattern("**/*.rs")
1447///     .paths()?;
1448///
1449/// // Quick utilities
1450/// let exists = repo.fs().exists("README.md")?;
1451/// let size = repo.fs().du("**/*")?;
1452/// let count = repo.fs().count("**/*.rs")?;
1453/// # Ok::<(), heroforge_core::FossilError>(())
1454/// ```
1455pub struct FsOpsBuilder<'a> {
1456    repo: &'a Repository,
1457}
1458
1459impl<'a> FsOpsBuilder<'a> {
1460    pub(crate) fn new(repo: &'a Repository) -> Self {
1461        Self { repo }
1462    }
1463
1464    /// Start building filesystem modification operations.
1465    ///
1466    /// Returns a [`FsBuilder`] for chaining copy, move, delete, and other operations.
1467    pub fn modify(self) -> FsBuilder<'a> {
1468        FsBuilder::new(self.repo)
1469    }
1470
1471    /// Start building a file search operation.
1472    ///
1473    /// Returns a [`FindBuilder`] for specifying patterns and filters.
1474    pub fn find(self) -> FindBuilder<'a> {
1475        FindBuilder::new(self.repo)
1476    }
1477
1478    /// Shorthand to find files matching a single pattern.
1479    ///
1480    /// Equivalent to `repo.fs().find().pattern(pattern)`.
1481    pub fn find_pattern(self, pattern: &str) -> FindBuilder<'a> {
1482        FindBuilder::new(self.repo).pattern(pattern)
1483    }
1484
1485    /// List all symbolic links in the repository.
1486    ///
1487    /// Returns pairs of (link_path, target_path).
1488    ///
1489    /// # Examples
1490    ///
1491    /// ```no_run
1492    /// # use heroforge_core::Repository;
1493    /// # let repo = Repository::open("project.forge")?;
1494    /// let symlinks = repo.fs().list_symlinks()?;
1495    /// for (link, target) in symlinks {
1496    ///     println!("{} -> {}", link, target);
1497    /// }
1498    /// # Ok::<(), heroforge_core::FossilError>(())
1499    /// ```
1500    pub fn list_symlinks(&self) -> Result<Vec<(String, String)>> {
1501        let tip = self.repo.branch_tip_internal("trunk")?;
1502        let files = self.repo.list_files_internal(&tip.hash)?;
1503        let mut symlinks = Vec::new();
1504
1505        for file in files {
1506            if let Ok(content) = self.repo.read_file_internal(&tip.hash, &file.name) {
1507                if let Ok(text) = String::from_utf8(content) {
1508                    if text.starts_with("link ") {
1509                        let target = text[5..].trim().to_string();
1510                        symlinks.push((file.name, target));
1511                    }
1512                }
1513            }
1514        }
1515
1516        Ok(symlinks)
1517    }
1518
1519    /// Check if a file or directory exists at the given path.
1520    ///
1521    /// # Examples
1522    ///
1523    /// ```no_run
1524    /// # use heroforge_core::Repository;
1525    /// # let repo = Repository::open("project.forge")?;
1526    /// if repo.fs().exists("README.md")? {
1527    ///     println!("README exists!");
1528    /// }
1529    /// # Ok::<(), heroforge_core::FossilError>(())
1530    /// ```
1531    pub fn exists(&self, path: &str) -> Result<bool> {
1532        let tip = self.repo.branch_tip_internal("trunk")?;
1533        let files = self.repo.list_files_internal(&tip.hash)?;
1534        Ok(files.iter().any(|f| f.name == path))
1535    }
1536
1537    /// Check if a path represents a directory (has files under it).
1538    ///
1539    /// # Examples
1540    ///
1541    /// ```no_run
1542    /// # use heroforge_core::Repository;
1543    /// # let repo = Repository::open("project.forge")?;
1544    /// if repo.fs().is_dir("src")? {
1545    ///     println!("src is a directory");
1546    /// }
1547    /// # Ok::<(), heroforge_core::FossilError>(())
1548    /// ```
1549    pub fn is_dir(&self, path: &str) -> Result<bool> {
1550        let tip = self.repo.branch_tip_internal("trunk")?;
1551        let files = self.repo.list_files_internal(&tip.hash)?;
1552        let prefix = format!("{}/", path.trim_end_matches('/'));
1553        Ok(files.iter().any(|f| f.name.starts_with(&prefix)))
1554    }
1555
1556    /// Get detailed information about a file.
1557    ///
1558    /// Returns `None` if the file doesn't exist.
1559    ///
1560    /// # Examples
1561    ///
1562    /// ```no_run
1563    /// # use heroforge_core::Repository;
1564    /// # let repo = Repository::open("project.forge")?;
1565    /// if let Some(info) = repo.fs().stat("README.md")? {
1566    ///     println!("Path: {}", info.path);
1567    ///     println!("Hash: {}", info.hash);
1568    ///     println!("Size: {:?} bytes", info.size);
1569    /// }
1570    /// # Ok::<(), heroforge_core::FossilError>(())
1571    /// ```
1572    pub fn stat(&self, path: &str) -> Result<Option<FileEntry>> {
1573        let tip = self.repo.branch_tip_internal("trunk")?;
1574        let files = self.repo.list_files_internal(&tip.hash)?;
1575
1576        for file in files {
1577            if file.name == path {
1578                let content = self.repo.read_file_internal(&tip.hash, path)?;
1579                let file_type = if let Ok(text) = String::from_utf8(content.clone()) {
1580                    if text.starts_with("link ") {
1581                        FileType::Symlink(text[5..].trim().to_string())
1582                    } else {
1583                        FileType::Regular
1584                    }
1585                } else {
1586                    FileType::Regular
1587                };
1588
1589                return Ok(Some(FileEntry {
1590                    path: file.name,
1591                    hash: file.hash,
1592                    file_type,
1593                    permissions: file.permissions.as_ref().map(|p| {
1594                        Permissions::from_octal(u32::from_str_radix(p, 8).unwrap_or(0o644))
1595                    }),
1596                    size: Some(content.len()),
1597                }));
1598            }
1599        }
1600
1601        Ok(None)
1602    }
1603
1604    /// Calculate total size of files matching a glob pattern.
1605    ///
1606    /// Similar to the Unix `du` command.
1607    ///
1608    /// # Examples
1609    ///
1610    /// ```no_run
1611    /// # use heroforge_core::Repository;
1612    /// # let repo = Repository::open("project.forge")?;
1613    /// let rust_size = repo.fs().du("**/*.rs")?;
1614    /// println!("Total Rust code: {} bytes", rust_size);
1615    ///
1616    /// let total = repo.fs().du("**/*")?;
1617    /// println!("Total repository size: {} bytes", total);
1618    /// # Ok::<(), heroforge_core::FossilError>(())
1619    /// ```
1620    pub fn du(&self, pattern: &str) -> Result<usize> {
1621        let tip = self.repo.branch_tip_internal("trunk")?;
1622        let files = self.repo.find_files_internal(&tip.hash, pattern)?;
1623        let mut total = 0;
1624
1625        for file in files {
1626            if let Ok(content) = self.repo.read_file_internal(&tip.hash, &file.name) {
1627                total += content.len();
1628            }
1629        }
1630
1631        Ok(total)
1632    }
1633
1634    /// Count files matching a glob pattern.
1635    ///
1636    /// # Examples
1637    ///
1638    /// ```no_run
1639    /// # use heroforge_core::Repository;
1640    /// # let repo = Repository::open("project.forge")?;
1641    /// let rust_files = repo.fs().count("**/*.rs")?;
1642    /// println!("Found {} Rust files", rust_files);
1643    /// # Ok::<(), heroforge_core::FossilError>(())
1644    /// ```
1645    pub fn count(&self, pattern: &str) -> Result<usize> {
1646        let tip = self.repo.branch_tip_internal("trunk")?;
1647        let files = self.repo.find_files_internal(&tip.hash, pattern)?;
1648        Ok(files.len())
1649    }
1650}