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

//! Framework detection for `deno compile .`.
//!
//! Detects web frameworks (Next.js, Astro, Remix, React Router, 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::Context;
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>>,
  /// Command to launch the framework's HMR server for desktop
  pub hmr_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
}

/// Run the framework's build step (e.g. `deno task build`) with `dir` as the
/// working directory, if the detection specifies one. This must run before the
/// framework's `include_paths` are bundled, since they point at the build
/// output (`dist`, `.next`, etc.) which doesn't exist until the build runs.
///
/// Shared by `deno compile .` and `deno desktop .` so the two can't drift.
pub fn run_build_command(
  detection: &FrameworkDetection,
  dir: &Path,
) -> Result<(), AnyError> {
  let Some(build_cmd) = &detection.build_command else {
    return Ok(());
  };
  log::info!(
    "{} {} project...",
    deno_terminal::colors::green("Building"),
    detection.name,
  );
  let status = std::process::Command::new(&build_cmd[0])
    .args(&build_cmd[1..])
    .current_dir(dir)
    .status()
    .with_context(|| {
      format!("Failed to run build command: {}", build_cmd.join(" "))
    })?;
  if !status.success() {
    deno_core::anyhow::bail!(
      "{} build failed (exit code: {})",
      detection.name,
      status.code().unwrap_or(-1)
    );
  }
  Ok(())
}

/// 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)));
    }

    // React Router (framework mode) - the successor to Remix. It uses the
    // `@react-router/dev` build tool rather than the old `@remix-run/*`
    // packages, so without this branch it would fall through to generic Vite
    // detection (which looks for `dist/` and misses React Router's
    // `build/client` output).
    if deps.has("@react-router/dev") || deps.has_dev("@react-router/dev") {
      return Ok(Some(detect_react_router(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 deno_task_dev() -> Vec<String> {
  vec![deno_exe(), "task".into(), "dev".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()),
    hmr_command: None, // TODO: add command to enable `deno desktop --hmr` for Next.js
  })
}

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()),
    hmr_command: None, // TODO: add command to enable `deno desktop --hmr` for Astro
  }
}

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(deno_task_build()),
      hmr_command: Some(deno_task_dev()),
    }
  } 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,
      hmr_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()),
    hmr_command: None, // TODO: add command to enable `deno desktop --hmr` for Remix
  }
}

fn detect_react_router(dir: &Path) -> FrameworkDetection {
  // React Router framework mode builds in one of two shapes:
  //   * SPA (`ssr: false`): a fully static site in `build/client` with a
  //     prerendered `index.html` fallback and no server to run.
  //   * SSR (default): a server build at `build/server/index.js`. This works in
  //     Deno when the app provides a Web Streams `app/entry.server.tsx` using
  //     `renderToReadableStream`; the default React Router Node entry uses
  //     `renderToPipeableStream` and fails during `react-router build` under
  //     Deno before this generated entrypoint is compiled.
  // Detection runs *before* the build, so decide from the config file.
  let spa_mode = [
    "react-router.config.ts",
    "react-router.config.js",
    "react-router.config.mjs",
  ]
  .iter()
  .find_map(|f| std::fs::read_to_string(dir.join(f)).ok())
  .map(|text| {
    // Strip comments first so a commented-out `// ssr: false` can't be mistaken
    // for the real setting, then match whitespace-insensitively so `ssr: false`
    // and `ssr:false` both count.
    strip_comments(&text)
      .chars()
      .filter(|c| !c.is_whitespace())
      .collect::<String>()
      .contains("ssr:false")
  })
  .unwrap_or(false);

  if spa_mode {
    // SPA: serve the static client build, falling back to `index.html` so the
    // client-side router survives a hard refresh on a history-API route.
    FrameworkDetection {
      name: "React Router",
      entrypoint_code: r#"// @ts-nocheck
import { serveDir } from "jsr:@std/http/file-server";
// React Router SPA mode emits a static site into `build/client`. Resolve it
// against the VFS in the compiled binary via import.meta.dirname rather than
// the runtime CWD.
const fsRoot = import.meta.dirname + "/build/client";
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!["build/client".into()],
      build_command: Some(deno_task_build()),
      hmr_command: Some(deno_task_dev()),
    }
  } else {
    // SSR: serve immutable client assets from `build/client`, then hand all
    // application routes, data requests, actions, and misses to React Router's
    // Fetch API request handler from `build/server/index.js`.
    FrameworkDetection {
      name: "React Router",
      entrypoint_code: r#"// @ts-nocheck
import { serveDir } from "jsr:@std/http/file-server";
import { createRequestHandler } from "react-router";
import * as build from "./build/server/index.js";

// React Router emits client assets into `build/client` and its server build
// into `build/server`. Resolve both against the VFS in the compiled binary via
// import.meta.dirname rather than the runtime CWD.
const fsRoot = import.meta.dirname + "/build/client";
const handler = createRequestHandler(build, "production");

Deno.serve(async (req) => {
  if (req.method === "GET" || req.method === "HEAD") {
    const staticRes = await serveDir(req, { fsRoot, quiet: true });
    if (staticRes.status !== 404) {
      return staticRes;
    }
  }
  return await handler(req);
});
"#
      .into(),
      include_paths: vec!["build".into()],
      build_command: Some(deno_task_build()),
      hmr_command: Some(deno_task_dev()),
    }
  }
}

