use biome_rowan::TextSize;
use oak_package_metadata::namespace::Import;
use oak_package_metadata::namespace::Namespace;
use salsa::Setter;

use crate::file_imports::CollationView;
use crate::tests::test_db::file_path;
use crate::tests::test_db::library_root;
use crate::tests::test_db::make_package;
use crate::tests::test_db::workspace_root;
use crate::tests::test_db::TestDb;
use crate::DbInputs;
use crate::File;
use crate::FileRevision;
use crate::ImportLayer;
use crate::Package;

/// Create a library root containing one installed package named `name`.
/// Returns the package and the root, both already wired up: the root's
/// `packages` is set to `[pkg]`. Callers register the returned roots on
/// `LibraryRoots` to make `package_by_name` see them.
fn make_installed(db: &mut TestDb, name: &str) -> (crate::Root, Package) {
    let root = library_root(db, &format!("libs/{name}"));
    let pkg = Package::new(
        db,
        file_path(&format!("libs/{name}/DESCRIPTION")),
        name.to_string(),
        FileRevision::zero(),
        FileRevision::zero(),
        None,
        None,
        Vec::new(),
        Vec::new(),
    );
    root.set_packages(db).to(vec![pkg]);
    (root, pkg)
}

/// Register a set of installed packages on `LibraryRoots`. Replaces any
/// previously registered library roots.
pub(super) fn install_packages(db: &mut TestDb, names: &[&str]) -> Vec<Package> {
    let mut roots = Vec::new();
    let mut packages = Vec::new();
    for &name in names {
        let (root, pkg) = make_installed(db, name);
        roots.push(root);
        packages.push(pkg);
    }
    db.library_roots().set_roots(db).to(roots);
    packages
}

#[test]
fn test_script_with_no_attaches_returns_only_default_search_path() {
    let mut db = TestDb::new();
    install_packages(&mut db, &[
        "stats",
        "graphics",
        "grDevices",
        "utils",
        "datasets",
        "methods",
        "base",
    ]);

    let file = File::new(
        &db,
        file_path("a.R"),
        FileRevision::zero(),
        Some("x <- 1\n".to_string()),
        None,
    );

    // The whole default search path shows up as `Package` layers in R's
    // `search()` order (`stats` first, `base` last).
    assert_eq!(shape(&db, file.imports(&db)), vec![
        "Package(stats)".to_string(),
        "Package(graphics)".to_string(),
        "Package(grDevices)".to_string(),
        "Package(utils)".to_string(),
        "Package(datasets)".to_string(),
        "Package(methods)".to_string(),
        "Package(base)".to_string(),
    ]);
}

#[test]
fn test_script_attach_produces_package_exports_layer_in_lifo_order() {
    let mut db = TestDb::new();
    // Only the attached packages are installed, so the default search path
    // drops out and the assertion sees just the two `library()` calls.
    install_packages(&mut db, &["dplyr", "ggplot2"]);

    let file = File::new(
        &db,
        file_path("a.R"),
        FileRevision::zero(),
        Some("library(dplyr)\nlibrary(ggplot2)\n".to_string()),
        None,
    );

    // LIFO: latest `library()` call comes first (matching R's runtime search
    // order).
    assert_eq!(shape(&db, file.imports(&db)), vec![
        "Package(ggplot2)".to_string(),
        "Package(dplyr)".to_string(),
    ]);
}

#[test]
fn test_script_attach_to_unregistered_package_drops_layer() {
    let db = TestDb::new();
    // No `dplyr` (or default search path) in any root, so nothing has a
    // `Package` entity and every layer drops out.
    let file = File::new(
        &db,
        file_path("a.R"),
        FileRevision::zero(),
        Some("library(dplyr)\n".to_string()),
        None,
    );

    assert!(file.imports(&db).is_empty());
}

#[test]
fn test_package_file_emits_namespace_and_collation_layers() {
    let mut db = TestDb::new();
    let installed = install_packages(&mut db, &["rlang", "base"]);
    let rlang = installed[0];

    let namespace = Namespace {
        imports: vec![Import {
            name: "abort".to_string(),
            package: "rlang".to_string(),
        }],
        package_imports: vec!["rlang".to_string()],
        ..Default::default()
    };

    // Build a workspace package with two R files. `Package.files`
    // holds them in declaration order; `package_layers` walks that
    // order to emit `File` layers.
    let workspace = workspace_root(&db, "w");
    let pkg = Package::new(
        &db,
        file_path("w/pkg/DESCRIPTION"),
        "pkg".to_string(),
        FileRevision::zero(),
        FileRevision::zero(),
        None,
        Some(namespace),
        Vec::new(),
        Vec::new(),
    );
    let first = File::new(
        &db,
        file_path("w/pkg/R/_a.R"),
        FileRevision::zero(),
        Some("first <- 1\n".to_string()),
        Some(pkg),
    );
    let second = File::new(
        &db,
        file_path("w/pkg/R/b.R"),
        FileRevision::zero(),
        Some("second <- 2\n".to_string()),
        Some(pkg),
    );
    pkg.set_files(&mut db).to(vec![first, second]);
    workspace.set_packages(&mut db).to(vec![pkg]);
    db.workspace_roots().set_roots(&mut db).to(vec![workspace]);

    let layers = second.imports(&db);

    let mut shape = Vec::new();
    for layer in layers {
        match layer {
            ImportLayer::From(package) => {
                let mut entries: Vec<(String, String)> = package
                    .imported_from(&db)
                    .iter()
                    .map(|(k, v)| (k.clone(), v.clone()))
                    .collect();
                entries.sort();
                shape.push(format!("From({entries:?})"));
            },
            ImportLayer::Package(p) => {
                shape.push(format!("Package({})", p.name(&db)));
            },
            ImportLayer::File(file) | ImportLayer::SourcingFile { file, .. } => {
                let url = file.path(&db).to_url();
                shape.push(format!(
                    "File({})",
                    url.path().rsplit('/').next().unwrap_or("?")
                ));
            },
        }
    }

    let _ = rlang;
    assert_eq!(shape, vec![
        // Collation files first (R's package namespace looks at the
        // package's own bindings before its imports). Self (b.R) is
        // excluded: a file's own top-level bindings live in `exports`,
        // not `imports`.
        "File(_a.R)".to_string(),
        "From([(\"abort\", \"rlang\")])".to_string(),
        "Package(rlang)".to_string(),
        "Package(base)".to_string(),
    ]);
}

