//! Integration-style tests for the `pkg_*` methods on `AppService`.

use crate::service::list_opts::ListOpts;
use crate::service::lockfile::{load_lockfile, LockFile, LockPackage};
use crate::service::source::PackageSource;
use crate::service::test_support::{
    make_app_service, make_app_service_at, make_app_service_at_with_search_paths,
    make_app_service_with_search_paths,
};

/// Build a `ListOpts` for tests. All fields default to `None`; override
/// individual fields with struct-update syntax.
fn opts() -> ListOpts {
    ListOpts {
        limit: None,
        sort: None,
        filter: None,
        fields: None,
        verbose: None,
    }
}

/// Build a filter `HashMap` from a `serde_json::json!({...})` value.
/// Panics if the value is not a JSON object.
fn filter_map(v: serde_json::Value) -> std::collections::HashMap<String, serde_json::Value> {
    v.as_object()
        .expect("filter must be a JSON object")
        .iter()
        .map(|(k, v)| (k.clone(), v.clone()))
        .collect()
}

fn make_lock_with_pkg(name: &str) -> LockFile {
    LockFile {
        version: 1,
        packages: vec![LockPackage {
            name: name.to_string(),
            version: None,
            source: PackageSource::Installed,
        }],
    }
}

/// Test helper: invoke `pkg_list` with the default summary preset.
///
/// Use this when the test only asserts on summary-preset fields
/// (`name`, `scope`, `version`, `active`, `resolved_source_path`,
/// `resolved_source_kind`).
async fn pkg_list_summary(
    svc: &crate::service::AppService,
    project_root: Option<String>,
) -> String {
    svc.pkg_list(project_root, opts()).await.unwrap()
}

/// Test helper: invoke `pkg_list` with `verbose="full"`.
///
/// Use this when the test asserts on full-preset-only fields
/// (`source_type`, `installed_at`, `install_source`, `override_paths`,
/// `overrides`, `linked`, `link_target`, `broken`, `path`, `source`,
/// `meta`, `error`, `updated_at`).
async fn pkg_list_full(svc: &crate::service::AppService, project_root: Option<String>) -> String {
    svc.pkg_list(
        project_root,
        ListOpts {
            verbose: Some("full".to_string()),
            ..opts()
        },
    )
    .await
    .unwrap()
}

// ── pkg_list tests ───────────────────────────────────────────

#[tokio::test]
async fn pkg_list_with_project() {
    let tmp = tempfile::tempdir().unwrap();
    let project_root = tmp.path();

    // Create alc.toml declaring the project-local package.
    std::fs::write(
        project_root.join("alc.toml"),
        "[packages]\nmy_local_pkg = \"*\"\n",
    )
    .unwrap();

    // Create a project-local package.
    let pkg_dir = project_root.join("my_local_pkg");
    std::fs::create_dir_all(&pkg_dir).unwrap();
    std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();

    // Write alc.lock with a Path entry for the package.
    let lock = LockFile {
        version: 1,
        packages: vec![LockPackage {
            name: "my_local_pkg".to_string(),
            version: None,
            source: PackageSource::Path {
                path: "my_local_pkg".to_string(),
            },
        }],
    };
    crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();

    let svc = make_app_service().await;
    let result = svc
        .pkg_list(
            Some(project_root.to_string_lossy().to_string()),
            ListOpts {
                verbose: Some("full".to_string()),
                ..opts()
            },
        )
        .await
        .unwrap();

    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    // Should have the project-local package.
    let project_pkg = packages
        .iter()
        .find(|p| p["name"] == "my_local_pkg")
        .expect("my_local_pkg not found in pkg_list output");

    assert_eq!(project_pkg["scope"], "project");
    assert_eq!(project_pkg["source_type"], "path");
    assert_eq!(project_pkg["active"], true);

    // project_root and lockfile_path must be present.
    assert!(json["project_root"].is_string());
    assert!(json["lockfile_path"].is_string());
}

#[tokio::test]
async fn pkg_list_no_project_root() {
    let svc = make_app_service().await;

    // Should succeed even without project_root (no crash).
    let result = pkg_list_summary(&svc, None).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    assert!(json["packages"].is_array());
}

// ── ST2: ListOpts wiring tests ───────────────────────────────
//
// These exercise the new list-tool pipeline (filter / sort / truncate /
// project_fields) introduced in ST2. They rely on a small synthetic
// fixture: a project root with N declared packages, each with its own
// vendor directory, registered via `alc.toml` + `alc.lock` (path
// entries). This keeps every entry deterministic and timezone-free.

/// Build a project_root with the given (`name`, `installed_at`) pairs.
///
/// Each package is created as a `path` entry in alc.toml + alc.lock.
/// `installed_at` is recorded into `alc.local.toml` under a stub
/// `manifest`-like field — but the `pkg_list` codepath populates
/// `installed_at` only from `installed.json` (manifest), which is
/// unrelated to project-local entries. So in these synthetic tests
/// `installed_at` is always `None` for project entries; we instead
/// exercise sort / filter on `name`, `scope`, and `active`.
async fn build_project_with_pkgs(
    project_root: &std::path::Path,
    names: &[&str],
) -> crate::service::AppService {
    let mut alc_toml = String::from("[packages]\n");
    let mut lock_pkgs = Vec::new();
    for name in names {
        alc_toml.push_str(&format!("{name} = {{ path = \"{name}\" }}\n"));
        let pkg_dir = project_root.join(name);
        std::fs::create_dir_all(&pkg_dir).unwrap();
        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
        lock_pkgs.push(LockPackage {
            name: (*name).to_string(),
            version: None,
            source: PackageSource::Path {
                path: (*name).to_string(),
            },
        });
    }
    std::fs::write(project_root.join("alc.toml"), alc_toml).unwrap();
    let lock = LockFile {
        version: 1,
        packages: lock_pkgs,
    };
    crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();
    make_app_service().await
}

/// Default summary preset must NOT include `install_source`.
#[tokio::test]
async fn pkg_list_summary_excludes_install_source() {
    let tmp = tempfile::tempdir().unwrap();
    let svc = build_project_with_pkgs(tmp.path(), &["alpha"]).await;
    let result = pkg_list_summary(&svc, Some(tmp.path().to_string_lossy().to_string())).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();
    let pkg = packages.iter().find(|p| p["name"] == "alpha").unwrap();
    let map = pkg.as_object().unwrap();
    assert!(
        !map.contains_key("install_source"),
        "install_source must be absent from summary preset, got: {map:?}"
    );
}

/// Summary preset includes `resolved_source_path` (the primary "where is
/// this package on disk" signal).
#[tokio::test]
async fn pkg_list_summary_includes_resolved_source_path() {
    let tmp = tempfile::tempdir().unwrap();
    let svc = build_project_with_pkgs(tmp.path(), &["alpha"]).await;
    let result = pkg_list_summary(&svc, Some(tmp.path().to_string_lossy().to_string())).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();
    let pkg = packages.iter().find(|p| p["name"] == "alpha").unwrap();
    assert!(
        pkg["resolved_source_path"].is_string(),
        "resolved_source_path must appear under summary preset"
    );
}

/// `verbose=full` brings back the extended fields: `path` is in the full
/// preset and is populated for project `path` entries.
#[tokio::test]
async fn pkg_list_verbose_full_includes_install_source() {
    let tmp = tempfile::tempdir().unwrap();
    let svc = build_project_with_pkgs(tmp.path(), &["alpha"]).await;
    // For project-local `path` entries `install_source` is None — the
    // field is only populated from installed.json (global packages).
    // Use `path` as the proxy "full-only" field that *is* set for these
    // synthetic entries.
    let result = pkg_list_full(&svc, Some(tmp.path().to_string_lossy().to_string())).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();
    let pkg = packages.iter().find(|p| p["name"] == "alpha").unwrap();
    let map = pkg.as_object().unwrap();
    assert!(
        map.contains_key("path"),
        "path must reappear under verbose=full, got: {map:?}"
    );
    assert!(
        map.contains_key("source_type"),
        "source_type must reappear under verbose=full, got: {map:?}"
    );
}

/// When both `fields` and `verbose` are supplied, `fields` wins.
#[tokio::test]
async fn pkg_list_fields_beats_verbose() {
    let tmp = tempfile::tempdir().unwrap();
    let svc = build_project_with_pkgs(tmp.path(), &["alpha"]).await;
    let result = svc
        .pkg_list(
            Some(tmp.path().to_string_lossy().to_string()),
            ListOpts {
                fields: Some(vec!["name".to_string()]),
                verbose: Some("full".to_string()),
                ..opts()
            },
        )
        .await
        .unwrap();
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();
    let pkg = packages.iter().find(|p| p["name"] == "alpha").unwrap();
    let map = pkg.as_object().unwrap();
    assert_eq!(map.len(), 1, "exact projection: only 'name' should survive");
    assert!(map.contains_key("name"));
}

