// Copyright 2018-2026 the Deno authors. MIT license.

//! Framework detection for `deno compile .`.
//!
//! Detects web frameworks (Next.js, Astro, Remix, SvelteKit, Nuxt, Fresh,
//! SolidStart, TanStack Start, Vite) and generates the appropriate
//! entrypoint and include paths so that `deno compile .` just works.

use std::path::Path;
use std::path::PathBuf;

use deno_core::anyhow::bail;
use deno_core::error::AnyError;
use deno_core::serde_json;

/// Result of framework detection.
#[derive(Debug)]
pub struct FrameworkDetection {
  /// Name of the detected framework (for display).
  pub name: &'static str,
  /// Generated entrypoint TypeScript/JavaScript code (production).
  pub entrypoint_code: String,
  /// Directories to include in the compiled binary.
  pub include_paths: Vec<String>,
  /// Optional build command to run before compilation (e.g. "next build").
  /// The command is run with the detected directory as cwd.
  pub build_command: Option<Vec<String>>,
}

impl FrameworkDetection {
  /// Directories (relative to the project root) where the framework keeps
  /// static assets like favicons. Used to auto-detect a desktop app icon
  /// when the user didn't supply `--icon`.
  pub fn static_asset_dirs(&self) -> &'static [&'static str] {
    match self.name {
      // Next.js App Router puts `favicon.ico` / `icon.*` directly in `app/`
      // (or `src/app/`); the Pages Router and older versions use `public/`.
      "Next.js" => &["public", "app", "src/app"],
      "Fresh" | "SvelteKit" => &["static"],
      _ => &["public"],
    }
  }
}

/// Search a framework's static asset directories for a favicon that can
/// double as the desktop app icon. Returns the first match in priority
/// order, restricted to file formats supported by `target_os` (so we
/// don't pick a `.png` for a Windows build where bundling would silently
/// drop it).
pub fn find_framework_favicon(
  dir: &Path,
  detection: &FrameworkDetection,
  target_os: &str,
) -> Option<PathBuf> {
  let exts: &[&str] = match target_os {
    "macos" => &["icns", "png"],
    "windows" => &["ico"],
    _ => &["png"],
  };
  let names = ["icon", "favicon", "apple-touch-icon", "logo"];
  for sub in detection.static_asset_dirs() {
    let base = dir.join(sub);
    if !base.is_dir() {
      continue;
    }
    for name in names {
      for ext in exts {
        let candidate = base.join(format!("{name}.{ext}"));
        if candidate.is_file() {
          return Some(candidate);
        }
      }
    }
  }
  None
}

/// Detect a web framework in the given directory.
///
/// Detection priority:
/// 1. Config-file based detection (highest priority)
/// 2. Package.json dependency-based detection
/// 3. deno.json import-based detection
pub fn detect_framework(
  dir: &Path,
) -> Result<Option<FrameworkDetection>, AnyError> {
  // --- Config-file based detection (highest priority) ---

  // Next.js: next.config.{js,mjs,ts}
  if has_config_file(dir, "next.config") {
    return Ok(Some(detect_nextjs(dir)?));
  }

  // Fresh: fresh.gen.ts or _fresh/
  if dir.join("fresh.gen.ts").exists() || dir.join("_fresh").is_dir() {
    return Ok(Some(detect_fresh(dir)));
  }

  // Astro: astro.config.{mjs,ts,js}
  if has_config_file(dir, "astro.config") {
    return Ok(Some(detect_astro(dir)));
  }

  // Nuxt: nuxt.config.{ts,js,mjs}
  if has_config_file(dir, "nuxt.config") {
    return Ok(Some(detect_nuxt(dir)));
  }

  // SvelteKit: svelte.config.{js,ts} plus a `@sveltejs/kit` dependency. Once
  // SvelteKit owns detection we must NOT fall through to the generic Vite
  // branch below (SvelteKit ships a vite.config + `vite` dependency, so it
  // would otherwise be misdetected as a plain Vite SPA and bundle a
  // non-existent `dist/`). `detect_sveltekit` resolves the adapter's output
  // shape, or errors with an actionable hint when the configured adapter isn't
  // one `deno desktop` can bundle. A bare `svelte.config.*` is not enough on
  // its own: the Vite Svelte template ships a `svelte.config.js` with only
  // `vitePreprocess()` and no SvelteKit, and such plain Svelte SPAs must keep
  // falling through to the Vite branch that bundles `dist/`.
  let has_sveltekit = read_package_deps(dir)
    .map(|deps| deps.has("@sveltejs/kit") || deps.has_dev("@sveltejs/kit"))
    .unwrap_or(false);
  if has_config_file(dir, "svelte.config") && has_sveltekit {
    return detect_sveltekit(dir).map(Some);
  }

  // --- Package.json dependency-based detection ---
  if let Some(deps) = read_package_deps(dir) {
    // Remix
    if deps.has("@remix-run/react") || deps.has_dev("@remix-run/dev") {
      return Ok(Some(detect_remix(dir)));
    }

    // SolidStart
    if deps.has("@solidjs/start") {
      return Ok(Some(detect_nitro_framework(dir, "SolidStart")));
    }

    // TanStack Start
    if deps.has("@tanstack/react-start") || deps.has("@tanstack/solid-start") {
      return Ok(Some(detect_nitro_framework(dir, "TanStack Start")));
    }
  }

  // --- Vite (lowest priority among bundlers) ---
  // A generic Vite project, recognized by its config file or a `vite`
  // dependency. Many meta-frameworks build on Vite, but those are all matched
  // earlier (by their own config file or framework dependency), so reaching
  // here means a plain Vite app (SPA/MPA or a hand-rolled SSR server).
  let has_vite_dep = read_package_deps(dir)
    .map(|deps| deps.has("vite") || deps.has_dev("vite"))
    .unwrap_or(false);
  if has_config_file(dir, "vite.config") || has_vite_dep {
    return Ok(Some(detect_vite(dir)));
  }

  // --- deno.json import-based detection ---
  if let Some(imports) = read_deno_json_imports(dir)
    && imports
      .iter()
      .any(|i| i.starts_with("fresh") || i.starts_with("@fresh/core"))
  {
    return Ok(Some(detect_fresh(dir)));
  }

  Ok(None)
}

