heroforge_core/fs/
ops.rs

1//! High-level filesystem operations (copy, move, delete, chmod, symlinks).
2//!
3//! This module provides a builder API for performing filesystem-like operations
4//! on repository contents. All modifications are staged and committed atomically.
5//!
6//! # Examples
7//!
8//! ```no_run
9//! use heroforge_core::Repository;
10//! use heroforge_core::fs::Modify;
11//!
12//! let repo = Repository::open_rw("project.forge")?;
13//!
14//! // Modify files (copy, move, delete, etc.)
15//! Modify::new(&repo)
16//!     .message("Reorganize project")
17//!     .author("developer")
18//!     .copy_file("README.md", "docs/README.md")
19//!     .move_dir("scripts", "tools")
20//!     .delete_file("old.txt")
21//!     .execute()?;
22//! # Ok::<(), heroforge_core::FossilError>(())
23//! ```
24
25use crate::error::{FossilError, Result};
26use crate::fs::find::Permissions;
27use crate::repo::Repository;
28use std::collections::HashMap;
29
30/// A staged filesystem operation.
31#[derive(Debug, Clone)]
32pub enum Op {
33    /// Copy file or directory
34    Copy {
35        /// Source path
36        src: String,
37        /// Destination path
38        dst: String,
39    },
40    /// Move/rename file or directory
41    Move {
42        /// Source path
43        src: String,
44        /// Destination path
45        dst: String,
46    },
47    /// Delete file or directory
48    Delete {
49        /// Path to delete
50        path: String,
51        /// Whether to delete recursively
52        recursive: bool,
53    },
54    /// Change permissions
55    Chmod {
56        /// Path to modify
57        path: String,
58        /// New permissions
59        permissions: Permissions,
60        /// Whether to apply recursively
61        recursive: bool,
62    },
63    /// Create symlink
64    Symlink {
65        /// Path where symlink will be created
66        link_path: String,
67        /// Target the symlink points to
68        target: String,
69    },
70    /// Write file content
71    Write {
72        /// File path
73        path: String,
74        /// Content to write
75        content: Vec<u8>,
76    },
77    /// Make file executable
78    MakeExecutable {
79        /// File path
80        path: String,
81    },
82}
83
84/// Preview of staged filesystem operations.
85#[derive(Debug)]
86pub struct Preview {
87    /// Hash of the commit operations would be applied to
88    pub base_commit: String,
89    /// Number of files in the base commit
90    pub base_file_count: usize,
91    /// List of operations that would be performed
92    pub operations: Vec<Op>,
93}
94
95impl Preview {
96    /// Get human-readable descriptions of all operations.
97    pub fn describe(&self) -> Vec<String> {
98        self.operations
99            .iter()
100            .map(|op| match op {
101                Op::Copy { src, dst } => format!("COPY {} -> {}", src, dst),
102                Op::Move { src, dst } => format!("MOVE {} -> {}", src, dst),
103                Op::Delete { path, recursive } => {
104                    if *recursive {
105                        format!("DELETE {} (recursive)", path)
106                    } else if path.starts_with("glob:") {
107                        format!("DELETE matching {}", &path[5..])
108                    } else {
109                        format!("DELETE {}", path)
110                    }
111                }
112                Op::Chmod {
113                    path,
114                    permissions,
115                    recursive,
116                } => {
117                    if *recursive {
118                        format!("CHMOD {} {:o} (recursive)", path, permissions.to_octal())
119                    } else {
120                        format!("CHMOD {} {:o}", path, permissions.to_octal())
121                    }
122                }
123                Op::Symlink { link_path, target } => {
124                    format!("SYMLINK {} -> {}", link_path, target)
125                }
126                Op::Write { path, content } => {
127                    format!("WRITE {} ({} bytes)", path, content.len())
128                }
129                Op::MakeExecutable { path } => format!("MAKE_EXECUTABLE {}", path),
130            })
131            .collect()
132    }
133}
134
135/// Builder for filesystem modification operations.
136///
137/// All operations are staged and then committed atomically when
138/// [`execute()`](Self::execute) is called.
139///
140/// # Examples
141///
142/// ## Copy and Move Files
143///
144/// ```no_run
145/// use heroforge_core::Repository;
146/// use heroforge_core::fs::Modify;
147///
148/// let repo = Repository::open_rw("project.forge")?;
149///
150/// let hash = Modify::new(&repo)
151///     .message("Reorganize project structure")
152///     .author("developer")
153///     .copy_file("README.md", "docs/README.md")
154///     .copy_dir("src", "src_backup")
155///     .move_file("old_name.rs", "new_name.rs")
156///     .move_dir("scripts", "tools")
157///     .execute()?;
158/// # Ok::<(), heroforge_core::FossilError>(())
159/// ```
160///
161/// ## Delete Files
162///
163/// ```no_run
164/// # use heroforge_core::Repository;
165/// # use heroforge_core::fs::Modify;
166/// # let repo = Repository::open_rw("project.forge")?;
167/// let hash = Modify::new(&repo)
168///     .message("Clean up old files")
169///     .author("developer")
170///     .delete_file("deprecated.rs")
171///     .delete_dir("old_module")
172///     .delete_matching("**/*.bak")
173///     .execute()?;
174/// # Ok::<(), heroforge_core::FossilError>(())
175/// ```
176///
177/// ## Change Permissions
178///
179/// ```no_run
180/// # use heroforge_core::Repository;
181/// # use heroforge_core::fs::Modify;
182/// # let repo = Repository::open_rw("project.forge")?;
183/// let hash = Modify::new(&repo)
184///     .message("Fix permissions")
185///     .author("developer")
186///     .make_executable("scripts/build.sh")
187///     .chmod("config.toml", 0o644)
188///     .chmod_dir("bin", 0o755)
189///     .execute()?;
190/// # Ok::<(), heroforge_core::FossilError>(())
191/// ```
192///
193/// ## Create Symlinks
194///
195/// ```no_run
196/// # use heroforge_core::Repository;
197/// # use heroforge_core::fs::Modify;
198/// # let repo = Repository::open_rw("project.forge")?;
199/// let hash = Modify::new(&repo)
200///     .message("Add convenience symlinks")
201///     .author("developer")
202///     .symlink("build", "scripts/build.sh")
203///     .symlink("latest", "releases/v1.0.0")
204///     .execute()?;
205/// # Ok::<(), heroforge_core::FossilError>(())
206/// ```
207///
208/// ## Write Files
209///
210/// ```no_run
211/// # use heroforge_core::Repository;
212/// # use heroforge_core::fs::Modify;
213/// # let repo = Repository::open_rw("project.forge")?;
214/// let hash = Modify::new(&repo)
215///     .message("Update configuration")
216///     .author("developer")
217///     .write_str("VERSION", "1.0.0\n")
218///     .write("data.bin", &[0x00, 0x01, 0x02])
219///     .touch(".gitkeep")
220///     .execute()?;
221/// # Ok::<(), heroforge_core::FossilError>(())
222/// ```
223pub struct Modify<'a> {
224    repo: &'a Repository,
225    base_commit: Option<String>,
226    operations: Vec<Op>,
227    commit_message: Option<String>,
228    author: Option<String>,
229    branch: Option<String>,
230}
231
232impl<'a> Modify<'a> {
233    /// Create a new Modify builder for the given repository.
234    pub fn new(repo: &'a Repository) -> Self {
235        Self {
236            repo,
237            base_commit: None,
238            operations: Vec::new(),
239            commit_message: None,
240            author: None,
241            branch: None,
242        }
243    }
244
245    /// Apply operations starting from a specific commit.
246    pub fn at_commit(mut self, hash: &str) -> Self {
247        self.base_commit = Some(hash.to_string());
248        self
249    }
250
251    /// Apply operations to trunk tip (default).
252    pub fn on_trunk(self) -> Self {
253        self
254    }
255
256    /// Apply operations to a branch tip.
257    pub fn on_branch(mut self, branch: &str) -> Result<Self> {
258        let tip = self.repo.branch_tip_internal(branch)?;
259        self.base_commit = Some(tip.hash);
260        self.branch = Some(branch.to_string());
261        Ok(self)
262    }
263
264    /// Set the commit message (required).
265    pub fn message(mut self, msg: &str) -> Self {
266        self.commit_message = Some(msg.to_string());
267        self
268    }
269
270    /// Set the commit author (required).
271    pub fn author(mut self, author: &str) -> Self {
272        self.author = Some(author.to_string());
273        self
274    }
275
276    // ========================================================================
277    // Copy Operations
278    // ========================================================================
279
280    /// Copy a file to a new location.
281    pub fn copy_file(mut self, src: &str, dst: &str) -> Self {
282        self.operations.push(Op::Copy {
283            src: src.to_string(),
284            dst: dst.to_string(),
285        });
286        self
287    }
288
289    /// Copy a directory and all its contents recursively.
290    pub fn copy_dir(mut self, src: &str, dst: &str) -> Self {
291        self.operations.push(Op::Copy {
292            src: format!("{}/", src.trim_end_matches('/')),
293            dst: format!("{}/", dst.trim_end_matches('/')),
294        });
295        self
296    }
297
298    // ========================================================================
299    // Move/Rename Operations
300    // ========================================================================
301
302    /// Move or rename a file.
303    pub fn move_file(mut self, src: &str, dst: &str) -> Self {
304        self.operations.push(Op::Move {
305            src: src.to_string(),
306            dst: dst.to_string(),
307        });
308        self
309    }
310
311    /// Move or rename a directory.
312    pub fn move_dir(mut self, src: &str, dst: &str) -> Self {
313        self.operations.push(Op::Move {
314            src: format!("{}/", src.trim_end_matches('/')),
315            dst: format!("{}/", dst.trim_end_matches('/')),
316        });
317        self
318    }
319
320    /// Rename a file (alias for [`move_file`](Self::move_file)).
321    pub fn rename(self, old_path: &str, new_path: &str) -> Self {
322        self.move_file(old_path, new_path)
323    }
324
325    // ========================================================================
326    // Delete Operations
327    // ========================================================================
328
329    /// Delete a file.
330    pub fn delete_file(mut self, path: &str) -> Self {
331        self.operations.push(Op::Delete {
332            path: path.to_string(),
333            recursive: false,
334        });
335        self
336    }
337
338    /// Delete a directory and all its contents.
339    pub fn delete_dir(mut self, path: &str) -> Self {
340        self.operations.push(Op::Delete {
341            path: format!("{}/", path.trim_end_matches('/')),
342            recursive: true,
343        });
344        self
345    }
346
347    /// Delete all files matching a glob pattern.
348    pub fn delete_matching(mut self, pattern: &str) -> Self {
349        self.operations.push(Op::Delete {
350            path: format!("glob:{}", pattern),
351            recursive: false,
352        });
353        self
354    }
355
356    // ========================================================================
357    // Permission Operations
358    // ========================================================================
359
360    /// Change file permissions using octal notation.
361    pub fn chmod(mut self, path: &str, mode: u32) -> Self {
362        self.operations.push(Op::Chmod {
363            path: path.to_string(),
364            permissions: Permissions::from_octal(mode),
365            recursive: false,
366        });
367        self
368    }
369
370    /// Change permissions using a [`Permissions`] struct.
371    pub fn chmod_permissions(mut self, path: &str, perms: Permissions) -> Self {
372        self.operations.push(Op::Chmod {
373            path: path.to_string(),
374            permissions: perms,
375            recursive: false,
376        });
377        self
378    }
379
380    /// Change permissions for all files in a directory recursively.
381    pub fn chmod_dir(mut self, path: &str, mode: u32) -> Self {
382        self.operations.push(Op::Chmod {
383            path: format!("{}/", path.trim_end_matches('/')),
384            permissions: Permissions::from_octal(mode),
385            recursive: true,
386        });
387        self
388    }
389
390    /// Make a file executable (sets mode to 755).
391    pub fn make_executable(mut self, path: &str) -> Self {
392        self.operations.push(Op::MakeExecutable {
393            path: path.to_string(),
394        });
395        self
396    }
397
398    // ========================================================================
399    // Symlink Operations
400    // ========================================================================
401
402    /// Create a symbolic link.
403    pub fn symlink(mut self, link_path: &str, target: &str) -> Self {
404        self.operations.push(Op::Symlink {
405            link_path: link_path.to_string(),
406            target: target.to_string(),
407        });
408        self
409    }
410
411    /// Create a symbolic link to a file (alias for [`symlink`](Self::symlink)).
412    pub fn symlink_file(self, link_path: &str, target_file: &str) -> Self {
413        self.symlink(link_path, target_file)
414    }
415
416    /// Create a symbolic link to a directory (alias for [`symlink`](Self::symlink)).
417    pub fn symlink_dir(self, link_path: &str, target_dir: &str) -> Self {
418        self.symlink(link_path, target_dir)
419    }
420
421    // ========================================================================
422    // Write Operations
423    // ========================================================================
424
425    /// Write binary content to a file.
426    pub fn write(mut self, path: &str, content: &[u8]) -> Self {
427        self.operations.push(Op::Write {
428            path: path.to_string(),
429            content: content.to_vec(),
430        });
431        self
432    }
433
434    /// Write a string to a file.
435    pub fn write_str(self, path: &str, content: &str) -> Self {
436        self.write(path, content.as_bytes())
437    }
438
439    /// Create an empty file or update its timestamp.
440    pub fn touch(self, path: &str) -> Self {
441        self.write(path, &[])
442    }
443
444    // ========================================================================
445    // Execute
446    // ========================================================================
447
448    /// Execute all staged operations and create a commit.
449    pub fn execute(self) -> Result<String> {
450        let message = self.commit_message.ok_or_else(|| {
451            FossilError::InvalidArtifact("commit message required for fs operations".to_string())
452        })?;
453        let author = self.author.ok_or_else(|| {
454            FossilError::InvalidArtifact("author required for fs operations".to_string())
455        })?;
456
457        let base_hash = if let Some(hash) = self.base_commit {
458            hash
459        } else {
460            let tip = self.repo.branch_tip_internal("trunk")?;
461            tip.hash
462        };
463
464        let base_files = self.repo.list_files_internal(&base_hash)?;
465        let mut file_contents: HashMap<String, Vec<u8>> = HashMap::new();
466        let mut file_permissions: HashMap<String, String> = HashMap::new();
467        let mut _symlinks: HashMap<String, String> = HashMap::new();
468
469        for file in &base_files {
470            let content = self.repo.read_file_internal(&base_hash, &file.name)?;
471            file_contents.insert(file.name.clone(), content);
472            if let Some(ref perms) = file.permissions {
473                file_permissions.insert(file.name.clone(), perms.clone());
474            }
475        }
476
477        for op in &self.operations {
478            match op {
479                Op::Copy { src, dst } => {
480                    if src.ends_with('/') {
481                        let src_prefix = src.trim_end_matches('/');
482                        let dst_prefix = dst.trim_end_matches('/');
483                        let to_copy: Vec<_> = file_contents
484                            .keys()
485                            .filter(|k| k.starts_with(src_prefix))
486                            .cloned()
487                            .collect();
488                        for path in to_copy {
489                            let new_path = path.replacen(src_prefix, dst_prefix, 1);
490                            if let Some(content) = file_contents.get(&path).cloned() {
491                                file_contents.insert(new_path.clone(), content);
492                            }
493                            if let Some(perms) = file_permissions.get(&path).cloned() {
494                                file_permissions.insert(new_path, perms);
495                            }
496                        }
497                    } else {
498                        if let Some(content) = file_contents.get(src).cloned() {
499                            file_contents.insert(dst.clone(), content);
500                        }
501                        if let Some(perms) = file_permissions.get(src).cloned() {
502                            file_permissions.insert(dst.clone(), perms);
503                        }
504                    }
505                }
506
507                Op::Move { src, dst } => {
508                    if src.ends_with('/') {
509                        let src_prefix = src.trim_end_matches('/');
510                        let dst_prefix = dst.trim_end_matches('/');
511                        let to_move: Vec<_> = file_contents
512                            .keys()
513                            .filter(|k| k.starts_with(src_prefix))
514                            .cloned()
515                            .collect();
516                        for path in to_move {
517                            let new_path = path.replacen(src_prefix, dst_prefix, 1);
518                            if let Some(content) = file_contents.remove(&path) {
519                                file_contents.insert(new_path.clone(), content);
520                            }
521                            if let Some(perms) = file_permissions.remove(&path) {
522                                file_permissions.insert(new_path, perms);
523                            }
524                        }
525                    } else {
526                        if let Some(content) = file_contents.remove(src) {
527                            file_contents.insert(dst.clone(), content);
528                        }
529                        if let Some(perms) = file_permissions.remove(src) {
530                            file_permissions.insert(dst.clone(), perms);
531                        }
532                    }
533                }
534
535                Op::Delete { path, recursive } => {
536                    if path.starts_with("glob:") {
537                        let pattern = &path[5..];
538                        if let Ok(glob_pattern) = glob::Pattern::new(pattern) {
539                            let to_delete: Vec<_> = file_contents
540                                .keys()
541                                .filter(|k| glob_pattern.matches(k))
542                                .cloned()
543                                .collect();
544                            for p in to_delete {
545                                file_contents.remove(&p);
546                                file_permissions.remove(&p);
547                            }
548                        }
549                    } else if *recursive || path.ends_with('/') {
550                        let prefix = path.trim_end_matches('/');
551                        let to_delete: Vec<_> = file_contents
552                            .keys()
553                            .filter(|k| k.starts_with(prefix) || *k == prefix)
554                            .cloned()
555                            .collect();
556                        for p in to_delete {
557                            file_contents.remove(&p);
558                            file_permissions.remove(&p);
559                        }
560                    } else {
561                        file_contents.remove(path);
562                        file_permissions.remove(path);
563                    }
564                }
565
566                Op::Chmod {
567                    path,
568                    permissions,
569                    recursive,
570                } => {
571                    let perm_str = format!("{:o}", permissions.to_octal());
572                    if *recursive || path.ends_with('/') {
573                        let prefix = path.trim_end_matches('/');
574                        let to_chmod: Vec<_> = file_contents
575                            .keys()
576                            .filter(|k| k.starts_with(prefix))
577                            .cloned()
578                            .collect();
579                        for p in to_chmod {
580                            file_permissions.insert(p, perm_str.clone());
581                        }
582                    } else if file_contents.contains_key(path) {
583                        file_permissions.insert(path.clone(), perm_str);
584                    }
585                }
586
587                Op::MakeExecutable { path } => {
588                    if file_contents.contains_key(path) {
589                        file_permissions.insert(path.clone(), "755".to_string());
590                    }
591                }
592
593                Op::Symlink { link_path, target } => {
594                    let symlink_content = format!("link {}", target);
595                    file_contents.insert(link_path.clone(), symlink_content.into_bytes());
596                    _symlinks.insert(link_path.clone(), target.clone());
597                }
598
599                Op::Write { path, content } => {
600                    file_contents.insert(path.clone(), content.clone());
601                }
602            }
603        }
604
605        let files: Vec<(&str, &[u8])> = file_contents
606            .iter()
607            .map(|(k, v)| (k.as_str(), v.as_slice()))
608            .collect();
609
610        self.repo.commit_internal(
611            &files,
612            &message,
613            &author,
614            Some(&base_hash),
615            self.branch.as_deref(),
616        )
617    }
618
619    /// Preview the operations without committing.
620    pub fn preview(&self) -> Result<Preview> {
621        let base_hash = if let Some(ref hash) = self.base_commit {
622            hash.clone()
623        } else {
624            let tip = self.repo.branch_tip_internal("trunk")?;
625            tip.hash
626        };
627
628        let base_files = self.repo.list_files_internal(&base_hash)?;
629
630        Ok(Preview {
631            base_commit: base_hash,
632            base_file_count: base_files.len(),
633            operations: self.operations.clone(),
634        })
635    }
636}
637
638// ============================================================================
639// Upload/Download Functions (OS filesystem <-> .forge database)
640// ============================================================================
641
642use std::path::Path;
643
644/// Upload a file from the OS filesystem to the repository.
645///
646/// Reads a file from the local filesystem and stores it in the repository
647/// at the specified path, creating a new commit.
648///
649/// # Arguments
650///
651/// * `repo` - The repository to upload to
652/// * `os_path` - Path to the file on the OS filesystem
653/// * `repo_path` - Destination path in the repository
654/// * `author` - Author of the commit
655/// * `message` - Commit message (optional, auto-generated if None)
656///
657/// # Examples
658///
659/// ```no_run
660/// use heroforge_core::Repository;
661/// use heroforge_core::fs::upload;
662///
663/// let repo = Repository::open_rw("project.forge")?;
664///
665/// // Upload a single file
666/// upload(&repo, "/home/user/config.json", "config.json", "developer", None)?;
667///
668/// // Upload with custom message
669/// upload(&repo, "/tmp/data.bin", "data/file.bin", "admin", Some("Import data file"))?;
670/// # Ok::<(), heroforge_core::FossilError>(())
671/// ```
672pub fn upload<P: AsRef<Path>>(
673    repo: &Repository,
674    os_path: P,
675    repo_path: &str,
676    author: &str,
677    message: Option<&str>,
678) -> Result<String> {
679    let os_path = os_path.as_ref();
680
681    // Read file from OS filesystem
682    let content = std::fs::read(os_path).map_err(|e| {
683        FossilError::Io(std::io::Error::new(
684            e.kind(),
685            format!("Failed to read file '{}': {}", os_path.display(), e),
686        ))
687    })?;
688
689    let msg = message.unwrap_or_else(|| "Upload file");
690    let full_message = format!("{}: {}", msg, repo_path);
691
692    Modify::new(repo)
693        .message(&full_message)
694        .author(author)
695        .write(repo_path, &content)
696        .execute()
697}
698
699/// Upload a directory from the OS filesystem to the repository.
700///
701/// Recursively reads all files from a local directory and stores them
702/// in the repository, creating a new commit.
703///
704/// # Arguments
705///
706/// * `repo` - The repository to upload to
707/// * `os_path` - Path to the directory on the OS filesystem
708/// * `repo_path` - Destination directory path in the repository
709/// * `author` - Author of the commit
710/// * `message` - Commit message (optional, auto-generated if None)
711///
712/// # Examples
713///
714/// ```no_run
715/// use heroforge_core::Repository;
716/// use heroforge_core::fs::upload_dir;
717///
718/// let repo = Repository::open_rw("project.forge")?;
719///
720/// // Upload entire directory
721/// upload_dir(&repo, "/home/user/project/src", "src", "developer", None)?;
722///
723/// // Upload with custom message
724/// upload_dir(&repo, "/tmp/assets", "assets", "admin", Some("Import assets"))?;
725/// # Ok::<(), heroforge_core::FossilError>(())
726/// ```
727pub fn upload_dir<P: AsRef<Path>>(
728    repo: &Repository,
729    os_path: P,
730    repo_path: &str,
731    author: &str,
732    message: Option<&str>,
733) -> Result<String> {
734    let os_path = os_path.as_ref();
735
736    if !os_path.is_dir() {
737        return Err(FossilError::Io(std::io::Error::new(
738            std::io::ErrorKind::NotADirectory,
739            format!("'{}' is not a directory", os_path.display()),
740        )));
741    }
742
743    let mut modify = Modify::new(repo);
744    let msg = message.unwrap_or("Upload directory");
745    modify = modify
746        .message(&format!("{}: {}", msg, repo_path))
747        .author(author);
748
749    // Recursively collect files
750    fn collect_files<'a, P: AsRef<Path>>(
751        dir: P,
752        base: &Path,
753        repo_base: &str,
754        modify: Modify<'a>,
755    ) -> Result<Modify<'a>> {
756        let mut m = modify;
757        let entries = std::fs::read_dir(dir.as_ref()).map_err(|e| {
758            FossilError::Io(std::io::Error::new(
759                e.kind(),
760                format!(
761                    "Failed to read directory '{}': {}",
762                    dir.as_ref().display(),
763                    e
764                ),
765            ))
766        })?;
767
768        for entry in entries {
769            let entry = entry.map_err(|e| FossilError::Io(e))?;
770            let path = entry.path();
771
772            if path.is_file() {
773                let relative = path.strip_prefix(base).unwrap_or(&path);
774                let repo_file_path = if repo_base.is_empty() {
775                    relative.to_string_lossy().to_string()
776                } else {
777                    format!(
778                        "{}/{}",
779                        repo_base.trim_end_matches('/'),
780                        relative.to_string_lossy()
781                    )
782                };
783
784                let content = std::fs::read(&path).map_err(|e| {
785                    FossilError::Io(std::io::Error::new(
786                        e.kind(),
787                        format!("Failed to read file '{}': {}", path.display(), e),
788                    ))
789                })?;
790
791                m = m.write(&repo_file_path, &content);
792            } else if path.is_dir() {
793                m = collect_files(&path, base, repo_base, m)?;
794            }
795        }
796
797        Ok(m)
798    }
799
800    let modify = collect_files(os_path, os_path, repo_path, modify)?;
801    modify.execute()
802}
803
804/// Download a file from the repository to the OS filesystem.
805///
806/// Reads a file from the repository and writes it to the local filesystem.
807///
808/// # Arguments
809///
810/// * `repo` - The repository to download from
811/// * `repo_path` - Path to the file in the repository
812/// * `os_path` - Destination path on the OS filesystem
813///
814/// # Examples
815///
816/// ```no_run
817/// use heroforge_core::Repository;
818/// use heroforge_core::fs::download;
819///
820/// let repo = Repository::open("project.forge")?;
821///
822/// // Download a single file
823/// download(&repo, "config.json", "/home/user/config.json")?;
824///
825/// // Download to current directory
826/// download(&repo, "README.md", "./README.md")?;
827/// # Ok::<(), heroforge_core::FossilError>(())
828/// ```
829pub fn download<P: AsRef<Path>>(repo: &Repository, repo_path: &str, os_path: P) -> Result<()> {
830    let os_path = os_path.as_ref();
831
832    // Get file content from repository
833    let tip = repo.branch_tip_internal("trunk")?;
834    let content = repo.read_file_internal(&tip.hash, repo_path)?;
835
836    // Create parent directories if needed
837    if let Some(parent) = os_path.parent() {
838        if !parent.as_os_str().is_empty() && !parent.exists() {
839            std::fs::create_dir_all(parent).map_err(|e| {
840                FossilError::Io(std::io::Error::new(
841                    e.kind(),
842                    format!("Failed to create directory '{}': {}", parent.display(), e),
843                ))
844            })?;
845        }
846    }
847
848    // Write to OS filesystem
849    std::fs::write(os_path, &content).map_err(|e| {
850        FossilError::Io(std::io::Error::new(
851            e.kind(),
852            format!("Failed to write file '{}': {}", os_path.display(), e),
853        ))
854    })?;
855
856    Ok(())
857}
858
859/// Download a file from a specific branch to the OS filesystem.
860///
861/// # Examples
862///
863/// ```no_run
864/// use heroforge_core::Repository;
865/// use heroforge_core::fs::download_from_branch;
866///
867/// let repo = Repository::open("project.forge")?;
868/// download_from_branch(&repo, "feature", "config.json", "/tmp/config.json")?;
869/// # Ok::<(), heroforge_core::FossilError>(())
870/// ```
871pub fn download_from_branch<P: AsRef<Path>>(
872    repo: &Repository,
873    branch: &str,
874    repo_path: &str,
875    os_path: P,
876) -> Result<()> {
877    let os_path = os_path.as_ref();
878
879    let tip = repo.branch_tip_internal(branch)?;
880    let content = repo.read_file_internal(&tip.hash, repo_path)?;
881
882    if let Some(parent) = os_path.parent() {
883        if !parent.as_os_str().is_empty() && !parent.exists() {
884            std::fs::create_dir_all(parent).map_err(|e| {
885                FossilError::Io(std::io::Error::new(
886                    e.kind(),
887                    format!("Failed to create directory '{}': {}", parent.display(), e),
888                ))
889            })?;
890        }
891    }
892
893    std::fs::write(os_path, &content).map_err(|e| {
894        FossilError::Io(std::io::Error::new(
895            e.kind(),
896            format!("Failed to write file '{}': {}", os_path.display(), e),
897        ))
898    })?;
899
900    Ok(())
901}
902
903/// Download a directory from the repository to the OS filesystem.
904///
905/// Recursively downloads all files from a repository directory to the local filesystem.
906///
907/// # Arguments
908///
909/// * `repo` - The repository to download from
910/// * `repo_path` - Directory path in the repository (use "" for root)
911/// * `os_path` - Destination directory on the OS filesystem
912///
913/// # Examples
914///
915/// ```no_run
916/// use heroforge_core::Repository;
917/// use heroforge_core::fs::download_dir;
918///
919/// let repo = Repository::open("project.forge")?;
920///
921/// // Download entire src directory
922/// download_dir(&repo, "src", "/home/user/project/src")?;
923///
924/// // Download entire repository
925/// download_dir(&repo, "", "/tmp/full_export")?;
926/// # Ok::<(), heroforge_core::FossilError>(())
927/// ```
928pub fn download_dir<P: AsRef<Path>>(
929    repo: &Repository,
930    repo_path: &str,
931    os_path: P,
932) -> Result<usize> {
933    let os_path = os_path.as_ref();
934    let tip = repo.branch_tip_internal("trunk")?;
935    let all_files = repo.list_files_internal(&tip.hash)?;
936
937    let prefix = if repo_path.is_empty() {
938        String::new()
939    } else {
940        format!("{}/", repo_path.trim_end_matches('/'))
941    };
942
943    let mut count = 0;
944
945    for file in all_files {
946        // Filter files that match the prefix
947        let relative_path = if prefix.is_empty() {
948            Some(file.name.as_str())
949        } else if file.name.starts_with(&prefix) {
950            Some(&file.name[prefix.len()..])
951        } else {
952            None
953        };
954
955        if let Some(rel_path) = relative_path {
956            let dest_path = os_path.join(rel_path);
957
958            // Create parent directories
959            if let Some(parent) = dest_path.parent() {
960                if !parent.exists() {
961                    std::fs::create_dir_all(parent).map_err(|e| {
962                        FossilError::Io(std::io::Error::new(
963                            e.kind(),
964                            format!("Failed to create directory '{}': {}", parent.display(), e),
965                        ))
966                    })?;
967                }
968            }
969
970            // Read and write file
971            let content = repo.read_file_internal(&tip.hash, &file.name)?;
972            std::fs::write(&dest_path, &content).map_err(|e| {
973                FossilError::Io(std::io::Error::new(
974                    e.kind(),
975                    format!("Failed to write file '{}': {}", dest_path.display(), e),
976                ))
977            })?;
978
979            count += 1;
980        }
981    }
982
983    Ok(count)
984}
985
986/// Download files matching a pattern from the repository to the OS filesystem.
987///
988/// # Examples
989///
990/// ```no_run
991/// use heroforge_core::Repository;
992/// use heroforge_core::fs::download_matching;
993///
994/// let repo = Repository::open("project.forge")?;
995///
996/// // Download all Rust files
997/// let count = download_matching(&repo, "**/*.rs", "/tmp/rust_files")?;
998/// println!("Downloaded {} files", count);
999/// # Ok::<(), heroforge_core::FossilError>(())
1000/// ```
1001pub fn download_matching<P: AsRef<Path>>(
1002    repo: &Repository,
1003    pattern: &str,
1004    os_path: P,
1005) -> Result<usize> {
1006    let os_path = os_path.as_ref();
1007    let tip = repo.branch_tip_internal("trunk")?;
1008    let matched_files = repo.find_files_internal(&tip.hash, pattern)?;
1009
1010    let mut count = 0;
1011
1012    for file in matched_files {
1013        let dest_path = os_path.join(&file.name);
1014
1015        if let Some(parent) = dest_path.parent() {
1016            if !parent.exists() {
1017                std::fs::create_dir_all(parent).map_err(|e| {
1018                    FossilError::Io(std::io::Error::new(
1019                        e.kind(),
1020                        format!("Failed to create directory '{}': {}", parent.display(), e),
1021                    ))
1022                })?;
1023            }
1024        }
1025
1026        let content = repo.read_file_internal(&tip.hash, &file.name)?;
1027        std::fs::write(&dest_path, &content).map_err(|e| {
1028            FossilError::Io(std::io::Error::new(
1029                e.kind(),
1030                format!("Failed to write file '{}': {}", dest_path.display(), e),
1031            ))
1032        })?;
1033
1034        count += 1;
1035    }
1036
1037    Ok(count)
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042    use super::*;
1043
1044    #[test]
1045    fn test_preview_describe() {
1046        let preview = Preview {
1047            base_commit: "abc123".to_string(),
1048            base_file_count: 10,
1049            operations: vec![
1050                Op::Copy {
1051                    src: "a.txt".to_string(),
1052                    dst: "b.txt".to_string(),
1053                },
1054                Op::Move {
1055                    src: "old/".to_string(),
1056                    dst: "new/".to_string(),
1057                },
1058                Op::Delete {
1059                    path: "temp.log".to_string(),
1060                    recursive: false,
1061                },
1062                Op::Delete {
1063                    path: "cache/".to_string(),
1064                    recursive: true,
1065                },
1066                Op::Chmod {
1067                    path: "script.sh".to_string(),
1068                    permissions: Permissions::executable(),
1069                    recursive: false,
1070                },
1071                Op::Symlink {
1072                    link_path: "link".to_string(),
1073                    target: "target".to_string(),
1074                },
1075                Op::Write {
1076                    path: "file.txt".to_string(),
1077                    content: b"hello".to_vec(),
1078                },
1079                Op::MakeExecutable {
1080                    path: "run.sh".to_string(),
1081                },
1082            ],
1083        };
1084
1085        let descriptions = preview.describe();
1086        assert_eq!(descriptions.len(), 8);
1087        assert_eq!(descriptions[0], "COPY a.txt -> b.txt");
1088        assert_eq!(descriptions[1], "MOVE old/ -> new/");
1089        assert_eq!(descriptions[2], "DELETE temp.log");
1090        assert_eq!(descriptions[3], "DELETE cache/ (recursive)");
1091        assert_eq!(descriptions[4], "CHMOD script.sh 755");
1092        assert_eq!(descriptions[5], "SYMLINK link -> target");
1093        assert_eq!(descriptions[6], "WRITE file.txt (5 bytes)");
1094        assert_eq!(descriptions[7], "MAKE_EXECUTABLE run.sh");
1095    }
1096
1097    #[test]
1098    fn test_preview_describe_glob_delete() {
1099        let preview = Preview {
1100            base_commit: "hash".to_string(),
1101            base_file_count: 5,
1102            operations: vec![Op::Delete {
1103                path: "glob:**/*.bak".to_string(),
1104                recursive: false,
1105            }],
1106        };
1107        let descriptions = preview.describe();
1108        assert_eq!(descriptions[0], "DELETE matching **/*.bak");
1109    }
1110
1111    #[test]
1112    fn test_preview_describe_recursive_chmod() {
1113        let preview = Preview {
1114            base_commit: "hash".to_string(),
1115            base_file_count: 5,
1116            operations: vec![Op::Chmod {
1117                path: "bin/".to_string(),
1118                permissions: Permissions::from_octal(0o755),
1119                recursive: true,
1120            }],
1121        };
1122        let descriptions = preview.describe();
1123        assert_eq!(descriptions[0], "CHMOD bin/ 755 (recursive)");
1124    }
1125
1126    #[test]
1127    fn test_preview_empty_operations() {
1128        let preview = Preview {
1129            base_commit: "abc123".to_string(),
1130            base_file_count: 0,
1131            operations: vec![],
1132        };
1133        assert!(preview.describe().is_empty());
1134        assert_eq!(preview.base_file_count, 0);
1135    }
1136
1137    #[test]
1138    fn test_op_copy_clone() {
1139        let op = Op::Copy {
1140            src: "src.txt".to_string(),
1141            dst: "dst.txt".to_string(),
1142        };
1143        let cloned = op.clone();
1144        if let Op::Copy { src, dst } = cloned {
1145            assert_eq!(src, "src.txt");
1146            assert_eq!(dst, "dst.txt");
1147        } else {
1148            panic!("Expected Copy operation");
1149        }
1150    }
1151
1152    #[test]
1153    fn test_op_move_clone() {
1154        let op = Op::Move {
1155            src: "old.txt".to_string(),
1156            dst: "new.txt".to_string(),
1157        };
1158        let cloned = op.clone();
1159        if let Op::Move { src, dst } = cloned {
1160            assert_eq!(src, "old.txt");
1161            assert_eq!(dst, "new.txt");
1162        } else {
1163            panic!("Expected Move operation");
1164        }
1165    }
1166
1167    #[test]
1168    fn test_op_delete_clone() {
1169        let op = Op::Delete {
1170            path: "file.txt".to_string(),
1171            recursive: true,
1172        };
1173        let cloned = op.clone();
1174        if let Op::Delete { path, recursive } = cloned {
1175            assert_eq!(path, "file.txt");
1176            assert!(recursive);
1177        } else {
1178            panic!("Expected Delete operation");
1179        }
1180    }
1181
1182    #[test]
1183    fn test_op_write_clone() {
1184        let op = Op::Write {
1185            path: "file.txt".to_string(),
1186            content: b"content".to_vec(),
1187        };
1188        let cloned = op.clone();
1189        if let Op::Write { path, content } = cloned {
1190            assert_eq!(path, "file.txt");
1191            assert_eq!(content, b"content");
1192        } else {
1193            panic!("Expected Write operation");
1194        }
1195    }
1196
1197    #[test]
1198    fn test_op_symlink_clone() {
1199        let op = Op::Symlink {
1200            link_path: "link".to_string(),
1201            target: "target".to_string(),
1202        };
1203        let cloned = op.clone();
1204        if let Op::Symlink { link_path, target } = cloned {
1205            assert_eq!(link_path, "link");
1206            assert_eq!(target, "target");
1207        } else {
1208            panic!("Expected Symlink operation");
1209        }
1210    }
1211
1212    #[test]
1213    fn test_op_chmod_clone() {
1214        let op = Op::Chmod {
1215            path: "file.sh".to_string(),
1216            permissions: Permissions::executable(),
1217            recursive: false,
1218        };
1219        let cloned = op.clone();
1220        if let Op::Chmod {
1221            path,
1222            permissions,
1223            recursive,
1224        } = cloned
1225        {
1226            assert_eq!(path, "file.sh");
1227            assert_eq!(permissions.to_octal(), 0o755);
1228            assert!(!recursive);
1229        } else {
1230            panic!("Expected Chmod operation");
1231        }
1232    }
1233
1234    #[test]
1235    fn test_op_make_executable_clone() {
1236        let op = Op::MakeExecutable {
1237            path: "script.sh".to_string(),
1238        };
1239        let cloned = op.clone();
1240        if let Op::MakeExecutable { path } = cloned {
1241            assert_eq!(path, "script.sh");
1242        } else {
1243            panic!("Expected MakeExecutable operation");
1244        }
1245    }
1246
1247    #[test]
1248    fn test_op_debug_format() {
1249        let op = Op::Copy {
1250            src: "a".to_string(),
1251            dst: "b".to_string(),
1252        };
1253        let debug_str = format!("{:?}", op);
1254        assert!(debug_str.contains("Copy"));
1255        assert!(debug_str.contains("a"));
1256        assert!(debug_str.contains("b"));
1257    }
1258
1259    #[test]
1260    fn test_preview_debug_format() {
1261        let preview = Preview {
1262            base_commit: "abc".to_string(),
1263            base_file_count: 5,
1264            operations: vec![],
1265        };
1266        let debug_str = format!("{:?}", preview);
1267        assert!(debug_str.contains("Preview"));
1268        assert!(debug_str.contains("abc"));
1269    }
1270
1271    // ========================================================================
1272    // Upload/Download Integration Tests
1273    // ========================================================================
1274
1275    use std::fs as std_fs;
1276    use tempfile::TempDir;
1277
1278    fn create_test_repo() -> (TempDir, crate::repo::Repository) {
1279        let tmp = TempDir::new().unwrap();
1280        let repo_path = tmp.path().join("test.forge");
1281        let repo = crate::repo::Repository::init(&repo_path).unwrap();
1282
1283        // Create initial commit with some files
1284        repo.commit_internal(
1285            &[
1286                ("README.md", b"# Test Project\n"),
1287                ("src/main.rs", b"fn main() {}\n"),
1288                ("src/lib.rs", b"pub fn hello() {}\n"),
1289                ("config.json", b"{\"key\": \"value\"}\n"),
1290            ],
1291            "Initial commit",
1292            "test_author",
1293            None,
1294            None,
1295        )
1296        .unwrap();
1297
1298        (tmp, repo)
1299    }
1300
1301    #[test]
1302    fn test_upload_single_file() {
1303        let (tmp, repo) = create_test_repo();
1304
1305        // Create a file on OS filesystem
1306        let os_file = tmp.path().join("upload_test.txt");
1307        std_fs::write(&os_file, b"uploaded content").unwrap();
1308
1309        // Upload it
1310        let hash = upload(&repo, &os_file, "uploaded.txt", "uploader", None).unwrap();
1311        assert!(!hash.is_empty());
1312
1313        // Verify file exists in repo
1314        let tip = repo.branch_tip_internal("trunk").unwrap();
1315        let content = repo.read_file_internal(&tip.hash, "uploaded.txt").unwrap();
1316        assert_eq!(content, b"uploaded content");
1317    }
1318
1319    #[test]
1320    fn test_upload_with_custom_message() {
1321        let (tmp, repo) = create_test_repo();
1322
1323        let os_file = tmp.path().join("custom_msg.txt");
1324        std_fs::write(&os_file, b"content").unwrap();
1325
1326        let hash = upload(
1327            &repo,
1328            &os_file,
1329            "custom.txt",
1330            "author",
1331            Some("Custom upload message"),
1332        )
1333        .unwrap();
1334        assert!(!hash.is_empty());
1335
1336        // Verify the commit was created
1337        let tip = repo.branch_tip_internal("trunk").unwrap();
1338        let content = repo.read_file_internal(&tip.hash, "custom.txt").unwrap();
1339        assert_eq!(content, b"content");
1340    }
1341
1342    #[test]
1343    fn test_upload_nonexistent_file() {
1344        let (tmp, repo) = create_test_repo();
1345
1346        let result = upload(
1347            &repo,
1348            tmp.path().join("nonexistent.txt"),
1349            "dest.txt",
1350            "author",
1351            None,
1352        );
1353        assert!(result.is_err());
1354    }
1355
1356    #[test]
1357    fn test_upload_dir() {
1358        let (tmp, repo) = create_test_repo();
1359
1360        // Create directory structure on OS
1361        let upload_dir_path = tmp.path().join("to_upload");
1362        std_fs::create_dir_all(upload_dir_path.join("subdir")).unwrap();
1363        std_fs::write(upload_dir_path.join("file1.txt"), b"content1").unwrap();
1364        std_fs::write(upload_dir_path.join("file2.txt"), b"content2").unwrap();
1365        std_fs::write(upload_dir_path.join("subdir/nested.txt"), b"nested").unwrap();
1366
1367        // Upload directory
1368        let hash = upload_dir(&repo, &upload_dir_path, "imported", "author", None).unwrap();
1369        assert!(!hash.is_empty());
1370
1371        // Verify files exist in repo
1372        let tip = repo.branch_tip_internal("trunk").unwrap();
1373        let content1 = repo
1374            .read_file_internal(&tip.hash, "imported/file1.txt")
1375            .unwrap();
1376        assert_eq!(content1, b"content1");
1377
1378        let content2 = repo
1379            .read_file_internal(&tip.hash, "imported/file2.txt")
1380            .unwrap();
1381        assert_eq!(content2, b"content2");
1382
1383        let nested = repo
1384            .read_file_internal(&tip.hash, "imported/subdir/nested.txt")
1385            .unwrap();
1386        assert_eq!(nested, b"nested");
1387    }
1388
1389    #[test]
1390    fn test_upload_dir_not_a_directory() {
1391        let (tmp, repo) = create_test_repo();
1392
1393        let file_path = tmp.path().join("regular_file.txt");
1394        std_fs::write(&file_path, b"content").unwrap();
1395
1396        let result = upload_dir(&repo, &file_path, "dest", "author", None);
1397        assert!(result.is_err());
1398    }
1399
1400    #[test]
1401    fn test_download_single_file() {
1402        let (tmp, repo) = create_test_repo();
1403
1404        let dest_path = tmp.path().join("downloaded.md");
1405        download(&repo, "README.md", &dest_path).unwrap();
1406
1407        let content = std_fs::read_to_string(&dest_path).unwrap();
1408        assert_eq!(content, "# Test Project\n");
1409    }
1410
1411    #[test]
1412    fn test_download_creates_parent_dirs() {
1413        let (tmp, repo) = create_test_repo();
1414
1415        let dest_path = tmp.path().join("deep/nested/path/config.json");
1416        download(&repo, "config.json", &dest_path).unwrap();
1417
1418        assert!(dest_path.exists());
1419        let content = std_fs::read_to_string(&dest_path).unwrap();
1420        assert_eq!(content, "{\"key\": \"value\"}\n");
1421    }
1422
1423    #[test]
1424    fn test_download_nonexistent_file() {
1425        let (tmp, repo) = create_test_repo();
1426
1427        let result = download(&repo, "nonexistent.txt", tmp.path().join("out.txt"));
1428        assert!(result.is_err());
1429    }
1430
1431    #[test]
1432    fn test_download_dir() {
1433        let (tmp, repo) = create_test_repo();
1434
1435        let dest_dir = tmp.path().join("exported_src");
1436        let count = download_dir(&repo, "src", &dest_dir).unwrap();
1437
1438        assert_eq!(count, 2); // main.rs and lib.rs
1439
1440        let main_content = std_fs::read_to_string(dest_dir.join("main.rs")).unwrap();
1441        assert_eq!(main_content, "fn main() {}\n");
1442
1443        let lib_content = std_fs::read_to_string(dest_dir.join("lib.rs")).unwrap();
1444        assert_eq!(lib_content, "pub fn hello() {}\n");
1445    }
1446
1447    #[test]
1448    fn test_download_dir_entire_repo() {
1449        let (tmp, repo) = create_test_repo();
1450
1451        let dest_dir = tmp.path().join("full_export");
1452        let count = download_dir(&repo, "", &dest_dir).unwrap();
1453
1454        assert_eq!(count, 4); // README.md, config.json, src/main.rs, src/lib.rs
1455
1456        assert!(dest_dir.join("README.md").exists());
1457        assert!(dest_dir.join("config.json").exists());
1458        assert!(dest_dir.join("src/main.rs").exists());
1459        assert!(dest_dir.join("src/lib.rs").exists());
1460    }
1461
1462    #[test]
1463    fn test_download_matching() {
1464        let (tmp, repo) = create_test_repo();
1465
1466        let dest_dir = tmp.path().join("rust_files");
1467        let count = download_matching(&repo, "**/*.rs", &dest_dir).unwrap();
1468
1469        assert_eq!(count, 2);
1470        assert!(dest_dir.join("src/main.rs").exists());
1471        assert!(dest_dir.join("src/lib.rs").exists());
1472    }
1473
1474    #[test]
1475    fn test_download_matching_no_matches() {
1476        let (tmp, repo) = create_test_repo();
1477
1478        let dest_dir = tmp.path().join("no_matches");
1479        let count = download_matching(&repo, "**/*.py", &dest_dir).unwrap();
1480
1481        assert_eq!(count, 0);
1482    }
1483
1484    #[test]
1485    fn test_upload_then_download_roundtrip() {
1486        let (tmp, repo) = create_test_repo();
1487
1488        // Create original file
1489        let original_content = b"This is test content for roundtrip!";
1490        let original_path = tmp.path().join("original.txt");
1491        std_fs::write(&original_path, original_content).unwrap();
1492
1493        // Upload
1494        upload(&repo, &original_path, "roundtrip.txt", "author", None).unwrap();
1495
1496        // Download to different location
1497        let downloaded_path = tmp.path().join("downloaded.txt");
1498        download(&repo, "roundtrip.txt", &downloaded_path).unwrap();
1499
1500        // Verify content matches
1501        let downloaded_content = std_fs::read(&downloaded_path).unwrap();
1502        assert_eq!(downloaded_content, original_content);
1503    }
1504
1505    #[test]
1506    fn test_upload_overwrites_existing() {
1507        let (tmp, repo) = create_test_repo();
1508
1509        // Create file to upload
1510        let os_file = tmp.path().join("new_readme.md");
1511        std_fs::write(&os_file, b"# Updated README\n").unwrap();
1512
1513        // Upload to existing path
1514        upload(&repo, &os_file, "README.md", "author", None).unwrap();
1515
1516        // Verify content was updated
1517        let tip = repo.branch_tip_internal("trunk").unwrap();
1518        let content = repo.read_file_internal(&tip.hash, "README.md").unwrap();
1519        assert_eq!(content, b"# Updated README\n");
1520    }
1521
1522    #[test]
1523    fn test_upload_creates_new_commit() {
1524        let (tmp, repo) = create_test_repo();
1525
1526        // Get initial tip
1527        let initial_tip = repo.branch_tip_internal("trunk").unwrap();
1528
1529        // Upload file
1530        let os_file = tmp.path().join("new_file.txt");
1531        std_fs::write(&os_file, b"new content").unwrap();
1532        upload(&repo, &os_file, "new.txt", "author", None).unwrap();
1533
1534        // Verify new commit was created
1535        let new_tip = repo.branch_tip_internal("trunk").unwrap();
1536        assert_ne!(initial_tip.hash, new_tip.hash);
1537    }
1538
1539    #[test]
1540    fn test_upload_dir_empty_repo_path() {
1541        let (tmp, repo) = create_test_repo();
1542
1543        // Create directory to upload
1544        let upload_path = tmp.path().join("root_upload");
1545        std_fs::create_dir_all(&upload_path).unwrap();
1546        std_fs::write(upload_path.join("root_file.txt"), b"at root").unwrap();
1547
1548        // Upload to root
1549        upload_dir(&repo, &upload_path, "", "author", None).unwrap();
1550
1551        // Verify file at root
1552        let tip = repo.branch_tip_internal("trunk").unwrap();
1553        let content = repo.read_file_internal(&tip.hash, "root_file.txt").unwrap();
1554        assert_eq!(content, b"at root");
1555    }
1556}