1use std::path::PathBuf;
50use std::sync::Arc;
51
52use crate::fs::commit_thread::{CommitConfig, CommitTimer, commit_now};
53use crate::fs::errors::{FsError, FsResult};
54use crate::fs::operations::{DirectoryEntry, FileKind, FileMetadata, FilePermissions, FindResults};
55use crate::fs::staging::{MAX_FILE_SIZE, Staging};
56use crate::repo::Repository;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum FsInterfaceStatus {
61 Active,
63 Committing,
65 Closed,
67}
68
69pub struct FsInterface {
80 repo: Arc<Repository>,
82
83 staging: Staging,
85
86 commit_timer: Option<CommitTimer>,
88
89 status: FsInterfaceStatus,
91
92 branch: String,
94}
95
96impl FsInterface {
97 pub fn new(repo: Arc<Repository>, author: &str) -> FsResult<Self> {
116 Self::with_config(repo, author, CommitConfig::default())
117 }
118
119 pub fn with_config(
121 repo: Arc<Repository>,
122 author: &str,
123 config: CommitConfig,
124 ) -> FsResult<Self> {
125 let staging_dir = Self::get_staging_dir(&repo)?;
127
128 let staging = Staging::new(staging_dir, author.to_string())?;
129
130 let commit_timer = Some(CommitTimer::start(config));
132
133 Ok(Self {
134 repo,
135 staging,
136 commit_timer,
137 status: FsInterfaceStatus::Active,
138 branch: "trunk".to_string(),
139 })
140 }
141
142 pub fn without_timer(repo: Arc<Repository>, author: &str) -> FsResult<Self> {
145 let staging_dir = Self::get_staging_dir(&repo)?;
146 let staging = Staging::new(staging_dir, author.to_string())?;
147
148 Ok(Self {
149 repo,
150 staging,
151 commit_timer: None,
152 status: FsInterfaceStatus::Active,
153 branch: "trunk".to_string(),
154 })
155 }
156
157 fn get_staging_dir(repo: &Repository) -> FsResult<PathBuf> {
159 let project_code = repo
161 .project_code()
162 .map_err(|e| FsError::DatabaseError(format!("Failed to get project code: {}", e)))?;
163
164 let staging_dir = std::env::temp_dir()
165 .join("heroforge-staging")
166 .join(&project_code[..8.min(project_code.len())]);
167
168 Ok(staging_dir)
169 }
170
171 pub fn status(&self) -> FsInterfaceStatus {
173 self.status
174 }
175
176 pub fn author(&self) -> String {
178 self.staging.read().author().to_string()
179 }
180
181 pub fn branch(&self) -> &str {
183 &self.branch
184 }
185
186 pub fn has_changes(&self) -> bool {
188 self.staging.read().is_dirty()
189 }
190
191 pub fn exists(&self, path: &str) -> FsResult<bool> {
197 self.validate_path(path)?;
198
199 let state = self.staging.read();
200
201 if state.has_file(path) {
203 return Ok(!state.is_deleted(path));
204 }
205
206 self.exists_in_db(path)
208 }
209
210 pub fn is_dir(&self, path: &str) -> FsResult<bool> {
212 self.validate_path(path)?;
213
214 let state = self.staging.read();
216 let prefix = if path.ends_with('/') {
217 path.to_string()
218 } else {
219 format!("{}/", path)
220 };
221
222 for key in state.files().keys() {
223 if key.starts_with(&prefix) && !state.is_deleted(key) {
224 return Ok(true);
225 }
226 }
227
228 self.is_dir_in_db(path)
230 }
231
232 pub fn is_file(&self, path: &str) -> FsResult<bool> {
234 self.validate_path(path)?;
235
236 let state = self.staging.read();
237
238 if state.has_file(path) {
239 let file = state.get_file(path).unwrap();
240 return Ok(!file.is_deleted);
241 }
242
243 self.is_file_in_db(path)
244 }
245
246 pub fn stat(&self, path: &str) -> FsResult<FileMetadata> {
248 self.validate_path(path)?;
249
250 let state = self.staging.read();
251
252 if let Some(staged) = state.get_file(path) {
254 if staged.is_deleted {
255 return Err(FsError::NotFound(path.to_string()));
256 }
257
258 return Ok(FileMetadata {
259 path: path.to_string(),
260 is_dir: false,
261 size: staged.size,
262 permissions: FilePermissions::file(),
263 is_symlink: false,
264 symlink_target: None,
265 modified: staged.staged_at.elapsed().as_secs() as i64,
266 hash: staged.original_hash.clone(),
267 kind: FileKind::File,
268 });
269 }
270
271 self.stat_from_db(path)
273 }
274
275 pub fn read_file(&self, path: &str) -> FsResult<Vec<u8>> {
279 self.validate_path(path)?;
280
281 let state = self.staging.read();
282
283 if state.has_file(path) {
285 if state.is_deleted(path) {
286 return Err(FsError::NotFound(path.to_string()));
287 }
288 return state.read_file(path);
289 }
290
291 self.read_file_from_db(path)
293 }
294
295 pub fn read_file_string(&self, path: &str) -> FsResult<String> {
297 let bytes = self.read_file(path)?;
298 String::from_utf8(bytes).map_err(|e| FsError::Encoding(e.to_string()))
299 }
300
301 pub fn list_dir(&self, path: &str) -> FsResult<Vec<DirectoryEntry>> {
303 self.validate_path(path)?;
304
305 let mut entries: std::collections::HashMap<String, DirectoryEntry> =
306 std::collections::HashMap::new();
307
308 let state = self.staging.read();
310 for name in state.list_dir(path) {
311 if let Some(staged) = state.get_file(&name) {
312 if !staged.is_deleted {
313 let entry_name = name.rsplit('/').next().unwrap_or(&name).to_string();
314 entries.insert(
315 entry_name.clone(),
316 DirectoryEntry {
317 name: entry_name,
318 is_dir: false,
319 size: staged.size,
320 permissions: FilePermissions::file(),
321 modified: staged.staged_at.elapsed().as_secs() as i64,
322 },
323 );
324 }
325 }
326 }
327
328 if let Ok(db_entries) = self.list_dir_from_db(path) {
330 for entry in db_entries {
331 if !entries.contains_key(&entry.name) {
332 let full_path = if path.is_empty() || path == "/" {
334 entry.name.clone()
335 } else {
336 format!("{}/{}", path.trim_end_matches('/'), entry.name)
337 };
338 if !state.is_deleted(&full_path) {
339 entries.insert(entry.name.clone(), entry);
340 }
341 }
342 }
343 }
344
345 let mut result: Vec<_> = entries.into_values().collect();
346 result.sort_by(|a, b| a.name.cmp(&b.name));
347 Ok(result)
348 }
349
350 pub fn find(&self, pattern: &str) -> FsResult<FindResults> {
352 if pattern.is_empty() {
353 return Err(FsError::PatternError("Pattern cannot be empty".to_string()));
354 }
355
356 let glob = glob::Pattern::new(pattern).map_err(|e| FsError::PatternError(e.to_string()))?;
357
358 let mut files: Vec<String> = Vec::new();
359 let state = self.staging.read();
360
361 for (path, staged) in state.files() {
363 if !staged.is_deleted && glob.matches(path) {
364 files.push(path.clone());
365 }
366 }
367
368 if let Ok(db_files) = self.find_in_db(pattern) {
370 for path in db_files {
371 if !files.contains(&path) && !state.is_deleted(&path) {
372 files.push(path);
373 }
374 }
375 }
376
377 files.sort();
378
379 Ok(FindResults {
380 count: files.len(),
381 files,
382 dirs_traversed: 0,
383 })
384 }
385
386 pub fn disk_usage(&self, path: &str) -> FsResult<u64> {
388 self.validate_path(path)?;
389
390 let mut total: u64 = 0;
391 let state = self.staging.read();
392
393 let prefix = if path.is_empty() || path == "/" {
395 String::new()
396 } else {
397 format!("{}/", path.trim_end_matches('/'))
398 };
399
400 for (file_path, staged) in state.files() {
401 if (prefix.is_empty() || file_path.starts_with(&prefix)) && !staged.is_deleted {
402 total += staged.size;
403 }
404 }
405
406 if let Ok(db_usage) = self.disk_usage_from_db(path) {
408 total += db_usage;
409 }
410
411 Ok(total)
412 }
413
414 pub fn count_files(&self, pattern: &str) -> FsResult<usize> {
416 let results = self.find(pattern)?;
417 Ok(results.count)
418 }
419
420 pub fn write_file(&self, path: &str, content: &[u8]) -> FsResult<()> {
430 self.validate_path(path)?;
431 self.validate_size(path, content.len() as u64)?;
432
433 let mut state = self.staging.write();
434 state.stage_file(path, content)
435 }
436
437 pub fn write_file_string(&self, path: &str, content: &str) -> FsResult<()> {
439 self.write_file(path, content.as_bytes())
440 }
441
442 pub fn write_at(&self, path: &str, offset: u64, data: &[u8]) -> FsResult<()> {
447 self.validate_path(path)?;
448
449 let mut state = self.staging.write();
450
451 if state.has_file(path) {
453 if state.is_deleted(path) {
454 return Err(FsError::NotFound(path.to_string()));
455 }
456 state.write_at(path, offset, data)?;
458 state.mark_modified(path);
459 return Ok(());
460 }
461
462 drop(state); let content = self.read_file_from_db(path)?;
466 let hash = self.get_file_hash_from_db(path)?;
467
468 let mut state = self.staging.write();
469
470 state.stage_promoted(path, &content, hash)?;
472
473 state.write_at(path, offset, data)?;
475 state.mark_modified(path);
476
477 Ok(())
478 }
479
480 pub fn delete_file(&self, path: &str) -> FsResult<()> {
482 self.validate_path(path)?;
483
484 let mut state = self.staging.write();
485 state.delete_file(path)
486 }
487
488 pub fn delete_dir(&self, path: &str) -> FsResult<()> {
490 self.validate_path(path)?;
491
492 let mut state = self.staging.write();
493 state.delete_dir(path)
494 }
495
496 pub fn copy_file(&self, src: &str, dst: &str) -> FsResult<()> {
498 self.validate_path(src)?;
499 self.validate_path(dst)?;
500
501 let content = self.read_file(src)?;
503
504 let mut state = self.staging.write();
505 state.stage_file(dst, &content)
506 }
507
508 pub fn move_file(&self, src: &str, dst: &str) -> FsResult<()> {
510 self.copy_file(src, dst)?;
511 self.delete_file(src)
512 }
513
514 pub fn copy_dir(&self, src: &str, dst: &str) -> FsResult<()> {
516 self.validate_path(src)?;
517 self.validate_path(dst)?;
518
519 let pattern = format!("{}/**/*", src.trim_end_matches('/'));
521 let files = self.find(&pattern)?;
522
523 for file_path in files.files {
524 let rel_path = file_path
525 .strip_prefix(src.trim_end_matches('/'))
526 .unwrap_or(&file_path)
527 .trim_start_matches('/');
528 let dst_path = format!("{}/{}", dst.trim_end_matches('/'), rel_path);
529
530 let content = self.read_file(&file_path)?;
531 let mut state = self.staging.write();
532 state.stage_file(&dst_path, &content)?;
533 }
534
535 Ok(())
536 }
537
538 pub fn move_dir(&self, src: &str, dst: &str) -> FsResult<()> {
540 self.copy_dir(src, dst)?;
541 self.delete_dir(src)
542 }
543
544 pub fn commit(&self) -> FsResult<String> {
552 commit_now(&self.staging, &self.repo)
553 }
554
555 pub fn commit_with_message(&self, message: &str) -> FsResult<String> {
557 let mut state = self.staging.write();
558
559 if !state.is_dirty() {
560 return Ok("no-changes".to_string());
561 }
562
563 let author = state.author().to_string();
564 let branch = state.branch().to_string();
565
566 let mut staged_files: Vec<(String, Vec<u8>)> = Vec::new();
568 let mut deletions: std::collections::HashSet<String> = std::collections::HashSet::new();
569
570 for (path, staged_file) in state.files() {
571 if staged_file.is_deleted {
572 deletions.insert(path.clone());
573 } else if staged_file.modified {
574 let content = state.read_file(path)?;
575 staged_files.push((path.clone(), content));
576 }
577 }
578
579 if staged_files.is_empty() && deletions.is_empty() {
580 state.mark_clean();
581 return Ok("no-changes".to_string());
582 }
583
584 let parent_hash = self
586 .repo
587 .branches()
588 .get(&branch)
589 .ok()
590 .and_then(|b| b.tip().ok())
591 .map(|c| c.hash);
592
593 let mut files_to_commit: Vec<(String, Vec<u8>)> = Vec::new();
595 let staged_paths: std::collections::HashSet<String> =
596 staged_files.iter().map(|(p, _)| p.clone()).collect();
597
598 if let Some(ref parent) = parent_hash {
600 if let Ok(parent_files) = self.repo.list_files_internal(parent) {
601 for file_info in parent_files {
602 if deletions.contains(&file_info.name) {
604 continue;
605 }
606 if staged_paths.contains(&file_info.name) {
608 continue;
609 }
610 if let Ok(content) = self.repo.read_file_internal(parent, &file_info.name) {
612 files_to_commit.push((file_info.name, content));
613 }
614 }
615 }
616 }
617
618 files_to_commit.extend(staged_files);
620
621 let files_refs: Vec<(&str, &[u8])> = files_to_commit
622 .iter()
623 .map(|(p, c)| (p.as_str(), c.as_slice()))
624 .collect();
625
626 let commit_hash = self
627 .repo
628 .commit_internal(
629 &files_refs,
630 message,
631 &author,
632 parent_hash.as_deref(),
633 Some(&branch),
634 )
635 .map_err(|e| FsError::DatabaseError(format!("Commit failed: {}", e)))?;
636
637 state.clear()?;
638
639 Ok(commit_hash)
640 }
641
642 pub fn switch_branch(&mut self, branch: &str) -> FsResult<()> {
650 self.commit()?;
652
653 self.branch = branch.to_string();
654 self.staging.write().set_branch(branch.to_string());
655
656 Ok(())
657 }
658
659 fn validate_path(&self, path: &str) -> FsResult<()> {
665 if path.is_empty() {
666 return Err(FsError::InvalidPath("Path cannot be empty".to_string()));
667 }
668 if path.contains('\0') {
669 return Err(FsError::InvalidPath(
670 "Path cannot contain null bytes".to_string(),
671 ));
672 }
673 Ok(())
674 }
675
676 fn validate_size(&self, path: &str, size: u64) -> FsResult<()> {
678 if size > MAX_FILE_SIZE {
679 return Err(FsError::FileTooLarge {
680 path: path.to_string(),
681 size,
682 max: MAX_FILE_SIZE,
683 });
684 }
685 Ok(())
686 }
687
688 fn exists_in_db(&self, path: &str) -> FsResult<bool> {
691 let checkin = self.get_branch_tip()?;
692 match self.repo.read_file_internal(&checkin, path) {
693 Ok(_) => Ok(true),
694 Err(_) => Ok(false),
695 }
696 }
697
698 fn is_dir_in_db(&self, path: &str) -> FsResult<bool> {
699 let checkin = self.get_branch_tip()?;
700 match self.repo.list_directory_internal(&checkin, path) {
701 Ok(files) => Ok(!files.is_empty()),
702 Err(_) => Ok(false),
703 }
704 }
705
706 fn is_file_in_db(&self, path: &str) -> FsResult<bool> {
707 let checkin = self.get_branch_tip()?;
708 match self.repo.read_file_internal(&checkin, path) {
709 Ok(_) => Ok(true),
710 Err(_) => Ok(false),
711 }
712 }
713
714 fn stat_from_db(&self, path: &str) -> FsResult<FileMetadata> {
715 let checkin = self.get_branch_tip()?;
716 let files = self
717 .repo
718 .list_files_internal(&checkin)
719 .map_err(|e| FsError::DatabaseError(e.to_string()))?;
720
721 for file in files {
722 if file.name == path {
723 return Ok(FileMetadata {
724 path: path.to_string(),
725 is_dir: false,
726 size: file.size.unwrap_or(0) as u64,
727 permissions: FilePermissions::file(),
728 is_symlink: false,
729 symlink_target: None,
730 modified: 0,
731 hash: Some(file.hash),
732 kind: FileKind::File,
733 });
734 }
735 }
736
737 Err(FsError::NotFound(path.to_string()))
738 }
739
740 fn read_file_from_db(&self, path: &str) -> FsResult<Vec<u8>> {
741 let checkin = self.get_branch_tip()?;
742 self.repo
743 .read_file_internal(&checkin, path)
744 .map_err(|e| FsError::NotFound(format!("{}: {}", path, e)))
745 }
746
747 fn get_file_hash_from_db(&self, path: &str) -> FsResult<String> {
748 let checkin = self.get_branch_tip()?;
749 let files = self
750 .repo
751 .list_files_internal(&checkin)
752 .map_err(|e| FsError::DatabaseError(e.to_string()))?;
753
754 for file in files {
755 if file.name == path {
756 return Ok(file.hash);
757 }
758 }
759
760 Err(FsError::NotFound(path.to_string()))
761 }
762
763 fn list_dir_from_db(&self, path: &str) -> FsResult<Vec<DirectoryEntry>> {
764 let checkin = self.get_branch_tip()?;
765 let files = self
766 .repo
767 .list_directory_internal(&checkin, path)
768 .map_err(|e| FsError::DatabaseError(e.to_string()))?;
769
770 Ok(files
771 .into_iter()
772 .map(|f| DirectoryEntry {
773 name: f.name.rsplit('/').next().unwrap_or(&f.name).to_string(),
774 is_dir: false,
775 size: f.size.unwrap_or(0) as u64,
776 permissions: FilePermissions::file(),
777 modified: 0,
778 })
779 .collect())
780 }
781
782 fn find_in_db(&self, pattern: &str) -> FsResult<Vec<String>> {
783 let checkin = self.get_branch_tip()?;
784 let files = self
785 .repo
786 .find_files_internal(&checkin, pattern)
787 .map_err(|e| FsError::DatabaseError(e.to_string()))?;
788
789 Ok(files.into_iter().map(|f| f.name).collect())
790 }
791
792 fn disk_usage_from_db(&self, path: &str) -> FsResult<u64> {
793 let checkin = self.get_branch_tip()?;
794 let files = self
795 .repo
796 .list_files_internal(&checkin)
797 .map_err(|e| FsError::DatabaseError(e.to_string()))?;
798
799 let prefix = if path.is_empty() || path == "/" {
800 String::new()
801 } else {
802 format!("{}/", path.trim_end_matches('/'))
803 };
804
805 let total: u64 = files
806 .into_iter()
807 .filter(|f| prefix.is_empty() || f.name.starts_with(&prefix))
808 .map(|f| f.size.unwrap_or(0) as u64)
809 .sum();
810
811 Ok(total)
812 }
813
814 fn get_branch_tip(&self) -> FsResult<String> {
815 let branch_ref = self
816 .repo
817 .branches()
818 .get(&self.branch)
819 .map_err(|e| FsError::DatabaseError(format!("Failed to get branch: {}", e)))?;
820
821 let tip = branch_ref
822 .tip()
823 .map_err(|e| FsError::DatabaseError(format!("Failed to get branch tip: {}", e)))?;
824
825 Ok(tip.hash)
826 }
827}
828
829impl Drop for FsInterface {
830 fn drop(&mut self) {
831 let _ = self.commit();
833 if let Some(mut timer) = self.commit_timer.take() {
834 timer.stop();
835 }
836 }
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842 use tempfile::tempdir;
843
844 #[test]
848 fn test_validate_path() {
849 }
851}