// --- Framework-specific detection ---

fn deno_exe() -> String {
  std::env::current_exe()
    .map(|p| p.display().to_string())
    .unwrap_or_else(|_| "deno".into())
}

fn deno_task_build() -> Vec<String> {
  vec![deno_exe(), "task".into(), "build".into()]
}

fn detect_nextjs(dir: &Path) -> Result<FrameworkDetection, AnyError> {
  let version = detect_package_version(dir, "next").unwrap_or(15);
  let entrypoint = format!(
    r#"// @ts-nocheck
import {{ nextStart }} from "npm:next@^{version}/dist/cli/next-start.js";
globalThis.addEventListener("unhandledrejection", (e) => {{
  console.error("[entrypoint] Unhandled rejection:", e.reason);
  if (e.reason?.stack) console.error("[entrypoint] Stack:", e.reason.stack);
}});
// Guard: skip for forked workers (child_process.fork sets NODE_CHANNEL_FD).
// Workers use override_main_module to run their target script directly.
if (!Deno.env.get("NODE_CHANNEL_FD")) {{
  // Use import.meta.dirname so paths resolve against the VFS in the
  // compiled binary rather than the runtime CWD.
  await nextStart({{ hostname: "0.0.0.0" }}, import.meta.dirname);
}}
"#,
  );
  // `next-server` serves files in `public/` at the URL root; without it
  // shipped, every `<img src="/foo.png">` 404s. Optional in the project
  // (some apps put nothing there), so only include when present.
  let mut include_paths = vec![".next".into()];
  if dir.join("public").is_dir() {
    include_paths.push("public".into());
  }
  Ok(FrameworkDetection {
    name: "Next.js",
    entrypoint_code: entrypoint,
    include_paths,
    build_command: Some(deno_task_build()),
  })
}

fn detect_astro(_dir: &Path) -> FrameworkDetection {
  FrameworkDetection {
    name: "Astro",
    entrypoint_code: "// @ts-nocheck\nimport \"./dist/server/entry.mjs\";\n"
      .into(),
    include_paths: vec!["dist".into()],
    build_command: Some(deno_task_build()),
  }
}

fn detect_fresh(dir: &Path) -> FrameworkDetection {
  // Fresh 2.x uses _fresh/server.js (build output) or imports @fresh/core
  // in deno.json. We intentionally do NOT use `fresh.gen.ts + deno.json`
  // as a heuristic because Fresh 1 projects also have both of those files.
  let is_fresh2 = dir.join("_fresh/server.js").exists()
    || read_deno_json_imports(dir)
      .map(|imports| imports.iter().any(|i| i.starts_with("@fresh/core")))
      .unwrap_or(false);
  if is_fresh2 {
    // `_fresh/snapshot.js` records static assets as `filePath:
    // "static/foo.png"` and `_fresh/server.js` constructs the
    // ProdBuildCache with `root = path.join(import.meta.dirname, "..")`,
    // so the runtime reads them via `<root>/static/...`. The `static/`
    // directory must therefore land in the VFS alongside `_fresh/` or
    // every image / font / video 404s. `static/` is conventional for
    // Fresh; if it doesn't exist the include is a harmless no-op.
    let mut include_paths = vec!["_fresh".into()];
    if dir.join("static").is_dir() {
      include_paths.push("static".into());
    }
    FrameworkDetection {
      name: "Fresh",
      entrypoint_code: r#"// @ts-nocheck
const mod = await import("./_fresh/server.js");
Deno.serve(mod.default.fetch);
"#
      .into(),
      include_paths,
      build_command: Some(vec![deno_exe(), "task".into(), "build".into()]),
    }
  } else {
    // Fresh 1.x — no build step needed, server-rendered
    FrameworkDetection {
      name: "Fresh",
      entrypoint_code: "// @ts-nocheck\nimport \"./main.ts\";\n".into(),
      include_paths: vec![],
      build_command: None,
    }
  }
}

fn detect_remix(dir: &Path) -> FrameworkDetection {
  // `remix-serve` serves files from `public/` at the URL root; ship it
  // when present so static assets resolve.
  let mut include_paths = vec!["build".into()];
  if dir.join("public").is_dir() {
    include_paths.push("public".into());
  }
  FrameworkDetection {
    name: "Remix",
    entrypoint_code:
      "// @ts-nocheck\nimport \"./node_modules/.bin/remix-serve\";\n".into(),
    include_paths,
    build_command: Some(deno_task_build()),
  }
}

fn detect_nuxt(dir: &Path) -> FrameworkDetection {
  detect_nitro_framework(dir, "Nuxt")
}