/// Default sort `-active,-installed_at` puts `active=true` entries first.
///
/// Synthetic setup: declare two project packages and one variant pkg that
/// shadows one of them. The shadowed project entry becomes
/// `active=false`, the variant + the unshadowed project are
/// `active=true`. With default sort (`-active`), all `active=true`
/// entries must come before any `active=false` entry.
#[tokio::test]
async fn pkg_list_sort_active_desc_installed_at() {
    let tmp = tempfile::tempdir().unwrap();
    let project_root = tmp.path();

    // Two project packages.
    let svc = build_project_with_pkgs(project_root, &["alpha", "beta"]).await;

    // Variant pkg shadowing `alpha` so the project alpha entry becomes
    // active=false.
    let variant_dir = tmp.path().join("variant_src").join("alpha");
    std::fs::create_dir_all(&variant_dir).unwrap();
    std::fs::write(variant_dir.join("init.lua"), "return {}").unwrap();
    std::fs::write(
        project_root.join("alc.local.toml"),
        format!(
            "[packages]\nalpha = {{ path = \"{}\" }}\n",
            variant_dir.display()
        ),
    )
    .unwrap();

    let result = pkg_list_summary(&svc, Some(project_root.to_string_lossy().to_string())).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    // Walk the array and check no active=false comes before any active=true.
    let mut seen_inactive = false;
    for pkg in packages {
        let active = pkg["active"].as_bool().unwrap_or(false);
        if !active {
            seen_inactive = true;
        } else if seen_inactive {
            panic!(
                "default sort should put active=true before active=false; \
                 saw active=true after active=false in {packages:?}"
            );
        }
    }
}

/// `filter={"scope":"global"}` excludes project entries.
#[tokio::test]
async fn pkg_list_filter_by_scope() {
    let tmp = tempfile::tempdir().unwrap();
    let svc = build_project_with_pkgs(tmp.path(), &["alpha", "beta"]).await;
    let filter = serde_json::json!({"scope": "global"});
    let result = svc
        .pkg_list(
            Some(tmp.path().to_string_lossy().to_string()),
            ListOpts {
                filter: Some(filter_map(filter)),
                ..opts()
            },
        )
        .await
        .unwrap();
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();
    for pkg in packages {
        assert_ne!(
            pkg["scope"], "project",
            "filter scope=global must exclude project entries, got: {pkg:?}"
        );
    }
}

/// `filter={"active":true}` excludes inactive entries.
#[tokio::test]
async fn pkg_list_filter_by_active_true() {
    let tmp = tempfile::tempdir().unwrap();
    let project_root = tmp.path();
    let svc = build_project_with_pkgs(project_root, &["alpha"]).await;

    // Variant shadowing alpha so the project alpha becomes inactive.
    let variant_dir = tmp.path().join("variant_src").join("alpha");
    std::fs::create_dir_all(&variant_dir).unwrap();
    std::fs::write(variant_dir.join("init.lua"), "return {}").unwrap();
    std::fs::write(
        project_root.join("alc.local.toml"),
        format!(
            "[packages]\nalpha = {{ path = \"{}\" }}\n",
            variant_dir.display()
        ),
    )
    .unwrap();

    let filter = serde_json::json!({"active": true});
    let result = svc
        .pkg_list(
            Some(project_root.to_string_lossy().to_string()),
            ListOpts {
                filter: Some(filter_map(filter)),
                ..opts()
            },
        )
        .await
        .unwrap();
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();
    for pkg in packages {
        assert_eq!(
            pkg["active"], true,
            "filter active=true must exclude inactive entries, got: {pkg:?}"
        );
    }
}

/// `limit=5` truncates the `packages` array to at most 5 entries.
#[tokio::test]
async fn pkg_list_limit_truncates() {
    let tmp = tempfile::tempdir().unwrap();
    let svc = build_project_with_pkgs(
        tmp.path(),
        &[
            "pkg_a", "pkg_b", "pkg_c", "pkg_d", "pkg_e", "pkg_f", "pkg_g",
        ],
    )
    .await;
    let result = svc
        .pkg_list(
            Some(tmp.path().to_string_lossy().to_string()),
            ListOpts {
                limit: Some(5),
                ..opts()
            },
        )
        .await
        .unwrap();
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();
    assert!(
        packages.len() <= 5,
        "limit=5 must truncate the array, got {} entries",
        packages.len()
    );
}

/// `limit=N` truncates `packages` but `search_paths` / `project_root` /
/// `lockfile_path` top-level keys remain present.
#[tokio::test]
async fn pkg_list_limit_preserves_top_level_shape() {
    let tmp = tempfile::tempdir().unwrap();
    let svc = build_project_with_pkgs(tmp.path(), &["pkg_a", "pkg_b", "pkg_c"]).await;
    let result = svc
        .pkg_list(
            Some(tmp.path().to_string_lossy().to_string()),
            ListOpts {
                limit: Some(1),
                ..opts()
            },
        )
        .await
        .unwrap();
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    assert!(
        json["search_paths"].is_array(),
        "search_paths must remain after limit truncation"
    );
    assert!(
        json["project_root"].is_string(),
        "project_root must remain after limit truncation"
    );
    assert!(
        json["lockfile_path"].is_string(),
        "lockfile_path must remain after limit truncation"
    );
    let packages = json["packages"].as_array().unwrap();
    assert!(packages.len() <= 1);
}

/// Unknown field names in `fields` are silently skipped (JSON:API
/// sparse fieldsets convention).
#[tokio::test]
async fn pkg_list_unknown_field_silently_skipped() {
    let tmp = tempfile::tempdir().unwrap();
    let svc = build_project_with_pkgs(tmp.path(), &["alpha"]).await;
    let result = svc
        .pkg_list(
            Some(tmp.path().to_string_lossy().to_string()),
            ListOpts {
                fields: Some(vec!["name".to_string(), "bogus_field".to_string()]),
                ..opts()
            },
        )
        .await
        .unwrap();
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();
    let pkg = packages.iter().find(|p| p["name"] == "alpha").unwrap();
    let map = pkg.as_object().unwrap();
    assert!(map.contains_key("name"));
    assert!(
        !map.contains_key("bogus_field"),
        "unknown field must be silently skipped, got: {map:?}"
    );
    assert_eq!(
        map.len(),
        1,
        "only known fields should appear, got: {map:?}"
    );
}

/// Invalid sort string (empty / dash-only) must produce an error
/// before any IO is done. Defends the `parse_sort` short-circuit.
#[tokio::test]
async fn pkg_list_invalid_sort_returns_error() {
    let tmp = tempfile::tempdir().unwrap();
    let svc = build_project_with_pkgs(tmp.path(), &["alpha"]).await;
    let result = svc
        .pkg_list(
            Some(tmp.path().to_string_lossy().to_string()),
            ListOpts {
                sort: Some("-".to_string()),
                ..opts()
            },
        )
        .await;
    assert!(
        result.is_err(),
        "bare '-' sort string must be rejected, got: {result:?}"
    );
}

/// Invalid `verbose` value must produce an error before IO.
#[tokio::test]
async fn pkg_list_invalid_verbose_returns_error() {
    let tmp = tempfile::tempdir().unwrap();
    let svc = build_project_with_pkgs(tmp.path(), &["alpha"]).await;
    let result = svc
        .pkg_list(
            Some(tmp.path().to_string_lossy().to_string()),
            ListOpts {
                verbose: Some("fat".to_string()),
                ..opts()
            },
        )
        .await;
    assert!(
        result.is_err(),
        "verbose='fat' must be rejected, got: {result:?}"
    );
}

// ── pkg_remove tests ─────────────────────────────────────────

#[tokio::test]
async fn pkg_remove_project_scope() {
    let tmp = tempfile::tempdir().unwrap();
    let project_root = tmp.path();

    // Create alc.toml declaring the package to remove.
    std::fs::write(
        project_root.join("alc.toml"),
        "[packages]\nmy_local_pkg = \"*\"\n",
    )
    .unwrap();

    // Create the physical directory (should remain after removal).
    let pkg_dir = project_root.join("my_local_pkg");
    std::fs::create_dir_all(&pkg_dir).unwrap();
    std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();

    // Write alc.lock with the package.
    let lock = LockFile {
        version: 1,
        packages: vec![LockPackage {
            name: "my_local_pkg".to_string(),
            version: None,
            source: PackageSource::Path {
                path: "my_local_pkg".to_string(),
            },
        }],
    };
    crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();

    let svc = make_app_service().await;
    let result = svc
        .pkg_remove(
            "my_local_pkg",
            Some(project_root.to_string_lossy().to_string()),
            None, // version
            None, // scope → default "project"
        )
        .await
        .unwrap();

    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    assert_eq!(json["removed"], "my_local_pkg");
    // New response has alc_toml and alc_lock fields (no scope field).
    assert!(json["alc_toml"].is_string());
    assert!(json["alc_lock"].is_string());

    // Physical directory must still exist.
    assert!(pkg_dir.exists(), "physical directory was deleted");

    // alc.lock must no longer contain the entry.
    let lock_after = load_lockfile(project_root).unwrap().unwrap();
    assert!(
        lock_after.packages.is_empty(),
        "alc.lock still contains the entry"
    );
}