#[test]
fn test_package_script_resolves_as_standalone_script() {
    // A `data-raw/` file carries a package back-pointer but lives in
    // `scripts`, not `files`. It isn't loaded with the package, so its
    // imports must be the standalone-script view, never the package view
    // (no `R/` `File` layers, no namespace layers). See #1270.
    let mut db = TestDb::new();
    let installed = install_packages(&mut db, &["dplyr", "base"]);
    let dplyr = installed[0];

    let workspace = workspace_root(&db, "w");
    let pkg = Package::new(
        &db,
        file_path("w/pkg/DESCRIPTION"),
        "pkg".to_string(),
        FileRevision::zero(),
        FileRevision::zero(),
        None,
        None,
        Vec::new(),
        Vec::new(),
    );
    let r_file = File::new(
        &db,
        file_path("w/pkg/R/a.R"),
        FileRevision::zero(),
        Some("internal <- 1\n".to_string()),
        Some(pkg),
    );
    let data_raw = File::new(
        &db,
        file_path("w/pkg/data-raw/prep.R"),
        FileRevision::zero(),
        Some("library(dplyr)\n".to_string()),
        Some(pkg),
    );
    pkg.set_files(&mut db).to(vec![r_file]);
    pkg.set_scripts(&mut db).to(vec![data_raw]);
    workspace.set_packages(&mut db).to(vec![pkg]);
    db.workspace_roots().set_roots(&mut db).to(vec![workspace]);

    let _ = dplyr;
    // Its own `library(dplyr)` on top of the default search path (only `base`
    // installed here). `R/a.R` and the namespace are absent because the script
    // isn't part of the package's namespace.
    assert_eq!(shape(&db, data_raw.imports(&db)), vec![
        "Package(dplyr)".to_string(),
        "Package(base)".to_string(),
    ]);
}

#[test]
fn test_package_file_includes_predecessor_attaches() {
    // A collation predecessor's own `library(dep)` puts dep on the search path
    // for files loaded after it, so `b.R`'s imports carry it as a `Package`
    // layer, below the predecessor's `File` layer and above `base`.
    let mut db = TestDb::new();
    install_packages(&mut db, &["dep", "base"]);
    let workspace = workspace_root(&db, "w");
    let pkg = Package::new(
        &db,
        file_path("w/pkg/DESCRIPTION"),
        "pkg".to_string(),
        FileRevision::zero(),
        FileRevision::zero(),
        None,
        Some(Namespace::default()),
        Vec::new(),
        Vec::new(),
    );
    let a = File::new(
        &db,
        file_path("w/pkg/R/a.R"),
        FileRevision::zero(),
        Some("library(dep)\n".to_string()),
        Some(pkg),
    );
    let b = File::new(
        &db,
        file_path("w/pkg/R/b.R"),
        FileRevision::zero(),
        Some("x <- 1\n".to_string()),
        Some(pkg),
    );
    pkg.set_files(&mut db).to(vec![a, b]);
    workspace.set_packages(&mut db).to(vec![pkg]);
    db.workspace_roots().set_roots(&mut db).to(vec![workspace]);

    assert_eq!(shape(&db, b.imports(&db)), vec![
        "File(a.R)".to_string(),
        "Package(dep)".to_string(),
        "Package(base)".to_string(),
    ]);
}

/// Render `ImportLayer`s to a stable, assertable shape. `File` layers
/// collapse to their basename.
pub(super) fn shape(db: &TestDb, layers: &[ImportLayer]) -> Vec<String> {
    layers
        .iter()
        .map(|layer| match layer {
            ImportLayer::From(package) => {
                let mut entries: Vec<(String, String)> = package
                    .imported_from(db)
                    .iter()
                    .map(|(k, v)| (k.clone(), v.clone()))
                    .collect();
                entries.sort();
                format!("From({entries:?})")
            },
            ImportLayer::Package(p) => format!("Package({})", p.name(db)),
            ImportLayer::File(file) | ImportLayer::SourcingFile { file, .. } => {
                let url = file.path(db).to_url();
                format!("File({})", url.path().rsplit('/').next().unwrap_or("?"))
            },
        })
        .collect()
}

#[test]
fn test_imports_is_cached_per_file() {
    let mut db = TestDb::new();
    let _ = install_packages(&mut db, &["dplyr"]);

    let file = File::new(
        &db,
        file_path("a.R"),
        FileRevision::zero(),
        Some("library(dplyr)\n".to_string()),
        None,
    );
    let _ = file.imports(&db);
    let _ = file.imports(&db);

    assert_eq!(db.executions("imports"), 1);
}

#[test]
fn test_cross_file_layers_cascade_stays_shallow() {
    // Building a late-collation file resolves its effects, which reaches back
    // through every predecessor's index. Without the forward-prime in
    // `predecessor_attach_layers` that walk recurses as deep as the collation,
    // which is what overflowed the stack in the IDE. Assert the nesting stays
    // flat regardless of collation length.
    //
    // We assert on recursion depth, not "does it crash", because
    // `stacker::maybe_grow` in `build_semantic_index` grows the stack and would
    // hide a regression from a crash-based check. Depth is independent of it.
    let mut db = TestDb::new();

    const N: usize = 150;
    let owned: Vec<(String, String)> = (0..N)
        .map(|i| (format!("w/pkg/R/f{i:04}.R"), "local(1)\n".to_string()))
        .collect();
    let files: Vec<(&str, &str)> = owned
        .iter()
        .map(|(path, contents)| (path.as_str(), contents.as_str()))
        .collect();
    let (_pkg, entities) = make_package(&mut db, "pkg", Namespace::default(), &files);

    crate::file::recursion_depth::reset();

    // Cold build of the last collation file: its effect resolution demands
    // every predecessor's index. Forward-priming keeps the nesting shallow.
    let _ = entities[N - 1].semantic_index(&db);

    assert!(crate::file::recursion_depth::max() < 10);
}

