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}