#[tokio::test]
async fn pkg_remove_project_scope_not_found_returns_error() {
    let tmp = tempfile::tempdir().unwrap();
    let project_root = tmp.path();

    // Create alc.toml with a different package (not the target).
    std::fs::write(
        project_root.join("alc.toml"),
        "[packages]\nother_pkg = \"*\"\n",
    )
    .unwrap();

    // Write an alc.lock without the target package.
    let lock = make_lock_with_pkg("other_pkg");
    crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();

    let svc = make_app_service().await;
    let result = svc
        .pkg_remove(
            "nonexistent_pkg",
            Some(project_root.to_string_lossy().to_string()),
            None, // version
            None, // scope → default "project"
        )
        .await;

    assert!(result.is_err());
    assert!(result.unwrap_err().contains("not found in alc.lock"));
}

// ── pkg_remove scope=global / all tests ─────────────────────

/// `scope = "global"` removes the entry from `~/.algocline/installed.json`
/// but leaves `~/.algocline/packages/{name}/` untouched. No `project_root`
/// needed.
#[tokio::test]
async fn pkg_remove_global_scope_removes_manifest_entry() {
    use crate::service::manifest::{load_manifest, record_install};

    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();
    let app_dir = crate::service::test_support::test_app_dir(home);

    record_install(
        &app_dir,
        "ghost_pkg",
        Some("0.1.0"),
        crate::service::source::PackageSource::Path {
            path: "/tmp/ghost_source".to_string(),
        },
    )
    .unwrap();
    assert!(load_manifest(&app_dir)
        .unwrap()
        .packages
        .contains_key("ghost_pkg"));

    let svc = make_app_service_at(home.to_path_buf()).await;
    let result = svc
        .pkg_remove(
            "ghost_pkg",
            None, // project_root — ignored for global scope
            None, // version
            Some("global".to_string()),
        )
        .await
        .unwrap();

    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    assert_eq!(json["removed"], "ghost_pkg");
    assert_eq!(json["scope"], "global");
    assert!(json["installed_json"].is_string());

    assert!(
        !load_manifest(&app_dir)
            .unwrap()
            .packages
            .contains_key("ghost_pkg"),
        "global manifest still contains the entry"
    );
}

/// `scope = "global"` errors when the name is not in the manifest so callers
/// cannot silently no-op on a typo. Mirrors the `alc.lock` authoritative
/// check in `scope = "project"`.
#[tokio::test]
async fn pkg_remove_global_scope_not_found_returns_error() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();

    let svc = make_app_service_at(home.to_path_buf()).await;
    let result = svc
        .pkg_remove("never_installed", None, None, Some("global".to_string()))
        .await;

    let err = result.expect_err("expected Err");
    assert!(
        err.contains("not found in global manifest"),
        "unexpected error: {err}"
    );
}

/// `scope = "global"` must not `rm -rf ~/.algocline/packages/{name}/`.
/// Symmetric with the project scope's "physical files preserved" policy.
#[tokio::test]
async fn pkg_remove_global_scope_preserves_physical_dir() {
    use crate::service::manifest::record_install;

    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();
    let pkg_dir = home.join("packages").join("kept");
    std::fs::create_dir_all(&pkg_dir).unwrap();
    std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();

    let app_dir = crate::service::test_support::test_app_dir(home);
    record_install(
        &app_dir,
        "kept",
        Some("0.1.0"),
        crate::service::source::PackageSource::Path {
            path: "/tmp/kept_source".to_string(),
        },
    )
    .unwrap();

    let svc = make_app_service_at(home.to_path_buf()).await;
    svc.pkg_remove("kept", None, None, Some("global".to_string()))
        .await
        .unwrap();

    assert!(
        pkg_dir.exists(),
        "global scope must not delete ~/.algocline/packages/{{name}}/"
    );
    assert!(
        pkg_dir.join("init.lua").exists(),
        "init.lua should still be present"
    );
}

/// `scope = "all"` is lenient: removes from whichever scope has the entry.
/// When only the global manifest has it (project scope legitimately absent
/// because the project never declared the package), the call still succeeds
/// and reports `project_removed = false`.
#[tokio::test]
async fn pkg_remove_all_scope_is_lenient_when_only_global_has_entry() {
    use crate::service::manifest::{load_manifest, record_install};

    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();
    let app_dir = crate::service::test_support::test_app_dir(home);
    record_install(
        &app_dir,
        "orphan",
        None,
        crate::service::source::PackageSource::Path {
            path: "/tmp/orphan_source".to_string(),
        },
    )
    .unwrap();

    let svc = make_app_service_at(home.to_path_buf()).await;
    // No project_root supplied — project scope will fail to resolve.
    let result = svc
        .pkg_remove("orphan", None, None, Some("all".to_string()))
        .await
        .unwrap();

    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    assert_eq!(json["removed"], "orphan");
    assert_eq!(json["scope"], "all");
    assert_eq!(json["global_removed"], true);
    assert_eq!(json["project_removed"], false);
    assert!(
        !load_manifest(&app_dir)
            .unwrap()
            .packages
            .contains_key("orphan"),
        "global manifest still contains the entry"
    );
}

/// `scope = "all"` errors only when neither scope has the entry. Both
/// scope-specific error strings are surfaced so the caller can diagnose.
#[tokio::test]
async fn pkg_remove_all_scope_errors_when_neither_scope_has_entry() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();

    let svc = make_app_service_at(home.to_path_buf()).await;
    let err = svc
        .pkg_remove("never_anywhere", None, None, Some("all".to_string()))
        .await
        .expect_err("expected Err");

    assert!(err.contains("not found in any scope"), "unexpected: {err}");
    assert!(err.contains("project:"), "missing project context: {err}");
    assert!(err.contains("global:"), "missing global context: {err}");
}

/// Unknown `scope` values are rejected rather than silently defaulting.
#[tokio::test]
async fn pkg_remove_invalid_scope_errors() {
    let svc = make_app_service().await;
    let err = svc
        .pkg_remove("x", None, None, Some("packages".to_string()))
        .await
        .expect_err("expected Err");
    assert!(
        err.contains("invalid scope") && err.contains("packages"),
        "unexpected: {err}"
    );
}

// ── resolved_source_path / resolved_source_kind / override_paths tests ────

/// Case 1: project `path` entry — `resolved_source_path` is the canonicalized
/// absolute path of the package directory; `resolved_source_kind = "local_path"`.
#[tokio::test]
async fn pkg_list_project_path_entry_has_resolved_source() {
    let tmp = tempfile::tempdir().unwrap();
    let project_root = tmp.path();

    // Create a vendor package directory inside the project.
    let pkg_dir = project_root.join("my_vendor_pkg");
    std::fs::create_dir_all(&pkg_dir).unwrap();
    std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();

    // alc.toml with path dependency.
    std::fs::write(
        project_root.join("alc.toml"),
        "[packages]\nmy_vendor_pkg = { path = \"my_vendor_pkg\" }\n",
    )
    .unwrap();

    // alc.lock with Path source.
    let lock = LockFile {
        version: 1,
        packages: vec![LockPackage {
            name: "my_vendor_pkg".to_string(),
            version: None,
            source: PackageSource::Path {
                path: "my_vendor_pkg".to_string(),
            },
        }],
    };
    crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();

    let svc = make_app_service().await;
    let result = pkg_list_summary(&svc, Some(project_root.to_string_lossy().to_string())).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    let pkg = packages
        .iter()
        .find(|p| p["name"] == "my_vendor_pkg")
        .expect("my_vendor_pkg not found");

    let expected_canonical = std::fs::canonicalize(&pkg_dir)
        .unwrap()
        .display()
        .to_string();

    assert_eq!(
        pkg["resolved_source_path"].as_str().unwrap(),
        expected_canonical,
        "resolved_source_path should be canonicalized path"
    );
    assert_eq!(pkg["resolved_source_kind"], "local_path");
}