/// Remove `//` line comments and `/* */` block comments from config source, so
/// a commented-out setting (e.g. `// ssr: false`) isn't mistaken for the real
/// one by the substring heuristics above.
fn strip_comments(src: &str) -> String {
  let mut out = String::with_capacity(src.len());
  let mut chars = src.chars().peekable();
  while let Some(c) = chars.next() {
    if c == '/' {
      match chars.peek() {
        Some('/') => {
          // Line comment: drop until end of line.
          for c2 in chars.by_ref() {
            if c2 == '\n' {
              out.push('\n');
              break;
            }
          }
          continue;
        }
        Some('*') => {
          // Block comment: drop until the closing `*/`.
          chars.next();
          let mut prev = '\0';
          for c2 in chars.by_ref() {
            if prev == '*' && c2 == '/' {
              break;
            }
            prev = c2;
          }
          continue;
        }
        _ => {}
      }
    }
    out.push(c);
  }
  out
}

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()),
      hmr_command: Some(deno_task_dev()),
    });
  }
  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()),
      hmr_command: Some(deno_task_dev()),
    });
  }
  // `@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()),
      hmr_command: Some(deno_task_dev()),
    });
  }
  // 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()),
      hmr_command: Some(deno_task_dev()),
    });
  }
  // `@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()),
      hmr_command: Some(deno_task_dev()),
    });
  }
  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()),
      hmr_command: Some(deno_task_dev()),
    });
  }
  // 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()),
    // Nuxt (`nuxi dev`), SolidStart and TanStack Start (`vinxi dev`) all run a
    // Vite-based dev server that prints the `Local:   http://…` line
    // `parse_dev_server_url` scans for, so `deno task dev` drives HMR for all
    // three.
    hmr_command: Some(deno_task_dev()),
  }
}

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()),
      // `vite` (and Vue/React SPA templates built on it) prints the
      // `Local:   http://…` line `parse_dev_server_url` scans for, so
      // `deno task dev` drives HMR.
      hmr_command: Some(deno_task_dev()),
    };
  }

  // 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
// Pin the major version so the compiled binary is reproducible and doesn't pick
// up a breaking `@std/http` release on a later rebuild.
import { serveDir } from "jsr:@std/http@^1/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()),
    // See the SSR branch above: the Vite dev server prints the `Local:` URL
    // that `parse_dev_server_url` scans for, so `deno task dev` drives HMR.
    hmr_command: Some(deno_task_dev()),
  }
}

