//! Stash create/apply built from gix commit/tree primitives.
//!
//! gix does not provide a high-level stash API.
//! We build stash from tree, index, and commit operations.

use std::io::Write;

use gix::bstr::ByteSlice;
use gix::objs::TreeRefIter;

use crate::error::GitError;
use crate::gix_repo::GixRepo;
use crate::types::{EntryMode, FileStatus, GitOid, TreeEdit};

/// Convert a `GitOid` to a `gix::ObjectId`.
fn to_gix_oid(oid: GitOid) -> gix::ObjectId {
    gix::ObjectId::from(*oid.as_bytes())
}

/// Convert a `gix::ObjectId` to our `GitOid`.
fn from_gix_oid(oid: gix::ObjectId) -> GitOid {
    let bytes: [u8; 20] = oid.as_slice().try_into().expect("SHA-1 is 20 bytes");
    GitOid::from_bytes(bytes)
}

/// Write the current index state as a tree object, returning its OID.
fn write_index_tree(repo: &GixRepo) -> Result<GitOid, GitError> {
    let index = repo.repo.open_index().map_err(|e| GitError::BackendError {
        message: format!("failed to open index: {e}"),
    })?;

    // Use a tree editor starting from an empty tree to build up the tree
    // from index entries.
    let empty_tree = gix::objs::Tree::empty();
    let empty_tree_id =
        repo.repo
            .write_object(&empty_tree)
            .map_err(|e| GitError::BackendError {
                message: format!("failed to write empty tree: {e}"),
            })?;

    let tree = repo
        .repo
        .find_tree(empty_tree_id)
        .map_err(|e| GitError::BackendError {
            message: format!("failed to find empty tree: {e}"),
        })?;

    let mut editor = tree.edit().map_err(|e| GitError::BackendError {
        message: format!("failed to create tree editor: {e}"),
    })?;

    for entry in index.entries() {
        let Ok(path) = entry.path(&index).to_str() else {
            continue;
        };

        let kind = match entry.mode {
            gix::index::entry::Mode::FILE => gix::objs::tree::EntryKind::Blob,
            gix::index::entry::Mode::FILE_EXECUTABLE => gix::objs::tree::EntryKind::BlobExecutable,
            gix::index::entry::Mode::SYMLINK => gix::objs::tree::EntryKind::Link,
            gix::index::entry::Mode::COMMIT => gix::objs::tree::EntryKind::Commit,
            _ => continue,
        };

        editor
            .upsert(path, kind, entry.id)
            .map_err(|e| GitError::BackendError {
                message: format!("tree editor upsert '{path}': {e}"),
            })?;
    }

    let tree_id = editor.write().map_err(|e| GitError::BackendError {
        message: format!("failed to write index tree: {e}"),
    })?;

    Ok(from_gix_oid(tree_id.detach()))
}

