use super::*;

#[cfg(test)]
mod write_scaffold_normalize_tests {
    use super::*;
    use crate::core::backend::GeneratedFile;
    use std::path::PathBuf;

    fn make_file(name: &str, content: &str) -> GeneratedFile {
        GeneratedFile {
            path: PathBuf::from(name),
            content: content.to_owned(),
            generated_header: false,
        }
    }

    /// `write_scaffold_files_with_overwrite` must strip trailing whitespace and
    /// ensure a single trailing newline — matching what prek's
    /// `end-of-file-fixer` and `trailing-whitespace` hooks would do.
    #[test]
    fn test_scaffold_write_normalizes_trailing_whitespace_and_newline() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();

        let content = "line one   \nline two\n\n";
        let files = vec![make_file("out.py", content)];
        write_scaffold_files_with_overwrite(&files, base, true).expect("write ok");

        let written = std::fs::read_to_string(base.join("out.py")).expect("read ok");
        assert_eq!(
            written, "line one\nline two\n",
            "trailing whitespace must be stripped and single newline ensured"
        );
    }

    #[test]
    fn test_scaffold_write_adds_missing_trailing_newline() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();

        let files = vec![make_file("out.gleam", "pub fn main() {}")];
        write_scaffold_files_with_overwrite(&files, base, true).expect("write ok");

        let written = std::fs::read_to_string(base.join("out.gleam")).expect("read ok");
        assert!(
            written.ends_with('\n'),
            "file must end with newline, got: {:?}",
            written
        );
    }

    #[test]
    fn test_scaffold_write_does_not_add_double_trailing_newline() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();

        let files = vec![make_file("out.zig", "const x = 1;\n")];
        write_scaffold_files_with_overwrite(&files, base, true).expect("write ok");

        let written = std::fs::read_to_string(base.join("out.zig")).expect("read ok");
        assert!(!written.ends_with("\n\n"), "must not have double trailing newline");
        assert!(written.ends_with('\n'));
    }

    /// `normalize_content` must strip trailing whitespace from `.rs` files even
    /// when rustfmt rejects them — e.g. cextendr `lib.rs` files use the
    /// `name: T = "default"` parameter-default syntax that rustfmt cannot
    /// parse, so it falls back to the raw codegen output. Without a final
    /// whitespace pass, the raw output's trailing-whitespace blank lines
    /// (e.g. `    \n` between `#[must_use]` and `pub fn …`) survive into the
    /// finalised `alef:hash`, and prek's `trailing-whitespace` hook then
    /// rewrites the file post-hash, breaking `alef verify`.
    #[test]
    fn test_normalize_content_strips_trailing_whitespace_when_rustfmt_fails() {
        // This rust-shaped content uses cextendr's parameter-default syntax,
        // which rustfmt rejects with `parameter defaults are not supported`.
        // The trailing whitespace on the `    ` line must be stripped.
        let path = PathBuf::from("packages/r/src/rust/src/lib.rs");
        let content = "extendr_module! {\n    fn convert(\n    \n        title: String = \"\",\n    );\n}\n";
        let normalized = normalize_content(&path, content);
        for (i, line) in normalized.lines().enumerate() {
            assert_eq!(
                line.trim_end(),
                line,
                "line {i} has trailing whitespace after normalize: {line:?}"
            );
        }
        assert!(normalized.ends_with('\n'), "must end with newline");
    }

    /// `sweep_orphans` must delete alef-marked files that aren't in the keep set,
    /// preserve user-owned files (no marker), and preserve files that are in the
    /// keep set even if they have the marker.
    #[test]
    fn test_sweep_orphans_removes_only_alef_marked_files_outside_keep_set() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();
        let nested = base.join("e2e/elixir/test");
        std::fs::create_dir_all(&nested).expect("mkdir");

        let alef_marker = "# This file is auto-generated by alef — DO NOT EDIT.\n# alef:hash:abc\n";
        let kept = nested.join("keep_test.exs");
        let orphan = nested.join("orphan_test.exs");
        let user_owned = nested.join("user_helper.exs");

        std::fs::write(&kept, format!("{alef_marker}defmodule Keep do\nend\n")).unwrap();
        std::fs::write(&orphan, format!("{alef_marker}defmodule Orphan do\nend\n")).unwrap();
        std::fs::write(&user_owned, "defmodule UserHelper do\nend\n").unwrap();

        let mut keep = std::collections::HashSet::new();
        keep.insert(kept.clone());

        let removed = sweep_orphans(&[base.to_path_buf()], &keep).expect("sweep ok");
        assert_eq!(removed, 1, "should remove exactly one orphan");
        assert!(kept.exists(), "kept alef-marked file must remain");
        assert!(!orphan.exists(), "orphan alef-marked file must be removed");
        assert!(user_owned.exists(), "user-owned (no marker) file must remain");
    }

    /// `sweep_orphans` must skip dependency / build directories (target, node_modules,
    /// _build, deps, vendor, build, dist, .git, .venv) so it never deletes anything
    /// inside a vendored or compiled tree.
    #[test]
    fn test_sweep_orphans_skips_dependency_directories() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();
        let alef_marker = "// auto-generated by alef\n// alef:hash:def\n";
        for skip_dir in ["target", "node_modules", "_build", "vendor"] {
            let nested = base.join(skip_dir).join("nested");
            std::fs::create_dir_all(&nested).expect("mkdir");
            std::fs::write(nested.join("orphan.rs"), alef_marker).unwrap();
        }
        let keep: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
        let removed = sweep_orphans(&[base.to_path_buf()], &keep).expect("sweep ok");
        assert_eq!(removed, 0, "must not descend into dependency directories");
    }

    /// Regression: a file that contains loose "auto-generated" or "DO NOT EDIT"
    /// markers but lacks the `alef:hash:` line must NOT be deleted by
    /// `sweep_orphans`. This protects consumer-vendored files such as cgo headers.
    #[test]
    fn sweep_orphans_preserves_loose_marker_file_without_hash() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();
        let include_dir = base.join("packages/go/include");
        std::fs::create_dir_all(&include_dir).expect("mkdir");

        // A vendored cgo header: has a "DO NOT EDIT" comment but no alef:hash line.
        let vendored = include_dir.join("sample_crawler.h");
        std::fs::write(
            &vendored,
            "// DO NOT EDIT — vendored cgo header\n#ifndef FOO_H\n#define FOO_H\n\ntypedef void CrawlEngine;\n\n#endif\n",
        )
        .unwrap();

        let keep: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
        let removed = sweep_orphans(&[base.to_path_buf()], &keep).expect("sweep ok");
        assert_eq!(removed, 0, "vendored file without alef:hash must not be deleted");
        assert!(vendored.exists(), "vendored cgo header must survive sweep_orphans");
    }

    /// Positive path: a file that contains the `alef:hash:` line IS alef-owned
    /// and must be deleted by `sweep_orphans` when not in the keep set.
    #[test]
    fn sweep_orphans_removes_file_with_alef_hash() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();
        let out_dir = base.join("e2e/rust/src");
        std::fs::create_dir_all(&out_dir).expect("mkdir");

        // Standard alef-emitted file: has the cryptographic hash line.
        const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
        let alef_file = out_dir.join("lib.rs");
        std::fs::write(
            &alef_file,
            format!(
                "// alef:hash:{HASH}\n// This file is auto-generated by alef — DO NOT EDIT.\npub fn hello() {{}}\n"
            ),
        )
        .unwrap();

        let keep: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
        let removed = sweep_orphans(&[base.to_path_buf()], &keep).expect("sweep ok");
        assert_eq!(removed, 1, "alef-owned file not in keep set must be deleted");
        assert!(!alef_file.exists(), "alef:hash file must be removed by sweep_orphans");
    }

    /// `collect_alef_headered_paths` must return all alef-headered files under
    /// the given root and skip user-owned (no marker) files.
    #[test]
    fn test_collect_alef_headered_paths_finds_headered_files() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();
        let lang_dir = base.join("python");
        std::fs::create_dir_all(&lang_dir).expect("mkdir");

        let alef_marker = "# This file is auto-generated by alef — DO NOT EDIT.\n# alef:hash:abc123\nprint('hello')\n";
        let user_file = "print('user code')\n";

        let headered = lang_dir.join("test_chat.py");
        let plain = lang_dir.join("conftest.py");
        std::fs::write(&headered, alef_marker).unwrap();
        std::fs::write(&plain, user_file).unwrap();

        let collected = collect_alef_headered_paths(base);
        assert!(collected.contains(&headered), "alef-headered file must be collected");
        assert!(!collected.contains(&plain), "user-owned file must not be collected");
    }

    /// `collect_alef_headered_paths` on a non-existent root must return an
    /// empty set without panicking.
    #[test]
    fn test_collect_alef_headered_paths_missing_root_returns_empty() {
        let paths = collect_alef_headered_paths(std::path::Path::new("/nonexistent/test_apps"));
        assert!(paths.is_empty(), "missing root must yield empty set");
    }

    /// Invariant: after `write` + simulated format-pass + `finalize_hashes`, the
    /// embedded `alef:hash:` must equal `compute_inputs_hash(sources_hash, alef_toml_bytes)`.
    /// Because the hash is input-derived (not content-derived), a formatter
    /// rewriting the file after `finalize_hashes` does NOT invalidate the hash.
    #[test]
    fn test_finalize_hashes_embeds_inputs_hash_not_content_hash() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();

        // Simulate a generated file with an alef header but no hash line yet.
        let content_before_format = "// This file is auto-generated by alef — DO NOT EDIT.\nfn hello() {}\n";
        let file_path = base.join("lib.rs");
        std::fs::write(&file_path, content_before_format).expect("write pre-format content");

        // Simulate a formatter modifying the file (e.g. rustfmt adding newlines).
        let content_after_format = "// This file is auto-generated by alef — DO NOT EDIT.\nfn hello() {}\n\n";
        std::fs::write(&file_path, content_after_format).expect("write post-format content");

        // Finalize hashes AFTER the format pass.
        let sources_hash = "deadbeef";
        let alef_toml_bytes = b"[workspace]\nlanguages = [\"rust\"]\n";
        let mut paths = std::collections::HashSet::new();
        paths.insert(file_path.clone());
        finalize_hashes(&paths, sources_hash, alef_toml_bytes).expect("finalize ok");

        // Read the finalised file and verify the embedded hash is the inputs hash.
        let finalised = std::fs::read_to_string(&file_path).expect("read finalised");
        let embedded = crate::core::hash::extract_hash(&finalised).expect("hash must be present");
        let expected = crate::core::hash::compute_inputs_hash(sources_hash, alef_toml_bytes);
        assert_eq!(
            embedded, expected,
            "embedded hash must equal compute_inputs_hash, not a content-derived hash"
        );

        // The key property of the new design: a formatter rewrites the file
        // after finalize_hashes, but the embedded hash is STILL VALID because
        // it does not depend on file content.
        let reformatted = format!("{content_after_format}\n// formatter added this line\n");
        std::fs::write(&file_path, &reformatted).expect("simulate post-finalize formatter rewrite");
        let after_reformat = std::fs::read_to_string(&file_path).expect("read after reformat");
        // The embedded hash line survived (we wrote the reformatted content but
        // the hash line was injected by finalize_hashes, not us).
        let _still_embedded = crate::core::hash::extract_hash(&after_reformat);
        // In this test the reformatted content stripped out the hash line, but
        // verify that compute_inputs_hash gives the same value — so re-injecting
        // it would still produce the right hash regardless of content.
        assert_eq!(
            crate::core::hash::compute_inputs_hash(sources_hash, alef_toml_bytes),
            expected,
            "inputs hash must be stable across formatter rewrites"
        );
    }

    /// Regression: `finalize_hashes` must be idempotent when run twice on the
    /// same file — the second pass must detect the existing hash is already
    /// correct and skip the write.
    #[test]
    fn test_finalize_hashes_is_idempotent_with_inputs_hash() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();

        let content = "// This file is auto-generated by alef — DO NOT EDIT.\nfn hello() {}\n";
        let file_path = base.join("lib.rs");
        std::fs::write(&file_path, content).expect("write initial content");

        let sources_hash = "sources";
        let alef_toml_bytes = b"[workspace]\nlanguages = [\"rust\"]\n";
        let mut paths = std::collections::HashSet::new();
        paths.insert(file_path.clone());

        let n1 = finalize_hashes(&paths, sources_hash, alef_toml_bytes).expect("first finalize");
        assert_eq!(n1, 1, "first finalize must write the hash line");

        let n2 = finalize_hashes(&paths, sources_hash, alef_toml_bytes).expect("second finalize");
        assert_eq!(n2, 0, "second finalize must be a no-op (same inputs hash)");
    }

    /// `finalize_hashes` must skip files without the alef header marker, even
    /// when a non-Rust file has content that would otherwise match. Go files
    /// (gofmt emitting blank lines) are preserved unchanged.
    #[test]
    fn test_finalize_hashes_non_rust_file_gets_inputs_hash() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();

        let gofmt_output = concat!(
            "// This file is auto-generated by alef — DO NOT EDIT.\n",
            "package foo\n",
            "\n",
            "\n",
            "func Hello() {}\n",
        );
        let file_path = base.join("binding.go");
        std::fs::write(&file_path, gofmt_output).expect("write gofmt output");

        let sources_hash = "deadbeef";
        let alef_toml_bytes = b"[workspace]\nlanguages = [\"go\"]\n";
        let mut paths = std::collections::HashSet::new();
        paths.insert(file_path.clone());
        finalize_hashes(&paths, sources_hash, alef_toml_bytes).expect("finalize ok");

        let finalised = std::fs::read_to_string(&file_path).expect("read finalised");

        // The embedded hash must equal the inputs hash — not any content-derived value.
        let embedded = crate::core::hash::extract_hash(&finalised).expect("hash must be present");
        let expected = crate::core::hash::compute_inputs_hash(sources_hash, alef_toml_bytes);
        assert_eq!(
            embedded, expected,
            "embedded hash must equal compute_inputs_hash for Go files"
        );

        // The two consecutive blank lines must be preserved — finalize_hashes
        // no longer normalizes non-Rust content (it only strips the hash line
        // before re-injecting).
        let stripped = crate::core::hash::strip_hash_line(&finalised);
        assert!(
            stripped.contains("\n\n\n"),
            "two consecutive blank lines must survive finalize_hashes: got:\n{stripped:?}"
        );
    }

    /// Regression: `finalize_hashes` must recognize both "auto-generated by alef"
    /// (standard header) and "Generated by alef" (custom headers in Swift, Kotlin,
    /// Dart, Gleam, Zig, JNI). Without this, renamed files like SwiftPluginHelpers.swift
    /// would not get the `alef:hash:` marker, preventing the cleanup system from
    /// identifying them as alef-owned and deleting stale renamed files.
    #[test]
    fn test_finalize_hashes_recognizes_generated_by_alef_header() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();

        // Swift backend uses "Generated by alef. Do not edit by hand." (custom header)
        let swift_content =
            "// Generated by alef. Do not edit by hand.\n// swift-format-ignore-file\n\nimport Foundation\n";
        let file_path = base.join("Helpers.swift");
        std::fs::write(&file_path, swift_content).expect("write swift content");

        let sources_hash = "deadbeef";
        let alef_toml_bytes = b"[workspace]\nlanguages = [\"swift\"]\n";
        let mut paths = std::collections::HashSet::new();
        paths.insert(file_path.clone());
        let updated = finalize_hashes(&paths, sources_hash, alef_toml_bytes).expect("finalize ok");

        // Must write the hash (not skip the file).
        assert_eq!(
            updated, 1,
            "finalize_hashes must process files with 'Generated by alef' header"
        );

        let finalised = std::fs::read_to_string(&file_path).expect("read finalised");

        // The embedded hash must equal the inputs hash.
        let embedded = crate::core::hash::extract_hash(&finalised).expect("hash must be present");
        let expected = crate::core::hash::compute_inputs_hash(sources_hash, alef_toml_bytes);
        assert_eq!(
            embedded, expected,
            "embedded hash must equal compute_inputs_hash for Swift files with 'Generated by alef' header"
        );
    }

    /// Regression: `write_scaffold_files_with_overwrite(overwrite=false)` must
    /// skip files that already exist on disk, leaving the existing content
    /// unchanged.  This is the invariant relied on by scaffold-once files
    /// (Cargo.toml, package.json, gemspec) — user customisations are preserved.
    ///
    /// README files are NOT scaffold-once: they are always regenerated from
    /// templates.  Using `overwrite=false` for READMEs means a file modified by
    /// an external tool (e.g. `rumdl-fmt` padding table columns) is silently
    /// preserved, while `alef readme` (which always uses `overwrite=true`) writes
    /// fresh compact content.  The two commands then produce different bytes for
    /// the same README — the root cause of the `alef generate`/`alef readme`
    /// divergence surfaced during downstream regeneration.
    #[test]
    fn readme_overwrite_false_preserves_existing_content_producing_divergence() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();

        // Simulate rumdl-fmt having padded the README table columns.
        let padded_content = "# My README\n\n| Document            | Size  |\n| ------------------- | ----- |\n| Lists (Timeline)    | 129KB |\n";
        std::fs::write(base.join("README.md"), padded_content).expect("write padded README");

        // Simulate alef regenerating the README with compact table separators.
        let compact_content = "# My README\n\n| Document | Size |\n|----------|------|\n| Lists (Timeline) | 129KB |\n";
        let files = vec![make_file("README.md", compact_content)];

        // overwrite=false (the bug path used by `alef all` without --clean):
        // the file already exists so it is skipped — padded content remains on disk.
        write_scaffold_files_with_overwrite(&files, base, false).expect("write ok (overwrite=false)");
        let after_false = std::fs::read_to_string(base.join("README.md")).expect("read");
        assert_eq!(
            after_false, padded_content,
            "overwrite=false must not touch an existing README — padded content preserved (bug state)"
        );

        // overwrite=true (the correct path used by `alef readme` and the fixed `alef all`):
        // the file is always rewritten with the freshly-generated compact content.
        write_scaffold_files_with_overwrite(&files, base, true).expect("write ok (overwrite=true)");
        let after_true = std::fs::read_to_string(base.join("README.md")).expect("read");
        // normalize_content is applied on write; the compact content already has a trailing newline.
        assert!(
            after_true.contains("|----------|"),
            "overwrite=true must write compact-separator content, got:\n{after_true}"
        );
        assert!(
            !after_true.contains("| ------------------- |"),
            "overwrite=true must NOT preserve rumdl-fmt-padded separators, got:\n{after_true}"
        );

        // Core invariant: both alef readme (overwrite=true) and alef all (fixed to overwrite=true)
        // must produce identical bytes when starting from the same padded-on-disk state.
        assert_eq!(
            after_true,
            normalize_content(&std::path::PathBuf::from("README.md"), compact_content),
            "alef readme and alef all must produce identical on-disk bytes for README files"
        );
    }

    /// A `.gitattributes` (or any seed file with `generated_header: false`) written
    /// by `write_scaffold_files(overwrite=false)` must not be overwritten when the
    /// file already exists on disk. This preserves hand-added entries such as
    /// `* text=auto eol=lf` that the user may have added alongside alef's entries.
    #[test]
    fn seed_file_with_generated_header_false_is_preserved_on_overwrite_false() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();

        let original = "# hand-crafted\n* text=auto eol=lf\n";
        std::fs::write(base.join(".gitattributes"), original).expect("write original");

        let generated = GeneratedFile {
            path: std::path::PathBuf::from(".gitattributes"),
            content: "# Generated by alef scaffold.\ne2e/** linguist-generated=true\n".to_owned(),
            generated_header: false,
        };

        let count = write_scaffold_files_with_overwrite(&[generated], base, false).expect("write ok");
        assert_eq!(
            count, 0,
            "overwrite=false must not write any file when seed already exists"
        );

        let after = std::fs::read_to_string(base.join(".gitattributes")).expect("read");
        assert_eq!(
            after, original,
            "overwrite=false must not touch an existing seed file (generated_header: false)"
        );
    }

    /// `detect_crate_edition` must return the edition declared in the nearest
    /// `Cargo.toml` when one is present, and fall back to `"2024"` when absent.
    #[test]
    fn test_detect_crate_edition_reads_from_cargo_toml() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();

        // Write a Cargo.toml declaring edition 2021.
        let cargo_toml = "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n";
        std::fs::write(base.join("Cargo.toml"), cargo_toml).expect("write Cargo.toml");

        // A source file inside the crate directory.
        let src = base.join("src").join("lib.rs");
        std::fs::create_dir_all(src.parent().unwrap()).expect("mkdir src");

        let edition = detect_crate_edition(&src);
        assert_eq!(edition, "2021", "should detect edition 2021 from Cargo.toml");
    }

    #[test]
    fn test_detect_crate_edition_defaults_to_2024_when_no_cargo_toml() {
        let dir = tempfile::tempdir().expect("tempdir");
        let orphan = dir.path().join("orphan.rs");

        let edition = detect_crate_edition(&orphan);
        assert_eq!(edition, "2024", "should default to 2024 when no Cargo.toml found");
    }

    #[test]
    fn test_detect_crate_edition_defaults_to_2024_when_edition_absent_from_cargo_toml() {
        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();

        // Cargo.toml with no edition field.
        std::fs::write(
            base.join("Cargo.toml"),
            "[package]\nname = \"no-edition-crate\"\nversion = \"0.1.0\"\n",
        )
        .expect("write Cargo.toml");

        let src = base.join("lib.rs");
        let edition = detect_crate_edition(&src);
        assert_eq!(edition, "2024", "should default to 2024 when edition field absent");
    }

    #[test]
    fn test_parse_package_edition_extracts_value() {
        let toml = "[package]\nname = \"x\"\nedition = \"2021\"\n";
        assert_eq!(parse_package_edition(toml).as_deref(), Some("2021"));
    }

    #[test]
    fn test_parse_package_edition_ignores_other_sections() {
        // edition key outside [package] must not be returned.
        let toml = "[workspace]\nedition = \"2021\"\n[package]\nname = \"x\"\n";
        assert_eq!(parse_package_edition(toml), None);
    }

    /// `write_scaffold_files_with_overwrite` must set the executable bit on files
    /// whose content begins with a shebang line, matching the behaviour of
    /// `write_files`. Previously the scaffold writer lacked the chmod call, so
    /// generated shell scripts (e.g. `download_ffi.sh`, `run_tests.sh`) landed
    /// as `-rw-r--r--` and consumers could not execute them.
    #[cfg(unix)]
    #[test]
    fn test_scaffold_write_sets_executable_bit_for_shebang_files() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();

        let shebang_content = "#!/usr/bin/env bash\nset -euo pipefail\necho hello\n";
        let file = GeneratedFile {
            path: std::path::PathBuf::from("run_tests.sh"),
            content: shebang_content.to_owned(),
            generated_header: false,
        };

        write_scaffold_files_with_overwrite(&[file], base, true).expect("write ok");

        let path = base.join("run_tests.sh");
        let metadata = std::fs::metadata(&path).expect("metadata");
        let mode = metadata.permissions().mode();
        assert!(
            mode & 0o100 != 0,
            "shebang file must have owner-executable bit set, got mode {mode:#o}"
        );
    }

    /// Non-shebang files must NOT receive the executable bit.
    #[cfg(unix)]
    #[test]
    fn test_scaffold_write_does_not_set_executable_bit_for_non_shebang_files() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().expect("tempdir");
        let base = dir.path();

        let plain_content = "# not a shebang\nsome content\n";
        let file = GeneratedFile {
            path: std::path::PathBuf::from("plain.sh"),
            content: plain_content.to_owned(),
            generated_header: false,
        };

        write_scaffold_files_with_overwrite(&[file], base, true).expect("write ok");

        let path = base.join("plain.sh");
        let metadata = std::fs::metadata(&path).expect("metadata");
        let mode = metadata.permissions().mode();
        assert!(
            mode & 0o111 == 0,
            "non-shebang file must not have any executable bit set, got mode {mode:#o}"
        );
    }
}