/// Case 2: project `path` entry where the vendor directory is itself a symlink —
/// `resolved_source_path` follows the symlink to the real target.
#[tokio::test]
async fn pkg_list_project_path_with_symlink_vendor_follows_target() {
    let tmp = tempfile::tempdir().unwrap();
    let project_root = tmp.path();

    // Create a real package directory somewhere else.
    let real_pkg = tmp.path().join("real_pkg_dir");
    std::fs::create_dir_all(&real_pkg).unwrap();
    std::fs::write(real_pkg.join("init.lua"), "return {}").unwrap();

    // Create a symlink inside the project pointing to the real dir.
    let symlink_in_project = project_root.join("sym_vendor_pkg");
    std::os::unix::fs::symlink(&real_pkg, &symlink_in_project).unwrap();

    std::fs::write(
        project_root.join("alc.toml"),
        "[packages]\nsym_vendor_pkg = { path = \"sym_vendor_pkg\" }\n",
    )
    .unwrap();

    let lock = LockFile {
        version: 1,
        packages: vec![LockPackage {
            name: "sym_vendor_pkg".to_string(),
            version: None,
            source: PackageSource::Path {
                path: "sym_vendor_pkg".to_string(),
            },
        }],
    };
    crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();

    let svc = make_app_service().await;
    let result = pkg_list_summary(&svc, Some(project_root.to_string_lossy().to_string())).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    let pkg = packages
        .iter()
        .find(|p| p["name"] == "sym_vendor_pkg")
        .expect("sym_vendor_pkg not found");

    // canonicalize follows the symlink to real_pkg.
    let expected_canonical = std::fs::canonicalize(&real_pkg)
        .unwrap()
        .display()
        .to_string();

    assert_eq!(
        pkg["resolved_source_path"].as_str().unwrap(),
        expected_canonical,
        "resolved_source_path should resolve through symlink to real target"
    );
    assert_eq!(pkg["resolved_source_kind"], "local_path");
}

/// Case 3: project `installed` entry — `resolved_source_path` is
/// `{packages_dir()}/{name}` canonicalized; `resolved_source_kind = "installed"`.
#[tokio::test]
async fn pkg_list_project_installed_entry_has_resolved_source() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();
    let packages_dir = home.join("packages");
    let pkg_dir = packages_dir.join("installed_pkg");
    std::fs::create_dir_all(&pkg_dir).unwrap();
    std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();

    let tmp = tempfile::tempdir().unwrap();
    let project_root = tmp.path();

    std::fs::write(
        project_root.join("alc.toml"),
        "[packages]\ninstalled_pkg = \"*\"\n",
    )
    .unwrap();

    let lock = LockFile {
        version: 1,
        packages: vec![LockPackage {
            name: "installed_pkg".to_string(),
            version: None,
            source: PackageSource::Installed,
        }],
    };
    crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();

    let svc = make_app_service_at(home.to_path_buf()).await;
    let result = pkg_list_summary(&svc, Some(project_root.to_string_lossy().to_string())).await;
    let expected_canonical = std::fs::canonicalize(&pkg_dir)
        .unwrap()
        .display()
        .to_string();
    drop(tmp);

    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    let pkg = packages
        .iter()
        .find(|p| p["name"] == "installed_pkg")
        .expect("installed_pkg not found");

    assert_eq!(
        pkg["resolved_source_path"].as_str().unwrap(),
        expected_canonical,
        "resolved_source_path should be packages_dir/<name> canonicalized"
    );
    assert_eq!(pkg["resolved_source_kind"], "installed");
}

/// Case 4 (light): project `installed` entry where `packages_dir/{name}` is itself
/// a symlink (linked package). The resolved path follows through to the real target.
#[tokio::test]
async fn pkg_list_project_installed_resolves_through_linked_pkg() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();
    let packages_dir = home.join("packages");
    std::fs::create_dir_all(&packages_dir).unwrap();

    // The "real" development directory (what the symlink points to).
    let real_dev_dir = home.join("dev").join("linked_pkg_real");
    std::fs::create_dir_all(&real_dev_dir).unwrap();
    std::fs::write(real_dev_dir.join("init.lua"), "return {}").unwrap();

    // Symlink in packages_dir pointing to the dev dir.
    let symlink_path = packages_dir.join("linked_pkg");
    std::os::unix::fs::symlink(&real_dev_dir, &symlink_path).unwrap();

    let tmp = tempfile::tempdir().unwrap();
    let project_root = tmp.path();

    std::fs::write(
        project_root.join("alc.toml"),
        "[packages]\nlinked_pkg = \"*\"\n",
    )
    .unwrap();

    let lock = LockFile {
        version: 1,
        packages: vec![LockPackage {
            name: "linked_pkg".to_string(),
            version: None,
            source: PackageSource::Installed,
        }],
    };
    crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();

    let svc = make_app_service_at(home.to_path_buf()).await;
    let result = pkg_list_summary(&svc, Some(project_root.to_string_lossy().to_string())).await;
    // canonicalize follows the symlink to the real dev dir.
    let expected_canonical = std::fs::canonicalize(&real_dev_dir)
        .unwrap()
        .display()
        .to_string();
    drop(tmp);

    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    let pkg = packages
        .iter()
        .find(|p| p["name"] == "linked_pkg")
        .expect("linked_pkg not found");

    assert_eq!(
        pkg["resolved_source_path"].as_str().unwrap(),
        expected_canonical,
        "resolved_source_path should follow symlink in packages_dir to real target"
    );
    assert_eq!(pkg["resolved_source_kind"], "installed");
}

/// Case 5: global regular (non-symlink) package —
/// `resolved_source_path = canonicalize({search_path}/{name})`,
/// `resolved_source_kind = "installed"` (no manifest entry → "installed").
#[tokio::test]
async fn pkg_list_global_regular_pkg_has_resolved_source() {
    let tmp = tempfile::tempdir().unwrap();
    let search_dir = tmp.path().join("pkgs");
    std::fs::create_dir_all(&search_dir).unwrap();

    let pkg_dir = search_dir.join("regular_pkg");
    std::fs::create_dir_all(&pkg_dir).unwrap();
    std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();

    let search_path = crate::service::resolve::SearchPath {
        path: search_dir.clone(),
        source: crate::service::resolve::SearchPathSource::Env,
    };
    let svc = make_app_service_with_search_paths(vec![search_path]).await;
    let result = pkg_list_summary(&svc, None).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    let pkg = packages
        .iter()
        .find(|p| p["name"] == "regular_pkg")
        .expect("regular_pkg not found");

    let expected_canonical = std::fs::canonicalize(&pkg_dir)
        .unwrap()
        .display()
        .to_string();

    assert_eq!(
        pkg["resolved_source_path"].as_str().unwrap(),
        expected_canonical
    );
    assert_eq!(pkg["resolved_source_kind"], "installed");
}

/// Case 6: global linked (symlink) package —
/// `resolved_source_path` is the canonicalized symlink target;
/// `resolved_source_kind = "linked"`.
#[tokio::test]
async fn pkg_list_global_linked_pkg_resolves_to_link_target() {
    let tmp = tempfile::tempdir().unwrap();
    let search_dir = tmp.path().join("pkgs");
    std::fs::create_dir_all(&search_dir).unwrap();

    // Real package directory (dev workspace).
    let real_dir = tmp.path().join("my_dev_pkg");
    std::fs::create_dir_all(&real_dir).unwrap();
    std::fs::write(real_dir.join("init.lua"), "return {}").unwrap();

    // Symlink in the search_dir pointing to real_dir.
    let link_path = search_dir.join("linked_global_pkg");
    std::os::unix::fs::symlink(&real_dir, &link_path).unwrap();

    let search_path = crate::service::resolve::SearchPath {
        path: search_dir,
        source: crate::service::resolve::SearchPathSource::Env,
    };
    let svc = make_app_service_with_search_paths(vec![search_path]).await;
    let result = pkg_list_full(&svc, None).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    let pkg = packages
        .iter()
        .find(|p| p["name"] == "linked_global_pkg")
        .expect("linked_global_pkg not found");

    let expected_canonical = std::fs::canonicalize(&real_dir)
        .unwrap()
        .display()
        .to_string();

    assert_eq!(
        pkg["resolved_source_path"].as_str().unwrap(),
        expected_canonical,
        "resolved_source_path should point to real target"
    );
    assert_eq!(pkg["resolved_source_kind"], "linked");
    assert_eq!(pkg["linked"], true);
}

/// Case 7: global linked package with a dangling (broken) symlink —
/// `resolved_source_path` must be absent; `resolved_source_kind = "linked"`;
/// `broken = true`.
#[tokio::test]
async fn pkg_list_global_linked_broken_omits_resolved_source() {
    let tmp = tempfile::tempdir().unwrap();
    let search_dir = tmp.path().join("pkgs");
    std::fs::create_dir_all(&search_dir).unwrap();

    // Create a symlink pointing to a nonexistent path.
    let nonexistent_target = tmp.path().join("this_does_not_exist");
    let link_path = search_dir.join("broken_pkg");
    std::os::unix::fs::symlink(&nonexistent_target, &link_path).unwrap();

    let search_path = crate::service::resolve::SearchPath {
        path: search_dir,
        source: crate::service::resolve::SearchPathSource::Env,
    };
    let svc = make_app_service_with_search_paths(vec![search_path]).await;
    let result = pkg_list_full(&svc, None).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    let pkg = packages
        .iter()
        .find(|p| p["name"] == "broken_pkg")
        .expect("broken_pkg not found");

    assert!(
        pkg.get("resolved_source_path").is_none() || pkg["resolved_source_path"].is_null(),
        "resolved_source_path must be absent for broken symlink"
    );
    assert_eq!(pkg["resolved_source_kind"], "linked");
    assert_eq!(pkg["broken"], true);
}