pub fn stash_create(repo: &GixRepo) -> Result<Option<GitOid>, GitError> {
    // 1. Check if worktree is dirty. If clean, nothing to stash.
    let dirty = repo.repo.is_dirty().map_err(|e| GitError::BackendError {
        message: format!("failed to check dirty state: {e}"),
    })?;
    if !dirty {
        return Ok(None);
    }

    // 2. Read HEAD to get current commit OID.
    let head_object = repo
        .repo
        .rev_parse_single("HEAD")
        .map_err(|e| GitError::BackendError {
            message: format!("failed to resolve HEAD: {e}"),
        })?;
    let head_oid = from_gix_oid(head_object.detach());

    // 3. Write the current index state as a tree.
    let index_tree_oid = write_index_tree(repo)?;

    // 4. Create index commit: parent=HEAD, tree=index_tree
    let index_commit = {
        let tree_gix = to_gix_oid(index_tree_oid);
        let head_gix = to_gix_oid(head_oid);

        let author_sig = repo
            .repo
            .author()
            .ok_or_else(|| GitError::BackendError {
                message: "no author identity configured".to_string(),
            })?
            .map_err(|e| GitError::BackendError {
                message: format!("failed to read author identity: {e}"),
            })?;

        let committer_sig = repo
            .repo
            .committer()
            .ok_or_else(|| GitError::BackendError {
                message: "no committer identity configured".to_string(),
            })?
            .map_err(|e| GitError::BackendError {
                message: format!("failed to read committer identity: {e}"),
            })?;

        let commit = gix::objs::Commit {
            message: "index on HEAD".into(),
            tree: tree_gix,
            author: author_sig.into(),
            committer: committer_sig.into(),
            encoding: None,
            parents: vec![head_gix].into(),
            extra_headers: Vec::default(),
        };
        let id = repo
            .repo
            .write_object(&commit)
            .map_err(|e| GitError::BackendError {
                message: format!("failed to write index commit: {e}"),
            })?;
        from_gix_oid(id.detach())
    };

    // 5. Create stash commit: merge commit with parents=[HEAD, index_commit], tree=index_tree
    let stash_commit = {
        let tree_gix = to_gix_oid(index_tree_oid);
        let head_gix = to_gix_oid(head_oid);
        let idx_gix = to_gix_oid(index_commit);

        let author_sig = repo
            .repo
            .author()
            .ok_or_else(|| GitError::BackendError {
                message: "no author identity configured".to_string(),
            })?
            .map_err(|e| GitError::BackendError {
                message: format!("failed to read author identity: {e}"),
            })?;

        let committer_sig = repo
            .repo
            .committer()
            .ok_or_else(|| GitError::BackendError {
                message: "no committer identity configured".to_string(),
            })?
            .map_err(|e| GitError::BackendError {
                message: format!("failed to read committer identity: {e}"),
            })?;

        let commit = gix::objs::Commit {
            message: "WIP on HEAD".into(),
            tree: tree_gix,
            author: author_sig.into(),
            committer: committer_sig.into(),
            encoding: None,
            parents: vec![head_gix, idx_gix].into(),
            extra_headers: Vec::default(),
        };
        let id = repo
            .repo
            .write_object(&commit)
            .map_err(|e| GitError::BackendError {
                message: format!("failed to write stash commit: {e}"),
            })?;
        from_gix_oid(id.detach())
    };

    Ok(Some(stash_commit))
}