#[test]
fn test_cross_file_layers_memoized_across_effect_calls() {
    // Each effectful call consults `cross_file_layers(file, view)` while the
    // file's index builds. Memoizing it keeps that to one execution per file
    // instead of one per call, which was the O(N^2) that froze the LSP.
    let mut db = TestDb::new();

    let body = "local(1)\n".repeat(20);
    let (_pkg, entities) = make_package(&mut db, "pkg", Namespace::default(), &[(
        "w/pkg/R/a.R",
        &body,
    )]);

    let _ = entities[0].semantic_index(&db);

    assert_eq!(db.executions("cross_file_layers"), 1);
}

#[test]
fn test_script_r_directory_siblings_see_each_other() {
    // Non-package scripts in an `R/` directory are collated alphabetically,
    // exactly like a package `R/` with no `Collate:` (#15144, #14790).
    let mut db = TestDb::new();
    let root = workspace_root(&db, "ws");
    let a = File::new(
        &db,
        file_path("ws/R/a.R"),
        FileRevision::zero(),
        Some("a_val <- 1\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("ws/R/b.R"),
        FileRevision::zero(),
        Some("b_val <- 2\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![a, b]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(shape(&db, a.imports(&db)), vec!["File(b.R)".to_string()]);
    assert_eq!(shape(&db, b.imports(&db)), vec!["File(a.R)".to_string()]);
}

#[test]
fn test_script_outside_r_directory_stays_standalone() {
    let mut db = TestDb::new();
    let root = workspace_root(&db, "ws");
    let a = File::new(
        &db,
        file_path("ws/scripts/a.R"),
        FileRevision::zero(),
        Some("a_val <- 1\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("ws/scripts/b.R"),
        FileRevision::zero(),
        Some("b_val <- 2\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![a, b]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(shape(&db, a.imports(&db)), Vec::<String>::new());
    assert_eq!(shape(&db, b.imports(&db)), Vec::<String>::new());
}

#[test]
fn test_package_owned_r_file_excluded_from_collate_stays_standalone() {
    // An `R/` file left out of `Collate:` carries a package back-pointer but
    // isn't in `package.files()`, so it must keep resolving as a standalone
    // script (same as `data-raw/`) and not take the new script-collation arm
    // just because it sits in an `R/` directory. That arm is gated on
    // `package(db) == None`.
    let mut db = TestDb::new();
    let workspace = workspace_root(&db, "w");
    let pkg = Package::new(
        &db,
        file_path("w/pkg/DESCRIPTION"),
        "pkg".to_string(),
        FileRevision::zero(),
        FileRevision::zero(),
        None,
        None,
        Vec::new(),
        Vec::new(),
    );
    let r_file = File::new(
        &db,
        file_path("w/pkg/R/a.R"),
        FileRevision::zero(),
        Some("internal <- 1\n".to_string()),
        Some(pkg),
    );
    let extra = File::new(
        &db,
        file_path("w/pkg/R/extra.R"),
        FileRevision::zero(),
        Some("x <- 1\n".to_string()),
        Some(pkg),
    );
    pkg.set_files(&mut db).to(vec![r_file]);
    pkg.set_scripts(&mut db).to(vec![extra]);
    workspace.set_packages(&mut db).to(vec![pkg]);
    db.workspace_roots().set_roots(&mut db).to(vec![workspace]);

    assert_eq!(shape(&db, extra.imports(&db)), Vec::<String>::new());
}

#[test]
fn test_script_r_directory_predecessor_attach_reaches_sibling() {
    let mut db = TestDb::new();
    install_packages(&mut db, &["dplyr"]);

    let root = workspace_root(&db, "ws");
    let a = File::new(
        &db,
        file_path("ws/R/a.R"),
        FileRevision::zero(),
        Some("library(dplyr)\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("ws/R/b.R"),
        FileRevision::zero(),
        Some("x <- 1\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![a, b]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(shape(&db, b.imports(&db)), vec![
        "File(a.R)".to_string(),
        "Package(dplyr)".to_string(),
    ]);
}

#[test]
fn test_script_r_directory_below_uses_full_default_search_path() {
    // `SearchPathTail::Base` is for package code whose dependencies come from
    // the NAMESPACE. A script needs the complete default search path.
    let mut db = TestDb::new();
    install_packages(&mut db, &[
        "stats",
        "graphics",
        "grDevices",
        "utils",
        "datasets",
        "methods",
        "base",
    ]);

    let root = workspace_root(&db, "ws");
    let a = File::new(
        &db,
        file_path("ws/R/a.R"),
        FileRevision::zero(),
        Some("x <- 1\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![a]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(shape(&db, a.imports(&db)), vec![
        "Package(stats)".to_string(),
        "Package(graphics)".to_string(),
        "Package(grDevices)".to_string(),
        "Package(utils)".to_string(),
        "Package(datasets)".to_string(),
        "Package(methods)".to_string(),
        "Package(base)".to_string(),
    ]);
}

#[test]
fn test_separate_r_directories_do_not_cross_collate() {
    // Each `R/` directory collates independently, keyed on its parent path,
    // so a monorepo with several `R/` folders doesn't cross-collate.
    let mut db = TestDb::new();
    let root = workspace_root(&db, "ws");
    let a = File::new(
        &db,
        file_path("ws/one/R/a.R"),
        FileRevision::zero(),
        Some("a_val <- 1\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("ws/two/R/b.R"),
        FileRevision::zero(),
        Some("b_val <- 2\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![a, b]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(shape(&db, a.imports(&db)), Vec::<String>::new());
    assert_eq!(shape(&db, b.imports(&db)), Vec::<String>::new());
}

#[test]
fn test_cross_file_layers_backdates_on_unrelated_script_change() {
    // `collation_siblings` reads every workspace root's `scripts`, so a
    // script added anywhere forces it to re-execute. But the result filtered
    // to `a`/`b`'s own `R/` directory is unchanged, so salsa backdates it and
    // `cross_file_layers` never re-executes.
    let mut db = TestDb::new();
    let root = workspace_root(&db, "ws");
    let a = File::new(
        &db,
        file_path("ws/R/a.R"),
        FileRevision::zero(),
        Some("a_val <- 1\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("ws/R/b.R"),
        FileRevision::zero(),
        Some("b_val <- 2\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![a, b]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    let _ = a.imports(&db);
    assert_eq!(db.executions("cross_file_layers"), 1);

    let elsewhere = File::new(
        &db,
        file_path("ws/other/z.R"),
        FileRevision::zero(),
        Some("z_val <- 1\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![a, b, elsewhere]);

    let _ = a.imports(&db);
    assert_eq!(db.executions("cross_file_layers"), 1);
}

#[test]
fn test_sourced_file_inherits_sourcing_files_imports() {
    let mut db = TestDb::new();
    let root = workspace_root(&db, "w");
    let main = File::new(
        &db,
        file_path("w/main.R"),
        FileRevision::zero(),
        Some("x <- 1\nsource(\"helpers.R\")\n".to_string()),
        None,
    );
    let helpers = File::new(
        &db,
        file_path("w/helpers.R"),
        FileRevision::zero(),
        Some("y <- 2\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![main, helpers]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(shape(&db, helpers.imports(&db)), vec![
        "File(main.R)".to_string()
    ]);
}

#[test]
fn test_inherited_attach_sits_below_sourced_files_own_attaches() {
    let mut db = TestDb::new();
    install_packages(&mut db, &["dplyr", "tibble"]);
    let root = workspace_root(&db, "w");
    let main = File::new(
        &db,
        file_path("w/main.R"),
        FileRevision::zero(),
        Some("library(dplyr)\nsource(\"helpers.R\")\n".to_string()),
        None,
    );
    let helpers = File::new(
        &db,
        file_path("w/helpers.R"),
        FileRevision::zero(),
        Some("library(tibble)\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![main, helpers]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    // `helpers.R`'s own attach (tibble) outranks the inherited one (dplyr),
    // same as a collation predecessor's attach would.
    //
    // Tibble appears twice because `source()` forwards a sourced file's own
    // top-level attaches into the sourcing file's index, and that copy is
    // indistinguishable from a `library()` written in `main.R`. The echo is
    // unreachable under `resolve()`'s first-hit-wins search.
    assert_eq!(shape(&db, helpers.imports(&db)), vec![
        "File(main.R)".to_string(),
        "Package(tibble)".to_string(),
        "Package(tibble)".to_string(),
        "Package(dplyr)".to_string(),
    ]);
}

#[test]
fn test_inherited_imports_are_transitive() {
    let mut db = TestDb::new();
    let root = workspace_root(&db, "w");
    let main = File::new(
        &db,
        file_path("w/main.R"),
        FileRevision::zero(),
        Some("source(\"setup.R\")\n".to_string()),
        None,
    );
    let setup = File::new(
        &db,
        file_path("w/setup.R"),
        FileRevision::zero(),
        Some("source(\"helpers.R\")\n".to_string()),
        None,
    );
    let helpers = File::new(
        &db,
        file_path("w/helpers.R"),
        FileRevision::zero(),
        Some("z <- 1\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![main, setup, helpers]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    // `setup.R` outranks `main.R`: it's the more immediate source site.
    assert_eq!(shape(&db, helpers.imports(&db)), vec![
        "File(setup.R)".to_string(),
        "File(main.R)".to_string(),
    ]);
}

#[test]
fn test_multiple_sourcing_files_appear_ordered_by_path() {
    let mut db = TestDb::new();
    let root = workspace_root(&db, "w");
    let a_main = File::new(
        &db,
        file_path("w/a_main.R"),
        FileRevision::zero(),
        Some("source(\"helpers.R\")\n".to_string()),
        None,
    );
    let b_main = File::new(
        &db,
        file_path("w/b_main.R"),
        FileRevision::zero(),
        Some("source(\"helpers.R\")\n".to_string()),
        None,
    );
    let helpers = File::new(
        &db,
        file_path("w/helpers.R"),
        FileRevision::zero(),
        Some("z <- 1\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![a_main, b_main, helpers]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(shape(&db, helpers.imports(&db)), vec![
        "File(a_main.R)".to_string(),
        "File(b_main.R)".to_string(),
    ]);
}

#[test]
fn test_inheritance_replaces_the_r_directory_fallback() {
    // A non-package `R/` directory only implies collation. Because `main.R`
    // explicitly sources `a.R` but not `b.R`, the inferred context is replaced
    // and `b.R` drops out.
    let mut db = TestDb::new();
    let root = workspace_root(&db, "ws");
    let a = File::new(
        &db,
        file_path("ws/R/a.R"),
        FileRevision::zero(),
        Some("a_val <- 1\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("ws/R/b.R"),
        FileRevision::zero(),
        Some("b_val <- 2\n".to_string()),
        None,
    );
    let main = File::new(
        &db,
        file_path("ws/main.R"),
        FileRevision::zero(),
        Some("source(\"R/a.R\")\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![a, b, main]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(shape(&db, a.imports(&db)), vec!["File(main.R)".to_string()]);
}

#[test]
fn test_file_nobody_sources_keeps_its_own_cross_file_layers() {
    let mut db = TestDb::new();
    install_packages(&mut db, &["dplyr", "base"]);
    let root = workspace_root(&db, "ws");
    let a = File::new(
        &db,
        file_path("ws/R/a.R"),
        FileRevision::zero(),
        Some("library(dplyr)\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("ws/R/b.R"),
        FileRevision::zero(),
        Some("x <- 1\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![a, b]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    // Nobody sources `b.R`, so it keeps exactly the collation view
    // `cross_file_layers` alone would give it.
    assert_eq!(shape(&db, b.imports(&db)), vec![
        "File(a.R)".to_string(),
        "Package(dplyr)".to_string(),
        "Package(base)".to_string(),
    ]);
}

#[test]
fn test_cross_file_layers_never_carries_inherited_layers() {
    // The scan-time resolver walks `cross_file_layers` while a file's own index
    // is still being built, so an inherited layer there would make every file's
    // index demand the workspace reverse map. `resolution_layers` splices
    // inheritance in afterwards instead, which is why the read side below sees
    // `main.R` and `cross_file_layers` doesn't.
    //
    // This is what makes the `SourcingFile` arm of `layer_effect` unreachable.
    let mut db = TestDb::new();
    install_packages(&mut db, &["base"]);
    let root = workspace_root(&db, "w");

    let main = File::new(
        &db,
        file_path("w/main.R"),
        FileRevision::zero(),
        Some("cfg <- 1\nsource(\"R/helpers.R\")\n".to_string()),
        None,
    );
    // Collates before `helpers.R`, so the scan side has a real `File` layer to
    // tell apart from a `SourcingFile` one.
    let sibling = File::new(
        &db,
        file_path("w/R/a_sib.R"),
        FileRevision::zero(),
        Some("sib <- 1\n".to_string()),
        None,
    );
    let helpers_source = "top <- 2\n";
    let helpers = File::new(
        &db,
        file_path("w/R/helpers.R"),
        FileRevision::zero(),
        Some(helpers_source.to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![main, sibling, helpers]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    // The read side narrows `main.R` to what had run by its `source()` call.
    let offset = TextSize::from(helpers_source.find("top").unwrap() as u32);
    assert!(helpers
        .imports_at(&db, offset)
        .iter()
        .any(|layer| { matches!(layer, ImportLayer::SourcingFile { file, .. } if *file == main) }));

    // The scan side has `File` layers but never a `SourcingFile`, either view.
    for view in [CollationView::Eager, CollationView::Deferred] {
        let scan_side = helpers.cross_file_layers(&db, view);
        assert!(scan_side
            .lookup_order(&db, &[])
            .any(|layer| matches!(layer, ImportLayer::File(file) if file == sibling)));
        assert!(!scan_side
            .lookup_order(&db, &[])
            .any(|layer| matches!(layer, ImportLayer::SourcingFile { .. })));
    }
}

#[test]
fn test_inherited_default_search_path_is_not_duplicated() {
    let mut db = TestDb::new();
    install_packages(&mut db, &["base"]);
    let root = workspace_root(&db, "w");
    let main = File::new(
        &db,
        file_path("w/main.R"),
        FileRevision::zero(),
        Some("source(\"helpers.R\")\n".to_string()),
        None,
    );
    let helpers = File::new(
        &db,
        file_path("w/helpers.R"),
        FileRevision::zero(),
        Some("z <- 1\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![main, helpers]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    // Both `helpers.R`'s own context and the one inherited from `main.R` end
    // their `below` band in the default search path. The union carries it once.
    assert_eq!(shape(&db, helpers.imports(&db)), vec![
        "File(main.R)".to_string(),
        "Package(base)".to_string(),
    ]);
}

#[test]
fn test_collation_attach_outranks_the_search_path_under_inheritance() {
    // `loadSupport()` is a detected loader, so `a.R` retains its collation
    // context alongside `main.R`'s sourced context. Merging their search-path
    // tails at the bottom keeps `tibble` above `base`.
    let mut db = TestDb::new();
    install_packages(&mut db, &["base", "dplyr", "shiny", "tibble"]);
    let root = workspace_root(&db, "ws");
    let app = File::new(
        &db,
        file_path("ws/app.R"),
        FileRevision::zero(),
        Some("shinyApp(ui, server)\n".to_string()),
        None,
    );
    let a = File::new(
        &db,
        file_path("ws/R/a.R"),
        FileRevision::zero(),
        Some("a_val <- 1\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("ws/R/b.R"),
        FileRevision::zero(),
        Some("library(dplyr)\n".to_string()),
        None,
    );
    let main = File::new(
        &db,
        file_path("ws/main.R"),
        FileRevision::zero(),
        Some("library(tibble)\nsource(\"R/a.R\")\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![app, a, b, main]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(shape(&db, a.imports(&db)), vec![
        "File(b.R)".to_string(),
        "File(main.R)".to_string(),
        "Package(dplyr)".to_string(),
        "Package(shiny)".to_string(),
        "Package(tibble)".to_string(),
        "Package(base)".to_string(),
    ]);
}

#[test]
fn test_attach_to_a_default_search_path_package_keeps_its_priority() {
    // `stats` is already on the default search path. Removing that path by
    // value would also remove `b.R`'s explicit attach and let `tibble` outrank it.
    let mut db = TestDb::new();
    install_packages(&mut db, &["base", "shiny", "stats", "tibble"]);
    let root = workspace_root(&db, "ws");
    let app = File::new(
        &db,
        file_path("ws/app.R"),
        FileRevision::zero(),
        Some("shinyApp(ui, server)\n".to_string()),
        None,
    );
    let a = File::new(
        &db,
        file_path("ws/R/a.R"),
        FileRevision::zero(),
        Some("a_val <- 1\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("ws/R/b.R"),
        FileRevision::zero(),
        Some("library(stats)\n".to_string()),
        None,
    );
    let main = File::new(
        &db,
        file_path("ws/main.R"),
        FileRevision::zero(),
        Some("library(tibble)\nsource(\"R/a.R\")\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![app, a, b, main]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(shape(&db, a.imports(&db)), vec![
        "File(b.R)".to_string(),
        "File(main.R)".to_string(),
        "Package(stats)".to_string(),
        "Package(shiny)".to_string(),
        "Package(tibble)".to_string(),
        "Package(stats)".to_string(),
        "Package(base)".to_string(),
    ]);
}

#[test]
fn test_transitive_inheritance_keeps_attaches_above_the_search_path() {
    // `setup.R` inherits `main.R`'s search-path tail before the `dplyr` attach
    // from `aaa.R`. Removing only the combined suffix would leave that tail
    // above `dplyr`, making it rank below `base`.
    let mut db = TestDb::new();
    install_packages(&mut db, &["base", "dplyr", "shiny", "tibble"]);
    let root = workspace_root(&db, "ws");
    let app = File::new(
        &db,
        file_path("ws/app.R"),
        FileRevision::zero(),
        Some("shinyApp(ui, server)\n".to_string()),
        None,
    );
    let sibling = File::new(
        &db,
        file_path("ws/R/aaa.R"),
        FileRevision::zero(),
        Some("library(dplyr)\n".to_string()),
        None,
    );
    let setup = File::new(
        &db,
        file_path("ws/R/setup.R"),
        FileRevision::zero(),
        Some("source(\"helpers.R\")\n".to_string()),
        None,
    );
    let helpers = File::new(
        &db,
        file_path("ws/helpers.R"),
        FileRevision::zero(),
        Some("foo <- 1\n".to_string()),
        None,
    );
    let main = File::new(
        &db,
        file_path("ws/main.R"),
        FileRevision::zero(),
        Some("library(tibble)\nsource(\"R/setup.R\")\n".to_string()),
        None,
    );
    root.set_scripts(&mut db)
        .to(vec![app, sibling, setup, helpers, main]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(shape(&db, helpers.imports(&db)), vec![
        "File(setup.R)".to_string(),
        "File(aaa.R)".to_string(),
        "File(main.R)".to_string(),
        "Package(tibble)".to_string(),
        "Package(dplyr)".to_string(),
        "Package(shiny)".to_string(),
        "Package(base)".to_string(),
    ]);
}

#[test]
fn test_shiny_autoload_survives_an_explicit_source() {
    // `loadSupport()` supplies an explicit loader context, so `main.R` sourcing
    // `R/a.R` adds an execution rather than replacing the autoload context.
    let mut db = TestDb::new();
    install_packages(&mut db, &["base", "shiny"]);
    let root = workspace_root(&db, "ws");
    let app = File::new(
        &db,
        file_path("ws/app.R"),
        FileRevision::zero(),
        Some("shinyApp(ui, server)\n".to_string()),
        None,
    );
    let a = File::new(
        &db,
        file_path("ws/R/a.R"),
        FileRevision::zero(),
        Some("a_val <- 1\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("ws/R/b.R"),
        FileRevision::zero(),
        Some("b_val <- 2\n".to_string()),
        None,
    );
    let main = File::new(
        &db,
        file_path("ws/main.R"),
        FileRevision::zero(),
        Some("source(\"R/a.R\")\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![app, a, b, main]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(shape(&db, a.imports(&db)), vec![
        "File(b.R)".to_string(),
        "File(main.R)".to_string(),
        "Package(shiny)".to_string(),
        "Package(base)".to_string(),
    ]);
}

#[test]
fn test_source_cycle_keeps_the_r_directory_fallback() {
    // Cycle recovery leaves no inherited source sites, so `a.R` keeps its
    // fallback collation context and still sees `b.R`.
    let mut db = TestDb::new();
    install_packages(&mut db, &["base"]);
    let root = workspace_root(&db, "ws");
    let a = File::new(
        &db,
        file_path("ws/R/a.R"),
        FileRevision::zero(),
        Some("source(\"R/c.R\")\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("ws/R/b.R"),
        FileRevision::zero(),
        Some("b_val <- 2\n".to_string()),
        None,
    );
    let c = File::new(
        &db,
        file_path("ws/R/c.R"),
        FileRevision::zero(),
        Some("source(\"R/a.R\")\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![a, b, c]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(a.sourced_by(&db), &Vec::<File>::new());

    // Per-sourcing-file resolution retains one fallback context when recovery
    // removes every inherited source site.
    let contexts = a.imports_by_sourcing_file(&db);
    assert_eq!(contexts.len(), 1);
    assert_eq!(shape(&db, &contexts[0]), vec![
        "File(c.R)".to_string(),
        "File(b.R)".to_string(),
        "Package(base)".to_string(),
    ]);
}

#[test]
fn test_mutual_sourcing_devolves_to_standalone_scripts() {
    // A mutual pair cycles `semantic_index` through `exports`, and the cycling
    // side is rebuilt with `NoopImportsResolver`, whose `resolve_effects`
    // defaults to `None`. So a bare `source()` isn't recognized as effectful on
    // either side and no `SourceSite` survives. The pair never reaches
    // `inherited_layers`' own `cycle_result`, and both files fall back to the
    // standalone-script context.
    let mut db = TestDb::new();
    install_packages(&mut db, &["base"]);
    let root = workspace_root(&db, "w");
    let a = File::new(
        &db,
        file_path("w/a.R"),
        FileRevision::zero(),
        Some("source(\"b.R\")\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("w/b.R"),
        FileRevision::zero(),
        Some("source(\"a.R\")\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![a, b]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(a.source_sites(&db), &Vec::new());
    assert_eq!(b.source_sites(&db), &Vec::new());
    assert_eq!(a.sourced_by(&db), &Vec::<File>::new());
    assert_eq!(b.sourced_by(&db), &Vec::<File>::new());
    assert_eq!(
        shape(&db, a.imports(&db)),
        vec!["Package(base)".to_string()]
    );
    assert_eq!(
        shape(&db, b.imports(&db)),
        vec!["Package(base)".to_string()]
    );
}

#[test]
fn test_qualified_mutual_sourcing_records_sites_but_no_edges() {
    // `base::source()` stays recognized under `NoopImportsResolver`, whose
    // `resolve_qualified_effects` defaults to `effects::lookup`, so unlike the
    // bare-call pair above both sites survive in the forward projection. They
    // resolve to nothing, though: `resolve_source` reads the target's
    // `exports`, which is the empty cycle fallback. No target means no reverse
    // edge, so inheritance sees a cyclic pair as two standalone scripts.
    //
    // This is what makes `inherited_layers`' own `cycle_result` unreachable
    // rather than load-bearing. Every resolved source site is also a
    // `semantic_index -> exports -> semantic_index` edge in the same direction,
    // so a cycle in source edges always wipes the very targets that would let
    // `inherited_layers` recurse into itself.
    let mut db = TestDb::new();
    install_packages(&mut db, &["base"]);
    let root = workspace_root(&db, "w");
    let a = File::new(
        &db,
        file_path("w/a.R"),
        FileRevision::zero(),
        Some("base::source(\"b.R\")\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("w/b.R"),
        FileRevision::zero(),
        Some("base::source(\"a.R\")\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![a, b]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(a.source_sites(&db).len(), 1);
    assert_eq!(b.source_sites(&db).len(), 1);
    assert_eq!(a.source_sites(&db)[0].target(), None);
    assert_eq!(b.source_sites(&db)[0].target(), None);

    assert_eq!(a.sourced_by(&db), &Vec::<File>::new());
    assert_eq!(b.sourced_by(&db), &Vec::<File>::new());
    assert_eq!(
        shape(&db, a.imports(&db)),
        vec!["Package(base)".to_string()]
    );
    assert_eq!(
        shape(&db, b.imports(&db)),
        vec!["Package(base)".to_string()]
    );
}

#[test]
fn test_package_r_file_ignores_source_sites() {
    let mut db = TestDb::new();
    install_packages(&mut db, &["base"]);

    let workspace = workspace_root(&db, "w");
    let pkg = Package::new(
        &db,
        file_path("w/pkg/DESCRIPTION"),
        "pkg".to_string(),
        FileRevision::zero(),
        FileRevision::zero(),
        None,
        None,
        Vec::new(),
        Vec::new(),
    );
    let a = File::new(
        &db,
        file_path("w/pkg/R/a.R"),
        FileRevision::zero(),
        Some("a_val <- 1\n".to_string()),
        Some(pkg),
    );
    let b = File::new(
        &db,
        file_path("w/pkg/R/b.R"),
        FileRevision::zero(),
        Some("b_val <- 2\n".to_string()),
        Some(pkg),
    );
    let dev = File::new(
        &db,
        file_path("w/pkg/data-raw/dev.R"),
        FileRevision::zero(),
        Some("source(\"pkg/R/b.R\")\n".to_string()),
        Some(pkg),
    );
    pkg.set_files(&mut db).to(vec![a, b]);
    pkg.set_scripts(&mut db).to(vec![dev]);
    workspace.set_packages(&mut db).to(vec![pkg]);
    db.workspace_roots().set_roots(&mut db).to(vec![workspace]);

    // `dev.R` really does source `b.R`, but `b.R` is collated and so has to
    // resolve under package load, where `dev.R`'s bindings don't exist. Adding
    // `dev.R`'s context would also swap the namespace tail, `base` alone, for
    // the default search path, losing the check that a package file declares
    // its imports.
    assert_eq!(b.sourced_by(&db), &vec![dev]);
    assert_eq!(shape(&db, b.imports(&db)), vec![
        "File(a.R)".to_string(),
        "Package(base)".to_string(),
    ]);
}

#[test]
fn test_non_collated_package_file_still_inherits() {
    let mut db = TestDb::new();
    install_packages(&mut db, &["base"]);

    let workspace = workspace_root(&db, "w");
    let pkg = Package::new(
        &db,
        file_path("w/pkg/DESCRIPTION"),
        "pkg".to_string(),
        FileRevision::zero(),
        FileRevision::zero(),
        None,
        None,
        Vec::new(),
        Vec::new(),
    );
    let r_file = File::new(
        &db,
        file_path("w/pkg/R/a.R"),
        FileRevision::zero(),
        Some("f <- 1\n".to_string()),
        Some(pkg),
    );
    let helpers = File::new(
        &db,
        file_path("w/pkg/data-raw/helpers.R"),
        FileRevision::zero(),
        Some("h <- 1\n".to_string()),
        Some(pkg),
    );
    let dev = File::new(
        &db,
        file_path("w/pkg/data-raw/dev.R"),
        FileRevision::zero(),
        Some("source(\"pkg/data-raw/helpers.R\")\n".to_string()),
        Some(pkg),
    );
    pkg.set_files(&mut db).to(vec![r_file]);
    pkg.set_scripts(&mut db).to(vec![helpers, dev]);
    workspace.set_packages(&mut db).to(vec![pkg]);
    db.workspace_roots().set_roots(&mut db).to(vec![workspace]);

    // The gate is about load order, not about carrying a package back-pointer.
    // A `data-raw/` script isn't loaded with the package, so a source site is
    // the only thing that says anything about its environment.
    assert_eq!(shape(&db, helpers.imports(&db)), vec![
        "File(dev.R)".to_string(),
        "Package(base)".to_string(),
    ]);
}

#[test]
fn test_shadowed_source_call_still_contributes_an_edge() {
    // ```r
    // # a.R                  # b.R              # c.R
    // source <- identity      foo <- 1          foo
    // base::source("b.R")     source("c.R")
    // ```
    //
    // Entered through `a.R`, `b.R`'s `source("c.R")` calls `identity` and `c.R`
    // never loads. But R declares no entry point, and `b.R` run on its own
    // really does source `c.R`. Each file's effects resolve in its standalone
    // context, the one context that assumes nothing about callers, so the edge
    // goes in the map and `c.R` inherits from both files up the chain.
    let mut db = TestDb::new();
    let root = workspace_root(&db, "w");
    let a = File::new(
        &db,
        file_path("w/a.R"),
        FileRevision::zero(),
        Some("source <- identity\nbase::source(\"b.R\")\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("w/b.R"),
        FileRevision::zero(),
        Some("foo <- 1\nsource(\"c.R\")\n".to_string()),
        None,
    );
    let c = File::new(
        &db,
        file_path("w/c.R"),
        FileRevision::zero(),
        Some("foo\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![a, b, c]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    assert_eq!(b.sourced_by(&db), &vec![a]);
    assert_eq!(c.sourced_by(&db), &vec![b]);

    // `b.R` outranks `a.R`, being the more immediate source site. No packages
    // are installed, so nothing follows the two `File` layers.
    assert_eq!(shape(&db, c.imports(&db)), vec![
        "File(b.R)".to_string(),
        "File(a.R)".to_string(),
    ]);
}

#[test]
fn test_body_edit_in_a_sourcing_file_does_not_invalidate_imports() {
    // Inheritance reads the sourcing file's `attach_layers`, hence its `no_eq`
    // `semantic_index`, so `inherited_layers` re-executes on any keystroke in
    // `main.R`. The firewall is one level up: an edit that changes no attach and
    // no source edge returns a value-equal `Vec`, so `helpers.R`'s `imports`
    // backdates and everything downstream of it stays green.
    let mut db = TestDb::new();
    install_packages(&mut db, &["dplyr"]);
    let root = workspace_root(&db, "w");
    let main = File::new(
        &db,
        file_path("w/main.R"),
        FileRevision::zero(),
        Some("library(dplyr)\nsource(\"helpers.R\")\nf <- function() 1\n".to_string()),
        None,
    );
    let helpers = File::new(
        &db,
        file_path("w/helpers.R"),
        FileRevision::zero(),
        Some("y <- 2\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![main, helpers]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);

    let before = shape(&db, helpers.imports(&db));
    assert_eq!(before, vec![
        "File(main.R)".to_string(),
        "Package(dplyr)".to_string(),
    ]);
    assert_eq!(db.executions("File::imports"), 1);
    // One per file in the chain: `helpers.R`'s, and `main.R`'s own (empty)
    // inheritance, which `build_inherited_layers` reads for transitivity.
    assert_eq!(db.executions("File::inherited_layers"), 2);

    // Rewrite `f`'s body, leaving the `library()` call and the `source()` call
    // untouched.
    main.set_source_text_override(&mut db).to(Some(
        "library(dplyr)\nsource(\"helpers.R\")\nf <- function() 2 + 2\n".to_string(),
    ));

    assert_eq!(shape(&db, helpers.imports(&db)), before);
    // `helpers.R`'s re-executed and backdated. `main.R`'s didn't: it reads only
    // `sourced_by(main)`, which the edit left alone.
    assert_eq!(db.executions("File::inherited_layers"), 3);
    assert_eq!(db.executions("File::imports"), 1);
}

// --- `SourceTarget::Dir` invalidation ---

/// A workspace with `main.R` sourcing an `R/` directory, plus `a.R` and `b.R`
/// in it. Returns `(db, root, main, a, b)`.
fn source_dir_workspace() -> (TestDb, crate::Root, File, File, File) {
    let mut db = TestDb::new();
    let root = workspace_root(&db, "ws");
    let main = File::new(
        &db,
        file_path("ws/main.R"),
        FileRevision::zero(),
        Some("sourceDir(\"R\")\n".to_string()),
        None,
    );
    let a = File::new(
        &db,
        file_path("ws/R/a.R"),
        FileRevision::zero(),
        Some("a_val <- function() 1\n".to_string()),
        None,
    );
    let b = File::new(
        &db,
        file_path("ws/R/b.R"),
        FileRevision::zero(),
        Some("b_val <- 2\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![main, a, b]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);
    (db, root, main, a, b)
}

/// Creates a bare `tar_source()` pipeline with `R/a.R` and `R/models/fit.R`.
fn tar_source_workspace() -> (TestDb, crate::Root, File) {
    let mut db = TestDb::new();
    install_packages(&mut db, &["targets"]);
    let root = workspace_root(&db, "ws");
    let pipeline = File::new(
        &db,
        file_path("ws/_targets.R"),
        FileRevision::zero(),
        Some("library(targets)\ntar_source()\n".to_string()),
        None,
    );
    let a = File::new(
        &db,
        file_path("ws/R/a.R"),
        FileRevision::zero(),
        Some("a_val <- 1\n".to_string()),
        None,
    );
    let nested = File::new(
        &db,
        file_path("ws/R/models/fit.R"),
        FileRevision::zero(),
        Some("fit_val <- 4\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![pipeline, a, nested]);
    db.workspace_roots().set_roots(&mut db).to(vec![root]);
    (db, root, pipeline)
}

#[test]
fn test_source_dir_injects_every_file_in_the_directory() {
    let (db, _root, main, _a, _b) = source_dir_workspace();
    let mut names: Vec<&str> = main.exports(&db).iter().map(|(name, _)| name).collect();
    names.sort();
    assert_eq!(names, vec!["a_val", "b_val"]);
}

#[test]
fn test_source_dir_backdates_on_a_body_edit_in_the_directory() {
    let (mut db, _root, main, a, _b) = source_dir_workspace();
    let _ = main.exports(&db);
    let before = db.executions("File::semantic_index");

    // A body edit leaves `a.R`'s top-level names alone, so `exports` backdates
    // and nothing demands `main.R`'s index again.
    a.set_source_text_override(&mut db)
        .to(Some("a_val <- function() 999\n".to_string()));
    let _ = main.exports(&db);

    assert_eq!(db.executions("File::semantic_index"), before + 1);
}

#[test]
fn test_source_dir_backdates_on_a_file_added_outside_the_directory() {
    let (mut db, root, main, a, b) = source_dir_workspace();
    let _ = main.exports(&db);
    let before = db.executions("File::semantic_index");

    // The listing reads every root's scripts, so `source_dir_scripts` re-runs.
    // It returns the same files, so `main.R`'s index is never demanded again.
    let elsewhere = File::new(
        &db,
        file_path("ws/other/z.R"),
        FileRevision::zero(),
        Some("z_val <- 1\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![main, a, b, elsewhere]);
    let _ = main.exports(&db);

    assert_eq!(db.executions("File::semantic_index"), before);
}

#[test]
fn test_source_dir_picks_up_a_file_added_inside_the_directory() {
    let (mut db, root, main, a, b) = source_dir_workspace();
    let _ = main.exports(&db);

    let added = File::new(
        &db,
        file_path("ws/R/c.R"),
        FileRevision::zero(),
        Some("c_val <- 3\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![main, a, b, added]);

    let mut names: Vec<&str> = main.exports(&db).iter().map(|(name, _)| name).collect();
    names.sort();
    assert_eq!(names, vec!["a_val", "b_val", "c_val"]);
}

#[test]
fn test_source_dir_does_not_recurse_into_subdirectories() {
    // `sourceDir()` leaves `list.files()` at its `recursive = FALSE` default,
    // so `R/models/fit.R` is excluded.
    let (mut db, root, main, a, b) = source_dir_workspace();
    let nested = File::new(
        &db,
        file_path("ws/R/models/fit.R"),
        FileRevision::zero(),
        Some("fit_val <- 4\n".to_string()),
        None,
    );
    root.set_scripts(&mut db).to(vec![main, a, b, nested]);

    let mut names: Vec<&str> = main.exports(&db).iter().map(|(name, _)| name).collect();
    names.sort();
    assert_eq!(names, vec!["a_val", "b_val"]);
}

#[test]
fn test_tar_source_recurses_into_subdirectories() {
    // `tar_source()` includes nested scripts because `file_list_files()` calls
    // `list.files(recursive = TRUE)` for directories.
    let (db, _root, pipeline) = tar_source_workspace();

    let mut names: Vec<&str> = pipeline.exports(&db).iter().map(|(name, _)| name).collect();
    names.sort();
    assert_eq!(names, vec!["a_val", "fit_val"]);
}