/// Case 8: two global search paths contain a package with the same name —
/// `override_paths` on the active entry lists the shadowed path(s) in
/// search-path order.
#[tokio::test]
async fn pkg_list_override_paths_global_shadow() {
    let tmp = tempfile::tempdir().unwrap();
    let search_dir1 = tmp.path().join("pkgs1");
    let search_dir2 = tmp.path().join("pkgs2");
    std::fs::create_dir_all(&search_dir1).unwrap();
    std::fs::create_dir_all(&search_dir2).unwrap();

    // Same package name in both search paths.
    for dir in [&search_dir1, &search_dir2] {
        let pkg_dir = dir.join("dup_pkg");
        std::fs::create_dir_all(&pkg_dir).unwrap();
        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
    }

    let svc = make_app_service_with_search_paths(vec![
        crate::service::resolve::SearchPath {
            path: search_dir1.clone(),
            source: crate::service::resolve::SearchPathSource::Env,
        },
        crate::service::resolve::SearchPath {
            path: search_dir2.clone(),
            source: crate::service::resolve::SearchPathSource::Env,
        },
    ])
    .await;

    let result = pkg_list_full(&svc, None).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    // Active entry is the one from search_dir1 (first wins).
    let active_pkg = packages
        .iter()
        .find(|p| p["name"] == "dup_pkg" && p["active"] == true)
        .expect("active dup_pkg not found");

    let override_paths = active_pkg["override_paths"]
        .as_array()
        .expect("override_paths should be an array on active entry");

    assert_eq!(
        override_paths.len(),
        1,
        "should have exactly one shadowed entry"
    );

    let expected_shadow = std::fs::canonicalize(search_dir2.join("dup_pkg"))
        .unwrap()
        .display()
        .to_string();

    assert_eq!(
        override_paths[0].as_str().unwrap(),
        expected_shadow,
        "override_paths[0] should be the canonicalized path in search_dir2"
    );
}

/// Case 9: project entry shadows a global entry —
/// the project entry's `override_paths` contains the global package path;
/// the inactive global entry has no `override_paths`.
#[tokio::test]
async fn pkg_list_override_paths_project_shadows_global() {
    let tmp = tempfile::tempdir().unwrap();
    let project_root = tmp.path();
    let search_dir = tmp.path().join("global_pkgs");
    std::fs::create_dir_all(&search_dir).unwrap();

    // Global package directory.
    let global_pkg_dir = search_dir.join("shared_pkg");
    std::fs::create_dir_all(&global_pkg_dir).unwrap();
    std::fs::write(global_pkg_dir.join("init.lua"), "return {}").unwrap();

    // Project vendor package directory.
    let local_pkg_dir = project_root.join("shared_pkg");
    std::fs::create_dir_all(&local_pkg_dir).unwrap();
    std::fs::write(local_pkg_dir.join("init.lua"), "return {}").unwrap();

    std::fs::write(
        project_root.join("alc.toml"),
        "[packages]\nshared_pkg = { path = \"shared_pkg\" }\n",
    )
    .unwrap();

    let lock = LockFile {
        version: 1,
        packages: vec![LockPackage {
            name: "shared_pkg".to_string(),
            version: None,
            source: PackageSource::Path {
                path: "shared_pkg".to_string(),
            },
        }],
    };
    crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();

    let svc = make_app_service_with_search_paths(vec![crate::service::resolve::SearchPath {
        path: search_dir.clone(),
        source: crate::service::resolve::SearchPathSource::Env,
    }])
    .await;

    let result = pkg_list_full(&svc, Some(project_root.to_string_lossy().to_string())).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    // Project entry (active, scope = "project") must have override_paths with the global path.
    let project_entry = packages
        .iter()
        .find(|p| p["name"] == "shared_pkg" && p["scope"] == "project")
        .expect("project shared_pkg not found");

    let override_paths = project_entry["override_paths"]
        .as_array()
        .expect("project entry should have override_paths listing shadowed global");

    let expected_global_canonical = std::fs::canonicalize(&global_pkg_dir)
        .unwrap()
        .display()
        .to_string();

    assert!(
        override_paths
            .iter()
            .any(|p| p.as_str().unwrap() == expected_global_canonical),
        "project override_paths should include the global pkg canonical path"
    );

    // Inactive global entry must NOT have override_paths.
    let global_entry = packages
        .iter()
        .find(|p| p["name"] == "shared_pkg" && p["scope"] == "global")
        .expect("global shared_pkg not found");

    assert_eq!(
        global_entry["active"], false,
        "global entry should be inactive"
    );
    let global_map = global_entry
        .as_object()
        .expect("global entry must be object");
    assert!(
        !global_map.contains_key("override_paths"),
        "inactive global entry must not have override_paths, got: {:?}",
        global_map.get("override_paths")
    );
}

/// Regression: a project `installed` entry must not list its own backing
/// directory (`packages_dir/{name}`) in `override_paths` just because the
/// global search paths include `packages_dir`. The entry's own
/// `resolved_source_path` canonicalizes to the same location, so that
/// occurrence is not a genuine shadow and must be filtered out.
#[tokio::test]
async fn pkg_list_project_installed_does_not_self_shadow() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();
    let packages_dir = home.join("packages");
    let pkg_dir = packages_dir.join("self_shadow_pkg");
    std::fs::create_dir_all(&pkg_dir).unwrap();
    std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();

    let tmp = tempfile::tempdir().unwrap();
    let project_root = tmp.path();

    std::fs::write(
        project_root.join("alc.toml"),
        "[packages]\nself_shadow_pkg = \"*\"\n",
    )
    .unwrap();

    let lock = LockFile {
        version: 1,
        packages: vec![LockPackage {
            name: "self_shadow_pkg".to_string(),
            version: None,
            source: PackageSource::Installed,
        }],
    };
    crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();

    // Include packages_dir as a search path — this is the real production
    // topology (see `resolve_lib_paths` in src/main.rs).
    let svc = make_app_service_at_with_search_paths(
        home.to_path_buf(),
        vec![crate::service::resolve::SearchPath {
            path: packages_dir.clone(),
            source: crate::service::resolve::SearchPathSource::Default,
        }],
    )
    .await;

    let result = pkg_list_full(&svc, Some(project_root.to_string_lossy().to_string())).await;
    drop(tmp);

    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    let project_entry = packages
        .iter()
        .find(|p| p["name"] == "self_shadow_pkg" && p["scope"] == "project")
        .expect("project self_shadow_pkg not found");

    let entry_map = project_entry
        .as_object()
        .expect("project entry must be object");

    assert!(
        !entry_map.contains_key("override_paths"),
        "project `installed` entry must not list its own backing dir as override_paths, got: {:?}",
        entry_map.get("override_paths")
    );
}

/// A global package that exists on disk but is NOT registered in
/// `installed.json` must NOT emit a `source_type` field.
///
/// Previously the code wrote `source_type: "global"` (an invalid enum
/// value) as a placeholder. After the typed DTO rewrite, absent manifest
/// entries leave `source_type` out of the output entirely.
#[tokio::test]
async fn pkg_list_global_unregistered_has_no_source_type() {
    let tmp = tempfile::tempdir().unwrap();
    let search_dir = tmp.path().join("pkgs");
    std::fs::create_dir_all(&search_dir).unwrap();

    // Create a package directory with init.lua — but do NOT write
    // installed.json (simulating a hand-copied / ALC_PACKAGES_PATH package).
    let pkg_dir = search_dir.join("hand_copied_pkg");
    std::fs::create_dir_all(&pkg_dir).unwrap();
    std::fs::write(
        pkg_dir.join("init.lua"),
        "return { meta = { name = 'hand_copied_pkg' } }",
    )
    .unwrap();

    let search_path = crate::service::resolve::SearchPath {
        path: search_dir,
        source: crate::service::resolve::SearchPathSource::Env,
    };
    let svc = make_app_service_with_search_paths(vec![search_path]).await;
    let result = pkg_list_full(&svc, None).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    let pkg = packages
        .iter()
        .find(|p| p["name"] == "hand_copied_pkg")
        .expect("hand_copied_pkg not found in pkg_list output");

    // source_type must be absent (not "global" or any other invalid value).
    let pkg_map = pkg
        .as_object()
        .expect("package entry must be a JSON object");
    assert!(
        !pkg_map.contains_key("source_type"),
        "source_type should be absent for unregistered package, got: {:?}",
        pkg_map.get("source_type")
    );
    assert_eq!(pkg["scope"], "global");
    assert_eq!(pkg["active"], true);

    // resolved_source_path must still be populated even without manifest
    // registration — filesystem access is independent of installed.json.
    let expected_canonical = std::fs::canonicalize(&pkg_dir)
        .unwrap()
        .display()
        .to_string();
    assert_eq!(
        pkg["resolved_source_path"].as_str().unwrap(),
        expected_canonical,
        "resolved_source_path should be populated regardless of manifest state"
    );
    // Unregistered packages default to "installed" kind (not "bundled").
    assert_eq!(
        pkg["resolved_source_kind"], "installed",
        "unregistered global package should default to installed kind"
    );
}