#[expect(
    clippy::too_many_lines,
    reason = "stash replay handles all tree diff cases"
)]
pub fn stash_apply(repo: &GixRepo, oid: GitOid) -> Result<(), GitError> {
    let workdir = repo
        .workdir
        .as_ref()
        .ok_or_else(|| GitError::BackendError {
            message: "repository has no working directory".to_string(),
        })?;

    // 1. Read the stash commit and get its tree.
    let stash_gix = to_gix_oid(oid);
    let stash_commit = repo
        .repo
        .find_commit(stash_gix)
        .map_err(|e| GitError::NotFound {
            message: format!("stash commit {oid}: {e}"),
        })?;
    let stash_decoded = stash_commit.decode().map_err(|e| GitError::BackendError {
        message: format!("failed to decode stash commit {oid}: {e}"),
    })?;
    let stash_tree_oid = stash_decoded.tree();

    // 2. Read the stash commit's first parent (HEAD at time of stash).
    let parent_oid = stash_decoded
        .parents()
        .next()
        .ok_or_else(|| GitError::BackendError {
            message: "stash commit has no parent".to_string(),
        })?;

    // 3. Get the parent's tree.
    let parent_commit = repo
        .repo
        .find_commit(parent_oid)
        .map_err(|e| GitError::NotFound {
            message: format!("stash parent commit {parent_oid}: {e}"),
        })?;
    let parent_decoded = parent_commit.decode().map_err(|e| GitError::BackendError {
        message: format!("failed to decode parent commit: {e}"),
    })?;
    let parent_tree_oid = parent_decoded.tree();

    // 4. Diff the parent tree vs stash tree to find changes.
    let parent_tree_data = repo
        .repo
        .find_object(parent_tree_oid)
        .map_err(|e| GitError::BackendError {
            message: format!("failed to find parent tree: {e}"),
        })?
        .data
        .clone();

    let stash_tree_data = repo
        .repo
        .find_object(stash_tree_oid)
        .map_err(|e| GitError::BackendError {
            message: format!("failed to find stash tree: {e}"),
        })?
        .data
        .clone();

    let old_iter = TreeRefIter::from_bytes(&parent_tree_data);
    let new_iter = TreeRefIter::from_bytes(&stash_tree_data);

    let mut recorder = gix::diff::tree::Recorder::default();
    gix::diff::tree(
        old_iter,
        new_iter,
        gix::diff::tree::State::default(),
        &repo.repo,
        &mut recorder,
    )
    .map_err(|e| GitError::BackendError {
        message: format!("tree diff failed: {e}"),
    })?;

    // 5. For each changed file, read the blob from stash tree and write it to worktree.
    for change in &recorder.records {
        match change {
            gix::diff::tree::recorder::Change::Addition {
                entry_mode,
                oid,
                path,
                ..
            }
            | gix::diff::tree::recorder::Change::Modification {
                entry_mode,
                oid,
                path,
                ..
            } => {
                if entry_mode.is_tree() {
                    continue;
                }
                let Ok(path_str) = path.to_str() else {
                    continue;
                };

                // Reject paths with .. components (path traversal protection).
                if path_str.split('/').any(|c| c == "..") {
                    return Err(GitError::BackendError {
                        message: format!("refusing path with '..' component: '{path_str}'"),
                    });
                }

                // Read blob from stash.
                let blob = repo
                    .repo
                    .find_blob(*oid)
                    .map_err(|e| GitError::BackendError {
                        message: format!("failed to read blob {oid} for '{path_str}': {e}"),
                    })?;

                let file_path = workdir.join(path_str);
                if let Some(parent) = file_path.parent() {
                    std::fs::create_dir_all(parent).map_err(|e| GitError::BackendError {
                        message: format!("failed to create directory for '{path_str}': {e}"),
                    })?;
                }

                // SAFETY: Remove any existing symlink (or file) before writing.
                // If we skip this, File::create follows existing symlinks and
                // corrupts the symlink target instead of replacing the symlink.
                // This was the root cause of the .bones/events shard corruption:
                // writing symlink target text through a symlink into the real file.
                if let Ok(meta) = std::fs::symlink_metadata(&file_path)
                    && (meta.is_symlink() || meta.is_file())
                {
                    let _ = std::fs::remove_file(&file_path);
                }

                if entry_mode.kind() == gix::objs::tree::EntryKind::Link {
                    // Symlink entry: blob content is the target path.
                    let target = blob.data.as_slice().as_bstr().to_str().map_err(|_| {
                        GitError::BackendError {
                            message: format!("symlink target for '{path_str}' is not valid UTF-8"),
                        }
                    })?;
                    #[cfg(unix)]
                    {
                        std::os::unix::fs::symlink(target, &file_path).map_err(|e| {
                            GitError::BackendError {
                                message: format!(
                                    "failed to create symlink '{path_str}' -> '{target}': {e}"
                                ),
                            }
                        })?;
                    }
                    #[cfg(not(unix))]
                    {
                        // On non-Unix, fall back to writing the target as a plain file
                        // (same behavior as git on Windows without symlink support).
                        let mut file = std::fs::File::create(&file_path).map_err(|e| {
                            GitError::BackendError {
                                message: format!("failed to create file '{path_str}': {e}"),
                            }
                        })?;
                        file.write_all(blob.data.as_ref())
                            .map_err(|e| GitError::BackendError {
                                message: format!("failed to write file '{path_str}': {e}"),
                            })?;
                    }
                } else {
                    // Regular file (blob or executable blob).
                    let mut file =
                        std::fs::File::create(&file_path).map_err(|e| GitError::BackendError {
                            message: format!("failed to create file '{path_str}': {e}"),
                        })?;
                    file.write_all(blob.data.as_ref())
                        .map_err(|e| GitError::BackendError {
                            message: format!("failed to write file '{path_str}': {e}"),
                        })?;

                    #[cfg(unix)]
                    if entry_mode.kind() == gix::objs::tree::EntryKind::BlobExecutable {
                        use std::os::unix::fs::PermissionsExt;
                        let perms = std::fs::Permissions::from_mode(0o755);
                        std::fs::set_permissions(&file_path, perms).ok();
                    }
                }
            }
            gix::diff::tree::recorder::Change::Deletion {
                entry_mode, path, ..
            } => {
                if entry_mode.is_tree() {
                    continue;
                }
                let Ok(path_str) = path.to_str() else {
                    continue;
                };

                // Reject paths with .. components (path traversal protection).
                if path_str.split('/').any(|c| c == "..") {
                    return Err(GitError::BackendError {
                        message: format!("refusing path with '..' component: '{path_str}'"),
                    });
                }

                // Remove file (or symlink) from worktree.
                // Use symlink_metadata instead of exists() so dangling symlinks
                // are also detected and removed.
                let file_path = workdir.join(path_str);
                if std::fs::symlink_metadata(&file_path).is_ok() {
                    std::fs::remove_file(&file_path).map_err(|e| GitError::BackendError {
                        message: format!("failed to remove file '{path_str}': {e}"),
                    })?;
                }
            }
        }
    }

    // 6. Update the index to match the stash tree.
    let stash_index =
        repo.repo
            .index_from_tree(&stash_tree_oid)
            .map_err(|e| GitError::BackendError {
                message: format!("failed to create index from stash tree: {e}"),
            })?;

    // Write the stash index state to disk.
    let index_path = repo.repo.index_path();
    let mut index_file = gix::index::File::from_state(stash_index.into(), index_path);
    index_file
        .write(gix::index::write::Options::default())
        .map_err(|e| GitError::BackendError {
            message: format!("failed to write index: {e}"),
        })?;

    Ok(())
}