// --- 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"]);
    // Nuxt runs a Vite-based dev server, so `deno desktop --hmr` is supported.
    let hmr = det.hmr_command.unwrap();
    assert_eq!(hmr[1..], vec!["task", "dev"]);
  }

  #[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");
  }

  // --- React Router ---

  #[test]
  fn detects_react_router_spa() {
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"devDependencies":{"@react-router/dev":"^7.0.0"}}"#,
    )
    .unwrap();
    fs::write(dir.path().join("vite.config.ts"), "").unwrap();
    fs::write(
      dir.path().join("react-router.config.ts"),
      "export default { ssr: false };\n",
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "React Router");
    // SPA serves the static client build, not `dist/`.
    assert!(det.entrypoint_code.contains("serveDir"));
    assert!(det.entrypoint_code.contains("build/client"));
    assert!(!det.entrypoint_code.contains("createRequestHandler"));
    assert_eq!(det.include_paths, vec!["build/client"]);
    let cmd = det.build_command.unwrap();
    assert_eq!(cmd[1..], vec!["task", "build"]);
    let hmr = det.hmr_command.unwrap();
    assert_eq!(hmr[1..], vec!["task", "dev"]);
  }

  #[test]
  fn detects_react_router_spa_whitespace_insensitive() {
    // The `ssr: false` heuristic strips whitespace, so `ssr:false` (no space)
    // must still be recognized as SPA.
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"devDependencies":{"@react-router/dev":"^7.0.0"}}"#,
    )
    .unwrap();
    fs::write(dir.path().join("vite.config.ts"), "").unwrap();
    fs::write(
      dir.path().join("react-router.config.ts"),
      "export default {\n  ssr:false,\n};\n",
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "React Router");
    assert_eq!(det.include_paths, vec!["build/client"]);
  }

  #[test]
  fn detects_react_router_ssr_config() {
    // SSR (the React Router default) serves static client assets first, then
    // hands application requests to the server build's Fetch API handler.
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"devDependencies":{"@react-router/dev":"^7.0.0"}}"#,
    )
    .unwrap();
    fs::write(dir.path().join("vite.config.ts"), "").unwrap();
    fs::write(
      dir.path().join("react-router.config.ts"),
      "export default {};\n",
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "React Router");
    assert!(det.entrypoint_code.contains("createRequestHandler"));
    assert!(det.entrypoint_code.contains("build/server/index.js"));
    assert!(det.entrypoint_code.contains("build/client"));
    assert_eq!(det.include_paths, vec!["build"]);
    let hmr = det.hmr_command.unwrap();
    assert_eq!(hmr[1..], vec!["task", "dev"]);
  }

  #[test]
  fn detects_react_router_without_config_defaults_to_ssr() {
    // No react-router.config file at all -> React Router defaults to SSR.
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"devDependencies":{"@react-router/dev":"^7.0.0"}}"#,
    )
    .unwrap();
    fs::write(dir.path().join("vite.config.ts"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "React Router");
    assert_eq!(det.include_paths, vec!["build"]);
    assert!(det.entrypoint_code.contains("createRequestHandler"));
  }

  #[test]
  fn react_router_commented_ssr_false_is_still_ssr() {
    // A commented-out `// ssr: false` above a real `ssr: true` must NOT be
    // mistaken for SPA.
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"devDependencies":{"@react-router/dev":"^7.0.0"}}"#,
    )
    .unwrap();
    fs::write(dir.path().join("vite.config.ts"), "").unwrap();
    fs::write(
      dir.path().join("react-router.config.ts"),
      "export default {\n  // set ssr: false to opt into SPA\n  ssr: true,\n};\n",
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "React Router");
    assert_eq!(det.include_paths, vec!["build"]);
    assert!(det.entrypoint_code.contains("createRequestHandler"));
  }

  #[test]
  fn react_router_takes_precedence_over_generic_vite() {
    // A React Router project always carries a vite.config + vite dep; make sure
    // an `ssr: false` SPA is detected as React Router rather than falling
    // through to generic Vite.
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"devDependencies":{"@react-router/dev":"^7.0.0","vite":"^5.0.0"}}"#,
    )
    .unwrap();
    fs::write(dir.path().join("vite.config.ts"), "").unwrap();
    fs::write(
      dir.path().join("react-router.config.ts"),
      "export default { ssr: false };\n",
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "React Router");
  }

  #[test]
  fn react_router_library_mode_is_detected_as_vite() {
    // React Router used purely as a routing *library* (no `@react-router/dev`
    // build tool) is just a Vite SPA. It must NOT be detected as the React
    // Router framework, which would look for a `build/` output a library user
    // never produces.
    let dir = setup_dir();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"react-router":"^7.0.0"},"devDependencies":{"vite":"^5.0.0"}}"#,
    )
    .unwrap();
    fs::write(dir.path().join("vite.config.ts"), "").unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Vite");
  }

  // --- 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"]);
    // The Vite dev server drives HMR for `deno desktop --hmr`.
    let hmr = det.hmr_command.unwrap();
    assert_eq!(hmr[1..], vec!["task", "dev"]);
  }

  #[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"));
    // The remote `@std/http` dependency must be version-pinned so the compiled
    // binary is reproducible.
    assert!(det.entrypoint_code.contains("jsr:@std/http@^1/file-server"));
    assert_eq!(det.include_paths, vec!["dist"]);
    let cmd = det.build_command.unwrap();
    assert_eq!(cmd[1..], vec!["task", "build"]);
    // A plain Vite SPA (e.g. the Vue template) supports `deno desktop --hmr`.
    let hmr = det.hmr_command.unwrap();
    assert_eq!(hmr[1..], vec!["task", "dev"]);
  }

  #[test]
  fn vue_vite_spa_supports_hmr() {
    // A Vue SPA scaffolded with `npm create vue@latest` is a plain Vite project
    // (`vue` + `vite` in devDependencies, a `vite.config.ts`). It must resolve
    // to the Vite path with `deno desktop --hmr` enabled so the dev-server
    // workflow from issue #35745 works.
    let dir = setup_dir();
    fs::write(dir.path().join("vite.config.ts"), "").unwrap();
    fs::write(
      dir.path().join("package.json"),
      r#"{"dependencies":{"vue":"^3.0.0"},"devDependencies":{"vite":"^5.0.0","@vitejs/plugin-vue":"^5.0.0"}}"#,
    )
    .unwrap();
    let det = detect_framework(dir.path()).unwrap().unwrap();
    assert_eq!(det.name, "Vite");
    let hmr = det.hmr_command.unwrap();
    assert_eq!(hmr[1..], vec!["task", "dev"]);
  }

  #[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,
      hmr_command: None,
    }
  }

  fn fresh_detection() -> FrameworkDetection {
    FrameworkDetection {
      name: "Fresh",
      entrypoint_code: String::new(),
      include_paths: vec![],
      build_command: None,
      hmr_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"));
  }

  // --- run_build_command ---

  #[test]
  fn run_build_command_none_is_noop() {
    let dir = setup_dir();
    let mut det = nextjs_detection();
    det.build_command = None;
    // No command to run, so this must succeed without doing anything.
    run_build_command(&det, dir.path()).unwrap();
  }

  #[test]
  fn run_build_command_runs_in_dir() {
    // The build command runs with the detected directory as cwd. Use a
    // shell command that writes the framework's build output so we can assert
    // it ran in the right place (a regression test for #35535, where the
    // build step was skipped and the include path didn't exist).
    let dir = setup_dir();
    let mut det = nextjs_detection();
    det.include_paths = vec!["dist".into()];
    det.build_command = if cfg!(windows) {
      Some(vec!["cmd".into(), "/C".into(), "mkdir dist".into()])
    } else {
      Some(vec!["sh".into(), "-c".into(), "mkdir dist".into()])
    };
    run_build_command(&det, dir.path()).unwrap();
    assert!(dir.path().join("dist").is_dir());
  }

  #[test]
  fn run_build_command_propagates_failure() {
    let dir = setup_dir();
    let mut det = nextjs_detection();
    det.build_command = if cfg!(windows) {
      Some(vec!["cmd".into(), "/C".into(), "exit 1".into()])
    } else {
      Some(vec!["sh".into(), "-c".into(), "exit 1".into()])
    };
    let err = run_build_command(&det, dir.path()).unwrap_err();
    assert!(err.to_string().contains("build failed"));
  }
}