// ── variant scope (alc.local.toml) ───────────────────────────

/// `alc.local.toml` declares a variant pkg → it appears in `pkg_list` with
/// `scope: "variant"`, `resolved_source_kind: "variant"`, `active: true`,
/// and `path` set to the absolute pkg dir.
#[tokio::test]
async fn pkg_list_variant_pkg_appears_with_variant_scope() {
    let tmp = tempfile::tempdir().unwrap();
    let project_root = tmp.path();

    // Variant pkg lives outside the project root (typical worktree workflow).
    let pkg_dir = tmp.path().join("variant_src").join("my_variant_pkg");
    std::fs::create_dir_all(&pkg_dir).unwrap();
    std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();

    std::fs::write(
        project_root.join("alc.local.toml"),
        format!(
            "[packages]\nmy_variant_pkg = {{ path = \"{}\" }}\n",
            pkg_dir.display()
        ),
    )
    .unwrap();

    let svc = make_app_service().await;
    let result = pkg_list_full(&svc, Some(project_root.to_string_lossy().to_string())).await;

    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    let entry = packages
        .iter()
        .find(|p| p["name"] == "my_variant_pkg")
        .expect("my_variant_pkg not found in pkg_list output");

    assert_eq!(entry["scope"], "variant");
    assert_eq!(entry["active"], true);
    assert_eq!(entry["source_type"], "path");
    assert_eq!(entry["resolved_source_kind"], "variant");

    let expected_canonical = std::fs::canonicalize(&pkg_dir)
        .unwrap()
        .display()
        .to_string();
    assert_eq!(
        entry["resolved_source_path"].as_str().unwrap(),
        expected_canonical,
        "resolved_source_path should canonicalize to the variant pkg dir"
    );
    assert_eq!(
        entry["path"].as_str().unwrap(),
        pkg_dir.display().to_string(),
        "path should be the absolute pkg_dir as declared in alc.local.toml"
    );
}

/// A variant pkg shadowing a same-name global package: the variant entry
/// is `active: true`, the global one is demoted to `active: false`.
#[tokio::test]
async fn pkg_list_variant_shadows_global() {
    let tmp = tempfile::tempdir().unwrap();
    let project_root = tmp.path();
    let global_dir = tmp.path().join("global_pkgs");
    std::fs::create_dir_all(&global_dir).unwrap();

    // Global pkg of the same name.
    let global_pkg = global_dir.join("shared");
    std::fs::create_dir_all(&global_pkg).unwrap();
    std::fs::write(global_pkg.join("init.lua"), "return {}").unwrap();

    // Variant pkg.
    let variant_pkg = tmp.path().join("variant_src").join("shared");
    std::fs::create_dir_all(&variant_pkg).unwrap();
    std::fs::write(variant_pkg.join("init.lua"), "return {}").unwrap();

    std::fs::write(
        project_root.join("alc.local.toml"),
        format!(
            "[packages]\nshared = {{ path = \"{}\" }}\n",
            variant_pkg.display()
        ),
    )
    .unwrap();

    let svc = make_app_service_with_search_paths(vec![crate::service::resolve::SearchPath {
        path: global_dir.clone(),
        source: crate::service::resolve::SearchPathSource::Env,
    }])
    .await;

    let result = pkg_list_summary(&svc, Some(project_root.to_string_lossy().to_string())).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    let variant_entry = packages
        .iter()
        .find(|p| p["name"] == "shared" && p["scope"] == "variant")
        .expect("variant 'shared' entry not found");
    assert_eq!(variant_entry["active"], true);

    let global_entry = packages
        .iter()
        .find(|p| p["name"] == "shared" && p["scope"] == "global")
        .expect("global 'shared' entry not found");
    assert_eq!(
        global_entry["active"], false,
        "global entry must be demoted when shadowed by variant"
    );
}

/// A variant pkg with the same name as an `alc.toml`-declared project pkg:
/// the variant entry wins (`active: true`), the project entry is demoted.
#[tokio::test]
async fn pkg_list_variant_shadows_project() {
    let tmp = tempfile::tempdir().unwrap();
    let project_root = tmp.path();

    // Project pkg via alc.toml + alc.lock (path entry).
    let project_pkg = project_root.join("shared");
    std::fs::create_dir_all(&project_pkg).unwrap();
    std::fs::write(project_pkg.join("init.lua"), "return {}").unwrap();
    std::fs::write(
        project_root.join("alc.toml"),
        "[packages]\nshared = { path = \"shared\" }\n",
    )
    .unwrap();
    let lock = LockFile {
        version: 1,
        packages: vec![LockPackage {
            name: "shared".to_string(),
            version: None,
            source: PackageSource::Path {
                path: "shared".to_string(),
            },
        }],
    };
    crate::service::lockfile::save_lockfile(project_root, &lock).unwrap();

    // Variant override.
    let variant_pkg = tmp.path().join("variant_src").join("shared");
    std::fs::create_dir_all(&variant_pkg).unwrap();
    std::fs::write(variant_pkg.join("init.lua"), "return {}").unwrap();
    std::fs::write(
        project_root.join("alc.local.toml"),
        format!(
            "[packages]\nshared = {{ path = \"{}\" }}\n",
            variant_pkg.display()
        ),
    )
    .unwrap();

    let svc = make_app_service().await;
    let result = pkg_list_summary(&svc, Some(project_root.to_string_lossy().to_string())).await;
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();
    let packages = json["packages"].as_array().unwrap();

    let variant_entry = packages
        .iter()
        .find(|p| p["name"] == "shared" && p["scope"] == "variant")
        .expect("variant 'shared' entry not found");
    assert_eq!(variant_entry["active"], true);

    let project_entry = packages
        .iter()
        .find(|p| p["name"] == "shared" && p["scope"] == "project")
        .expect("project 'shared' entry not found");
    assert_eq!(
        project_entry["active"], false,
        "project entry must be demoted when shadowed by variant"
    );
}

// ── pkg_repair tests ────────────────────────────────────────

/// (B) installed dir missing: pkg_install populates manifest, then we delete
/// the dest dir, then pkg_repair must restore it via reinstall.
#[tokio::test]
async fn pkg_repair_reinstalls_missing_installed_dir() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();

    // Build a source pkg dir outside of HOME.
    let source = home.join("src_repo").join("repair_pkg");
    std::fs::create_dir_all(&source).unwrap();
    std::fs::write(
        source.join("init.lua"),
        "return { meta = { version = '0.1.0' } }",
    )
    .unwrap();

    let svc = make_app_service_at(home.to_path_buf()).await;

    // Initial install — populates installed.json and creates dest dir.
    svc.pkg_install(source.display().to_string(), None, None)
        .await
        .expect("initial install");

    let dest = home.join("packages").join("repair_pkg");
    assert!(dest.exists(), "dest must exist after install");

    // Simulate breakage: remove the dest dir.
    std::fs::remove_dir_all(&dest).unwrap();
    assert!(!dest.exists());

    // Repair — should re-run install from manifest source.
    let result = svc.pkg_repair(None, None).await.unwrap();
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();

    let repaired = json["repaired"].as_array().expect("repaired array");
    assert_eq!(repaired.len(), 1, "exactly one repair, got: {json}");
    assert_eq!(repaired[0]["name"], "repair_pkg");
    assert_eq!(repaired[0]["kind"], "installed_missing");
    assert_eq!(repaired[0]["action"], "reinstall");
    assert!(dest.exists(), "dest must be restored after repair");
}

/// Healthy package — manifest entry + dest exist → Skipped.
#[tokio::test]
async fn pkg_repair_skips_healthy_pkg() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();

    let source = home.join("src_repo").join("healthy_pkg");
    std::fs::create_dir_all(&source).unwrap();
    std::fs::write(source.join("init.lua"), "return {}").unwrap();

    let svc = make_app_service_at(home.to_path_buf()).await;
    svc.pkg_install(source.display().to_string(), None, None)
        .await
        .unwrap();

    let result = svc.pkg_repair(None, None).await.unwrap();
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();

    assert!(
        json["repaired"].as_array().unwrap().is_empty(),
        "no repair expected"
    );
    let skipped = json["skipped"].as_array().unwrap();
    assert!(
        skipped.iter().any(|e| e["name"] == "healthy_pkg"),
        "healthy_pkg must be in skipped, got: {json}"
    );
}