fn detect_sveltekit(dir: &Path) -> Result<FrameworkDetection, AnyError> {
  // Prefer post-build evidence of a supported output shape, since that
  // proves which adapter was actually used.
  if dir.join(".deno-deploy/server.ts").exists() {
    return Ok(FrameworkDetection {
      name: "SvelteKit",
      entrypoint_code: "// @ts-nocheck\nimport \"./.deno-deploy/server.ts\";\n"
        .into(),
      include_paths: vec![".deno-deploy".into()],
      build_command: Some(deno_task_build()),
    });
  }
  if dir.join(".output/server/index.ts").exists()
    || dir.join(".output/server/index.mjs").exists()
  {
    let ext = if dir.join(".output/server/index.ts").exists() {
      "ts"
    } else {
      "mjs"
    };
    return Ok(FrameworkDetection {
      name: "SvelteKit",
      entrypoint_code: format!(
        "// @ts-nocheck\nimport \"./.output/server/index.{ext}\";\n"
      ),
      include_paths: vec![".output".into()],
      build_command: Some(deno_task_build()),
    });
  }
  // `@sveltejs/adapter-node` and `svelte-adapter-deno` both emit a `build/`
  // directory whose `index.js` boots the server and whose `handler.js` serves
  // the static assets from sibling `client`/`static`/`prerendered`
  // directories. Those asset directories aren't part of the module graph, so
  // they must be included explicitly or every request 404s in the compiled
  // binary. The adapter-node server runs under Deno via Node.js compatibility.
  if dir.join("build/index.js").exists()
    && dir.join("build/handler.js").exists()
  {
    return Ok(FrameworkDetection {
      name: "SvelteKit",
      entrypoint_code: "// @ts-nocheck\nimport \"./build/index.js\";\n".into(),
      include_paths: sveltekit_build_includes(dir),
      build_command: Some(deno_task_build()),
    });
  }
  // No build artifacts yet — fall back to config inspection.
  let config_text = read_config_text(dir, "svelte.config");
  if config_text.contains("@deno/svelte-adapter") {
    return Ok(FrameworkDetection {
      name: "SvelteKit",
      entrypoint_code: "// @ts-nocheck\nimport \"./.deno-deploy/server.ts\";\n"
        .into(),
      include_paths: vec![".deno-deploy".into()],
      build_command: Some(deno_task_build()),
    });
  }
  // `@sveltejs/adapter-node` emits the same `build/index.js` shape as
  // `svelte-adapter-deno` and runs under Deno via Node.js compatibility, so no
  // Deno-specific adapter is required. Default `out` is `build`; only
  // `build/client` is guaranteed to exist after the build, so include just
  // that here — the post-build branch above picks up `static`/`prerendered`
  // when they're present.
  if config_text.contains("svelte-adapter-deno")
    || config_text.contains("@sveltejs/adapter-node")
  {
    return Ok(FrameworkDetection {
      name: "SvelteKit",
      entrypoint_code: "// @ts-nocheck\nimport \"./build/index.js\";\n".into(),
      include_paths: vec!["build/client".into()],
      build_command: Some(deno_task_build()),
    });
  }
  if config_text.contains("nitro") {
    return Ok(FrameworkDetection {
      name: "SvelteKit",
      entrypoint_code:
        "// @ts-nocheck\nimport \"./.output/server/index.mjs\";\n".into(),
      include_paths: vec![".output".into()],
      build_command: Some(deno_task_build()),
    });
  }
  // SvelteKit is present but the configured adapter isn't one we can locate a
  // bundleable server output for (e.g. `adapter-auto`, `adapter-vercel`,
  // `adapter-cloudflare`). `adapter-auto` is the `npx sv create` default and
  // produces *no* local server output unless a known cloud platform is
  // detected at build time, so point the user at a concrete adapter.
  let auto_hint = if config_text.contains("adapter-auto") {
    "`@sveltejs/adapter-auto` produces no local server output, so there is \
     nothing to bundle. "
  } else {
    ""
  };
  bail!(
    "SvelteKit detected, but no adapter that `deno desktop` can bundle is \
     configured.\n{auto_hint}Configure a supported adapter in your \
     svelte.config:\n  - `@sveltejs/adapter-node` (runs under Deno via \
     Node.js compatibility)\n  - `@deno/svelte-adapter` (for Deno Deploy)"
  );
}

/// Existing asset directories under a `svelte-adapter-deno` `build/` output
/// that need to be embedded so the adapter's static file server can find them.
fn sveltekit_build_includes(dir: &Path) -> Vec<String> {
  ["client", "static", "prerendered"]
    .iter()
    .map(|sub| format!("build/{sub}"))
    .filter(|rel| dir.join(rel).is_dir())
    .collect()
}

/// Nuxt, SolidStart, TanStack Start all use Nitro with the `deno_server`
/// preset, outputting to `.output/server/index.{ts,mjs}`.
fn detect_nitro_framework(
  dir: &Path,
  name: &'static str,
) -> FrameworkDetection {
  let ext = if dir.join(".output/server/index.ts").exists() {
    "ts"
  } else {
    "mjs"
  };
  FrameworkDetection {
    name,
    entrypoint_code: format!(
      "// @ts-nocheck\nimport \"./.output/server/index.{ext}\";\n"
    ),
    include_paths: vec![".output".into()],
    build_command: Some(deno_task_build()),
  }
}