/// Materialize the current working tree (including untracked files) into a
/// commit, without modifying the index, the worktree, or any ref.
///
/// Equivalent to `git stash create`: snapshots HEAD with all working-tree
/// modifications and untracked files applied on top, then writes a commit
/// with `parents = [HEAD]` and the supplied message. Returns the new commit
/// OID, or `None` if there is nothing to capture (clean worktree).
///
/// # How
/// 1. Read HEAD tree.
/// 2. Run [`status`](crate::status_impl::status) to enumerate working-tree
///    changes (modified, deleted, added, untracked).
/// 3. Hash each modified/added/untracked file in the worktree and apply
///    [`TreeEdit::Upsert`] edits to the HEAD tree; apply [`TreeEdit::Remove`]
///    for each deleted entry.
/// 4. [`create_commit`](crate::objects_impl::create_commit) with the resulting
///    tree, `parents=[HEAD]`, and `message`.
///
/// # Limitations
/// - Symlinks: their target text is hashed as the blob (matches gix tree
///   semantics for mode `120000`).
/// - File modes: regular vs executable vs symlink is inferred from
///   `symlink_metadata` on Unix; on other platforms all non-symlink files
///   are treated as regular blobs.
/// - Like `git stash create`, the resulting commit is detached — it is not
///   reachable from any ref unless the caller writes one.
///
/// # Errors
/// Returns a `GitError` if HEAD cannot be resolved, the worktree cannot be
/// scanned, any blob cannot be hashed, or the commit cannot be written.
#[expect(
    clippy::too_many_lines,
    reason = "single pass capturing HEAD tree + status edits + commit"
)]
pub fn worktree_state_commit(repo: &GixRepo, message: &str) -> Result<Option<GitOid>, GitError> {
    // 1. Resolve HEAD; without a HEAD there is nothing meaningful to capture.
    let Some(head_oid) = crate::refs_impl::rev_parse_opt(repo, "HEAD")? else {
        return Ok(None);
    };

    // 2. Enumerate worktree changes vs HEAD (incl. staged + untracked).
    //    Must be HEAD→worktree, not index→worktree: a staged-but-not-
    //    re-edited fix would otherwise be dropped, promoting the unfixed
    //    tree (Prime Invariant: no staged work is ever lost).
    let status = crate::status_impl::status_head_to_worktree(repo)?;
    if status.is_empty() {
        return Ok(None);
    }

    // 3. Resolve HEAD's tree.
    let head_tree_oid = {
        let gix_head = gix::ObjectId::from_bytes_or_panic(head_oid.as_bytes());
        let obj = repo
            .repo
            .find_object(gix_head)
            .map_err(|e| GitError::NotFound {
                message: format!("HEAD object {head_oid}: {e}"),
            })?;
        match obj.kind {
            gix::object::Kind::Commit => {
                let commit = obj.into_commit();
                let tree_id = commit
                    .tree_id()
                    .map_err(|e| GitError::BackendError {
                        message: format!("failed to get HEAD tree: {e}"),
                    })?
                    .detach();
                from_gix_oid(tree_id)
            }
            gix::object::Kind::Tree => from_gix_oid(gix_head),
            other => {
                return Err(GitError::BackendError {
                    message: format!("HEAD points to unexpected kind: {other}"),
                });
            }
        }
    };

    let workdir = repo
        .workdir
        .as_ref()
        .ok_or_else(|| GitError::BackendError {
            message: "repository has no working directory".to_string(),
        })?
        .clone();

    // 4. Walk status entries; hash content for upserts.
    let mut edits: Vec<TreeEdit> = Vec::new();
    for entry in status {
        // Reject paths with .. components (path traversal protection).
        if entry.path.split('/').any(|c| c == "..") {
            return Err(GitError::BackendError {
                message: format!("refusing path with '..' component: '{}'", entry.path),
            });
        }
        match entry.status {
            FileStatus::Deleted => {
                edits.push(TreeEdit::Remove {
                    path: entry.path.clone(),
                });
            }
            FileStatus::Added
            | FileStatus::Modified
            | FileStatus::Renamed
            | FileStatus::Untracked => {
                let full = workdir.join(&entry.path);
                let Ok(meta) = std::fs::symlink_metadata(&full) else {
                    // File enumerated by status but missing on disk — treat
                    // as a deletion so the snapshot stays internally
                    // consistent.
                    edits.push(TreeEdit::Remove {
                        path: entry.path.clone(),
                    });
                    continue;
                };

                #[cfg(unix)]
                let is_executable = {
                    use std::os::unix::fs::PermissionsExt;
                    !meta.is_symlink() && (meta.permissions().mode() & 0o111) != 0
                };
                #[cfg(not(unix))]
                let is_executable = false;

                let (data, mode) = if meta.is_symlink() {
                    let target = std::fs::read_link(&full).map_err(|e| GitError::BackendError {
                        message: format!("read symlink '{}': {e}", entry.path),
                    })?;
                    // Hash the raw bytes of the symlink target. On Unix the
                    // target's OsString may legitimately contain non-UTF-8
                    // bytes; `to_string_lossy()` would replace them with
                    // U+FFFD and corrupt the blob (mismatching `git stash
                    // create`).
                    #[cfg(unix)]
                    let bytes = {
                        use std::os::unix::ffi::OsStrExt;
                        target.as_os_str().as_bytes().to_vec()
                    };
                    #[cfg(not(unix))]
                    let bytes = target.to_string_lossy().into_owned().into_bytes();
                    (bytes, EntryMode::Link)
                } else if meta.is_file() {
                    let bytes = std::fs::read(&full).map_err(|e| GitError::BackendError {
                        message: format!("read file '{}': {e}", entry.path),
                    })?;
                    let mode = if is_executable {
                        EntryMode::BlobExecutable
                    } else {
                        EntryMode::Blob
                    };
                    (bytes, mode)
                } else {
                    // Directory or other non-blob type — skip; gix's status
                    // pipeline only surfaces file-like entries, but guard
                    // anyway.
                    continue;
                };

                let blob_oid = crate::objects_impl::write_blob(repo, &data)?;
                edits.push(TreeEdit::Upsert {
                    path: entry.path.clone(),
                    mode,
                    oid: blob_oid,
                });
            }
        }
    }

    if edits.is_empty() {
        return Ok(None);
    }

    // 5. Apply edits to the HEAD tree and commit.
    let new_tree = crate::objects_impl::edit_tree(repo, head_tree_oid, &edits)?;
    let commit_oid =
        crate::objects_impl::create_commit(repo, new_tree, &[head_oid], message, None)?;
    Ok(Some(commit_oid))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::process::Command;

    /// Helper: init a git repo with an initial commit, return (tempdir, `GixRepo`).
    fn setup_repo() -> (tempfile::TempDir, GixRepo) {
        // bn-5rdz: shared init + seed-commit helper. The helper writes
        // README.md as its seed file; the tests below don't depend on the
        // specific seed-file name (they create their own .bones/events,
        // links, etc.) so swapping `init.txt` for `README.md` is safe.
        let (dir, root, _oid) = crate::test_support::init_test_repo_with_commit();
        let repo = GixRepo::open(&root).expect("test setup should succeed");
        (dir, repo)
    }

    /// Regression test: `stash_apply` must create OS symlinks for mode 120000 entries,
    /// not write the target path as regular file content.
    ///
    /// This was the root cause of the .bones/events shard corruption where a 1.6MB
    /// event log was overwritten with a 14-byte symlink target string.
    #[cfg(unix)]
    #[test]
    fn stash_apply_creates_symlinks_not_regular_files() {
        let (dir, repo) = setup_repo();
        let root = dir.path();

        // Add a real file and a symlink as dirty (unstaged) changes.
        std::fs::write(root.join("real-data.txt"), "important data\n")
            .expect("test setup should succeed");
        std::os::unix::fs::symlink("real-data.txt", root.join("current.txt"))
            .expect("test setup should succeed");

        // Stage so the stash captures them (stash reads from index).
        Command::new("git")
            .args(["add", "-A"])
            .current_dir(root)
            .output()
            .expect("test setup should succeed");

        // Create a stash from the dirty state.
        let stash_oid = stash_create(&repo)
            .expect("test setup should succeed")
            .expect("stash should not be empty");

        // Clean the worktree (remove the files we just added).
        std::fs::remove_file(root.join("current.txt")).expect("test setup should succeed");
        std::fs::remove_file(root.join("real-data.txt")).expect("test setup should succeed");

        // Reset index to HEAD.
        Command::new("git")
            .args(["reset", "HEAD", "--", "."])
            .current_dir(root)
            .output()
            .expect("test setup should succeed");

        assert!(!root.join("current.txt").exists());
        assert!(!root.join("real-data.txt").exists());

        // Apply the stash — this should recreate the symlink.
        stash_apply(&repo, stash_oid).expect("test setup should succeed");

        // Verify: current.txt should be a symlink, not a regular file.
        let meta =
            std::fs::symlink_metadata(root.join("current.txt")).expect("test setup should succeed");
        assert!(
            meta.is_symlink(),
            "current.txt should be a symlink, but is type {:?}",
            meta.file_type()
        );

        // Verify: symlink target is correct.
        let target =
            std::fs::read_link(root.join("current.txt")).expect("test setup should succeed");
        assert_eq!(
            target.to_str().expect("test setup should succeed"),
            "real-data.txt"
        );

        // Verify: real-data.txt is a regular file with correct content.
        let content =
            std::fs::read_to_string(root.join("real-data.txt")).expect("test setup should succeed");
        assert_eq!(content, "important data\n");
    }

    /// Regression test: writing a regular file where a symlink exists on disk
    /// must NOT follow the symlink. The symlink must be removed first.
    ///
    /// Without the fix, <File::create> follows the symlink and overwrites the
    /// target file with the new content.
    #[cfg(unix)]
    #[test]
    fn stash_apply_does_not_follow_existing_symlinks() {
        let (dir, repo) = setup_repo();
        let root = dir.path();

        // Set up: a regular file "data.txt" with important content.
        std::fs::write(root.join("data.txt"), "precious data that must survive\n")
            .expect("test setup should succeed");
        Command::new("git")
            .args(["add", "-A"])
            .current_dir(root)
            .output()
            .expect("test setup should succeed");
        Command::new("git")
            .args(["commit", "-m", "add data"])
            .current_dir(root)
            .output()
            .expect("test setup should succeed");

        // Now create a stash where "link.txt" is a regular file.
        std::fs::write(root.join("link.txt"), "regular content\n")
            .expect("test setup should succeed");
        Command::new("git")
            .args(["add", "-A"])
            .current_dir(root)
            .output()
            .expect("test setup should succeed");
        let stash_oid = stash_create(&repo)
            .expect("test setup should succeed")
            .expect("stash should not be empty");

        // But on disk, replace link.txt with a symlink pointing to data.txt.
        std::fs::remove_file(root.join("link.txt")).expect("test setup should succeed");
        std::os::unix::fs::symlink("data.txt", root.join("link.txt"))
            .expect("test setup should succeed");

        // Apply stash: should replace the symlink with a regular file,
        // NOT write "regular content" through the symlink into data.txt.
        stash_apply(&repo, stash_oid).expect("test setup should succeed");

        // Verify: data.txt must NOT be corrupted.
        let data =
            std::fs::read_to_string(root.join("data.txt")).expect("test setup should succeed");
        assert_eq!(
            data, "precious data that must survive\n",
            "data.txt was corrupted by symlink following"
        );

        // Verify: link.txt should now be a regular file.
        let meta =
            std::fs::symlink_metadata(root.join("link.txt")).expect("test setup should succeed");
        assert!(
            meta.is_file() && !meta.is_symlink(),
            "link.txt should be a regular file, not a symlink"
        );
        let content =
            std::fs::read_to_string(root.join("link.txt")).expect("test setup should succeed");
        assert_eq!(content, "regular content\n");
    }
}