/// (A) global symlink dangling — surfaced as unrepairable.
#[tokio::test]
async fn pkg_repair_reports_dangling_symlink_as_unrepairable() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();

    // Create the packages dir and a dangling symlink in it.
    let pkg_dir = home.join("packages");
    std::fs::create_dir_all(&pkg_dir).unwrap();

    let target = home.join("does_not_exist");
    let link = pkg_dir.join("dangling_pkg");
    std::os::unix::fs::symlink(&target, &link).unwrap();

    let svc = make_app_service_at(home.to_path_buf()).await;
    let result = svc.pkg_repair(None, None).await.unwrap();
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();

    let unrepairable = json["unrepairable"].as_array().expect("unrepairable array");
    let entry = unrepairable
        .iter()
        .find(|e| e["name"] == "dangling_pkg")
        .expect("dangling_pkg must surface as unrepairable");
    assert_eq!(entry["kind"], "symlink_dangling");
    assert!(
        entry["suggestion"]
            .as_str()
            .unwrap()
            .contains("alc_pkg_unlink"),
        "suggestion should mention alc_pkg_unlink"
    );
}

/// (C) project-scope `path = ...` declared in alc.toml but the path doesn't
/// exist on disk — surfaced as unrepairable with `scope: "project"`.
#[tokio::test]
async fn pkg_repair_reports_project_path_missing_as_unrepairable() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();
    let project_root = home.join("proj");
    std::fs::create_dir_all(&project_root).unwrap();

    std::fs::write(
        project_root.join("alc.toml"),
        "[packages]\nghost = { path = \"missing_dir\" }\n",
    )
    .unwrap();

    let svc = make_app_service_at(home.to_path_buf()).await;
    let result = svc
        .pkg_repair(None, Some(project_root.to_string_lossy().to_string()))
        .await
        .unwrap();
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();

    let unrepairable = json["unrepairable"].as_array().unwrap();
    let entry = unrepairable
        .iter()
        .find(|e| e["name"] == "ghost" && e["scope"] == "project")
        .unwrap_or_else(|| panic!("ghost must surface as project path_missing, got: {json}"));
    assert_eq!(entry["kind"], "path_missing");
    assert!(entry["suggestion"].as_str().unwrap().contains("alc.toml"));
}

/// (D) variant-scope `path = ...` declared in alc.local.toml but the path
/// doesn't exist on disk — surfaced as unrepairable with `scope: "variant"`.
#[tokio::test]
async fn pkg_repair_reports_variant_path_missing_as_unrepairable() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();
    let project_root = home.join("proj");
    std::fs::create_dir_all(&project_root).unwrap();

    let absent = project_root.join("nope_pkg");
    std::fs::write(
        project_root.join("alc.local.toml"),
        format!(
            "[packages]\nnope_pkg = {{ path = \"{}\" }}\n",
            absent.display()
        ),
    )
    .unwrap();

    let svc = make_app_service_at(home.to_path_buf()).await;
    let result = svc
        .pkg_repair(None, Some(project_root.to_string_lossy().to_string()))
        .await
        .unwrap();
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();

    let unrepairable = json["unrepairable"].as_array().unwrap();
    let entry = unrepairable
        .iter()
        .find(|e| e["name"] == "nope_pkg" && e["scope"] == "variant")
        .expect("nope_pkg must surface as variant path_missing");
    assert_eq!(entry["kind"], "path_missing");
    assert!(entry["suggestion"]
        .as_str()
        .unwrap()
        .contains("alc_pkg_unlink"));
}

/// `name` filter that matches nothing → Err with informative message.
#[tokio::test]
async fn pkg_repair_unknown_name_returns_error() {
    // Tempdir-rooted AppDir isolates the test from the developer's
    // real `~/.algocline/packages/` so no probe name conflicts.
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();

    let svc = make_app_service_at(home.to_path_buf()).await;
    let err = svc
        .pkg_repair(Some("nonexistent_pkg".to_string()), None)
        .await
        .unwrap_err();
    assert!(
        err.contains("nonexistent_pkg"),
        "error should mention the missing name, got: {err}"
    );
}

/// LocalPath source dir vanished after manifest was written: classify as
/// Unrepairable (structural impossibility — no bytes to copy from), not
/// Failed (runtime error). Also verifies the `reason` names the missing path
/// so the operator can act on it without reading installed.json by hand.
#[tokio::test]
async fn pkg_repair_reports_localpath_source_missing_as_unrepairable() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();

    let source = home.join("gone").join("ghost_pkg");
    std::fs::create_dir_all(&source).unwrap();
    std::fs::write(source.join("init.lua"), "return {}").unwrap();

    let svc = make_app_service_at(home.to_path_buf()).await;
    svc.pkg_install(source.display().to_string(), None, None)
        .await
        .expect("initial install");

    // Break both the installed dest AND the source so repair can't auto-heal.
    let dest = home.join("packages").join("ghost_pkg");
    std::fs::remove_dir_all(&dest).unwrap();
    std::fs::remove_dir_all(&source).unwrap();

    let result = svc.pkg_repair(None, None).await.unwrap();
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();

    assert!(
        json["failed"].as_array().unwrap().is_empty(),
        "missing-source must not leak into `failed`; got: {json}"
    );
    let unrepairable = json["unrepairable"].as_array().expect("unrepairable");
    let entry = unrepairable
        .iter()
        .find(|e| e["name"] == "ghost_pkg")
        .unwrap_or_else(|| panic!("ghost_pkg must appear in unrepairable, got: {json}"));
    assert_eq!(entry["kind"], "installed_missing");
    let reason = entry["reason"].as_str().unwrap();
    assert!(
        reason.contains("source directory missing"),
        "reason should mention missing source, got: {reason}"
    );
    assert!(
        reason.contains("ghost_pkg"),
        "reason should name the path, got: {reason}"
    );
}

/// LocalPath source exists but has no `init.lua` at root: current install
/// layer routes this into collection mode which rejects `name`, so repair
/// can't produce bytes for the named pkg. Classify Unrepairable (structural
/// impossibility) rather than Failed.
#[tokio::test]
async fn pkg_repair_reports_localpath_without_init_lua_as_unrepairable() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();

    // Build a valid source first so pkg_install succeeds and writes manifest.
    let source = home.join("shell").join("shell_pkg");
    std::fs::create_dir_all(&source).unwrap();
    std::fs::write(source.join("init.lua"), "return {}").unwrap();

    let svc = make_app_service_at(home.to_path_buf()).await;
    svc.pkg_install(source.display().to_string(), None, None)
        .await
        .expect("initial install");

    // Break the installed dest, then mutate the source into an empty-dir
    // shape (remove init.lua) so repair's pre-check lands on the
    // "no init.lua at root" branch rather than the source-missing one.
    let dest = home.join("packages").join("shell_pkg");
    std::fs::remove_dir_all(&dest).unwrap();
    std::fs::remove_file(source.join("init.lua")).unwrap();

    let result = svc.pkg_repair(None, None).await.unwrap();
    let json: serde_json::Value = serde_json::from_str(&result).unwrap();

    assert!(
        json["failed"].as_array().unwrap().is_empty(),
        "init.lua-missing must not leak into `failed`; got: {json}"
    );
    let entry = json["unrepairable"]
        .as_array()
        .unwrap()
        .iter()
        .find(|e| e["name"] == "shell_pkg")
        .unwrap_or_else(|| panic!("shell_pkg must appear in unrepairable, got: {json}"))
        .clone();
    assert_eq!(entry["kind"], "installed_missing");
    let reason = entry["reason"].as_str().unwrap();
    assert!(
        reason.contains("no init.lua at root"),
        "reason should cite missing init.lua, got: {reason}"
    );
}

/// Direct `pkg_install` against a non-existent path must surface a clear
/// "Source directory does not exist" error rather than the misleading
/// collection-mode "'name' parameter is only supported..." error (which
/// previously fired because a missing source has no `init.lua` at root and
/// fell through to the collection branch).
#[tokio::test]
async fn pkg_install_rejects_missing_local_source_with_clear_error() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();

    let svc = make_app_service_at(home.to_path_buf()).await;
    let missing = "/tmp/alc-nonexistent-source-for-test-2e8f3a";
    let err = svc
        .pkg_install(missing.to_string(), Some("anything".to_string()), None)
        .await
        .unwrap_err();
    assert!(
        err.contains("Source directory does not exist"),
        "expected explicit source-missing error, got: {err}"
    );
    assert!(
        !err.contains("'name' parameter"),
        "must not regress to collection-mode misleading error, got: {err}"
    );
}