fn detect_vite(dir: &Path) -> FrameworkDetection {
  // SSR: a hand-written server entrypoint (`server.{js,ts,mjs}`) that boots the
  // Vite-built server bundle. Prefer it when present.
  if let Some(server_file) = ["server.js", "server.ts", "server.mjs"]
    .iter()
    .find(|f| dir.join(f).exists())
  {
    return FrameworkDetection {
      name: "Vite",
      entrypoint_code: format!("// @ts-nocheck\nimport \"./{server_file}\";\n"),
      include_paths: vec!["dist".into()],
      build_command: Some(deno_task_build()),
    };
  }

  // SPA / MPA: no server entrypoint. `vite build` emits a static site to
  // `dist/`; serve it over HTTP so the webview can load it, falling back to
  // `index.html` for client-side routes so a hard refresh on a history-API
  // route still resolves.
  FrameworkDetection {
    name: "Vite",
    entrypoint_code: r#"// @ts-nocheck
import { serveDir } from "jsr:@std/http/file-server";
// `vite build` emits a static site into `dist/`. Resolve it against the VFS in
// the compiled binary via import.meta.dirname rather than the runtime CWD.
const fsRoot = import.meta.dirname + "/dist";
Deno.serve(async (req) => {
  const res = await serveDir(req, { fsRoot, quiet: true });
  // SPA fallback: route unmatched HTML navigations back to index.html so
  // client-side routers keep working after a hard refresh.
  if (
    res.status === 404 &&
    req.method === "GET" &&
    (req.headers.get("accept") ?? "").includes("text/html")
  ) {
    const index = new Request(new URL("/index.html", req.url), {
      headers: req.headers,
    });
    return await serveDir(index, { fsRoot, quiet: true });
  }
  return res;
});
"#
    .into(),
    include_paths: vec!["dist".into()],
    build_command: Some(deno_task_build()),
  }
}

// --- Helpers ---

/// Check if a config file exists with any common extension.
/// Config-file extensions framework detection recognizes. Used both to detect
/// a config's presence and to read its text, so the two can't drift.
const CONFIG_EXTENSIONS: [&str; 5] = ["js", "mjs", "ts", "mts", "cjs"];

fn has_config_file(dir: &Path, base_name: &str) -> bool {
  CONFIG_EXTENSIONS
    .iter()
    .any(|ext| dir.join(format!("{base_name}.{ext}")).exists())
}

/// Read the text of the first matching `base_name.<ext>` config file, trying
/// the same extension set as [`has_config_file`] so a config that passes the
/// presence gate is never read as empty.
fn read_config_text(dir: &Path, base_name: &str) -> String {
  CONFIG_EXTENSIONS
    .iter()
    .find_map(|ext| {
      std::fs::read_to_string(dir.join(format!("{base_name}.{ext}"))).ok()
    })
    .unwrap_or_default()
}

/// Read package.json dependencies.
fn read_package_deps(dir: &Path) -> Option<PackageDeps> {
  let content = std::fs::read_to_string(dir.join("package.json")).ok()?;
  let pkg: serde_json::Value = serde_json::from_str(&content).ok()?;
  Some(PackageDeps {
    deps: pkg
      .get("dependencies")
      .cloned()
      .unwrap_or(serde_json::Value::Object(Default::default())),
    dev_deps: pkg
      .get("devDependencies")
      .cloned()
      .unwrap_or(serde_json::Value::Object(Default::default())),
  })
}

struct PackageDeps {
  deps: serde_json::Value,
  dev_deps: serde_json::Value,
}

impl PackageDeps {
  fn has(&self, name: &str) -> bool {
    self.deps.get(name).is_some()
  }

  fn has_dev(&self, name: &str) -> bool {
    self.dev_deps.get(name).is_some()
  }
}

/// Extract the major version number from a package.json dependency.
fn detect_package_version(dir: &Path, package: &str) -> Option<u32> {
  let content = std::fs::read_to_string(dir.join("package.json")).ok()?;
  let pkg: serde_json::Value = serde_json::from_str(&content).ok()?;
  let ver_str = pkg
    .get("dependencies")
    .and_then(|d| d.get(package))
    .or_else(|| pkg.get("devDependencies").and_then(|d| d.get(package)))?
    .as_str()?;
  // Extract major version from "^16.1.6", "~15.0.0", "14.2.3", etc.
  ver_str
    .chars()
    .skip_while(|c: &char| !c.is_ascii_digit())
    .take_while(|c: &char| c.is_ascii_digit())
    .collect::<String>()
    .parse()
    .ok()
}

/// Read the `imports` keys from deno.json / deno.jsonc.
///
/// Uses JSONC-aware parsing so that commented deno.jsonc files are
/// handled correctly instead of silently failing detection.
fn read_deno_json_imports(dir: &Path) -> Option<Vec<String>> {
  let content = std::fs::read_to_string(dir.join("deno.json"))
    .or_else(|_| std::fs::read_to_string(dir.join("deno.jsonc")))
    .ok()?;
  let config: serde_json::Value =
    jsonc_parser::parse_to_serde_value(&content, &Default::default())
      .ok()
      .flatten()?;
  let imports = config.get("imports")?.as_object()?;
  Some(imports.keys().cloned().collect())
}

#[cfg(test)]
mod tests {
  use std::fs;

  use super::*;

  fn setup_dir() -> tempfile::TempDir {
    tempfile::tempdir().unwrap()
  }

  /// Write a minimal `package.json` declaring `@sveltejs/kit` so the SvelteKit
  /// detection gate (which requires positive SvelteKit evidence) fires.
  fn write_sveltekit_pkg(dir: &Path) {
    fs::write(
      dir.join("package.json"),
      r#"{"devDependencies":{"@sveltejs/kit":"^2.0.0"}}"#,
    )
    .unwrap();
  }

  #[test]
  fn no_framework_empty_dir() {
    let dir = setup_dir();
    let result = detect_framework(dir.path()).unwrap();
    assert!(result.is_none());
  }

  // --- Config-file based detection ---

  #[test]
  fn detects_nextjs_with_config_js() {
    let dir = setup_dir();
    fs::write(dir.path().join("next.config.js"), "").unwrap();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"next":"^15.0.0"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Next.js");
    assert_eq!(det.include_paths, vec![".next"]);
    assert!(det.entrypoint_code.contains("next@^15"));
    // No .next dir => build_command is set
    assert!(det.build_command.is_some());
    let cmd = det.build_command.unwrap();
    assert_eq!(cmd[1..], vec!["task", "build"]);
  }