// ─── Size regression guard (ST3) ──────────────────────────────
//
// Before the list-tool unification (pre-ST1), `alc_pkg_list` and
// `alc_hub_search` emitted 63K–68K-char single-line JSON that exceeded
// Claude Code's context window on populated environments. The summary
// preset now caps per-entry output to roughly 6 fields; at the default
// `limit=50` that should stay well under 15,000 chars. These two tests
// pin that contract so any regression toward the fat shape is caught
// immediately, regardless of preset churn.

/// FNV-1a hash — reproduces `hub::cache_key` verbatim so the test can
/// pre-populate the per-source cache without exposing the internal.
fn fnv1a_hex(url: &str) -> String {
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
    for b in url.as_bytes() {
        h ^= *b as u64;
        h = h.wrapping_mul(0x0100_0000_01b3);
    }
    format!("{h:016x}")
}

/// `alc_pkg_list` default summary (`verbose="summary"`) must stay under
/// 15_000 chars with a realistically populated test environment.
///
/// Setup: write 60 synthetic installed packages under a search path
/// (each with an `init.lua`). Summary preset projects 6 fields per
/// entry; default `limit=50` caps the projected array, so the fixture
/// populates **60 packages** to exercise the default-limit worst case
/// (truncation to 50 entries). Real-world environments have ~100-150
/// installed packages but hit the same 50-entry cap — 50 entries × ~224
/// chars/entry puts the ceiling around 11 KB, so the 15_000 budget
/// leaves ~34% headroom for natural field churn.
#[tokio::test]
async fn pkg_list_default_summary_is_compact() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();

    let tmp = tempfile::tempdir().unwrap();
    let search_dir = tmp.path().join("pkgs");
    std::fs::create_dir_all(&search_dir).unwrap();

    // 60 > default limit (50). Guards the actual default-limit worst case:
    // real-world setups (100-150 pkgs) hit the same 50-entry truncation.
    for i in 0..60 {
        let pkg_dir = search_dir.join(format!("size_regression_pkg_{i:02}"));
        std::fs::create_dir_all(&pkg_dir).unwrap();
        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
    }

    let search_path = crate::service::resolve::SearchPath {
        path: search_dir,
        source: crate::service::resolve::SearchPathSource::Env,
    };
    let svc = make_app_service_at_with_search_paths(home.to_path_buf(), vec![search_path]).await;
    let out = pkg_list_summary(&svc, None).await;

    assert!(
        out.len() < 15_000,
        "pkg_list default summary should stay compact (got {} chars)",
        out.len()
    );
    // Sanity: output must be valid JSON and include at least one
    // package — otherwise the budget is meaningless.
    let json: serde_json::Value = serde_json::from_str(&out).unwrap();
    assert!(
        !json["packages"].as_array().unwrap().is_empty(),
        "size test is meaningless without populated packages"
    );
}

/// `alc_hub_search` default summary (`verbose="summary"`) must stay under
/// 10_000 chars. To keep the test deterministic (and offline), the
/// per-source cache is primed with empty `HubIndex` entries for every
/// URL that `discover_index_urls` can surface under the tempdir-rooted
/// `AppDir` this test installs:
///
/// - `hub.collection_url` is unset → no Tier 0 entry.
/// - No registries / manifest → only the compiled-in `AUTO_INSTALL_SOURCES`
///   remain (2 entries), transformed by `repo_to_index_url` to raw
///   GitHub URLs.
///
/// Populating the cache for those 2 URLs with an empty `HubIndex` means
/// `fetch_one` returns early and never makes an HTTP call, so the test
/// works offline and finishes in milliseconds.
#[tokio::test]
async fn hub_search_default_summary_is_compact() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();

    let cache_dir = home.join("hub_cache");
    std::fs::create_dir_all(&cache_dir).unwrap();

    // Empty HubIndex JSON — matches the schema `fetch_one` deserializes.
    let empty_index = serde_json::json!({
        "schema_version": "hub_index/v0",
        "updated_at": "",
        "packages": [],
    })
    .to_string();

    // Seed cache for every URL `discover_index_urls` may surface
    // under the tempdir-rooted `AppDir`. The compiled-in seeds live
    // in `AUTO_INSTALL_SOURCES`; they are passed through
    // `repo_to_index_url` before hitting the cache layer. If this
    // list drifts the test must follow — `repo_to_index_url` is
    // verified verbatim by `hub.rs` unit tests.
    for repo in [
        "https://github.com/ynishi/algocline-bundled-packages",
        "https://github.com/ynishi/evalframe",
    ] {
        let owner_repo = repo.trim_start_matches("https://github.com/");
        let index_url =
            format!("https://raw.githubusercontent.com/{owner_repo}/main/hub_index.json");
        let cache_path = cache_dir.join(format!("{}.json", fnv1a_hex(&index_url)));
        std::fs::write(&cache_path, &empty_index).unwrap();
    }

    let svc = make_app_service_at(home.to_path_buf()).await;
    // Call the internal `AppService::hub_search` directly (same form
    // `engine_api_impl::hub_search` uses after folding MCP params into
    // `ListOpts`). Keeps the test at the app-layer boundary and avoids
    // the EngineApi trait import dance.
    let out = svc.hub_search(None, None, None, opts()).unwrap();

    assert!(
        out.len() < 10_000,
        "hub_search default summary should stay compact (got {} chars)",
        out.len()
    );
    // Sanity: output is valid JSON.
    let _: serde_json::Value = serde_json::from_str(&out).unwrap();
}

// ─── limit=0 → "no limit" (empty-means-all idiom) ──────────────
//
// `limit = Some(0)` means "return all entries" — the list-tool contract
// mirrors common `empty=all & some=filter` idioms. Default (`None`) still
// caps at 50. Pins the truncation branch for both tools.

/// `alc_pkg_list` with `limit = Some(0)` returns every entry (no cap).
#[tokio::test]
async fn pkg_list_limit_zero_returns_all() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();

    let tmp = tempfile::tempdir().unwrap();
    let search_dir = tmp.path().join("pkgs");
    std::fs::create_dir_all(&search_dir).unwrap();

    // Populate 60 packages — more than the default cap of 50 — so a
    // regression that silently applies the default limit to `Some(0)`
    // would surface as a truncated array.
    for i in 0..60 {
        let pkg_dir = search_dir.join(format!("limit_zero_pkg_{i:02}"));
        std::fs::create_dir_all(&pkg_dir).unwrap();
        std::fs::write(pkg_dir.join("init.lua"), "return {}").unwrap();
    }

    let search_path = crate::service::resolve::SearchPath {
        path: search_dir,
        source: crate::service::resolve::SearchPathSource::Env,
    };
    let svc = make_app_service_at_with_search_paths(home.to_path_buf(), vec![search_path]).await;

    let out = svc
        .pkg_list(
            None,
            ListOpts {
                limit: Some(0),
                ..opts()
            },
        )
        .await
        .unwrap();

    let json: serde_json::Value = serde_json::from_str(&out).unwrap();
    let packages = json["packages"].as_array().expect("packages array");
    assert_eq!(
        packages.len(),
        60,
        "limit=0 must return all 60 entries (got {})",
        packages.len()
    );
}

/// `alc_hub_search` with `limit = Some(0)` skips truncation — the result
/// array length equals `total`. Uses a primed empty index (same pattern
/// as `hub_search_default_summary_is_compact`) so `results` is an empty
/// array whose length equals `total`; the non-truncation branch is
/// confirmed by both sides agreeing.
#[tokio::test]
async fn hub_search_limit_zero_returns_all() {
    let tmp = tempfile::tempdir().unwrap();
    let home = tmp.path();

    let cache_dir = home.join("hub_cache");
    std::fs::create_dir_all(&cache_dir).unwrap();

    let empty_index = serde_json::json!({
        "schema_version": "hub_index/v0",
        "updated_at": "",
        "packages": [],
    })
    .to_string();

    for repo in [
        "https://github.com/ynishi/algocline-bundled-packages",
        "https://github.com/ynishi/evalframe",
    ] {
        let owner_repo = repo.trim_start_matches("https://github.com/");
        let index_url =
            format!("https://raw.githubusercontent.com/{owner_repo}/main/hub_index.json");
        let cache_path = cache_dir.join(format!("{}.json", fnv1a_hex(&index_url)));
        std::fs::write(&cache_path, &empty_index).unwrap();
    }

    let svc = make_app_service_at(home.to_path_buf()).await;
    let out = svc
        .hub_search(
            None,
            None,
            None,
            ListOpts {
                limit: Some(0),
                ..opts()
            },
        )
        .unwrap();

    let json: serde_json::Value = serde_json::from_str(&out).unwrap();
    let results = json["results"].as_array().expect("results array");
    let total = json["total"].as_u64().expect("total number");
    assert_eq!(
        results.len() as u64,
        total,
        "limit=0 must not truncate: results.len={} vs total={}",
        results.len(),
        total
    );
}