  #[test]
  fn nextjs_always_builds() {
    let dir = setup_dir();
    fs::write(dir.path().join("next.config.js"), "").unwrap();
    fs::create_dir(dir.path().join(".next")).unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Next.js");
    assert!(det.build_command.is_some());
  }

  #[test]
  fn detects_nextjs_with_config_mjs() {
    let dir = setup_dir();
    fs::write(dir.path().join("next.config.mjs"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Next.js");
  }

  #[test]
  fn detects_nextjs_with_config_ts() {
    let dir = setup_dir();
    fs::write(dir.path().join("next.config.ts"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Next.js");
  }

  #[test]
  fn nextjs_version_from_package_json() {
    let dir = setup_dir();
    fs::write(dir.path().join("next.config.js"), "").unwrap();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"next":"^14.2.3"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert!(det.entrypoint_code.contains("next@^14"));
  }

  #[test]
  fn nextjs_defaults_to_v15() {
    let dir = setup_dir();
    fs::write(dir.path().join("next.config.js"), "").unwrap();
    // no package.json
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert!(det.entrypoint_code.contains("next@^15"));
  }

  #[test]
  fn detects_fresh_gen_ts() {
    let dir = setup_dir();
    fs::write(dir.path().join("fresh.gen.ts"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Fresh");
    assert!(det.include_paths.is_empty());
    // no _fresh/server.js => Fresh 1.x
    assert!(det.entrypoint_code.contains("main.ts"));
  }

  #[test]
  fn detects_fresh2_with_server_js() {
    let dir = setup_dir();
    fs::create_dir_all(dir.path().join("_fresh")).unwrap();
    fs::write(dir.path().join("_fresh/server.js"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Fresh");
    assert!(det.entrypoint_code.contains("_fresh/server.js"));
    let cmd = det.build_command.unwrap();
    assert_eq!(cmd[1..], vec!["task", "build"]);
  }

  #[test]
  fn fresh1_has_no_build_command() {
    let dir = setup_dir();
    fs::write(dir.path().join("fresh.gen.ts"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Fresh");
    assert!(det.build_command.is_none());
  }

  #[test]
  fn fresh1_with_deno_json_stays_fresh1() {
    // Regression: fresh.gen.ts + deno.json (without @fresh/core import)
    // should be treated as Fresh 1, not Fresh 2.
    let dir = setup_dir();
    fs::write(dir.path().join("fresh.gen.ts"), "").unwrap();
    fs::write(
      dir.path().join("deno.json"),
      r#"{"tasks":{"start":"deno run -A main.ts"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Fresh");
    // Fresh 1.x uses main.ts, not _fresh/server.js
    assert!(det.entrypoint_code.contains("main.ts"));
    assert!(det.build_command.is_none());
  }

  #[test]
  fn fresh2_detected_via_fresh_core_import() {
    let dir = setup_dir();
    fs::write(dir.path().join("fresh.gen.ts"), "").unwrap();
    fs::write(
      dir.path().join("deno.json"),
      r#"{"imports":{"@fresh/core":"jsr:@fresh/core@^2"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Fresh");
    // Should be Fresh 2 because @fresh/core is in imports
    assert!(det.entrypoint_code.contains("_fresh/server.js"));
    assert!(det.build_command.is_some());
  }

  #[test]
  fn detects_astro() {
    let dir = setup_dir();
    fs::write(dir.path().join("astro.config.mjs"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Astro");
    assert!(det.entrypoint_code.contains("dist/server/entry.mjs"));
    assert_eq!(det.include_paths, vec!["dist"]);
    assert!(det.build_command.is_some());
    let cmd = det.build_command.unwrap();
    assert_eq!(cmd[1..], vec!["task", "build"]);
  }

  #[test]
  fn detects_nuxt() {
    let dir = setup_dir();
    fs::write(dir.path().join("nuxt.config.ts"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Nuxt");
    assert_eq!(det.include_paths, vec![".output"]);
    let cmd = det.build_command.unwrap();
    assert_eq!(cmd[1..], vec!["task", "build"]);
  }

  #[test]
  fn detects_nuxt_with_ts_output() {
    let dir = setup_dir();
    fs::write(dir.path().join("nuxt.config.ts"), "").unwrap();
    fs::create_dir_all(dir.path().join(".output/server")).unwrap();
    fs::write(dir.path().join(".output/server/index.ts"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert!(det.entrypoint_code.contains("index.ts"));
  }

  #[test]
  fn detects_sveltekit_deno_deploy() {
    let dir = setup_dir();
    write_sveltekit_pkg(dir.path());
    fs::write(dir.path().join("svelte.config.js"), "").unwrap();
    fs::create_dir_all(dir.path().join(".deno-deploy")).unwrap();
    fs::write(dir.path().join(".deno-deploy/server.ts"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "SvelteKit");
    assert!(det.entrypoint_code.contains(".deno-deploy/server.ts"));
    assert_eq!(det.include_paths, vec![".deno-deploy"]);
    let cmd = det.build_command.unwrap();
    assert_eq!(cmd[1..], vec!["task", "build"]);
  }

  #[test]
  fn detects_sveltekit_nitro_from_built_output() {
    let dir = setup_dir();
    write_sveltekit_pkg(dir.path());
    fs::write(dir.path().join("svelte.config.ts"), "").unwrap();
    fs::create_dir_all(dir.path().join(".output/server")).unwrap();
    fs::write(dir.path().join(".output/server/index.mjs"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "SvelteKit");
    assert_eq!(det.include_paths, vec![".output"]);
    assert!(det.build_command.is_some());
  }

  #[test]
  fn detects_sveltekit_adapter_deno_from_config() {
    // `svelte-adapter-deno` emits a `build/` directory (not `.output`).
    let dir = setup_dir();
    write_sveltekit_pkg(dir.path());
    fs::write(
      dir.path().join("svelte.config.js"),
      "import adapter from 'svelte-adapter-deno';\nexport default { kit: { adapter: adapter() } };\n",
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "SvelteKit");
    assert!(det.entrypoint_code.contains("build/index.js"));
    assert_eq!(det.include_paths, vec!["build/client"]);
  }

  #[test]
  fn detects_sveltekit_adapter_deno_from_built_output() {
    // Post-build evidence: `build/index.js` + `build/handler.js` is the
    // `svelte-adapter-deno` output shape. Existing asset dirs are included.
    let dir = setup_dir();
    write_sveltekit_pkg(dir.path());
    fs::write(dir.path().join("svelte.config.js"), "").unwrap();
    fs::create_dir_all(dir.path().join("build/client")).unwrap();
    fs::create_dir_all(dir.path().join("build/prerendered")).unwrap();
    fs::write(dir.path().join("build/index.js"), "").unwrap();
    fs::write(dir.path().join("build/handler.js"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "SvelteKit");
    assert!(det.entrypoint_code.contains("build/index.js"));
    assert_eq!(det.include_paths, vec!["build/client", "build/prerendered"]);
    assert!(det.build_command.is_some());
  }

  #[test]
  fn detects_sveltekit_nitro_from_config() {
    let dir = setup_dir();
    write_sveltekit_pkg(dir.path());
    fs::write(
      dir.path().join("svelte.config.js"),
      "// uses a nitro-based adapter\nexport default { kit: {} };\n",
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "SvelteKit");
    assert_eq!(det.include_paths, vec![".output"]);
  }

  #[test]
  fn detects_sveltekit_adapter_node_from_config() {
    // `@sveltejs/adapter-node` emits the `build/index.js` shape and runs under
    // Deno via Node.js compatibility — no Deno-specific adapter required.
    let dir = setup_dir();
    write_sveltekit_pkg(dir.path());
    fs::write(
      dir.path().join("svelte.config.js"),
      "import adapter from '@sveltejs/adapter-node';\nexport default { kit: { adapter: adapter() } };\n",
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "SvelteKit");
    assert!(det.entrypoint_code.contains("build/index.js"));
    assert_eq!(det.include_paths, vec!["build/client"]);
  }

  #[test]
  fn detects_sveltekit_adapter_node_from_mts_config() {
    // `svelte.config.mts` passes the presence gate, so its text must be read
    // too — otherwise a valid adapter-node config reads as empty and bails.
    let dir = setup_dir();
    write_sveltekit_pkg(dir.path());
    fs::write(
      dir.path().join("svelte.config.mts"),
      "import adapter from '@sveltejs/adapter-node';\nexport default { kit: { adapter: adapter() } };\n",
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "SvelteKit");
    assert!(det.entrypoint_code.contains("build/index.js"));
    assert_eq!(det.include_paths, vec!["build/client"]);
  }

  #[test]
  fn errors_on_sveltekit_with_unsupported_adapter() {
    // A SvelteKit project using e.g. adapter-vercel must NOT fall through to
    // the generic Vite branch (which would bundle a non-existent `dist/`).
    // Instead we surface an actionable error.
    let dir = setup_dir();
    write_sveltekit_pkg(dir.path());
    fs::write(
      dir.path().join("svelte.config.js"),
      "import adapter from '@sveltejs/adapter-vercel';\nexport default { kit: { adapter: adapter() } };\n",
    )
    .unwrap();
    let err = detect_framework(dir.path()).unwrap_err().to_string();
    assert!(err.contains("SvelteKit detected"));
    assert!(err.contains("adapter-node"));
  }

  #[test]
  fn errors_on_sveltekit_adapter_auto() {
    // `adapter-auto` is the `sv create` default and produces no local server
    // output, so we bail with a hint pointing at a concrete adapter.
    let dir = setup_dir();
    write_sveltekit_pkg(dir.path());
    fs::write(
      dir.path().join("svelte.config.js"),
      "import adapter from '@sveltejs/adapter-auto';\nexport default { kit: { adapter: adapter() } };\n",
    )
    .unwrap();
    let err = detect_framework(dir.path()).unwrap_err().to_string();
    assert!(err.contains("adapter-auto"));
    assert!(err.contains("no local server output"));
  }

  #[test]
  fn plain_svelte_spa_falls_through_to_vite() {
    // The Vite Svelte template (`npm create vite -- --template svelte`) ships a
    // `svelte.config.js` with only `vitePreprocess()` and no `@sveltejs/kit`.
    // Without positive SvelteKit evidence it must NOT be taken over by
    // SvelteKit detection (which would bail on the missing adapter) — it stays
    // on the Vite path that bundles `dist/`.
    let dir = setup_dir();
    fs::write(
      dir.path().join("svelte.config.js"),
      "import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';\nexport default { preprocess: vitePreprocess() };\n",
    )
    .unwrap();
    fs::write(dir.path().join("vite.config.js"), "").unwrap();
    fs::write(
      dir.path().join("package.json"),
      r#"{"devDependencies":{"svelte":"^5.0.0","vite":"^5.0.0"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Vite");
    assert_eq!(det.include_paths, vec!["dist"]);
  }

  // --- Package.json dependency-based detection ---

  #[test]
  fn detects_remix_from_deps() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"@remix-run/react":"^2.0.0"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Remix");
    assert_eq!(det.include_paths, vec!["build"]);
    let cmd = det.build_command.unwrap();
    assert_eq!(cmd[1..], vec!["task", "build"]);
  }

  #[test]
  fn detects_remix_from_dev_deps() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"devDependencies":{"@remix-run/dev":"^2.0.0"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Remix");
  }

  #[test]
  fn detects_solidstart() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"@solidjs/start":"^1.0.0"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "SolidStart");
    assert_eq!(det.include_paths, vec![".output"]);
    let cmd = det.build_command.unwrap();
    assert_eq!(cmd[1..], vec!["task", "build"]);
  }

  #[test]
  fn detects_tanstack_start_react() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"@tanstack/react-start":"^1.0.0"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "TanStack Start");
  }

  #[test]
  fn detects_tanstack_start_solid() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"@tanstack/solid-start":"^1.0.0"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "TanStack Start");
  }

  // --- Vite ---

  #[test]
  fn detects_vite_ssr_with_server_js() {
    let dir = setup_dir();
    fs::write(dir.path().join("vite.config.js"), "").unwrap();
    fs::write(dir.path().join("server.js"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Vite");
    assert!(det.entrypoint_code.contains("server.js"));
    assert_eq!(det.include_paths, vec!["dist"]);
    let cmd = det.build_command.unwrap();
    assert_eq!(cmd[1..], vec!["task", "build"]);
  }

  #[test]
  fn detects_vite_ssr_with_server_ts() {
    let dir = setup_dir();
    fs::write(dir.path().join("vite.config.ts"), "").unwrap();
    fs::write(dir.path().join("server.ts"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Vite");
    assert!(det.entrypoint_code.contains("server.ts"));
  }

  #[test]
  fn detects_vite_spa_from_config_without_server_file() {
    // A plain Vite SPA (config file, no server.{js,ts,mjs}) serves the static
    // `dist/` build over HTTP rather than importing a server entrypoint.
    let dir = setup_dir();
    fs::write(dir.path().join("vite.config.js"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Vite");
    assert!(det.entrypoint_code.contains("serveDir"));
    assert!(det.entrypoint_code.contains("/dist"));
    assert!(det.entrypoint_code.contains("Deno.serve"));
    assert_eq!(det.include_paths, vec!["dist"]);
    let cmd = det.build_command.unwrap();
    assert_eq!(cmd[1..], vec!["task", "build"]);
  }

  #[test]
  fn detects_vite_from_package_dep() {
    // No config file, but `vite` in devDependencies — still a Vite project.
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"devDependencies":{"vite":"^5.0.0"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Vite");
    assert!(det.entrypoint_code.contains("serveDir"));
    assert_eq!(det.include_paths, vec!["dist"]);
  }

  #[test]
  fn detects_vite_ssr_when_dep_and_server_present() {
    // `vite` dependency plus a server entrypoint resolves to the SSR variant.
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"vite":"^5.0.0"}}"#,
    )
    .unwrap();
    fs::write(dir.path().join("server.ts"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Vite");
    assert!(det.entrypoint_code.contains("server.ts"));
  }

  // --- deno.json import-based detection ---

  #[test]
  fn detects_fresh_from_deno_json_imports() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("deno.json"),
      r#"{"imports":{"fresh":"jsr:@fresh/core"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Fresh");
  }

  #[test]
  fn detects_fresh_from_deno_json_fresh_core() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("deno.json"),
      r#"{"imports":{"@fresh/core":"jsr:@fresh/core@^2"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Fresh");
  }

  #[test]
  fn detects_fresh_from_deno_jsonc() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("deno.jsonc"),
      r#"{"imports":{"fresh":"jsr:@fresh/core"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Fresh");
  }

  #[test]
  fn detects_fresh_from_commented_deno_jsonc() {
    // Regression: deno.jsonc with comments should still be parsed correctly.
    let dir = setup_dir();
    fs::write(
      dir.path().join("deno.jsonc"),
      "{\n  // This is a comment\n  \"imports\": {\n    \"fresh\": \"jsr:@fresh/core\"\n  }\n}\n",
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Fresh");
  }

  // --- Priority ---

  #[test]
  fn config_file_takes_priority_over_package_json() {
    let dir = setup_dir();
    // Has both next.config.js and remix in package.json
    fs::write(dir.path().join("next.config.js"), "").unwrap();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"@remix-run/react":"^2.0.0"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Next.js");
  }

  #[test]
  fn package_json_takes_priority_over_vite_ssr() {
    let dir = setup_dir();
    fs::write(dir.path().join("vite.config.js"), "").unwrap();
    fs::write(dir.path().join("server.js"), "").unwrap();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"@solidjs/start":"^1.0.0"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "SolidStart");
  }

  #[test]
  fn config_file_takes_priority_over_deno_json() {
    let dir = setup_dir();
    fs::write(dir.path().join("astro.config.mjs"), "").unwrap();
    fs::write(
      dir.path().join("deno.json"),
      r#"{"imports":{"fresh":"jsr:@fresh/core"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Astro");
  }

  // --- Helper unit tests ---

  #[test]
  fn has_config_file_various_extensions() {
    let dir = setup_dir();
    assert!(!has_config_file(dir.path(), "next.config"));
    fs::write(dir.path().join("next.config.mts"), "").unwrap();
    assert!(has_config_file(dir.path(), "next.config"));
  }

  #[test]
  fn detect_package_version_parses_caret() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"next":"^16.1.6"}}"#,
    )
    .unwrap();
    assert_eq!(detect_package_version(dir.path(), "next"), Some(16));
  }

  #[test]
  fn detect_package_version_parses_tilde() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"next":"~15.0.0"}}"#,
    )
    .unwrap();
    assert_eq!(detect_package_version(dir.path(), "next"), Some(15));
  }

  #[test]
  fn detect_package_version_parses_exact() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"next":"14.2.3"}}"#,
    )
    .unwrap();
    assert_eq!(detect_package_version(dir.path(), "next"), Some(14));
  }

  #[test]
  fn detect_package_version_from_dev_deps() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"devDependencies":{"next":"^13.0.0"}}"#,
    )
    .unwrap();
    assert_eq!(detect_package_version(dir.path(), "next"), Some(13));
  }

  #[test]
  fn detect_package_version_missing_package() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"react":"^18.0.0"}}"#,
    )
    .unwrap();
    assert_eq!(detect_package_version(dir.path(), "next"), None);
  }

  #[test]
  fn detect_package_version_no_package_json() {
    let dir = setup_dir();
    assert_eq!(detect_package_version(dir.path(), "next"), None);
  }

  #[test]
  fn read_deno_json_imports_returns_keys() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("deno.json"),
      r#"{"imports":{"foo":"jsr:@foo/bar","baz":"npm:baz"}}"#,
    )
    .unwrap();
    let mut imports = read_deno_json_imports(dir.path()).unwrap();
    imports.sort();
    assert_eq!(imports, vec!["baz", "foo"]);
  }

  #[test]
  fn read_deno_json_imports_no_file() {
    let dir = setup_dir();
    assert!(read_deno_json_imports(dir.path()).is_none());
  }

  #[test]
  fn read_deno_json_imports_no_imports_key() {
    let dir = setup_dir();
    fs::write(dir.path().join("deno.json"), r#"{"tasks":{}}"#).unwrap();
    assert!(read_deno_json_imports(dir.path()).is_none());
  }

  // --- Favicon discovery ---

  fn nextjs_detection() -> FrameworkDetection {
    FrameworkDetection {
      name: "Next.js",
      entrypoint_code: String::new(),
      include_paths: vec![],
      build_command: None,
    }
  }

  fn fresh_detection() -> FrameworkDetection {
    FrameworkDetection {
      name: "Fresh",
      entrypoint_code: String::new(),
      include_paths: vec![],
      build_command: None,
    }
  }

  #[test]
  fn finds_favicon_in_public_for_linux() {
    let dir = setup_dir();
    fs::create_dir_all(dir.path().join("public")).unwrap();
    fs::write(dir.path().join("public/favicon.png"), "").unwrap();
    let det = nextjs_detection();
    let p = find_framework_favicon(dir.path(), &det, "linux").unwrap();
    assert_eq!(p, dir.path().join("public/favicon.png"));
  }

  #[test]
  fn finds_ico_for_windows_but_not_png() {
    let dir = setup_dir();
    fs::create_dir_all(dir.path().join("public")).unwrap();
    fs::write(dir.path().join("public/favicon.png"), "").unwrap();
    let det = nextjs_detection();
    assert!(find_framework_favicon(dir.path(), &det, "windows").is_none());
    fs::write(dir.path().join("public/favicon.ico"), "").unwrap();
    let p = find_framework_favicon(dir.path(), &det, "windows").unwrap();
    assert_eq!(p, dir.path().join("public/favicon.ico"));
  }

  #[test]
  fn macos_prefers_icns_over_png() {
    let dir = setup_dir();
    fs::create_dir_all(dir.path().join("public")).unwrap();
    fs::write(dir.path().join("public/icon.png"), "").unwrap();
    fs::write(dir.path().join("public/icon.icns"), "").unwrap();
    let det = nextjs_detection();
    let p = find_framework_favicon(dir.path(), &det, "macos").unwrap();
    assert_eq!(p, dir.path().join("public/icon.icns"));
  }

  #[test]
  fn icon_name_preferred_over_favicon() {
    let dir = setup_dir();
    fs::create_dir_all(dir.path().join("public")).unwrap();
    fs::write(dir.path().join("public/favicon.png"), "").unwrap();
    fs::write(dir.path().join("public/icon.png"), "").unwrap();
    let det = nextjs_detection();
    let p = find_framework_favicon(dir.path(), &det, "linux").unwrap();
    assert_eq!(p, dir.path().join("public/icon.png"));
  }

  #[test]
  fn nextjs_app_router_favicon_in_app_dir() {
    let dir = setup_dir();
    // No public/, but app/favicon.ico — Next 13+ App Router layout.
    fs::create_dir_all(dir.path().join("app")).unwrap();
    fs::write(dir.path().join("app/favicon.ico"), "").unwrap();
    let det = nextjs_detection();
    let p = find_framework_favicon(dir.path(), &det, "windows").unwrap();
    assert_eq!(p, dir.path().join("app/favicon.ico"));
  }

  #[test]
  fn fresh_uses_static_dir() {
    let dir = setup_dir();
    fs::create_dir_all(dir.path().join("static")).unwrap();
    fs::write(dir.path().join("static/favicon.png"), "").unwrap();
    let det = fresh_detection();
    let p = find_framework_favicon(dir.path(), &det, "linux").unwrap();
    assert_eq!(p, dir.path().join("static/favicon.png"));
  }

  #[test]
  fn no_favicon_returns_none() {
    let dir = setup_dir();
    fs::create_dir_all(dir.path().join("public")).unwrap();
    let det = nextjs_detection();
    assert!(find_framework_favicon(dir.path(), &det, "linux").is_none());
  }

  #[test]
  fn public_takes_priority_over_app_for_nextjs() {
    let dir = setup_dir();
    fs::create_dir_all(dir.path().join("public")).unwrap();
    fs::create_dir_all(dir.path().join("app")).unwrap();
    fs::write(dir.path().join("public/favicon.png"), "").unwrap();
    fs::write(dir.path().join("app/icon.png"), "").unwrap();
    let det = nextjs_detection();
    let p = find_framework_favicon(dir.path(), &det, "linux").unwrap();
    // public/ is checked before app/.
    assert_eq!(p, dir.path().join("public/favicon.png"));
  }
}
