diff --git a/Cargo.lock b/Cargo.lock index 54bed69..f1fd329 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -521,7 +521,7 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "paraglide-launch" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aho-corasick", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index b219a56..e1f469d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "paraglide-launch" -version = "0.2.1" +version = "0.2.2" edition = "2024" description = "Analyze a project and detect deployable services, languages, frameworks, commands, and env vars" license = "MIT" diff --git a/src/discovery.rs b/src/discovery.rs index ac71693..063f4db 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -202,6 +202,10 @@ fn assemble(outputs: Vec, root: &Path) -> Discovery { for service in &mut services { if let Some(pkg) = mono.packages.values().find(|p| p.dir == service.dir) { service.detected_by.push(format!("monorepo:{}", pkg.name)); + // Rewrite build/dev commands to use the orchestrator tool + if let Some(ref tool) = mono.tool { + enrich_monorepo_commands(service, tool, &pkg.name); + } } } } @@ -210,6 +214,28 @@ fn assemble(outputs: Vec, root: &Path) -> Discovery { } /// Get the "dir name" for a path — used for derived name detection. +/// Rewrite build/dev commands on a service's runtimes to use the monorepo +/// orchestrator tool (turborepo, nx, lerna) instead of bare npm/pnpm scripts. +fn enrich_monorepo_commands(service: &mut Service, tool: &str, package_name: &str) { + for runtime in &mut service.runtimes { + if runtime.build.is_some() { + runtime.build = Some(orchestrator_command(tool, "build", package_name)); + } + if runtime.dev.is_some() { + runtime.dev = Some(orchestrator_command(tool, "dev", package_name)); + } + } +} + +fn orchestrator_command(tool: &str, task: &str, package_name: &str) -> String { + match tool { + "turborepo" => format!("turbo run {task} --filter={package_name}"), + "nx" => format!("nx run {package_name}:{task}"), + "lerna" => format!("lerna run {task} --scope={package_name}"), + _ => format!("{tool} run {task}"), + } +} + fn dir_name(dir: &str, _root: &Path) -> String { if dir == "." || dir.is_empty() { // Root: use "app" — matches Dockerfile/Package signal naming conventions diff --git a/src/signals/framework.rs b/src/signals/framework.rs index 69af058..45f39e9 100644 --- a/src/signals/framework.rs +++ b/src/signals/framework.rs @@ -515,7 +515,7 @@ mod tests { // Package's php module sets start (registered before framework) assert_eq!( svc.start(), - Some("php bin/console server:start 0.0.0.0:8000") + Some("php -S 0.0.0.0:8000 -t public") ); } diff --git a/src/signals/package/deno.rs b/src/signals/package/deno.rs index ec09cc7..23a41cb 100644 --- a/src/signals/package/deno.rs +++ b/src/signals/package/deno.rs @@ -92,6 +92,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt language_config: None, output_dir: None, commands, + healthcheck: None, env: Vec::new(), system_deps: Vec::new(), }) diff --git a/src/signals/package/elixir.rs b/src/signals/package/elixir.rs index 05b6258..5c94f1f 100644 --- a/src/signals/package/elixir.rs +++ b/src/signals/package/elixir.rs @@ -9,7 +9,7 @@ use regex::Regex; use super::DirFiles; use crate::fs::FileSystem; use crate::signals::package::common::{ - dir_string, parse_mise_toml, parse_tool_versions, read_text, + dir_string, parse_mise_toml, parse_tool_versions, read_text, strip_version_prefix, }; use crate::types::{ Commands, DirContext, ElixirConfig, EnvVar, Language, LanguageConfig, PackageManagerInfo, @@ -134,6 +134,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt language_config: Some(language_config), output_dir: None, commands, + healthcheck: None, env, system_deps, }) @@ -149,7 +150,7 @@ fn detect_elixir_version( // 0. .elixir-version if files.elixir_version_file { if let Some(raw) = read_text(fs, &dir.join(".elixir-version")) { - let v = raw.trim().to_string(); + let v = strip_version_prefix(&raw); if !v.is_empty() { return (Some(v), Some(".elixir-version".into())); } @@ -193,7 +194,7 @@ fn detect_erlang_version( // 0. .erlang-version if files.erlang_version_file { if let Some(raw) = read_text(fs, &dir.join(".erlang-version")) { - let v = raw.trim().to_string(); + let v = strip_version_prefix(&raw); if !v.is_empty() { return (Some(v), Some(".erlang-version".into())); } diff --git a/src/signals/package/go.rs b/src/signals/package/go.rs index e763a34..3b4b644 100644 --- a/src/signals/package/go.rs +++ b/src/signals/package/go.rs @@ -10,6 +10,7 @@ use super::DirFiles; use crate::fs::FileSystem; use crate::signals::package::common::{ detect_framework, dir_string, parse_mise_toml, parse_tool_versions, read_text, + strip_version_prefix, }; use crate::types::{ Commands, DirContext, GoConfig, Language, LanguageConfig, PackageManagerInfo, RuntimeInfo, @@ -19,7 +20,7 @@ static GO_VERSION_RE: LazyLock = LazyLock::new(|| Regex::new(r"^go[ \t]+([0-9]+\.[0-9]+(?:\.[0-9]+)?)").unwrap()); pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Option { - if !files.go_mod { + if !files.go_mod && !files.go_work { return None; } @@ -65,7 +66,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt .any(|d| cgo_deps.iter().any(|c| d.contains(c))); // -- System deps -- - let mut system_deps = vec!["ca-certificates".to_string()]; + let mut system_deps = vec!["ca-certificates".to_string(), "tzdata".to_string()]; if cgo { for dep in &["gcc", "g++", "libc6-dev"] { system_deps.push(dep.to_string()); @@ -113,19 +114,30 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt language_config: Some(language_config), output_dir: None, commands, + healthcheck: None, env: Vec::new(), system_deps, }) } -/// Extract Go version from go.mod, .tool-versions, or .mise.toml. +/// Extract Go version from .go-version, go.mod, .tool-versions, or .mise.toml. fn detect_go_version( dir: &Path, files: &DirFiles, go_mod: &str, fs: &dyn FileSystem, ) -> (Option, Option) { - // 1. go.mod "go X.Y.Z" + // 1. .go-version + if files.go_version_file { + if let Some(raw) = read_text(fs, &dir.join(".go-version")) { + let v = strip_version_prefix(&raw); + if !v.is_empty() { + return (Some(v), Some(".go-version".into())); + } + } + } + + // 2. go.mod "go X.Y.Z" for line in go_mod.lines() { if let Some(caps) = GO_VERSION_RE.captures(line.trim()) { if let Some(v) = caps.get(1) { @@ -134,16 +146,18 @@ fn detect_go_version( } } - // 2. .tool-versions + // 3. .tool-versions (supports both "golang" and "go" keys) if files.tool_versions { if let Some(content) = read_text(fs, &dir.join(".tool-versions")) { - if let Some(v) = parse_tool_versions(&content, "golang") { + if let Some(v) = parse_tool_versions(&content, "golang") + .or_else(|| parse_tool_versions(&content, "go")) + { return (Some(v), Some(".tool-versions".into())); } } } - // 3. .mise.toml + // 4. .mise.toml if files.mise_toml { if let Some(content) = read_text(fs, &dir.join(".mise.toml")) { if let Some(v) = parse_mise_toml(&content, "go") { diff --git a/src/signals/package/java.rs b/src/signals/package/java.rs index 0c19e26..7b219bc 100644 --- a/src/signals/package/java.rs +++ b/src/signals/package/java.rs @@ -31,6 +31,15 @@ static GRADLE_JAVA_RE: LazyLock = LazyLock::new(|| { .unwrap() }); +/// Matches JavaLanguageVersion.of(21) in build.gradle.kts toolchain blocks. +static GRADLE_TOOLCHAIN_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"JavaLanguageVersion\.of\((\d+)\)"#).unwrap() +}); + +/// Matches 21 in pom.xml (maven-compiler-plugin 3.6+). +static MAVEN_RELEASE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"([0-9]+)").unwrap()); + pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Option { let has_gradle = files.build_gradle || files.build_gradle_kts; if !files.pom_xml && !has_gradle { @@ -50,7 +59,8 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt }; // -- Runtime version -- - let (version, source) = detect_java_version(dir, files, build_content.as_deref(), is_maven, fs); + let (version, source) = + detect_java_version(dir, files, build_content.as_deref(), is_maven, fs); let runtime = RuntimeInfo { name: "java".into(), @@ -69,20 +79,22 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt let framework_patterns: &[(&str, &str)] = &[ ("spring-boot", "spring-boot"), + ("org.springframework.boot", "spring-boot"), ("quarkus", "quarkus"), ("micronaut", "micronaut"), ("grails", "grails"), + ("vertx", "vertx"), ]; // Use the whole content as a single "dep" for matching let content_as_dep = [content]; let framework = detect_framework(&content_as_dep, framework_patterns).map(String::from); - // -- Commands (Java always gets defaults) -- + // -- Commands -- let build_tool_wrapper = files.gradlew || files.mvnw; let (build_cmd, start_cmd) = if is_maven { let prefix = if files.mvnw { "./mvnw" } else { "mvn" }; - let port_cfg = detect_maven_port_config(content); + let port_cfg = detect_port_config(content, framework.as_deref(), is_maven); ( format!("{prefix} -B -DskipTests clean package -Pproduction"), format!("java {port_cfg}$JAVA_OPTS -jar target/*jar") @@ -91,12 +103,16 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt ) } else { let prefix = if files.gradlew { "./gradlew" } else { "gradle" }; - let port_cfg = detect_gradle_port_config(content); + let port_cfg = detect_port_config(content, framework.as_deref(), is_maven); ( format!("{prefix} clean build -x check -x test -Pproduction"), - format!("java {port_cfg}$JAVA_OPTS -jar build/libs/*jar") - .trim() - .to_string(), + // Filter out -plain jars — Gradle produces both app.jar and app-plain.jar; + // the -plain jar has no dependencies bundled and won't run. + format!( + "java {port_cfg}$JAVA_OPTS -jar $(find build/libs -name '*jar' ! -name '*-plain*' | head -1)" + ) + .trim() + .to_string(), ) }; @@ -111,9 +127,9 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt } else { let prefix = if files.gradlew { "./gradlew" } else { "gradle" }; match framework.as_deref() { - Some("spring-boot") => Some(format!("{prefix} bootRun")), + Some("spring-boot") | Some("grails") => Some(format!("{prefix} bootRun")), Some("quarkus") => Some(format!("{prefix} quarkusDev")), - Some("micronaut") => Some(format!("{prefix} run")), + Some("micronaut") | Some("vertx") => Some(format!("{prefix} run")), _ => Some(format!("{prefix} run")), } }; @@ -125,8 +141,29 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt dev, }; + // -- Health check -- + let healthcheck = match framework.as_deref() { + Some("spring-boot") | Some("grails") if content.contains("actuator") => { + Some("/actuator/health".into()) + } + Some("quarkus") if content.contains("smallrye-health") => Some("/q/health".into()), + Some("micronaut") if content.contains("micronaut-management") => { + Some("/health".into()) + } + _ => None, + }; + + // -- System deps -- + let system_deps = vec!["ca-certificates".into()]; + // -- Language config -- - let language_config = LanguageConfig::Java(JavaConfig { build_tool_wrapper }); + let has_flyway = content.contains("flyway"); + let has_liquibase = content.contains("liquibase"); + let language_config = LanguageConfig::Java(JavaConfig { + build_tool_wrapper, + has_flyway, + has_liquibase, + }); Some(DirContext { dir: dir_string(dir), @@ -137,8 +174,9 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt language_config: Some(language_config), output_dir: None, commands, + healthcheck, env: Vec::new(), - system_deps: Vec::new(), + system_deps, }) } @@ -175,6 +213,12 @@ fn detect_java_version( return (Some(v.as_str().to_string()), Some("pom.xml".into())); } } + // pom.xml: 21 (maven-compiler-plugin 3.6+) + if let Some(caps) = MAVEN_RELEASE_RE.captures(content) { + if let Some(v) = caps.get(1) { + return (Some(v.as_str().to_string()), Some("pom.xml".into())); + } + } } else { // build.gradle: sourceCompatibility = 21 if let Some(caps) = GRADLE_JAVA_RE.captures(content) { @@ -187,6 +231,17 @@ fn detect_java_version( return (Some(v.as_str().to_string()), Some(source_file.into())); } } + // build.gradle.kts: JavaLanguageVersion.of(21) (toolchain API) + if let Some(caps) = GRADLE_TOOLCHAIN_RE.captures(content) { + if let Some(v) = caps.get(1) { + let source_file = if files.build_gradle_kts { + "build.gradle.kts" + } else { + "build.gradle" + }; + return (Some(v.as_str().to_string()), Some(source_file.into())); + } + } } } @@ -211,20 +266,27 @@ fn detect_java_version( (None, None) } -/// Detect Spring Boot or Wildfly Swarm port config for Maven projects. -fn detect_maven_port_config(content: &str) -> &'static str { - if content.contains("org.wildfly.swarm") { - "-Dswarm.http.port=$PORT " - } else if content.contains("org.springframework.boot") && content.contains("spring-boot") { - "-Dserver.port=$PORT " - } else { - "" +/// Detect port configuration flag based on framework and build file content. +fn detect_port_config(content: &str, framework: Option<&str>, is_maven: bool) -> &'static str { + // Grails runs on Spring Boot — same port config + match framework { + Some("spring-boot") | Some("grails") => return "-Dserver.port=$PORT ", + Some("quarkus") => return "-Dquarkus.http.port=$PORT ", + Some("micronaut") => return "-Dmicronaut.server.port=$PORT ", + Some("vertx") => return "", + _ => {} } -} -/// Detect Spring Boot port config for Gradle projects. -fn detect_gradle_port_config(content: &str) -> &'static str { - if content.contains("org.springframework.boot:spring-boot") + // Fallback: detect from build file content + if is_maven { + if content.contains("org.wildfly.swarm") { + "-Dswarm.http.port=$PORT " + } else if content.contains("org.springframework.boot") && content.contains("spring-boot") { + "-Dserver.port=$PORT " + } else { + "" + } + } else if content.contains("org.springframework.boot:spring-boot") || content.contains("spring-boot-gradle-plugin") || content.contains("org.springframework.boot") { diff --git a/src/signals/package/mod.rs b/src/signals/package/mod.rs index d0ca042..1968966 100644 --- a/src/signals/package/mod.rs +++ b/src/signals/package/mod.rs @@ -32,6 +32,7 @@ pub(super) struct DirFiles { pub tsconfig_json: bool, pub node_version: bool, pub nvmrc: bool, + pub bun_version: bool, // Python pub pyproject_toml: bool, @@ -46,11 +47,14 @@ pub(super) struct DirFiles { pub python_version: bool, pub runtime_txt: bool, pub manage_py: bool, + /// A known Python entry file (main.py, app.py, etc.) exists + pub python_entry_file: bool, // Go pub go_mod: bool, pub go_sum: bool, pub go_work: bool, + pub go_version_file: bool, pub has_cmd_dir: bool, // Rust @@ -73,6 +77,7 @@ pub(super) struct DirFiles { pub composer_lock: bool, pub artisan: bool, pub index_php: bool, + pub php_version_file: bool, // Deno pub deno_json: bool, @@ -105,15 +110,16 @@ pub(super) struct DirFiles { static WATCHED_FILES: phf::Set<&'static str> = phf::phf_set! { // Node / JS / TS "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", - "bun.lockb", "bun.lock", ".yarnrc.yml", "tsconfig.json", ".node-version", ".nvmrc", + "bun.lockb", "bun.lock", ".yarnrc.yml", "tsconfig.json", ".node-version", ".nvmrc", ".bun-version", // Python "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "Pipfile.lock", "poetry.lock", "uv.lock", "pdm.lock", ".python-version", "runtime.txt", "manage.py", + "main.py", "app.py", "start.py", "server.py", "bot.py", "wsgi.py", "asgi.py", // Go - "go.mod", "go.sum", "go.work", + "go.mod", "go.sum", "go.work", ".go-version", // Rust "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "rust-toolchain", ".rust-version", "rust-version.txt", @@ -122,7 +128,7 @@ static WATCHED_FILES: phf::Set<&'static str> = phf::phf_set! { "Gemfile", "Gemfile.lock", ".ruby-version", "config.ru", "Rakefile", // PHP - "composer.json", "composer.lock", "artisan", "index.php", + "composer.json", "composer.lock", "artisan", "index.php", ".php-version", // Java / Kotlin "pom.xml", "build.gradle", "build.gradle.kts", "gradlew", "mvnw", @@ -195,6 +201,7 @@ impl Signal for PackageSignal { "tsconfig.json" => files.tsconfig_json = true, ".node-version" => files.node_version = true, ".nvmrc" => files.nvmrc = true, + ".bun-version" => files.bun_version = true, // Python "pyproject.toml" => files.pyproject_toml = true, @@ -209,11 +216,15 @@ impl Signal for PackageSignal { ".python-version" => files.python_version = true, "runtime.txt" => files.runtime_txt = true, "manage.py" => files.manage_py = true, + "main.py" | "app.py" | "start.py" | "server.py" | "bot.py" | "wsgi.py" | "asgi.py" => { + files.python_entry_file = true; + } // Go "go.mod" => files.go_mod = true, "go.sum" => files.go_sum = true, "go.work" => files.go_work = true, + ".go-version" => files.go_version_file = true, // Rust "Cargo.toml" => files.cargo_toml = true, @@ -235,6 +246,7 @@ impl Signal for PackageSignal { "composer.lock" => files.composer_lock = true, "artisan" => files.artisan = true, "index.php" => files.index_php = true, + ".php-version" => files.php_version_file = true, // Java "pom.xml" => files.pom_xml = true, @@ -576,6 +588,126 @@ start = "uvicorn main:app" assert!(disc.services[0].start().is_some()); } + #[test] + fn go_work_only_triggers_detection() { + // go.work without go.mod should still detect Go + let fs = MemoryFs::new(&[ + ("go.work", "go 1.22.0\n\nuse (\n\t./svc1\n\t./svc2\n)"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + let svc = &disc.services[0]; + assert_eq!(svc.language(), Some(Language::Go)); + if let Some(LanguageConfig::Go(ref gc)) = svc.runtimes[0].language_config { + assert!(gc.workspace); + } else { + panic!("expected GoConfig with workspace=true"); + } + } + + #[test] + fn go_version_file() { + let fs = MemoryFs::new(&[ + ("go.mod", "module example.com/app\n\ngo 1.22.0\n"), + (".go-version", "1.23.0"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + // .go-version should win over go.mod + assert_eq!(rt.version.as_deref(), Some("1.23.0")); + assert_eq!(rt.version_source.as_deref(), Some(".go-version")); + } + + #[test] + fn go_version_file_strips_v_prefix() { + let fs = MemoryFs::new(&[ + ("go.mod", "module example.com/app\n\ngo 1.22.0\n"), + (".go-version", "v1.23.1\n"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("1.23.1")); + } + + #[test] + fn go_tool_versions_golang_key() { + let fs = MemoryFs::new(&[ + ("go.mod", "module example.com/app\n"), + (".tool-versions", "golang 1.22.5\n"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("1.22.5")); + assert_eq!(rt.version_source.as_deref(), Some(".tool-versions")); + } + + #[test] + fn go_tool_versions_go_key() { + let fs = MemoryFs::new(&[ + ("go.mod", "module example.com/app\n"), + (".tool-versions", "go 1.22.6\n"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("1.22.6")); + assert_eq!(rt.version_source.as_deref(), Some(".tool-versions")); + } + + // -- Node start fallback -- + + #[test] + fn node_start_fallback_main_field() { + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"app","main":"src/server.js"}"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let svc = &disc.services[0]; + assert_eq!(svc.start(), Some("node src/server.js")); + } + + #[test] + fn node_start_fallback_index_js() { + let fs = MemoryFs::new(&[ + ("package.json", r#"{"name":"app"}"#), + ("index.js", "console.log('hello')"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let svc = &disc.services[0]; + assert_eq!(svc.start(), Some("node index.js")); + } + + #[test] + fn node_start_fallback_index_ts() { + let fs = MemoryFs::new(&[ + ("package.json", r#"{"name":"app"}"#), + ("index.ts", "console.log('hello')"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let svc = &disc.services[0]; + assert_eq!(svc.start(), Some("node index.ts")); + } + + #[test] + fn node_start_fallback_bun_runtime() { + let fs = MemoryFs::new(&[ + ("package.json", r#"{"name":"app","main":"src/server.ts"}"#), + ("bun.lockb", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let svc = &disc.services[0]; + assert_eq!(svc.start(), Some("bun src/server.ts")); + } + // -- Rust -- #[test] @@ -702,1385 +834,2648 @@ axum = "0.8" } } - // -- Elixir -- - - #[test] - fn elixir_mix() { - let fs = MemoryFs::new(&[( - "mix.exs", - r#" -defmodule MyApp.MixProject do - use Mix.Project - def project do - [app: :my_app, elixir: "~> 1.17", deps: deps()] - end - defp deps do - [{:phoenix, "~> 1.7"}, {:postgrex, "~> 0.18"}] - end -end -"#, - )]); - let disc = - crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 1); - let svc = &disc.services[0]; - assert_eq!(svc.language(), Some(Language::Elixir)); - assert_eq!(svc.framework(), Some("phoenix")); - } - - // -- Cross-cutting -- - #[test] - fn tool_versions() { + fn java_maven_commands() { let fs = MemoryFs::new(&[ ( - "package.json", - r#"{"name":"app","scripts":{"start":"node index.js"}}"#, + "pom.xml", + r#" + 21 + + + org.springframework.boot + spring-boot-starter-web + + +"#, ), - (".tool-versions", "nodejs 20.11.0\n"), + ("mvnw", "#!/bin/sh"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("20.11.0")); - assert_eq!(rt.version_source.as_deref(), Some(".tool-versions")); + let svc = &disc.services[0]; + assert_eq!( + svc.build(), + Some("./mvnw -B -DskipTests clean package -Pproduction") + ); + assert_eq!( + svc.start(), + Some("java -Dserver.port=$PORT $JAVA_OPTS -jar target/*jar") + ); + assert_eq!(svc.dev(), Some("./mvnw spring-boot:run")); } #[test] - fn mise_toml() { + fn java_gradle_commands() { let fs = MemoryFs::new(&[ ( - "package.json", - r#"{"name":"app","scripts":{"start":"node index.js"}}"#, + "build.gradle", + "plugins { id 'org.springframework.boot' version '3.2.0' }", ), - (".mise.toml", "[tools]\nnode = \"20.11.0\"\n"), + ("gradlew", "#!/bin/sh"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("20.11.0")); - assert_eq!(rt.version_source.as_deref(), Some(".mise.toml")); + let svc = &disc.services[0]; + assert_eq!( + svc.build(), + Some("./gradlew clean build -x check -x test -Pproduction") + ); + // Gradle start filters out -plain jars + assert!(svc.start().unwrap().contains("! -name '*-plain*'")); + assert_eq!(svc.dev(), Some("./gradlew bootRun")); } #[test] - fn framework_no_scripts_no_promotion() { - // next in deps but no scripts → DirContext emitted but no start command, so not promoted + fn java_maven_no_wrapper() { let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","dependencies":{"next":"14.0.0"}}"#, + "pom.xml", + r#""#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(disc.services.is_empty()); + let svc = &disc.services[0]; + assert_eq!( + svc.build(), + Some("mvn -B -DskipTests clean package -Pproduction") + ); + assert_eq!(svc.dev(), Some("mvn compile exec:java")); } #[test] - fn no_framework_no_scripts_no_promotion() { - // bare package.json with no deps, no scripts → not promoted - let fs = MemoryFs::new(&[("package.json", r#"{"name":"lib"}"#)]); + fn java_gradle_no_wrapper() { + let fs = MemoryFs::new(&[("build.gradle", "apply plugin: 'java'")]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(disc.services.is_empty()); + let svc = &disc.services[0]; + assert!(svc.build().unwrap().starts_with("gradle")); + assert_eq!(svc.dev(), Some("gradle run")); } #[test] - fn multi_language_dir() { - // package.json + go.mod in same dir → two DirContexts + fn java_quarkus_maven() { + let fs = MemoryFs::new(&[( + "pom.xml", + r#" + io.quarkusquarkus-core +"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let svc = &disc.services[0]; + assert_eq!(svc.framework(), Some("quarkus")); + assert!(svc.start().unwrap().contains("-Dquarkus.http.port=$PORT")); + assert_eq!(svc.dev(), Some("mvn quarkus:dev")); + } + + #[test] + fn java_micronaut_gradle() { let fs = MemoryFs::new(&[ ( - "package.json", - r#"{"name":"app","scripts":{"start":"node index.js"}}"#, + "build.gradle", + "plugins { id 'io.micronaut.application' version '4.0.0' }", ), - ("go.mod", "module example.com/app\n\ngo 1.22.0\n"), + ("gradlew", "#!/bin/sh"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - // Both emit context, but Go has default commands (start) so it gets promoted. - // Node also has start script. Multiple services possible. - assert!(!disc.services.is_empty()); + let svc = &disc.services[0]; + assert_eq!(svc.framework(), Some("micronaut")); + assert!( + svc.start() + .unwrap() + .contains("-Dmicronaut.server.port=$PORT") + ); + assert_eq!(svc.dev(), Some("./gradlew run")); } - // ========================================================================== - // Node.js — new feature tests - // ========================================================================== - #[test] - fn node_bun_lock_text_format() { - // bun.lock (text, Bun 1.2+) should detect bun as PM and runtime + fn java_grails_port_config() { let fs = MemoryFs::new(&[ ( - "package.json", - r#"{"name":"app","scripts":{"start":"bun run index.ts"}}"#, + "build.gradle", + "plugins { id 'org.grails.grails-web' version '6.0.0' }", ), - ("bun.lock", "lockfileVersion: 0"), + ("gradlew", "#!/bin/sh"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); let svc = &disc.services[0]; - let rt = &svc.runtimes[0]; - assert_eq!(rt.name, "bun"); - let pm = svc.runtimes[0].package_manager.as_ref().unwrap(); - assert_eq!(pm.name, "bun"); + assert_eq!(svc.framework(), Some("grails")); + // Grails runs on Spring Boot — same port config + assert!(svc.start().unwrap().contains("-Dserver.port=$PORT")); + assert_eq!(svc.dev(), Some("./gradlew bootRun")); } #[test] - fn node_bun_lockb_binary_format() { - // bun.lockb (binary, legacy) should also detect bun + fn java_vertx_gradle() { let fs = MemoryFs::new(&[ ( - "package.json", - r#"{"name":"app","scripts":{"start":"bun run index.ts"}}"#, + "build.gradle.kts", + r#"dependencies { implementation("io.vertx:vertx-core:4.5.0") }"#, ), - ("bun.lockb", "\x00"), + ("gradlew", "#!/bin/sh"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.name, "bun"); + assert_eq!(disc.services[0].framework(), Some("vertx")); + assert_eq!(disc.services[0].dev(), Some("./gradlew run")); } #[test] - fn node_react_router_framework() { + fn java_system_deps() { let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"start":"node server.js"}, - "dependencies":{"@react-router/dev":"^7.0.0","react":"^18.0.0"}}"#, + "pom.xml", + r#""#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services[0].framework(), Some("react-router")); - assert_eq!(disc.services[0].output_dir(), Some("build")); + assert!( + disc.services[0] + .system_deps + .contains(&"ca-certificates".to_string()) + ); } #[test] - fn node_tanstack_start_framework() { + fn java_version_maven_release_tag() { let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"start":"node server.js"}, - "dependencies":{"@tanstack/start":"^1.0.0"}}"#, + "pom.xml", + r#" + + maven-compiler-plugin + 17 + +"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!( - disc.services[0].framework(), - Some("tanstack-start") - ); - assert_eq!(disc.services[0].output_dir(), Some(".output")); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("17")); } #[test] - fn node_sveltekit_adapter_node() { + fn java_version_gradle_toolchain() { let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"start":"node build/index.js"}, - "dependencies":{"@sveltejs/adapter-node":"^5.0.0","@sveltejs/kit":"^2.0.0"}}"#, + "build.gradle.kts", + r#"java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } }"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services[0].framework(), Some("sveltekit")); - assert_eq!(disc.services[0].output_dir(), Some("build")); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("21")); } #[test] - fn node_angular_cli() { + fn java_wildfly_swarm_port() { let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"start":"ng serve"}, - "devDependencies":{"@angular/cli":"^17.0.0"}}"#, + "pom.xml", + r#" + org.wildfly.swarmundertow +"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services[0].framework(), Some("angular")); - assert_eq!(disc.services[0].output_dir(), Some("dist")); + assert!( + disc.services[0] + .start() + .unwrap() + .contains("-Dswarm.http.port=$PORT") + ); } #[test] - fn node_puppeteer_real_system_deps() { + fn java_flyway_detection() { let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"start":"node index.js"}, - "dependencies":{"puppeteer":"^22.0.0"}}"#, + "pom.xml", + r#" + org.flywaydbflyway-core + org.springframework.bootspring-boot-starter-web +"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let svc = &disc.services[0]; - // Should have real library packages, not just "chromium" - assert!(svc.system_deps.contains(&"libnss3".to_string())); - assert!(svc.system_deps.contains(&"libgbm1".to_string())); - assert!(svc.system_deps.contains(&"libasound2".to_string())); - assert!(!svc.system_deps.contains(&"chromium".to_string())); - // NodeConfig should flag puppeteer - if let Some(LanguageConfig::Node(ref nc)) = svc.runtimes[0].language_config { - assert!(nc.has_puppeteer); + if let Some(LanguageConfig::Java(ref jc)) = disc.services[0].runtimes[0].language_config { + assert!(jc.has_flyway); + assert!(!jc.has_liquibase); } else { - panic!("expected NodeConfig"); + panic!("expected JavaConfig"); } } #[test] - fn node_version_precedence_node_version_over_engines() { - // .node-version should win over engines.node - let fs = MemoryFs::new(&[ - ( - "package.json", - r#"{"name":"app","scripts":{"start":"node index.js"},"engines":{"node":">=18.0.0"}}"#, - ), - (".node-version", "22.1.0"), - ]); - let disc = - crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("22.1.0")); - assert_eq!(rt.version_source.as_deref(), Some(".node-version")); - } - - #[test] - fn node_version_precedence_nvmrc_over_engines() { - // .nvmrc should win over engines.node (but lose to .node-version) - let fs = MemoryFs::new(&[ - ( - "package.json", - r#"{"name":"app","scripts":{"start":"node index.js"},"engines":{"node":">=18.0.0"}}"#, - ), - (".nvmrc", "20.11.0"), - ]); + fn java_liquibase_detection() { + let fs = MemoryFs::new(&[( + "build.gradle", + r#"plugins { id 'org.springframework.boot' } +dependencies { implementation 'org.liquibase:liquibase-core' }"#, + )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("20.11.0")); - assert_eq!(rt.version_source.as_deref(), Some(".nvmrc")); + if let Some(LanguageConfig::Java(ref jc)) = disc.services[0].runtimes[0].language_config { + assert!(jc.has_liquibase); + assert!(!jc.has_flyway); + } else { + panic!("expected JavaConfig"); + } } #[test] - fn node_engine_version_caret() { - // ^18.17.0 → just major "18" + fn java_spring_boot_actuator_healthcheck() { let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"start":"node index.js"},"engines":{"node":"^18.17.0"}}"#, + "pom.xml", + r#" + org.springframework.bootspring-boot-starter-web + org.springframework.bootspring-boot-starter-actuator +"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("18")); - assert_eq!(rt.version_source.as_deref(), Some("package.json")); + assert_eq!( + disc.services[0].healthcheck.as_deref(), + Some("/actuator/health") + ); } #[test] - fn node_engine_version_tilde() { - // ~20.11.0 → just major "20" + fn java_spring_boot_no_actuator_no_healthcheck() { let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"start":"node index.js"},"engines":{"node":"~20.11.0"}}"#, + "pom.xml", + r#" + org.springframework.bootspring-boot-starter-web +"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("20")); + assert!(disc.services[0].healthcheck.is_none()); } #[test] - fn node_engine_version_gt() { - // >18 → just major "18" + fn java_quarkus_healthcheck() { let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"start":"node index.js"},"engines":{"node":">18"}}"#, + "pom.xml", + r#" + io.quarkusquarkus-core + io.quarkusquarkus-smallrye-health +"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("18")); + assert_eq!(disc.services[0].healthcheck.as_deref(), Some("/q/health")); } #[test] - fn node_engine_version_exact_pinned() { - // Exact version without range → full version returned + fn java_micronaut_healthcheck() { let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"start":"node index.js"},"engines":{"node":"20.11.1"}}"#, + "build.gradle", + r#"plugins { id 'io.micronaut.application' } +dependencies { implementation 'io.micronaut:micronaut-management' }"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("20.11.1")); + assert_eq!(disc.services[0].healthcheck.as_deref(), Some("/health")); } #[test] - fn node_next_output_dir() { + fn ruby_rails_healthcheck() { let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"start":"next start"}, - "dependencies":{"next":"14.0.0"}}"#, + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails'\n", )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services[0].output_dir(), Some(".next")); + assert_eq!(disc.services[0].healthcheck.as_deref(), Some("/up")); } - // ========================================================================== - // Node.js — static site detection - // ========================================================================== - #[test] - fn node_static_vite_react() { - // Vite + React + build script + no start → static SPA + fn ruby_sinatra_no_healthcheck() { let fs = MemoryFs::new(&[ ( - "package.json", - r#"{"name":"app","scripts":{"build":"vite build"}, - "dependencies":{"react":"^18.0.0","vite":"^5.0.0"}}"#, + "Gemfile", + "source 'https://rubygems.org'\ngem 'sinatra'\n", ), - ("vite.config.ts", "export default {}"), + ("config.ru", "run Sinatra::Application"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 1); - assert!(is_node_spa(&disc.services[0])); + assert!(disc.services[0].healthcheck.is_none()); } #[test] - fn node_static_vite_with_start_not_static() { - // Vite + React + start script → NOT static (has a server) - let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"start":"node server.js","build":"vite build"}, - "dependencies":{"react":"^18.0.0","vite":"^5.0.0"}}"#, - )]); + fn php_laravel_healthcheck() { + let fs = MemoryFs::new(&[ + ( + "composer.json", + r#"{"require":{"php":">=8.2","laravel/framework":"^11.0"}}"#, + ), + ("artisan", "#!/usr/bin/env php"), + ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - // Has start script → promoted as server, not SPA - assert!(!is_node_spa(&disc.services[0])); + assert_eq!(disc.services[0].healthcheck.as_deref(), Some("/up")); } - #[test] - fn node_static_vite_sveltekit_not_static() { - // SvelteKit uses Vite internally but is NOT static by default - let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"build":"vite build"}, - "dependencies":{"vite":"^5.0.0","@sveltejs/kit":"^2.0.0"}}"#, - )]); - let disc = - crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - // SvelteKit → server framework, not promoted without start - assert!(disc.services.is_empty() || !is_node_spa(&disc.services[0])); - } + // -- Elixir -- #[test] - fn node_static_astro_default() { - // Astro without SSR adapter → static + fn elixir_mix() { let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"build":"astro build"}, - "dependencies":{"astro":"^4.0.0"}}"#, + "mix.exs", + r#" +defmodule MyApp.MixProject do + use Mix.Project + def project do + [app: :my_app, elixir: "~> 1.17", deps: deps()] + end + defp deps do + [{:phoenix, "~> 1.7"}, {:postgrex, "~> 0.18"}] + end +end +"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); assert_eq!(disc.services.len(), 1); - assert!(is_node_spa(&disc.services[0])); + let svc = &disc.services[0]; + assert_eq!(svc.language(), Some(Language::Elixir)); + assert_eq!(svc.framework(), Some("phoenix")); } - #[test] - fn node_static_astro_ssr_not_static() { - // Astro with @astrojs/node adapter → SSR, NOT static - let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"build":"astro build"}, - "dependencies":{"astro":"^4.0.0","@astrojs/node":"^8.0.0"}}"#, - )]); - let disc = - crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(disc.services.is_empty() || !is_node_spa(&disc.services[0])); - } + // -- Cross-cutting -- #[test] - fn node_static_astro_server_config_not_static() { - // Astro with output: 'server' in config → NOT static + fn tool_versions() { let fs = MemoryFs::new(&[ ( "package.json", - r#"{"name":"app","scripts":{"build":"astro build"}, - "dependencies":{"astro":"^4.0.0"}}"#, - ), - ( - "astro.config.mjs", - "import { defineConfig } from 'astro/config';\nexport default defineConfig({ output: 'server' });", + r#"{"name":"app","scripts":{"start":"node index.js"}}"#, ), + (".tool-versions", "nodejs 20.11.0\n"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(disc.services.is_empty() || !is_node_spa(&disc.services[0])); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("20.11.0")); + assert_eq!(rt.version_source.as_deref(), Some(".tool-versions")); } #[test] - fn node_static_cra() { - // Create React App: react-scripts dep + react-scripts build → static - let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"build":"react-scripts build"}, - "dependencies":{"react":"^18.0.0","react-scripts":"^5.0.0"}}"#, - )]); + fn mise_toml() { + let fs = MemoryFs::new(&[ + ( + "package.json", + r#"{"name":"app","scripts":{"start":"node index.js"}}"#, + ), + (".mise.toml", "[tools]\nnode = \"20.11.0\"\n"), + ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 1); - assert!(is_node_spa(&disc.services[0])); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("20.11.0")); + assert_eq!(rt.version_source.as_deref(), Some(".mise.toml")); } #[test] - fn node_static_angular() { - // Angular with ng build → static SPA + fn framework_no_scripts_no_promotion() { + // next in deps but no scripts → DirContext emitted but no start command, so not promoted let fs = MemoryFs::new(&[( "package.json", - r#"{"name":"app","scripts":{"build":"ng build"}, - "dependencies":{"@angular/core":"^17.0.0"}}"#, + r#"{"name":"app","dependencies":{"next":"14.0.0"}}"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 1); - assert!(is_node_spa(&disc.services[0])); + assert!(disc.services.is_empty()); } #[test] - fn node_static_no_build_script_not_static() { - // Vite dep but no build script → NOT static (nothing to build) - let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","dependencies":{"react":"^18.0.0","vite":"^5.0.0"}}"#, - )]); + fn no_framework_no_scripts_no_promotion() { + // bare package.json with no deps, no scripts → not promoted + let fs = MemoryFs::new(&[("package.json", r#"{"name":"lib"}"#)]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); assert!(disc.services.is_empty()); } #[test] - fn node_static_express_not_static() { - // Vite + Express → server app, NOT static - let fs = MemoryFs::new(&[( - "package.json", - r#"{"name":"app","scripts":{"build":"vite build"}, - "dependencies":{"react":"^18.0.0","vite":"^5.0.0","express":"^4.0.0"}}"#, - )]); + fn multi_language_dir() { + // package.json + go.mod in same dir → two DirContexts + let fs = MemoryFs::new(&[ + ( + "package.json", + r#"{"name":"app","scripts":{"start":"node index.js"}}"#, + ), + ("go.mod", "module example.com/app\n\ngo 1.22.0\n"), + ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(disc.services.is_empty() || !is_node_spa(&disc.services[0])); + // Both emit context, but Go has default commands (start) so it gets promoted. + // Node also has start script. Multiple services possible. + assert!(!disc.services.is_empty()); } // ========================================================================== - // Python — new feature tests + // Node.js — new feature tests // ========================================================================== #[test] - fn python_pdm_package_manager() { + fn node_bun_lock_text_format() { + // bun.lock (text, Bun 1.2+) should detect bun as PM and runtime let fs = MemoryFs::new(&[ ( - "pyproject.toml", - "[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = [\"flask\"]\n\n[project.scripts]\nstart = \"flask run\"\n", + "package.json", + r#"{"name":"app","scripts":{"start":"bun run index.ts"}}"#, ), - ("pdm.lock", ""), + ("bun.lock", "lockfileVersion: 0"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let pm = disc.services[0].runtimes[0].package_manager.as_ref().unwrap(); - assert_eq!(pm.name, "pdm"); - assert_eq!( - disc.services[0].install(), - Some("pdm install") - ); + let svc = &disc.services[0]; + let rt = &svc.runtimes[0]; + assert_eq!(rt.name, "bun"); + let pm = svc.runtimes[0].package_manager.as_ref().unwrap(); + assert_eq!(pm.name, "bun"); } #[test] - fn python_fasthtml_framework() { - let fs = MemoryFs::new(&[( - "requirements.txt", - "python-fasthtml==0.6.0\nuvicorn==0.30.0\n", - )]); - let disc = - crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services[0].framework(), Some("fasthtml")); - } - - #[test] - fn python_pipfile_version() { + fn node_bun_lockb_binary_format() { + // bun.lockb (binary, legacy) should also detect bun let fs = MemoryFs::new(&[ ( - "Pipfile", - "[packages]\nflask = \"*\"\ngunicorn = \"*\"\n\n[requires]\npython_version = \"3.12\"\n", + "package.json", + r#"{"name":"app","scripts":{"start":"bun run index.ts"}}"#, ), - ("Pipfile.lock", "{}"), + ("bun.lockb", "\x00"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("3.12")); - assert_eq!(rt.version_source.as_deref(), Some("Pipfile")); + assert_eq!(rt.name, "bun"); } #[test] - fn python_pipfile_deps() { - // Pipfile [packages] should be collected for framework detection + fn node_react_router_framework() { let fs = MemoryFs::new(&[( - "Pipfile", - "[packages]\ndjango = \"*\"\npsycopg2 = \"*\"\ngunicorn = \"*\"\n\n[requires]\npython_version = \"3.11\"\n", + "package.json", + r#"{"name":"app","scripts":{"start":"node server.js"}, + "dependencies":{"@react-router/dev":"^7.0.0","react":"^18.0.0"}}"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services[0].framework(), Some("django")); - } - - #[test] - fn python_main_file_detection() { - let fs = MemoryFs::new(&[ - ("requirements.txt", "flask==3.0.0\ngunicorn==22.0.0\n"), - ("app.py", "from flask import Flask"), - ]); - let disc = - crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - if let Some(LanguageConfig::Python(ref pc)) = disc.services[0].runtimes[0].language_config { - assert_eq!(pc.main_file.as_deref(), Some("app.py")); - } else { - panic!("expected PythonConfig"); - } + assert_eq!(disc.services[0].framework(), Some("react-router")); + assert_eq!(disc.services[0].output_dir(), Some("build")); } #[test] - fn python_pydub_ffmpeg_sysdep() { + fn node_tanstack_start_framework() { let fs = MemoryFs::new(&[( - "requirements.txt", - "flask==3.0.0\npydub==0.25.1\ngunicorn==22.0.0\n", + "package.json", + r#"{"name":"app","scripts":{"start":"node server.js"}, + "dependencies":{"@tanstack/start":"^1.0.0"}}"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(disc.services[0].system_deps.contains(&"ffmpeg".to_string())); + assert_eq!( + disc.services[0].framework(), + Some("tanstack-start") + ); + assert_eq!(disc.services[0].output_dir(), Some(".output")); } - // ========================================================================== - // Go — new feature tests - // ========================================================================== - #[test] - fn go_always_has_ca_certificates() { - let fs = MemoryFs::new(&[("go.mod", "module example.com/app\n\ngo 1.22.0\n")]); + fn node_sveltekit_adapter_node() { + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"app","scripts":{"start":"node build/index.js"}, + "dependencies":{"@sveltejs/adapter-node":"^5.0.0","@sveltejs/kit":"^2.0.0"}}"#, + )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!( - disc.services[0] - .system_deps - .contains(&"ca-certificates".to_string()) - ); + assert_eq!(disc.services[0].framework(), Some("sveltekit")); + assert_eq!(disc.services[0].output_dir(), Some("build")); } #[test] - fn go_build_ldflags() { - let fs = MemoryFs::new(&[("go.mod", "module example.com/app\n\ngo 1.22.0\n")]); + fn node_angular_cli() { + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"app","scripts":{"start":"ng serve"}, + "devDependencies":{"@angular/cli":"^17.0.0"}}"#, + )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!( - disc.services[0].build(), - Some("go build -ldflags=\"-w -s\" -o app .") - ); + assert_eq!(disc.services[0].framework(), Some("angular")); + assert_eq!(disc.services[0].output_dir(), Some("dist")); } - // ========================================================================== - // Rust — new feature tests - // ========================================================================== - #[test] - fn rust_toolchain_toml_version() { - let fs = MemoryFs::new(&[ - ( - "Cargo.toml", - "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", - ), - ("rust-toolchain.toml", "[toolchain]\nchannel = \"1.85.0\"\n"), - ]); + fn node_puppeteer_real_system_deps() { + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"app","scripts":{"start":"node index.js"}, + "dependencies":{"puppeteer":"^22.0.0"}}"#, + )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("1.85.0")); - assert_eq!(rt.version_source.as_deref(), Some("rust-toolchain.toml")); + let svc = &disc.services[0]; + // Should have real library packages, not just "chromium" + assert!(svc.system_deps.contains(&"libnss3".to_string())); + assert!(svc.system_deps.contains(&"libgbm1".to_string())); + assert!(svc.system_deps.contains(&"libasound2".to_string())); + assert!(!svc.system_deps.contains(&"chromium".to_string())); + // NodeConfig should flag puppeteer + if let Some(LanguageConfig::Node(ref nc)) = svc.runtimes[0].language_config { + assert!(nc.has_puppeteer); + } else { + panic!("expected NodeConfig"); + } } #[test] - fn rust_toolchain_plain_text() { + fn node_version_precedence_node_version_over_engines() { + // .node-version should win over engines.node let fs = MemoryFs::new(&[ ( - "Cargo.toml", - "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", + "package.json", + r#"{"name":"app","scripts":{"start":"node index.js"},"engines":{"node":">=18.0.0"}}"#, ), - ("rust-toolchain", "1.82.0\n"), + (".node-version", "22.1.0"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("1.82.0")); - assert_eq!(rt.version_source.as_deref(), Some("rust-toolchain")); + assert_eq!(rt.version.as_deref(), Some("22.1.0")); + assert_eq!(rt.version_source.as_deref(), Some(".node-version")); } #[test] - fn rust_toolchain_toml_beats_plain_text() { - // rust-toolchain.toml should take precedence over rust-toolchain (plain text) + fn node_version_precedence_nvmrc_over_engines() { + // .nvmrc should win over engines.node (but lose to .node-version) let fs = MemoryFs::new(&[ ( - "Cargo.toml", - "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", + "package.json", + r#"{"name":"app","scripts":{"start":"node index.js"},"engines":{"node":">=18.0.0"}}"#, ), - ("rust-toolchain.toml", "[toolchain]\nchannel = \"1.85.0\"\n"), - ("rust-toolchain", "1.80.0\n"), + (".nvmrc", "20.11.0"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("1.85.0")); - assert_eq!(rt.version_source.as_deref(), Some("rust-toolchain.toml")); + assert_eq!(rt.version.as_deref(), Some("20.11.0")); + assert_eq!(rt.version_source.as_deref(), Some(".nvmrc")); } #[test] - fn rust_toolchain_stable_ignored() { - // "stable" in rust-toolchain.toml → no version (not a pinned version) - let fs = MemoryFs::new(&[ - ( - "Cargo.toml", - "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\nrust-version = \"1.75\"\n\n[dependencies]\n", - ), - ("rust-toolchain.toml", "[toolchain]\nchannel = \"stable\"\n"), - ]); + fn node_engine_version_caret() { + // ^18.17.0 → just major "18" + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"app","scripts":{"start":"node index.js"},"engines":{"node":"^18.17.0"}}"#, + )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); let rt = &disc.services[0].runtimes[0]; - // Should fall through to Cargo.toml rust-version - assert_eq!(rt.version.as_deref(), Some("1.75")); - assert_eq!(rt.version_source.as_deref(), Some("Cargo.toml")); + assert_eq!(rt.version.as_deref(), Some("18")); + assert_eq!(rt.version_source.as_deref(), Some("package.json")); } #[test] - fn rust_version_file() { - let fs = MemoryFs::new(&[ - ( - "Cargo.toml", - "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", - ), - (".rust-version", "1.83.0"), - ]); + fn node_engine_version_tilde() { + // ~20.11.0 → just major "20" + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"app","scripts":{"start":"node index.js"},"engines":{"node":"~20.11.0"}}"#, + )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("1.83.0")); - assert_eq!(rt.version_source.as_deref(), Some(".rust-version")); + assert_eq!(rt.version.as_deref(), Some("20")); } #[test] - fn rust_workspace_detection() { + fn node_engine_version_gt() { + // >18 → just major "18" let fs = MemoryFs::new(&[( - "Cargo.toml", - "[workspace]\nmembers = [\"crates/*\"]\n\n[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", + "package.json", + r#"{"name":"app","scripts":{"start":"node index.js"},"engines":{"node":">18"}}"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - if let Some(LanguageConfig::Rust(ref rc)) = disc.services[0].runtimes[0].language_config { - assert!(rc.workspace); - } else { - panic!("expected RustConfig"); - } + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("18")); } #[test] - fn rust_default_run() { + fn node_engine_version_exact_pinned() { + // Exact version without range → full version returned let fs = MemoryFs::new(&[( - "Cargo.toml", - "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\ndefault-run = \"server\"\n\n[dependencies]\n", + "package.json", + r#"{"name":"app","scripts":{"start":"node index.js"},"engines":{"node":"20.11.1"}}"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - if let Some(LanguageConfig::Rust(ref rc)) = disc.services[0].runtimes[0].language_config { - assert_eq!(rc.binary_name.as_deref(), Some("server")); - } else { - panic!("expected RustConfig"); - } - assert_eq!( - disc.services[0].start(), - Some("./target/release/server") - ); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("20.11.1")); } #[test] - fn rust_bin_table() { + fn node_next_output_dir() { let fs = MemoryFs::new(&[( - "Cargo.toml", - "[package]\nname = \"mylib\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[[bin]]\nname = \"cli\"\npath = \"src/main.rs\"\n\n[dependencies]\n", + "package.json", + r#"{"name":"app","scripts":{"start":"next start"}, + "dependencies":{"next":"14.0.0"}}"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - if let Some(LanguageConfig::Rust(ref rc)) = disc.services[0].runtimes[0].language_config { - assert_eq!(rc.binary_name.as_deref(), Some("cli")); - } else { - panic!("expected RustConfig"); - } - assert_eq!( - disc.services[0].start(), - Some("./target/release/cli") - ); + assert_eq!(disc.services[0].output_dir(), Some(".next")); } // ========================================================================== - // Ruby — new feature tests + // Node.js — static site detection // ========================================================================== #[test] - fn ruby_gemfile_lock_version() { + fn node_static_vite_react() { + // Vite + React + build script + no start → static SPA let fs = MemoryFs::new(&[ ( - "Gemfile", - "source 'https://rubygems.org'\ngem 'rails'\ngem 'pg'\n", - ), - ( - "Gemfile.lock", - "GEM\n remote: https://rubygems.org/\n specs:\n rails (7.1.0)\n\nPLATFORMS\n ruby\n\nRUBY VERSION\n ruby 3.3.0p0\n\nBUNDLED WITH\n 2.5.4\n", + "package.json", + r#"{"name":"app","scripts":{"build":"vite build"}, + "dependencies":{"react":"^18.0.0","vite":"^5.0.0"}}"#, ), + ("vite.config.ts", "export default {}"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("3.3.0")); - assert_eq!(rt.version_source.as_deref(), Some("Gemfile.lock")); - let pm = disc.services[0].runtimes[0].package_manager.as_ref().unwrap(); - assert_eq!(pm.version.as_deref(), Some("2.5.4")); - } + assert_eq!(disc.services.len(), 1); + assert!(is_node_spa(&disc.services[0])); + } #[test] - fn ruby_ruby_version_beats_gemfile_lock() { - // .ruby-version should take precedence over Gemfile.lock + fn node_static_vite_with_start_not_static() { + // Vite + React + start script → NOT static (has a server) + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"app","scripts":{"start":"node server.js","build":"vite build"}, + "dependencies":{"react":"^18.0.0","vite":"^5.0.0"}}"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + // Has start script → promoted as server, not SPA + assert!(!is_node_spa(&disc.services[0])); + } + + #[test] + fn node_static_vite_sveltekit_not_static() { + // SvelteKit uses Vite internally but is NOT static by default + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"app","scripts":{"build":"vite build"}, + "dependencies":{"vite":"^5.0.0","@sveltejs/kit":"^2.0.0"}}"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + // SvelteKit → server framework, not promoted without start + assert!(disc.services.is_empty() || !is_node_spa(&disc.services[0])); + } + + #[test] + fn node_static_astro_default() { + // Astro without SSR adapter → static + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"app","scripts":{"build":"astro build"}, + "dependencies":{"astro":"^4.0.0"}}"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + assert!(is_node_spa(&disc.services[0])); + } + + #[test] + fn node_static_astro_ssr_not_static() { + // Astro with @astrojs/node adapter → SSR, NOT static + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"app","scripts":{"build":"astro build"}, + "dependencies":{"astro":"^4.0.0","@astrojs/node":"^8.0.0"}}"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert!(disc.services.is_empty() || !is_node_spa(&disc.services[0])); + } + + #[test] + fn node_static_astro_server_config_not_static() { + // Astro with output: 'server' in config → NOT static let fs = MemoryFs::new(&[ - ("Gemfile", "source 'https://rubygems.org'\ngem 'rails'\n"), - (".ruby-version", "3.3.1"), ( - "Gemfile.lock", - "RUBY VERSION\n ruby 3.3.0p0\n\nBUNDLED WITH\n 2.5.4\n", + "package.json", + r#"{"name":"app","scripts":{"build":"astro build"}, + "dependencies":{"astro":"^4.0.0"}}"#, + ), + ( + "astro.config.mjs", + "import { defineConfig } from 'astro/config';\nexport default defineConfig({ output: 'server' });", ), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("3.3.1")); - assert_eq!(rt.version_source.as_deref(), Some(".ruby-version")); + assert!(disc.services.is_empty() || !is_node_spa(&disc.services[0])); } #[test] - fn ruby_execjs_needs_node() { + fn node_static_cra() { + // Create React App: react-scripts dep + react-scripts build → static let fs = MemoryFs::new(&[( - "Gemfile", - "source 'https://rubygems.org'\ngem 'rails'\ngem 'execjs'\n", + "package.json", + r#"{"name":"app","scripts":{"build":"react-scripts build"}, + "dependencies":{"react":"^18.0.0","react-scripts":"^5.0.0"}}"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - if let Some(LanguageConfig::Ruby(ref rc)) = disc.services[0].runtimes[0].language_config { - assert!(rc.needs_node); - } else { - panic!("expected RubyConfig"); - } + assert_eq!(disc.services.len(), 1); + assert!(is_node_spa(&disc.services[0])); } #[test] - fn ruby_config_ru_rack_start() { - // Sinatra-like app: Gemfile + config.ru, no framework → rack start command + fn node_static_angular() { + // Angular with ng build → static SPA + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"app","scripts":{"build":"ng build"}, + "dependencies":{"@angular/core":"^17.0.0"}}"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + assert!(is_node_spa(&disc.services[0])); + } + + #[test] + fn node_static_no_build_script_not_static() { + // Vite dep but no build script → NOT static (nothing to build) + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"app","dependencies":{"react":"^18.0.0","vite":"^5.0.0"}}"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert!(disc.services.is_empty()); + } + + #[test] + fn node_static_express_not_static() { + // Vite + Express → server app, NOT static + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"app","scripts":{"build":"vite build"}, + "dependencies":{"react":"^18.0.0","vite":"^5.0.0","express":"^4.0.0"}}"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert!(disc.services.is_empty() || !is_node_spa(&disc.services[0])); + } + + // ========================================================================== + // Python — new feature tests + // ========================================================================== + + #[test] + fn python_pdm_package_manager() { let fs = MemoryFs::new(&[ - ("Gemfile", "source 'https://rubygems.org'\ngem 'rack'\n"), - ("config.ru", "run Rack::App"), + ( + "pyproject.toml", + "[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = [\"flask\"]\n\n[project.scripts]\nstart = \"flask run\"\n", + ), + ("pdm.lock", ""), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let pm = disc.services[0].runtimes[0].package_manager.as_ref().unwrap(); + assert_eq!(pm.name, "pdm"); assert_eq!( - disc.services[0].start(), - Some("bundle exec rackup config.ru -p ${PORT:-3000}") + disc.services[0].install(), + Some("pdm install") ); } #[test] - fn ruby_bootsnap_detection() { + fn python_fasthtml_framework() { let fs = MemoryFs::new(&[( - "Gemfile", - "source 'https://rubygems.org'\ngem 'rails'\ngem 'bootsnap'\n", + "requirements.txt", + "python-fasthtml==0.6.0\nuvicorn==0.30.0\n", )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - if let Some(LanguageConfig::Ruby(ref rc)) = disc.services[0].runtimes[0].language_config { - assert!(rc.has_bootsnap); + assert_eq!(disc.services[0].framework(), Some("fasthtml")); + } + + #[test] + fn python_pipfile_version() { + let fs = MemoryFs::new(&[ + ( + "Pipfile", + "[packages]\nflask = \"*\"\ngunicorn = \"*\"\n\n[requires]\npython_version = \"3.12\"\n", + ), + ("Pipfile.lock", "{}"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("3.12")); + assert_eq!(rt.version_source.as_deref(), Some("Pipfile")); + } + + #[test] + fn python_pipfile_deps() { + // Pipfile [packages] should be collected for framework detection + let fs = MemoryFs::new(&[( + "Pipfile", + "[packages]\ndjango = \"*\"\npsycopg2 = \"*\"\ngunicorn = \"*\"\n\n[requires]\npython_version = \"3.11\"\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services[0].framework(), Some("django")); + } + + #[test] + fn python_main_file_detection() { + let fs = MemoryFs::new(&[ + ("requirements.txt", "flask==3.0.0\ngunicorn==22.0.0\n"), + ("app.py", "from flask import Flask"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Python(ref pc)) = disc.services[0].runtimes[0].language_config { + assert_eq!(pc.main_file.as_deref(), Some("app.py")); } else { - panic!("expected RubyConfig"); + panic!("expected PythonConfig"); } } #[test] - fn ruby_system_deps_libvips_charlock() { + fn python_pydub_ffmpeg_sysdep() { let fs = MemoryFs::new(&[( - "Gemfile", - "source 'https://rubygems.org'\ngem 'rails'\ngem 'libvips'\ngem 'charlock_holmes'\n", + "requirements.txt", + "flask==3.0.0\npydub==0.25.1\ngunicorn==22.0.0\n", )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert!(disc.services[0].system_deps.contains(&"ffmpeg".to_string())); + } + + // ========================================================================== + // Go — new feature tests + // ========================================================================== + + #[test] + fn go_always_has_ca_certificates() { + let fs = MemoryFs::new(&[("go.mod", "module example.com/app\n\ngo 1.22.0\n")]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); assert!( disc.services[0] .system_deps - .contains(&"libvips-dev".to_string()) + .contains(&"ca-certificates".to_string()) ); - assert!( - disc.services[0] - .system_deps - .contains(&"libicu-dev".to_string()) + } + + #[test] + fn go_build_ldflags() { + let fs = MemoryFs::new(&[("go.mod", "module example.com/app\n\ngo 1.22.0\n")]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!( + disc.services[0].build(), + Some("go build -ldflags=\"-w -s\" -o app .") ); } // ========================================================================== - // PHP — new feature tests + // Rust — new feature tests // ========================================================================== #[test] - fn php_index_php_with_artisan() { - // index.php + artisan → Laravel detection even without composer.json framework dep + fn rust_toolchain_toml_version() { let fs = MemoryFs::new(&[ - ("index.php", ""), - ("composer.json", r#"{"require":{"php":">=8.2"}}"#), - ("artisan", "#!/usr/bin/env php"), + ( + "Cargo.toml", + "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", + ), + ("rust-toolchain.toml", "[toolchain]\nchannel = \"1.85.0\"\n"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("1.85.0")); + assert_eq!(rt.version_source.as_deref(), Some("rust-toolchain.toml")); + } + + #[test] + fn rust_toolchain_plain_text() { + let fs = MemoryFs::new(&[ + ( + "Cargo.toml", + "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", + ), + ("rust-toolchain", "1.82.0\n"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("1.82.0")); + assert_eq!(rt.version_source.as_deref(), Some("rust-toolchain")); + } + + #[test] + fn rust_toolchain_toml_beats_plain_text() { + // rust-toolchain.toml should take precedence over rust-toolchain (plain text) + let fs = MemoryFs::new(&[ + ( + "Cargo.toml", + "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", + ), + ("rust-toolchain.toml", "[toolchain]\nchannel = \"1.85.0\"\n"), + ("rust-toolchain", "1.80.0\n"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("1.85.0")); + assert_eq!(rt.version_source.as_deref(), Some("rust-toolchain.toml")); + } + + #[test] + fn rust_toolchain_stable_ignored() { + // "stable" in rust-toolchain.toml → no version (not a pinned version) + let fs = MemoryFs::new(&[ + ( + "Cargo.toml", + "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\nrust-version = \"1.75\"\n\n[dependencies]\n", + ), + ("rust-toolchain.toml", "[toolchain]\nchannel = \"stable\"\n"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + // Should fall through to Cargo.toml rust-version + assert_eq!(rt.version.as_deref(), Some("1.75")); + assert_eq!(rt.version_source.as_deref(), Some("Cargo.toml")); + } + + #[test] + fn rust_version_file() { + let fs = MemoryFs::new(&[ + ( + "Cargo.toml", + "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", + ), + (".rust-version", "1.83.0"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("1.83.0")); + assert_eq!(rt.version_source.as_deref(), Some(".rust-version")); + } + + #[test] + fn rust_workspace_detection() { + let fs = MemoryFs::new(&[( + "Cargo.toml", + "[workspace]\nmembers = [\"crates/*\"]\n\n[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Rust(ref rc)) = disc.services[0].runtimes[0].language_config { + assert!(rc.workspace); + } else { + panic!("expected RustConfig"); + } + } + + #[test] + fn rust_default_run() { + let fs = MemoryFs::new(&[( + "Cargo.toml", + "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\ndefault-run = \"server\"\n\n[dependencies]\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Rust(ref rc)) = disc.services[0].runtimes[0].language_config { + assert_eq!(rc.binary_name.as_deref(), Some("server")); + } else { + panic!("expected RustConfig"); + } + assert_eq!( + disc.services[0].start(), + Some("./target/release/server") + ); + } + + #[test] + fn rust_bin_table() { + let fs = MemoryFs::new(&[( + "Cargo.toml", + "[package]\nname = \"mylib\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[[bin]]\nname = \"cli\"\npath = \"src/main.rs\"\n\n[dependencies]\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Rust(ref rc)) = disc.services[0].runtimes[0].language_config { + assert_eq!(rc.binary_name.as_deref(), Some("cli")); + } else { + panic!("expected RustConfig"); + } + assert_eq!( + disc.services[0].start(), + Some("./target/release/cli") + ); + } + + // ========================================================================== + // Ruby — new feature tests + // ========================================================================== + + #[test] + fn ruby_gemfile_lock_version() { + let fs = MemoryFs::new(&[ + ( + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails'\ngem 'pg'\n", + ), + ( + "Gemfile.lock", + "GEM\n remote: https://rubygems.org/\n specs:\n rails (7.1.0)\n\nPLATFORMS\n ruby\n\nRUBY VERSION\n ruby 3.3.0p0\n\nBUNDLED WITH\n 2.5.4\n", + ), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("3.3.0")); + assert_eq!(rt.version_source.as_deref(), Some("Gemfile.lock")); + let pm = disc.services[0].runtimes[0].package_manager.as_ref().unwrap(); + assert_eq!(pm.version.as_deref(), Some("2.5.4")); + } + + #[test] + fn ruby_ruby_version_beats_gemfile_lock() { + // .ruby-version should take precedence over Gemfile.lock + let fs = MemoryFs::new(&[ + ("Gemfile", "source 'https://rubygems.org'\ngem 'rails'\n"), + (".ruby-version", "3.3.1"), + ( + "Gemfile.lock", + "RUBY VERSION\n ruby 3.3.0p0\n\nBUNDLED WITH\n 2.5.4\n", + ), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("3.3.1")); + assert_eq!(rt.version_source.as_deref(), Some(".ruby-version")); + } + + #[test] + fn ruby_execjs_needs_node() { + let fs = MemoryFs::new(&[( + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails'\ngem 'execjs'\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Ruby(ref rc)) = disc.services[0].runtimes[0].language_config { + assert!(rc.needs_node); + } else { + panic!("expected RubyConfig"); + } + } + + #[test] + fn ruby_config_ru_rack_start() { + // Sinatra-like app: Gemfile + config.ru, no framework → rack start command + let fs = MemoryFs::new(&[ + ("Gemfile", "source 'https://rubygems.org'\ngem 'rack'\n"), + ("config.ru", "run Rack::App"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!( + disc.services[0].start(), + Some("bundle exec rackup config.ru -p ${PORT:-3000}") + ); + } + + #[test] + fn ruby_bootsnap_detection() { + let fs = MemoryFs::new(&[( + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails'\ngem 'bootsnap'\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Ruby(ref rc)) = disc.services[0].runtimes[0].language_config { + assert!(rc.has_bootsnap); + } else { + panic!("expected RubyConfig"); + } + } + + #[test] + fn ruby_system_deps_libvips_charlock() { + let fs = MemoryFs::new(&[( + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails'\ngem 'libvips'\ngem 'charlock_holmes'\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert!( + disc.services[0] + .system_deps + .contains(&"libvips-dev".to_string()) + ); + assert!( + disc.services[0] + .system_deps + .contains(&"libicu-dev".to_string()) + ); + } + + #[test] + fn ruby_always_has_libyaml_jemalloc() { + let fs = MemoryFs::new(&[( + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails'\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let svc = &disc.services[0]; + assert!(svc.system_deps.contains(&"libyaml-dev".to_string())); + assert!(svc.system_deps.contains(&"libjemalloc2".to_string())); + } + + #[test] + fn ruby_yjit_deps_for_3_2_plus() { + let fs = MemoryFs::new(&[ + ( + "Gemfile", + "source 'https://rubygems.org'\nruby '3.3.0'\ngem 'rails'\n", + ), + (".ruby-version", "3.3.0"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let svc = &disc.services[0]; + assert!(svc.system_deps.contains(&"rustc".to_string())); + assert!(svc.system_deps.contains(&"cargo".to_string())); + } + + #[test] + fn ruby_no_yjit_deps_for_3_1() { + let fs = MemoryFs::new(&[ + ( + "Gemfile", + "source 'https://rubygems.org'\nruby '3.1.4'\ngem 'rails'\n", + ), + (".ruby-version", "3.1.4"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let svc = &disc.services[0]; + assert!(!svc.system_deps.contains(&"rustc".to_string())); + } + + #[test] + fn ruby_sqlite3_system_deps() { + let fs = MemoryFs::new(&[( + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails'\ngem 'sqlite3'\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert!( + disc.services[0] + .system_deps + .contains(&"libsqlite3-dev".to_string()) + ); + } + + #[test] + fn ruby_ffi_system_deps() { + let fs = MemoryFs::new(&[( + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails'\ngem 'ffi'\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert!( + disc.services[0] + .system_deps + .contains(&"libffi-dev".to_string()) + ); + } + + #[test] + fn ruby_grpc_system_deps() { + let fs = MemoryFs::new(&[( + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails'\ngem 'grpc'\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert!( + disc.services[0] + .system_deps + .contains(&"build-essential".to_string()) + ); + } + + #[test] + fn ruby_app_server_puma() { + let fs = MemoryFs::new(&[( + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails'\ngem 'puma'\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Ruby(ref rc)) = disc.services[0].runtimes[0].language_config { + assert_eq!(rc.app_server.as_deref(), Some("puma")); + } else { + panic!("expected RubyConfig"); + } + } + + #[test] + fn ruby_app_server_unicorn() { + let fs = MemoryFs::new(&[( + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails'\ngem 'unicorn'\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Ruby(ref rc)) = disc.services[0].runtimes[0].language_config { + assert_eq!(rc.app_server.as_deref(), Some("unicorn")); + } else { + panic!("expected RubyConfig"); + } + } + + #[test] + fn ruby_sidekiq_detection() { + let fs = MemoryFs::new(&[( + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails'\ngem 'sidekiq'\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Ruby(ref rc)) = disc.services[0].runtimes[0].language_config { + assert!(rc.has_sidekiq); + } else { + panic!("expected RubyConfig"); + } + } + + #[test] + fn ruby_good_job_detection() { + let fs = MemoryFs::new(&[( + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails'\ngem 'good_job'\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Ruby(ref rc)) = disc.services[0].runtimes[0].language_config { + assert!(rc.has_good_job); + } else { + panic!("expected RubyConfig"); + } + } + + #[test] + fn ruby_mini_racer_system_deps() { + let fs = MemoryFs::new(&[( + "Gemfile", + "source 'https://rubygems.org'\ngem 'rails'\ngem 'mini_racer'\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert!( + disc.services[0] + .system_deps + .contains(&"libv8-dev".to_string()) + ); + } + + // ========================================================================== + // PHP — new feature tests + // ========================================================================== + + #[test] + fn php_index_php_with_artisan() { + // index.php + artisan → Laravel detection even without composer.json framework dep + let fs = MemoryFs::new(&[ + ("index.php", ""), + ("composer.json", r#"{"require":{"php":">=8.2"}}"#), + ("artisan", "#!/usr/bin/env php"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + let svc = &disc.services[0]; + assert_eq!(svc.language(), Some(Language::Php)); + assert_eq!(svc.framework(), Some("laravel")); + // Has composer.json → install command + assert_eq!( + svc.install(), + Some("composer install --optimize-autoloader --no-scripts --no-interaction") + ); + } + + #[test] + fn php_artisan_laravel_detection() { + // artisan file → Laravel framework even without laravel/framework in require + let fs = MemoryFs::new(&[ + ("composer.json", r#"{"require":{"php":">=8.2"}}"#), + ("artisan", "#!/usr/bin/env php"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services[0].framework(), Some("laravel")); + } + + #[test] + fn php_laravel_auto_extensions() { + // Laravel detected → auto-include required extensions + let fs = MemoryFs::new(&[( + "composer.json", + r#"{"require":{"php":">=8.2","laravel/framework":"^11.0"}}"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Php(ref pc)) = disc.services[0].runtimes[0].language_config { + assert!(pc.extensions.contains(&"pdo".to_string())); + assert!(pc.extensions.contains(&"mbstring".to_string())); + assert!(pc.extensions.contains(&"tokenizer".to_string())); + assert!(pc.extensions.contains(&"xml".to_string())); + assert!(pc.extensions.contains(&"bcmath".to_string())); + } else { + panic!("expected PhpConfig"); + } + } + + // ========================================================================== + // Elixir — new feature tests + // ========================================================================== + + #[test] + fn elixir_version_file() { + let fs = MemoryFs::new(&[ + ( + "mix.exs", + r#" +defmodule MyApp.MixProject do + use Mix.Project + def project do + [app: :my_app, elixir: "~> 1.17", deps: deps()] + end + defp deps do + [{:phoenix, "~> 1.7"}] + end +end +"#, + ), + (".elixir-version", "1.18.1"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + // .elixir-version should win over mix.exs + assert_eq!(rt.version.as_deref(), Some("1.18.1")); + assert_eq!(rt.version_source.as_deref(), Some(".elixir-version")); + } + + #[test] + fn elixir_erlang_version_file() { + let fs = MemoryFs::new(&[ + ( + "mix.exs", + r#" +defmodule MyApp.MixProject do + use Mix.Project + def project do + [app: :my_app, elixir: "~> 1.17", deps: deps()] + end + defp deps do + [{:phoenix, "~> 1.7"}] + end +end +"#, + ), + (".erlang-version", "26.2.1"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Elixir(ref ec)) = disc.services[0].runtimes[0].language_config { + assert_eq!(ec.erlang_version.as_deref(), Some("26.2.1")); + assert_eq!(ec.erlang_version_source.as_deref(), Some(".erlang-version")); + } else { + panic!("expected ElixirConfig"); + } + } + + #[test] + fn elixir_app_name() { + let fs = MemoryFs::new(&[( + "mix.exs", + r#" +defmodule MyApp.MixProject do + use Mix.Project + def project do + [app: :my_cool_app, elixir: "~> 1.17", deps: deps()] + end + defp deps do + [{:phoenix, "~> 1.7"}] + end +end +"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Elixir(ref ec)) = disc.services[0].runtimes[0].language_config { + assert_eq!(ec.app_name.as_deref(), Some("my_cool_app")); + } else { + panic!("expected ElixirConfig"); + } + } + + #[test] + fn elixir_has_ecto() { + let fs = MemoryFs::new(&[( + "mix.exs", + r#" +defmodule MyApp.MixProject do + use Mix.Project + def project do + [app: :my_app, elixir: "~> 1.17", deps: deps()] + end + defp deps do + [{:phoenix, "~> 1.7"}, {:ecto_sql, "~> 3.11"}, {:postgrex, "~> 0.18"}] + end +end +"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Elixir(ref ec)) = disc.services[0].runtimes[0].language_config { + assert!(ec.has_ecto); + } else { + panic!("expected ElixirConfig"); + } + } + + #[test] + fn elixir_has_assets_deploy() { + let fs = MemoryFs::new(&[( + "mix.exs", + r#" +defmodule MyApp.MixProject do + use Mix.Project + def project do + [app: :my_app, elixir: "~> 1.17", deps: deps()] + end + defp deps do + [{:phoenix, "~> 1.7"}] + end + defp aliases do + ["assets.deploy": ["esbuild default --minify", "phx.digest"]] + end +end +"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Elixir(ref ec)) = disc.services[0].runtimes[0].language_config { + assert!(ec.has_assets_deploy); + } else { + panic!("expected ElixirConfig"); + } + } + + // ========================================================================== + // Deno + // ========================================================================== + + #[test] + fn deno_json_basic() { + let fs = MemoryFs::new(&[ + ( + "deno.json", + r#"{"tasks":{"start":"deno run --allow-all main.ts"}}"#, + ), + ("main.ts", "console.log('hello')"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + let svc = &disc.services[0]; + assert_eq!(svc.language(), Some(Language::TypeScript)); + assert_eq!(svc.runtimes[0].name, "deno"); + assert_eq!(svc.runtimes[0].package_manager.as_ref().unwrap().name, "deno"); + assert_eq!(svc.start(), Some("deno task start")); + } + + #[test] + fn deno_jsonc() { + let fs = MemoryFs::new(&[ + ( + "deno.jsonc", + r#"{"tasks":{"start":"deno run --allow-all main.ts"}}"#, + ), + ("main.ts", "console.log('hello')"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + let svc = &disc.services[0]; + assert_eq!(svc.runtimes[0].name, "deno"); + } + + #[test] + fn deno_version_file() { + let fs = MemoryFs::new(&[ + ("deno.json", r#"{}"#), + (".deno-version", "2.1.4"), + ("main.ts", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("2.1.4")); + assert_eq!(rt.version_source.as_deref(), Some(".deno-version")); + } + + #[test] + fn deno_version_from_tool_versions() { + let fs = MemoryFs::new(&[ + ("deno.json", r#"{}"#), + (".tool-versions", "deno 2.0.0\nnodejs 20.11.0"), + ("main.ts", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let svc = disc + .services + .iter() + .find(|s| s.runtimes.first().is_some_and(|r| r.name == "deno")) + .unwrap(); + assert_eq!( + svc.runtimes[0].version.as_deref(), + Some("2.0.0") + ); + assert_eq!( + svc.runtimes[0].version_source.as_deref(), + Some(".tool-versions") + ); + } + + #[test] + fn deno_version_from_mise_toml() { + let fs = MemoryFs::new(&[ + ("deno.json", r#"{}"#), + (".mise.toml", "[tools]\ndeno = \"2.1.0\""), + ("main.ts", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let svc = disc + .services + .iter() + .find(|s| s.runtimes.first().is_some_and(|r| r.name == "deno")) + .unwrap(); + assert_eq!( + svc.runtimes[0].version.as_deref(), + Some("2.1.0") + ); + assert_eq!( + svc.runtimes[0].version_source.as_deref(), + Some(".mise.toml") + ); + } + + #[test] + fn deno_main_file_detection() { + // No explicit start task → auto-generates from main file + let fs = MemoryFs::new(&[ + ("deno.json", r#"{}"#), + ("main.ts", "Deno.serve(() => new Response('hi'))"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + let svc = &disc.services[0]; + assert_eq!( + svc.start(), + Some("deno run --allow-all main.ts") + ); + assert_eq!(svc.build(), Some("deno cache main.ts")); + } + + #[test] + fn deno_main_js_fallback() { + let fs = MemoryFs::new(&[("deno.json", r#"{}"#), ("main.js", "console.log('hi')")]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + assert_eq!( + disc.services[0].start(), + Some("deno run --allow-all main.js") + ); + } + + #[test] + fn deno_tasks_override_auto() { + // Explicit tasks in deno.json take priority over auto-generated commands + let fs = MemoryFs::new(&[ + ( + "deno.json", + r#"{"tasks":{"start":"deno run -A server.ts","build":"deno compile server.ts","dev":"deno run --watch server.ts"}}"#, + ), + ("main.ts", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let svc = &disc.services[0]; + assert_eq!(svc.start(), Some("deno task start")); + assert_eq!(svc.build(), Some("deno task build")); + assert_eq!(svc.dev(), Some("deno task dev")); + } + + #[test] + fn deno_fresh_framework() { + let fs = MemoryFs::new(&[ + ( + "deno.json", + r#"{"imports":{"$fresh/":"https://deno.land/x/fresh@1.6.8/"},"tasks":{"start":"deno run -A main.ts"}}"#, + ), + ("main.ts", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services[0].framework(), Some("fresh")); + } + + #[test] + fn deno_hono_framework() { + let fs = MemoryFs::new(&[ + ( + "deno.json", + r#"{"imports":{"hono/":"https://deno.land/x/hono/"},"tasks":{"start":"deno run -A main.ts"}}"#, + ), + ("main.ts", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services[0].framework(), Some("hono")); + } + + #[test] + fn deno_oak_framework() { + let fs = MemoryFs::new(&[ + ( + "deno.json", + r#"{"imports":{"@oak/oak":"jsr:@oak/oak@^17"},"tasks":{"start":"deno run -A main.ts"}}"#, + ), + ("main.ts", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services[0].framework(), Some("oak")); + } + + #[test] + fn deno_no_framework() { + let fs = MemoryFs::new(&[ + ("deno.json", r#"{"tasks":{"start":"deno run -A main.ts"}}"#), + ("main.ts", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert!(disc.services[0].framework().is_none()); + } + + #[test] + fn deno_version_precedence_deno_version_over_tool_versions() { + let fs = MemoryFs::new(&[ + ("deno.json", r#"{"tasks":{"start":"deno run -A main.ts"}}"#), + (".deno-version", "2.1.4"), + (".tool-versions", "deno 2.0.0"), + ("main.ts", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("2.1.4")); + assert_eq!(rt.version_source.as_deref(), Some(".deno-version")); + } + + #[test] + fn deno_no_install_command() { + // Deno has no install command — deps are cached on run + let fs = MemoryFs::new(&[ + ("deno.json", r#"{"tasks":{"start":"deno run -A main.ts"}}"#), + ("main.ts", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert!(disc.services[0].install().is_none()); + } + + #[test] + fn deno_and_node_coexist() { + // Both deno.json and package.json → both emit DirContext at same dir, + // dedup merges them into one service (derived-name dedup rule). + // First emitter (node) wins for fields that are set by both. + let fs = MemoryFs::new(&[ + ("deno.json", r#"{"tasks":{"start":"deno run -A main.ts"}}"#), + ( + "package.json", + r#"{"name":"app","scripts":{"start":"node server.js"}}"#, + ), + ("main.ts", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + // Merged into one service — both languages detected the same dir + assert_eq!(disc.services.len(), 1); + // The service should have one of the start commands (first wins in merge) + assert!(disc.services[0].start().is_some()); + } + + #[test] + fn deno_not_staticfile() { + // deno.json + index.html → Deno claims it, not staticfile + let fs = MemoryFs::new(&[ + ("deno.json", r#"{"tasks":{"start":"deno run -A main.ts"}}"#), + ("index.html", ""), + ("main.ts", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + assert_eq!(disc.services[0].runtimes[0].name, "deno"); + assert_ne!(disc.services[0].language(), Some(Language::Html)); + } + + // ========================================================================== + // Staticfile — pure static site detection (Language::Html) + // ========================================================================== + + #[test] + fn staticfile_index_html() { + let fs = MemoryFs::new(&[( + "index.html", + "Hello", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + let svc = &disc.services[0]; + assert_eq!(svc.language(), Some(Language::Html)); + assert_eq!(svc.output_dir(), Some(".")); + assert_eq!(svc.runtimes[0].name, "static"); + assert!(svc.start().is_none()); + } + + #[test] + fn staticfile_public_dir() { + // public/index.html → walker visits public/ dir, staticfile detects there + let fs = MemoryFs::new(&[( + "public/index.html", + "Hello", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + let svc = &disc.services[0]; + assert_eq!(svc.language(), Some(Language::Html)); + assert_eq!(svc.output_dir(), Some(".")); + assert_eq!(svc.dir, "public"); + } + + #[test] + fn staticfile_config() { + let fs = MemoryFs::new(&[ + ("Staticfile", "root: dist"), + ("dist/index.html", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + let svc = &disc.services[0]; + assert_eq!(svc.language(), Some(Language::Html)); + assert_eq!(svc.output_dir(), Some("dist")); + } + + #[test] + fn staticfile_not_when_package_json() { + // index.html + package.json → Node claims it, not staticfile + let fs = MemoryFs::new(&[ + ("index.html", ""), + ( + "package.json", + r#"{"name":"app","scripts":{"start":"node server.js"}}"#, + ), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + // Should be a Node service, not a staticfile service + if !disc.services.is_empty() { + assert_ne!(disc.services[0].language(), Some(Language::Html)); + } + } + + #[test] + fn staticfile_not_when_no_html() { + // No index.html, no Staticfile, no public/ → nothing detected + let fs = MemoryFs::new(&[("readme.txt", "hello")]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert!(disc.services.is_empty()); + } + + #[test] + fn staticfile_both_root_and_public() { + // Both index.html and public/index.html → two Html services + // (root dir and public/ dir each detected independently) + let fs = MemoryFs::new(&[ + ("index.html", "root"), + ("public/index.html", "public"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 2); + assert!( + disc.services + .iter() + .all(|s| s.language() == Some(Language::Html)) + ); + } + + // ========================================================================== + // New detection improvements + // ========================================================================== + + #[test] + fn node_bun_version_file() { + // .bun-version should detect bun as runtime and PM + let fs = MemoryFs::new(&[ + ( + "package.json", + r#"{"name":"app","scripts":{"start":"bun run index.ts"}}"#, + ), + (".bun-version", "1.1.30"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let svc = &disc.services[0]; + let rt = &svc.runtimes[0]; + assert_eq!(rt.name, "bun"); + assert_eq!(rt.version.as_deref(), Some("1.1.30")); + assert_eq!(rt.version_source.as_deref(), Some(".bun-version")); + let pm = svc.runtimes[0].package_manager.as_ref().unwrap(); + assert_eq!(pm.name, "bun"); + } + + #[test] + fn node_bun_version_file_with_node_lockfile() { + // .bun-version + package-lock.json → node runtime (npm lockfile wins) + let fs = MemoryFs::new(&[ + ( + "package.json", + r#"{"name":"app","scripts":{"start":"node index.js"}}"#, + ), + (".bun-version", "1.1.30"), + ("package-lock.json", "{}"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.name, "node"); + let pm = disc.services[0].runtimes[0].package_manager.as_ref().unwrap(); + assert_eq!(pm.name, "npm"); + } + + #[test] + fn node_angular_json_output_path() { + // angular.json with custom outputPath should override default "dist" + let fs = MemoryFs::new(&[ + ( + "package.json", + r#"{"name":"app","scripts":{"start":"ng serve"}, + "devDependencies":{"@angular/cli":"^17.0.0"}}"#, + ), + ( + "angular.json", + r#"{"projects":{"my-app":{"architect":{"build":{"options":{"outputPath":"dist/my-app"}}}}}}"#, + ), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services[0].output_dir(), Some("dist/my-app")); + } + + #[test] + fn node_angular_json_default_project() { + // angular.json with defaultProject should use that project's outputPath + let fs = MemoryFs::new(&[ + ( + "package.json", + r#"{"name":"app","scripts":{"start":"ng serve"}, + "devDependencies":{"@angular/cli":"^17.0.0"}}"#, + ), + ( + "angular.json", + r#"{"defaultProject":"frontend","projects":{"backend":{"architect":{"build":{"options":{"outputPath":"dist/backend"}}}},"frontend":{"architect":{"build":{"options":{"outputPath":"dist/frontend"}}}}}}"#, + ), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services[0].output_dir(), Some("dist/frontend")); + } + + #[test] + fn node_angular_no_angular_json_fallback() { + // No angular.json → fallback to "dist" + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"app","scripts":{"start":"ng serve"}, + "devDependencies":{"@angular/cli":"^17.0.0"}}"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services[0].output_dir(), Some("dist")); + } + + #[test] + fn node_hono_framework() { + let fs = MemoryFs::new(&[( + "package.json", + r#"{"name":"api","dependencies":{"hono":"^4.0.0"},"scripts":{"start":"node index.js"}}"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services[0].framework(), Some("hono")); + } + + #[test] + fn node_elysia_framework() { + let fs = MemoryFs::new(&[ + ( + "package.json", + r#"{"name":"api","dependencies":{"elysia":"^1.0.0"},"scripts":{"start":"bun index.ts"}}"#, + ), + ("bun.lockb", ""), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services[0].framework(), Some("elysia")); + } + + #[test] + fn python_django_wsgi_app() { + // Django project with WSGI_APPLICATION in settings.py + let fs = MemoryFs::new(&[ + ( + "requirements.txt", + "django==5.0.0\ngunicorn==22.0.0\n", + ), + ("manage.py", "#!/usr/bin/env python"), + ( + "myapp/settings.py", + "DEBUG = True\nWSGI_APPLICATION = \"myapp.wsgi.application\"\nASGI_APPLICATION = \"myapp.asgi.application\"\n", + ), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Python(ref pc)) = disc.services[0].runtimes[0].language_config { + assert_eq!(pc.wsgi_app.as_deref(), Some("myapp.wsgi.application")); + assert_eq!(pc.asgi_app.as_deref(), Some("myapp.asgi.application")); + } else { + panic!("expected PythonConfig"); + } + } + + #[test] + fn python_django_wsgi_config_dir() { + // Django project with settings in config/ directory + let fs = MemoryFs::new(&[ + ( + "requirements.txt", + "django==5.0.0\n", + ), + ("manage.py", "#!/usr/bin/env python"), + ( + "config/settings.py", + "WSGI_APPLICATION = 'config.wsgi.application'\n", + ), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Python(ref pc)) = disc.services[0].runtimes[0].language_config { + assert_eq!(pc.wsgi_app.as_deref(), Some("config.wsgi.application")); + } else { + panic!("expected PythonConfig"); + } + } + + #[test] + fn python_bare_main_py() { + // A bare main.py with no package manager files should still detect Python + let fs = MemoryFs::new(&[("main.py", "print('hello world')")]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + let svc = &disc.services[0]; + assert_eq!(svc.language(), Some(Language::Python)); + assert_eq!(svc.start(), Some("python main.py")); + } + + #[test] + fn python_bare_app_py() { + let fs = MemoryFs::new(&[("app.py", "from flask import Flask")]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services.len(), 1); + assert_eq!(disc.services[0].language(), Some(Language::Python)); + assert_eq!(disc.services[0].start(), Some("python app.py")); + } + + #[test] + fn python_django_gunicorn_with_wsgi_app() { + // Django + gunicorn + detected WSGI app → full gunicorn command + let fs = MemoryFs::new(&[ + ("requirements.txt", "django==5.0.0\ngunicorn==22.0.0\n"), + ("manage.py", "#!/usr/bin/env python"), + ( + "myapp/settings.py", + "WSGI_APPLICATION = \"myapp.wsgi.application\"\n", + ), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 1); - let svc = &disc.services[0]; - assert_eq!(svc.language(), Some(Language::Php)); - assert_eq!(svc.framework(), Some("laravel")); - // Has composer.json → install command assert_eq!( - svc.install(), - Some("composer install --optimize-autoloader --no-scripts --no-interaction") + disc.services[0].start(), + Some("gunicorn --bind 0.0.0.0:${PORT:-8000} myapp.wsgi.application") ); } #[test] - fn php_artisan_laravel_detection() { - // artisan file → Laravel framework even without laravel/framework in require + fn python_django_gunicorn_no_wsgi_fallback() { + // Django + gunicorn but no settings.py found → plain gunicorn let fs = MemoryFs::new(&[ - ("composer.json", r#"{"require":{"php":">=8.2"}}"#), - ("artisan", "#!/usr/bin/env php"), + ("requirements.txt", "django==5.0.0\ngunicorn==22.0.0\n"), + ("manage.py", "#!/usr/bin/env python"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services[0].framework(), Some("laravel")); + assert_eq!(disc.services[0].start(), Some("gunicorn")); } #[test] - fn php_laravel_auto_extensions() { - // Laravel detected → auto-include required extensions - let fs = MemoryFs::new(&[( - "composer.json", - r#"{"require":{"php":">=8.2","laravel/framework":"^11.0"}}"#, - )]); + fn python_django_db_backend_postgres() { + // Django settings with postgresql backend → libpq-dev + let fs = MemoryFs::new(&[ + ("requirements.txt", "django==5.0.0\n"), + ("manage.py", "#!/usr/bin/env python"), + ( + "myapp/settings.py", + "DATABASES = {'default': {'ENGINE': 'django.db.backends.postgresql'}}\n", + ), + ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - if let Some(LanguageConfig::Php(ref pc)) = disc.services[0].runtimes[0].language_config { - assert!(pc.extensions.contains(&"pdo".to_string())); - assert!(pc.extensions.contains(&"mbstring".to_string())); - assert!(pc.extensions.contains(&"tokenizer".to_string())); - assert!(pc.extensions.contains(&"xml".to_string())); - assert!(pc.extensions.contains(&"bcmath".to_string())); - } else { - panic!("expected PhpConfig"); - } + assert!(disc.services[0].system_deps.contains(&"libpq-dev".to_string())); } - // ========================================================================== - // Elixir — new feature tests - // ========================================================================== - #[test] - fn elixir_version_file() { + fn python_django_db_backend_mysql() { let fs = MemoryFs::new(&[ + ("requirements.txt", "django==5.0.0\n"), + ("manage.py", "#!/usr/bin/env python"), ( - "mix.exs", - r#" -defmodule MyApp.MixProject do - use Mix.Project - def project do - [app: :my_app, elixir: "~> 1.17", deps: deps()] - end - defp deps do - [{:phoenix, "~> 1.7"}] - end -end -"#, + "myapp/settings.py", + "DATABASES = {'default': {'ENGINE': 'django.db.backends.mysql'}}\n", ), - (".elixir-version", "1.18.1"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let rt = &disc.services[0].runtimes[0]; - // .elixir-version should win over mix.exs - assert_eq!(rt.version.as_deref(), Some("1.18.1")); - assert_eq!(rt.version_source.as_deref(), Some(".elixir-version")); + assert!(disc.services[0].system_deps.contains(&"libmysqlclient-dev".to_string())); } #[test] - fn elixir_erlang_version_file() { + fn python_psycopg2_binary_no_sysdep() { + // psycopg2-binary → no libpq-dev needed let fs = MemoryFs::new(&[ - ( - "mix.exs", - r#" -defmodule MyApp.MixProject do - use Mix.Project - def project do - [app: :my_app, elixir: "~> 1.17", deps: deps()] - end - defp deps do - [{:phoenix, "~> 1.7"}] - end -end -"#, - ), - (".erlang-version", "26.2.1"), + ("requirements.txt", "django==5.0.0\npsycopg2-binary==2.9.9\n"), + ("manage.py", "#!/usr/bin/env python"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - if let Some(LanguageConfig::Elixir(ref ec)) = disc.services[0].runtimes[0].language_config { - assert_eq!(ec.erlang_version.as_deref(), Some("26.2.1")); - assert_eq!(ec.erlang_version_source.as_deref(), Some(".erlang-version")); - } else { - panic!("expected ElixirConfig"); - } + assert!(!disc.services[0].system_deps.contains(&"libpq-dev".to_string())); } #[test] - fn elixir_app_name() { - let fs = MemoryFs::new(&[( - "mix.exs", - r#" -defmodule MyApp.MixProject do - use Mix.Project - def project do - [app: :my_cool_app, elixir: "~> 1.17", deps: deps()] - end - defp deps do - [{:phoenix, "~> 1.7"}] - end -end -"#, - )]); + fn python_psycopg2_source_needs_sysdep() { + // psycopg2 (source) → libpq-dev needed + let fs = MemoryFs::new(&[ + ("requirements.txt", "django==5.0.0\npsycopg2==2.9.9\n"), + ("manage.py", "#!/usr/bin/env python"), + ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - if let Some(LanguageConfig::Elixir(ref ec)) = disc.services[0].runtimes[0].language_config { - assert_eq!(ec.app_name.as_deref(), Some("my_cool_app")); - } else { - panic!("expected ElixirConfig"); - } + assert!(disc.services[0].system_deps.contains(&"libpq-dev".to_string())); } #[test] - fn elixir_has_ecto() { + fn python_maturin_needs_rust() { let fs = MemoryFs::new(&[( - "mix.exs", - r#" -defmodule MyApp.MixProject do - use Mix.Project - def project do - [app: :my_app, elixir: "~> 1.17", deps: deps()] - end - defp deps do - [{:phoenix, "~> 1.7"}, {:ecto_sql, "~> 3.11"}, {:postgrex, "~> 0.18"}] - end -end -"#, + "pyproject.toml", + "[build-system]\nrequires = [\"maturin>=1.0\"]\nbuild-backend = \"maturin\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = []\n\n[project.scripts]\nstart = \"myapp\"\n", )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - if let Some(LanguageConfig::Elixir(ref ec)) = disc.services[0].runtimes[0].language_config { - assert!(ec.has_ecto); - } else { - panic!("expected ElixirConfig"); - } + assert!(disc.services[0].system_deps.contains(&"rustc".to_string())); + assert!(disc.services[0].system_deps.contains(&"cargo".to_string())); } #[test] - fn elixir_has_assets_deploy() { + fn python_scikit_build_needs_cmake() { let fs = MemoryFs::new(&[( - "mix.exs", - r#" -defmodule MyApp.MixProject do - use Mix.Project - def project do - [app: :my_app, elixir: "~> 1.17", deps: deps()] - end - defp deps do - [{:phoenix, "~> 1.7"}] - end - defp aliases do - ["assets.deploy": ["esbuild default --minify", "phx.digest"]] - end -end -"#, + "pyproject.toml", + "[build-system]\nrequires = [\"scikit-build-core\"]\nbuild-backend = \"scikit_build_core.build\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = []\n\n[project.scripts]\nstart = \"myapp\"\n", )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - if let Some(LanguageConfig::Elixir(ref ec)) = disc.services[0].runtimes[0].language_config { - assert!(ec.has_assets_deploy); - } else { - panic!("expected ElixirConfig"); - } + assert!(disc.services[0].system_deps.contains(&"cmake".to_string())); + assert!(disc.services[0].system_deps.contains(&"gcc".to_string())); + assert!(disc.services[0].system_deps.contains(&"python3-dev".to_string())); } - // ========================================================================== - // Deno - // ========================================================================== - #[test] - fn deno_json_basic() { + fn python_scipy_system_deps() { let fs = MemoryFs::new(&[ - ( - "deno.json", - r#"{"tasks":{"start":"deno run --allow-all main.ts"}}"#, - ), - ("main.ts", "console.log('hello')"), + ("requirements.txt", "scipy==1.14.0\nnumpy==2.0.0\nflask==3.0.0\n"), + ("app.py", "import scipy"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 1); - let svc = &disc.services[0]; - assert_eq!(svc.language(), Some(Language::TypeScript)); - assert_eq!(svc.runtimes[0].name, "deno"); - assert_eq!(svc.runtimes[0].package_manager.as_ref().unwrap().name, "deno"); - assert_eq!(svc.start(), Some("deno task start")); + assert!(disc.services[0].system_deps.contains(&"python3-dev".to_string())); + assert!(disc.services[0].system_deps.contains(&"gfortran".to_string())); + assert!(disc.services[0].system_deps.contains(&"libopenblas-dev".to_string())); } #[test] - fn deno_jsonc() { + fn python_cryptography_system_deps() { let fs = MemoryFs::new(&[ - ( - "deno.jsonc", - r#"{"tasks":{"start":"deno run --allow-all main.ts"}}"#, - ), - ("main.ts", "console.log('hello')"), + ("requirements.txt", "flask==3.0.0\ncryptography==42.0.0\n"), + ("app.py", "from flask import Flask"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 1); - let svc = &disc.services[0]; - assert_eq!(svc.runtimes[0].name, "deno"); + assert!(disc.services[0].system_deps.contains(&"libssl-dev".to_string())); + assert!(disc.services[0].system_deps.contains(&"libffi-dev".to_string())); } #[test] - fn deno_version_file() { + fn python_celery_detection() { let fs = MemoryFs::new(&[ - ("deno.json", r#"{}"#), - (".deno-version", "2.1.4"), - ("main.ts", ""), + ("requirements.txt", "django==5.0.0\ncelery==5.4.0\nredis==5.0.0\n"), + ("manage.py", "#!/usr/bin/env python"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 1); - let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("2.1.4")); - assert_eq!(rt.version_source.as_deref(), Some(".deno-version")); + if let Some(LanguageConfig::Python(ref pc)) = disc.services[0].runtimes[0].language_config { + assert!(pc.has_celery); + } else { + panic!("expected PythonConfig"); + } } #[test] - fn deno_version_from_tool_versions() { + fn python_no_celery() { let fs = MemoryFs::new(&[ - ("deno.json", r#"{}"#), - (".tool-versions", "deno 2.0.0\nnodejs 20.11.0"), - ("main.ts", ""), + ("requirements.txt", "django==5.0.0\n"), + ("manage.py", "#!/usr/bin/env python"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let svc = disc - .services - .iter() - .find(|s| s.runtimes.first().is_some_and(|r| r.name == "deno")) - .unwrap(); - assert_eq!( - svc.runtimes[0].version.as_deref(), - Some("2.0.0") - ); - assert_eq!( - svc.runtimes[0].version_source.as_deref(), - Some(".tool-versions") - ); + if let Some(LanguageConfig::Python(ref pc)) = disc.services[0].runtimes[0].language_config { + assert!(!pc.has_celery); + } else { + panic!("expected PythonConfig"); + } } #[test] - fn deno_version_from_mise_toml() { + fn python_whitenoise_detection() { let fs = MemoryFs::new(&[ - ("deno.json", r#"{}"#), - (".mise.toml", "[tools]\ndeno = \"2.1.0\""), - ("main.ts", ""), + ("requirements.txt", "django==5.0.0\nwhitenoise==6.7.0\ngunicorn==22.0.0\n"), + ("manage.py", "#!/usr/bin/env python"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let svc = disc - .services - .iter() - .find(|s| s.runtimes.first().is_some_and(|r| r.name == "deno")) - .unwrap(); - assert_eq!( - svc.runtimes[0].version.as_deref(), - Some("2.1.0") - ); - assert_eq!( - svc.runtimes[0].version_source.as_deref(), - Some(".mise.toml") - ); + if let Some(LanguageConfig::Python(ref pc)) = disc.services[0].runtimes[0].language_config { + assert!(pc.has_whitenoise); + } else { + panic!("expected PythonConfig"); + } } #[test] - fn deno_main_file_detection() { - // No explicit start task → auto-generates from main file + fn python_build_command_with_backend() { + // pyproject.toml with build-system + pip → "python -m build" + let fs = MemoryFs::new(&[( + "pyproject.toml", + "[build-system]\nrequires = [\"setuptools\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = [\"flask\"]\n\n[project.scripts]\nstart = \"flask run\"\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services[0].build(), Some("python -m build")); + } + + #[test] + fn python_build_command_poetry() { let fs = MemoryFs::new(&[ - ("deno.json", r#"{}"#), - ("main.ts", "Deno.serve(() => new Response('hi'))"), + ( + "pyproject.toml", + "[build-system]\nrequires = [\"poetry-core\"]\nbuild-backend = \"poetry.core.masonry.api\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = [\"flask\"]\n\n[project.scripts]\nstart = \"flask run\"\n", + ), + ("poetry.lock", ""), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 1); - let svc = &disc.services[0]; - assert_eq!( - svc.start(), - Some("deno run --allow-all main.ts") - ); - assert_eq!(svc.build(), Some("deno cache main.ts")); + assert_eq!(disc.services[0].build(), Some("poetry build")); } #[test] - fn deno_main_js_fallback() { - let fs = MemoryFs::new(&[("deno.json", r#"{}"#), ("main.js", "console.log('hi')")]); + fn python_build_command_maturin() { + let fs = MemoryFs::new(&[( + "pyproject.toml", + "[build-system]\nrequires = [\"maturin>=1.0\"]\nbuild-backend = \"maturin\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = []\n\n[project.scripts]\nstart = \"myapp\"\n", + )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 1); - assert_eq!( - disc.services[0].start(), - Some("deno run --allow-all main.js") - ); + assert_eq!(disc.services[0].build(), Some("maturin build --release")); + } + + #[test] + fn python_no_build_command_without_backend() { + // No build-system → no build command + let fs = MemoryFs::new(&[ + ("requirements.txt", "flask==3.0.0\n"), + ("app.py", "from flask import Flask"), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert!(disc.services[0].build().is_none()); + } + + #[test] + fn python_build_backend_setuptools() { + let fs = MemoryFs::new(&[ + ( + "pyproject.toml", + "[build-system]\nrequires = [\"setuptools>=68\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = [\"flask\"]\n\n[project.scripts]\nstart = \"flask run\"\n", + ), + ]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + if let Some(LanguageConfig::Python(ref pc)) = disc.services[0].runtimes[0].language_config { + assert_eq!(pc.build_backend.as_deref(), Some("setuptools")); + } else { + panic!("expected PythonConfig"); + } } #[test] - fn deno_tasks_override_auto() { - // Explicit tasks in deno.json take priority over auto-generated commands + fn python_build_backend_hatchling() { let fs = MemoryFs::new(&[ ( - "deno.json", - r#"{"tasks":{"start":"deno run -A server.ts","build":"deno compile server.ts","dev":"deno run --watch server.ts"}}"#, + "pyproject.toml", + "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = [\"fastapi\", \"uvicorn\"]\n\n[project.scripts]\nstart = \"uvicorn main:app\"\n", ), - ("main.ts", ""), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let svc = &disc.services[0]; - assert_eq!(svc.start(), Some("deno task start")); - assert_eq!(svc.build(), Some("deno task build")); - assert_eq!(svc.dev(), Some("deno task dev")); + if let Some(LanguageConfig::Python(ref pc)) = disc.services[0].runtimes[0].language_config { + assert_eq!(pc.build_backend.as_deref(), Some("hatchling")); + } else { + panic!("expected PythonConfig"); + } } #[test] - fn deno_fresh_framework() { + fn python_build_backend_maturin_adds_rustc() { + // maturin build backend → needs rustc system dep let fs = MemoryFs::new(&[ ( - "deno.json", - r#"{"imports":{"$fresh/":"https://deno.land/x/fresh@1.6.8/"},"tasks":{"start":"deno run -A main.ts"}}"#, + "pyproject.toml", + "[build-system]\nrequires = [\"maturin>=1.0\"]\nbuild-backend = \"maturin\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = []\n\n[project.scripts]\nstart = \"myapp\"\n", ), - ("main.ts", ""), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services[0].framework(), Some("fresh")); + assert!(disc.services[0].system_deps.contains(&"rustc".to_string())); + if let Some(LanguageConfig::Python(ref pc)) = disc.services[0].runtimes[0].language_config { + assert_eq!(pc.build_backend.as_deref(), Some("maturin")); + } else { + panic!("expected PythonConfig"); + } } #[test] - fn deno_hono_framework() { + fn python_pip_install_with_build_backend() { + // pyproject.toml with build-system + pip → "pip install ." (not requirements.txt) + let fs = MemoryFs::new(&[( + "pyproject.toml", + "[build-system]\nrequires = [\"setuptools\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = [\"flask\"]\n\n[project.scripts]\nstart = \"flask run\"\n", + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services[0].install(), Some("pip install .")); + } + + #[test] + fn python_pip_install_without_build_backend() { + // requirements.txt only, no build backend → "pip install -r requirements.txt" let fs = MemoryFs::new(&[ - ( - "deno.json", - r#"{"imports":{"hono/":"https://deno.land/x/hono/"},"tasks":{"start":"deno run -A main.ts"}}"#, - ), - ("main.ts", ""), + ("requirements.txt", "flask==3.0.0\ngunicorn==22.0.0\n"), + ("app.py", "from flask import Flask"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services[0].framework(), Some("hono")); + assert_eq!( + disc.services[0].install(), + Some("pip install -r requirements.txt") + ); } #[test] - fn deno_oak_framework() { + fn python_build_backend_flit() { let fs = MemoryFs::new(&[ ( - "deno.json", - r#"{"imports":{"@oak/oak":"jsr:@oak/oak@^17"},"tasks":{"start":"deno run -A main.ts"}}"#, + "pyproject.toml", + "[build-system]\nrequires = [\"flit_core>=3.2\"]\nbuild-backend = \"flit_core.buildapi\"\n\n[project]\nname = \"app\"\nrequires-python = \">=3.12\"\ndependencies = [\"flask\"]\n\n[project.scripts]\nstart = \"flask run\"\n", ), - ("main.ts", ""), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services[0].framework(), Some("oak")); + if let Some(LanguageConfig::Python(ref pc)) = disc.services[0].runtimes[0].language_config { + assert_eq!(pc.build_backend.as_deref(), Some("flit")); + } else { + panic!("expected PythonConfig"); + } } #[test] - fn deno_no_framework() { - let fs = MemoryFs::new(&[ - ("deno.json", r#"{"tasks":{"start":"deno run -A main.ts"}}"#), - ("main.ts", ""), - ]); + fn php_redis_extension_from_predis() { + let fs = MemoryFs::new(&[( + "composer.json", + r#"{"require":{"php":">=8.2","laravel/framework":"^11.0","predis/predis":"^2.0"}}"#, + )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(disc.services[0].framework().is_none()); + if let Some(LanguageConfig::Php(ref pc)) = disc.services[0].runtimes[0].language_config { + assert!(pc.extensions.contains(&"redis".to_string())); + } else { + panic!("expected PhpConfig"); + } } #[test] - fn deno_version_precedence_deno_version_over_tool_versions() { + fn php_ext_redis_from_require() { let fs = MemoryFs::new(&[ - ("deno.json", r#"{"tasks":{"start":"deno run -A main.ts"}}"#), - (".deno-version", "2.1.4"), - (".tool-versions", "deno 2.0.0"), - ("main.ts", ""), + ( + "composer.json", + r#"{"require":{"php":">=8.2","laravel/framework":"^11.0","ext-redis":"*","ext-intl":"*"}}"#, + ), + ("artisan", "#!/usr/bin/env php"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - let rt = &disc.services[0].runtimes[0]; - assert_eq!(rt.version.as_deref(), Some("2.1.4")); - assert_eq!(rt.version_source.as_deref(), Some(".deno-version")); + if let Some(LanguageConfig::Php(ref pc)) = disc.services[0].runtimes[0].language_config { + assert!(pc.extensions.contains(&"redis".to_string())); + assert!(pc.extensions.contains(&"intl".to_string())); + } else { + panic!("expected PhpConfig"); + } } #[test] - fn deno_no_install_command() { - // Deno has no install command — deps are cached on run + fn php_system_deps() { let fs = MemoryFs::new(&[ - ("deno.json", r#"{"tasks":{"start":"deno run -A main.ts"}}"#), - ("main.ts", ""), + ( + "composer.json", + r#"{"require":{"php":">=8.2","laravel/framework":"^11.0"}}"#, + ), + ("artisan", "#!/usr/bin/env php"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(disc.services[0].install().is_none()); + let svc = &disc.services[0]; + for dep in &["git", "zip", "unzip", "ca-certificates"] { + assert!( + svc.system_deps.contains(&dep.to_string()), + "missing system dep: {dep}" + ); + } } #[test] - fn deno_and_node_coexist() { - // Both deno.json and package.json → both emit DirContext at same dir, - // dedup merges them into one service (derived-name dedup rule). - // First emitter (node) wins for fields that are set by both. + fn php_doctrine_pdo_extension() { let fs = MemoryFs::new(&[ - ("deno.json", r#"{"tasks":{"start":"deno run -A main.ts"}}"#), ( - "package.json", - r#"{"name":"app","scripts":{"start":"node server.js"}}"#, + "composer.json", + r#"{"require":{"php":">=8.2","laravel/framework":"^11.0","doctrine/dbal":"^3.0"}}"#, ), - ("main.ts", ""), + ("artisan", "#!/usr/bin/env php"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - // Merged into one service — both languages detected the same dir - assert_eq!(disc.services.len(), 1); - // The service should have one of the start commands (first wins in merge) - assert!(disc.services[0].start().is_some()); + if let Some(LanguageConfig::Php(ref pc)) = disc.services[0].runtimes[0].language_config { + assert!(pc.extensions.contains(&"pdo".to_string())); + } else { + panic!("expected PhpConfig"); + } } #[test] - fn deno_not_staticfile() { - // deno.json + index.html → Deno claims it, not staticfile + fn php_explicit_pdo_mysql_extension() { let fs = MemoryFs::new(&[ - ("deno.json", r#"{"tasks":{"start":"deno run -A main.ts"}}"#), - ("index.html", ""), - ("main.ts", ""), + ( + "composer.json", + r#"{"require":{"php":">=8.2","slim/slim":"^4.0","ext-pdo_mysql":"*"}}"#, + ), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 1); - assert_eq!(disc.services[0].runtimes[0].name, "deno"); - assert_ne!(disc.services[0].language(), Some(Language::Html)); + if let Some(LanguageConfig::Php(ref pc)) = disc.services[0].runtimes[0].language_config { + assert!(pc.extensions.contains(&"pdo".to_string())); + assert!(pc.extensions.contains(&"pdo_mysql".to_string())); + } else { + panic!("expected PhpConfig"); + } } - // ========================================================================== - // Staticfile — pure static site detection (Language::Html) - // ========================================================================== + #[test] + fn php_symfony_framework() { + let fs = MemoryFs::new(&[( + "composer.json", + r#"{"require":{"php":">=8.2","symfony/framework-bundle":"^7.0"}}"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services[0].framework(), Some("symfony")); + if let Some(LanguageConfig::Php(ref pc)) = disc.services[0].runtimes[0].language_config { + for ext in &["ctype", "iconv", "intl", "mbstring", "xml", "tokenizer"] { + assert!( + pc.extensions.contains(&ext.to_string()), + "symfony missing extension: {ext}" + ); + } + } else { + panic!("expected PhpConfig"); + } + } #[test] - fn staticfile_index_html() { + fn php_slim_framework() { let fs = MemoryFs::new(&[( - "index.html", - "Hello", + "composer.json", + r#"{"require":{"php":">=8.1","slim/slim":"^4.0"}}"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 1); - let svc = &disc.services[0]; - assert_eq!(svc.language(), Some(Language::Html)); - assert_eq!(svc.output_dir(), Some(".")); - assert_eq!(svc.runtimes[0].name, "static"); - assert!(svc.start().is_none()); + assert_eq!(disc.services[0].framework(), Some("slim")); + assert_eq!( + disc.services[0].start(), + Some("php -S 0.0.0.0:8000 -t public") + ); } #[test] - fn staticfile_public_dir() { - // public/index.html → walker visits public/ dir, staticfile detects there + fn php_cakephp_framework() { let fs = MemoryFs::new(&[( - "public/index.html", - "Hello", + "composer.json", + r#"{"require":{"php":">=8.1","cakephp/cakephp":"^5.0"}}"#, )]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 1); - let svc = &disc.services[0]; - assert_eq!(svc.language(), Some(Language::Html)); - assert_eq!(svc.output_dir(), Some(".")); - assert_eq!(svc.dir, "public"); + assert_eq!(disc.services[0].framework(), Some("cakephp")); + assert_eq!( + disc.services[0].start(), + Some("php -S 0.0.0.0:8000 -t public") + ); } #[test] - fn staticfile_config() { + fn php_codeigniter_framework() { + let fs = MemoryFs::new(&[( + "composer.json", + r#"{"require":{"php":">=8.1","codeigniter4/framework":"^4.0"}}"#, + )]); + let disc = + crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); + assert_eq!(disc.services[0].framework(), Some("codeigniter")); + assert_eq!( + disc.services[0].start(), + Some("php -S 0.0.0.0:8000 -t public") + ); + } + + #[test] + fn php_version_file() { let fs = MemoryFs::new(&[ - ("Staticfile", "root: dist"), - ("dist/index.html", ""), + ( + "composer.json", + r#"{"require":{"php":">=8.1","laravel/framework":"^11.0"}}"#, + ), + (".php-version", "8.3.0"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 1); - let svc = &disc.services[0]; - assert_eq!(svc.language(), Some(Language::Html)); - assert_eq!(svc.output_dir(), Some("dist")); + let rt = &disc.services[0].runtimes[0]; + // .php-version should win over composer.json + assert_eq!(rt.version.as_deref(), Some("8.3.0")); + assert_eq!(rt.version_source.as_deref(), Some(".php-version")); } #[test] - fn staticfile_not_when_package_json() { - // index.html + package.json → Node claims it, not staticfile + fn php_version_file_strips_v_prefix() { let fs = MemoryFs::new(&[ - ("index.html", ""), ( - "package.json", - r#"{"name":"app","scripts":{"start":"node server.js"}}"#, + "composer.json", + r#"{"require":{"php":">=8.1","laravel/framework":"^11.0"}}"#, ), + (".php-version", "v8.3.1\n"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - // Should be a Node service, not a staticfile service - if !disc.services.is_empty() { - assert_ne!(disc.services[0].language(), Some(Language::Html)); - } + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("8.3.1")); } #[test] - fn staticfile_not_when_no_html() { - // No index.html, no Staticfile, no public/ → nothing detected - let fs = MemoryFs::new(&[("readme.txt", "hello")]); + fn elixir_version_file_strips_v_prefix() { + let fs = MemoryFs::new(&[ + ( + "mix.exs", + r#" +defmodule MyApp.MixProject do + use Mix.Project + def project do + [app: :my_app, elixir: "~> 1.17", deps: deps()] + end + defp deps do + [{:phoenix, "~> 1.7"}] + end +end +"#, + ), + (".elixir-version", "v1.18.2\n"), + ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert!(disc.services.is_empty()); + let rt = &disc.services[0].runtimes[0]; + assert_eq!(rt.version.as_deref(), Some("1.18.2")); + assert_eq!(rt.version_source.as_deref(), Some(".elixir-version")); } #[test] - fn staticfile_both_root_and_public() { - // Both index.html and public/index.html → two Html services - // (root dir and public/ dir each detected independently) + fn erlang_version_file_strips_v_prefix() { let fs = MemoryFs::new(&[ - ("index.html", "root"), - ("public/index.html", "public"), + ( + "mix.exs", + r#" +defmodule MyApp.MixProject do + use Mix.Project + def project do + [app: :my_app, elixir: "~> 1.17", deps: deps()] + end + defp deps do + [{:phoenix, "~> 1.7"}] + end +end +"#, + ), + (".erlang-version", "v26.2.1\n"), ]); let disc = crate::discover_with_fs(Path::new("."), signals::default_signals(), &fs).unwrap(); - assert_eq!(disc.services.len(), 2); - assert!( - disc.services - .iter() - .all(|s| s.language() == Some(Language::Html)) - ); + if let Some(LanguageConfig::Elixir(ref ec)) = disc.services[0].runtimes[0].language_config { + assert_eq!(ec.erlang_version.as_deref(), Some("26.2.1")); + } else { + panic!("expected ElixirConfig"); + } } } diff --git a/src/signals/package/node.rs b/src/signals/package/node.rs index 2a62a16..f32884b 100644 --- a/src/signals/package/node.rs +++ b/src/signals/package/node.rs @@ -28,7 +28,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt }; // -- Runtime -- - let is_bun_only = (files.bun_lockb || files.bun_lock) + let is_bun_only = (files.bun_lockb || files.bun_lock || files.bun_version) && !files.package_lock && !files.pnpm_lock && !files.yarn_lock; @@ -69,6 +69,8 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt ("@sveltejs/adapter-node", "sveltekit"), ("astro", "astro"), ("gatsby", "gatsby"), + ("hono", "hono"), + ("elysia", "elysia"), ("vite", "vite"), ("@angular/core", "angular"), ("@angular/cli", "angular"), @@ -87,7 +89,11 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt }; let run_script = |name: &str| format!("{pm_name} run {name}"); - let start = if has_script("start") { Some(run_script("start")) } else { None }; + let start = if has_script("start") { + Some(run_script("start")) + } else { + infer_node_start(runtime_name, &pkg, dir, fs) + }; let build = if has_script("build") { Some(run_script("build")) } else { None }; let dev = if has_script("dev") { Some(run_script("dev")) @@ -110,19 +116,14 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt let output_dir = framework .as_deref() .and_then(|fw| match fw { - "next" => Some(".next"), - "nuxt" => Some(".output"), - "gatsby" => Some("public"), - "vite" => Some("dist"), - "astro" => Some("dist"), - "angular" => Some("dist"), - "react" => Some("build"), - "sveltekit" => Some("build"), - "remix" | "react-router" => Some("build"), - "tanstack-start" => Some(".output"), + "next" => Some(".next".to_string()), + "nuxt" | "tanstack-start" => Some(".output".to_string()), + "gatsby" => Some("public".to_string()), + "vite" | "astro" => Some("dist".to_string()), + "angular" => parse_angular_output_dir(dir, fs).or_else(|| Some("dist".to_string())), + "react" | "sveltekit" | "remix" | "react-router" => Some("build".to_string()), _ => None, - }) - .map(String::from); + }); // -- System deps -- let sys_patterns: &[(&str, &[&str])] = &[ @@ -141,11 +142,65 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt "libcairo2", "libasound2", "libxshmfence1", + "libx11-xcb1", + "libxcb1", + "libxext6", + "libxfixes3", + "libxi6", + "libxrender1", + "libxss1", + "libxtst6", + "libxcursor1", + "libfontconfig1", + "fonts-liberation", + "ca-certificates", ], ), ("sharp", &["libvips-dev"]), ("prisma", &["openssl"]), - ("canvas", &["libcairo2-dev"]), + ("canvas", &["libcairo2-dev", "libjpeg-dev", "libpango1.0-dev", "libgif-dev"]), + ( + "playwright", + &[ + "libnss3", + "libatk1.0-0", + "libatk-bridge2.0-0", + "libcups2", + "libxcomposite1", + "libxdamage1", + "libxrandr2", + "libgbm1", + "libpango-1.0-0", + "libasound2", + "libxshmfence1", + "libx11-xcb1", + "libxcb1", + "libxext6", + "libxfixes3", + "fonts-liberation", + ], + ), + ("@playwright/test", &[ + "libnss3", + "libatk1.0-0", + "libatk-bridge2.0-0", + "libcups2", + "libxcomposite1", + "libxdamage1", + "libxrandr2", + "libgbm1", + "libpango-1.0-0", + "libasound2", + "libxshmfence1", + "libx11-xcb1", + "libxcb1", + "libxext6", + "libxfixes3", + "fonts-liberation", + ]), + ("bcrypt", &["python3", "make", "g++"]), + ("argon2", &["python3", "make", "g++"]), + ("better-sqlite3", &["python3", "make", "g++"]), ]; let system_deps = detect_system_deps(&dep_refs, sys_patterns); @@ -153,6 +208,8 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt let corepack = pm_field.is_some() && pm_name != "bun"; let has_puppeteer = dep_refs.contains(&"puppeteer"); + let has_playwright = + dep_refs.contains(&"playwright") || dep_refs.contains(&"@playwright/test"); let has_prisma = dep_refs.contains(&"prisma"); let has_sharp = dep_refs.contains(&"sharp"); @@ -161,6 +218,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt let language_config = LanguageConfig::Node(NodeConfig { corepack, has_puppeteer, + has_playwright, has_prisma, has_sharp, is_spa: is_static, @@ -175,6 +233,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt language_config: Some(language_config), output_dir, commands, + healthcheck: None, env: Vec::new(), system_deps, }) @@ -187,7 +246,7 @@ fn detect_node_version( pkg: &Value, fs: &dyn FileSystem, ) -> (Option, Option) { - // 1. .node-version + // 1. .node-version / .bun-version if files.node_version { if let Some(raw) = read_text(fs, &dir.join(".node-version")) { let v = strip_version_prefix(&raw); @@ -196,6 +255,14 @@ fn detect_node_version( } } } + if files.bun_version { + if let Some(raw) = read_text(fs, &dir.join(".bun-version")) { + let v = strip_version_prefix(&raw); + if !v.is_empty() { + return (Some(v), Some(".bun-version".into())); + } + } + } // 2. .nvmrc if files.nvmrc { @@ -239,7 +306,7 @@ fn detect_node_version( (None, None) } -/// Determine package manager name from lockfiles. +/// Determine package manager name from lockfiles, falling back to .bun-version. fn detect_pm_name(files: &DirFiles) -> &'static str { if files.package_lock { return "npm"; @@ -255,10 +322,7 @@ fn detect_pm_name(files: &DirFiles) -> &'static str { "yarn-classic" }; } - if files.bun_lockb { - return "bun"; - } - if files.bun_lock { + if files.bun_lockb || files.bun_lock || files.bun_version { return "bun"; } "npm" @@ -376,6 +440,67 @@ fn detect_static_site( if is_static { Some(true) } else { None } } +/// Infer a start command when scripts.start is missing. +/// Falls back to: main field → index.js → index.ts. +fn infer_node_start( + runtime_name: &str, + pkg: &Value, + dir: &Path, + fs: &dyn FileSystem, +) -> Option { + // 1. package.json "main" field + if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) { + let runner = if runtime_name == "bun" { "bun" } else { "node" }; + return Some(format!("{runner} {main}")); + } + + // 2. index.js / index.ts + for entry in &["index.js", "index.ts"] { + if fs.read_file(&dir.join(entry)).is_ok() { + let runner = if runtime_name == "bun" { "bun" } else { "node" }; + return Some(format!("{runner} {entry}")); + } + } + + None +} + +/// Parse angular.json to extract the output path for the default project. +/// Handles both classic (`outputPath: "dist/app"`) and Angular 17+ application builder +/// formats where the browser output lands in `outputPath/browser`. +fn parse_angular_output_dir(dir: &Path, fs: &dyn FileSystem) -> Option { + let raw = read_text(fs, &dir.join("angular.json"))?; + let config: Value = serde_json::from_str(&raw).ok()?; + + // Try defaultProject first, then first project alphabetically + let projects = config.get("projects")?.as_object()?; + let default_project = config + .get("defaultProject") + .and_then(|v| v.as_str()) + .and_then(|name| projects.get(name)); + let project = default_project.or_else(|| projects.values().next())?; + + let build = project + .get("architect") + .and_then(|a| a.get("build"))?; + + let output_path = build + .get("options") + .and_then(|o| o.get("outputPath")) + .and_then(|v| v.as_str()); + + // Angular 17+ application builder uses @angular-devkit/build-angular:application + // and outputs browser files to outputPath/browser + let builder = build.get("builder").and_then(|v| v.as_str()).unwrap_or(""); + let is_new_builder = builder.contains(":application"); + + match output_path { + Some(path) if is_new_builder => Some(format!("{path}/browser")), + Some(path) => Some(path.to_string()), + None => None, + } +} + /// Infer a dev command from the detected framework when scripts.dev is missing. /// Uses the package manager's exec command so binaries resolve without PATH setup. fn infer_node_dev_command(pm: &str, framework: Option<&str>) -> Option { diff --git a/src/signals/package/php.rs b/src/signals/package/php.rs index cf02e74..f281bb8 100644 --- a/src/signals/package/php.rs +++ b/src/signals/package/php.rs @@ -6,7 +6,7 @@ use super::DirFiles; use crate::fs::FileSystem; use crate::signals::package::common::{ detect_framework, dir_string, extract_semver_version, parse_mise_toml, parse_tool_versions, - read_text, + read_text, strip_version_prefix, }; use crate::types::{ Commands, DirContext, Language, LanguageConfig, PackageManagerInfo, PhpConfig, RuntimeInfo, @@ -53,6 +53,9 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt let framework_patterns: &[(&str, &str)] = &[ ("laravel/framework", "laravel"), ("symfony/framework-bundle", "symfony"), + ("slim/slim", "slim"), + ("cakephp/cakephp", "cakephp"), + ("codeigniter4/framework", "codeigniter"), ]; let framework = detect_framework(&dep_refs, framework_patterns) .map(String::from) @@ -74,7 +77,9 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt let dev = match framework.as_deref() { Some("laravel") => Some("php artisan serve".into()), - Some("symfony") => Some("php bin/console server:start".into()), + Some("symfony") | Some("slim") | Some("codeigniter") | Some("cakephp") => { + Some("php -S 0.0.0.0:8000 -t public".into()) + } _ => Some("php -S 0.0.0.0:8000".into()), }; @@ -92,6 +97,46 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt .map(String::from) .collect(); + // Detect extensions from common composer packages + let extension_packages: &[(&str, &str)] = &[ + ("predis/predis", "redis"), + ("phpredis/phpredis", "redis"), + ("ext-redis", "redis"), + ("ext-memcached", "memcached"), + ("ext-imagick", "imagick"), + ("ext-gd", "gd"), + ("ext-intl", "intl"), + ("ext-pgsql", "pgsql"), + ("ext-mysqli", "mysqli"), + ("ext-zip", "zip"), + ]; + for (pkg, ext) in extension_packages { + if deps.iter().any(|d| d == *pkg) && !extensions.contains(&ext.to_string()) { + extensions.push(ext.to_string()); + } + } + + // DB extensions from ORM/database packages + let db_extension_packages: &[(&str, &[&str])] = &[ + ("doctrine/dbal", &["pdo"]), + ("doctrine/orm", &["pdo"]), + ("illuminate/database", &["pdo"]), + ("cakephp/database", &["pdo"]), + ("ext-pdo_mysql", &["pdo", "pdo_mysql"]), + ("ext-pdo_pgsql", &["pdo", "pdo_pgsql"]), + ("ext-pdo_sqlite", &["pdo", "pdo_sqlite"]), + ]; + for (pkg, exts) in db_extension_packages { + if deps.iter().any(|d| d == *pkg) { + for ext in *exts { + let s = ext.to_string(); + if !extensions.contains(&s) { + extensions.push(s); + } + } + } + } + if framework.as_deref() == Some("laravel") { for ext in &[ "ctype", @@ -116,9 +161,32 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt } } + if framework.as_deref() == Some("symfony") { + for ext in &["ctype", "iconv", "intl", "mbstring", "xml", "tokenizer"] { + let s = ext.to_string(); + if !extensions.contains(&s) { + extensions.push(s); + } + } + } + + // -- System deps -- + let system_deps = vec![ + "git".into(), + "zip".into(), + "unzip".into(), + "ca-certificates".into(), + ]; + // -- Language config -- let language_config = LanguageConfig::Php(PhpConfig { extensions }); + // -- Health check -- + let healthcheck = match framework.as_deref() { + Some("laravel") => Some("/up".into()), + _ => None, + }; + Some(DirContext { dir: dir_string(dir), language: Some(Language::Php), @@ -128,26 +196,37 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt language_config: Some(language_config), output_dir: None, commands, + healthcheck, env: Vec::new(), - system_deps: Vec::new(), + system_deps, }) } -/// Detect PHP version from composer.json require.php, .tool-versions, or .mise.toml. +/// Detect PHP version from .php-version, composer.json require.php, .tool-versions, or .mise.toml. fn detect_php_version( dir: &Path, files: &DirFiles, require: Option<&serde_json::Map>, fs: &dyn FileSystem, ) -> (Option, Option) { - // 1. composer.json require.php + // 1. .php-version + if files.php_version_file { + if let Some(raw) = read_text(fs, &dir.join(".php-version")) { + let v = strip_version_prefix(&raw); + if !v.is_empty() { + return (Some(v), Some(".php-version".into())); + } + } + } + + // 2. composer.json require.php if let Some(v) = require.and_then(|r| r.get("php")).and_then(|v| v.as_str()) { if let Some(ver) = extract_semver_version(v) { return (Some(ver), Some("composer.json".into())); } } - // 2. .tool-versions + // 3. .tool-versions if files.tool_versions { if let Some(content) = read_text(fs, &dir.join(".tool-versions")) { if let Some(v) = parse_tool_versions(&content, "php") { @@ -156,7 +235,7 @@ fn detect_php_version( } } - // 3. .mise.toml + // 4. .mise.toml if files.mise_toml { if let Some(content) = read_text(fs, &dir.join(".mise.toml")) { if let Some(v) = parse_mise_toml(&content, "php") { @@ -176,12 +255,12 @@ fn collect_dep_names(require: Option<&serde_json::Map>) -> Vec) -> (Option, Option) { match framework { Some("laravel") => (Some("php artisan serve --host=0.0.0.0".into()), None), - Some("symfony") => ( - Some("php bin/console server:start 0.0.0.0:8000".into()), + Some("symfony") | Some("slim") | Some("codeigniter") | Some("cakephp") => ( + Some("php -S 0.0.0.0:8000 -t public".into()), None, ), _ => (None, None), diff --git a/src/signals/package/python.rs b/src/signals/package/python.rs index 7479706..cbd37e1 100644 --- a/src/signals/package/python.rs +++ b/src/signals/package/python.rs @@ -5,7 +5,7 @@ use serde_json::Value; use super::DirFiles; use crate::fs::FileSystem; use crate::signals::package::common::{ - detect_framework, detect_system_deps, dir_string, extract_python_requires, install_command, + detect_framework, detect_system_deps, dir_string, extract_python_requires, parse_mise_toml, parse_runtime_txt, parse_tool_versions, read_text, strip_version_prefix, }; use crate::types::{ @@ -18,6 +18,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt && !files.setup_cfg && !files.requirements_txt && !files.pipfile + && !files.python_entry_file { return None; } @@ -66,6 +67,16 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt ]; let framework = detect_framework(&dep_refs, framework_patterns).map(String::from); + // -- Main file detection -- + let main_file = detect_main_file(dir, fs); + + // -- WSGI/ASGI app detection (Django) -- + let (wsgi_app, asgi_app) = if framework.as_deref() == Some("django") { + detect_django_apps(dir, fs) + } else { + (None, None) + }; + // -- Commands -- let start = pyproject .as_ref() @@ -75,37 +86,83 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt .and_then(|v| v.as_str()) .map(String::from); - // -- Main file detection (needed for start command inference) -- - let main_file = detect_main_file(dir, fs); + let start = start.or_else(|| { + infer_start_command( + &dep_refs, + framework.as_deref(), + main_file.as_deref(), + wsgi_app.as_deref(), + ) + }); - let start = start - .or_else(|| infer_start_command(&dep_refs, framework.as_deref(), main_file.as_deref())); + // -- Build backend from pyproject.toml -- + let build_backend = pyproject + .as_ref() + .and_then(|p| p.get("build-system")) + .and_then(|bs| bs.get("build-backend")) + .and_then(|v| v.as_str()) + .map(normalize_build_backend); - let install = install_command(pm_name); + let install = infer_python_install(pm_name, build_backend.as_deref(), files); + let build = infer_python_build(pm_name, build_backend.as_deref()); let dev = infer_python_dev_command(&dep_refs, framework.as_deref(), main_file.as_deref()); let commands = Commands { install, - build: None, + build, start, dev, }; // -- System deps -- - // For psycopg2, only add libpq-dev if the dep is exactly "psycopg2", not "psycopg2-binary". let mut system_deps = Vec::new(); - if dep_refs.contains(&"psycopg2") { + // psycopg2 (source build) needs libpq-dev; psycopg2-binary does not + let uses_binary_psycopg = dep_refs.contains(&"psycopg2-binary"); + if dep_refs.contains(&"psycopg2") && !uses_binary_psycopg { system_deps.push("libpq-dev".to_string()); } + // psycopg (v3) with [binary] extra also doesn't need libpq-dev; + // but if psycopg is present without the binary variant, it may need it + if dep_refs.contains(&"psycopg") && !uses_binary_psycopg { + if !system_deps.contains(&"libpq-dev".to_string()) { + system_deps.push("libpq-dev".to_string()); + } + } + + // Scan Django settings for database backends + if framework.as_deref() == Some("django") { + detect_django_db_deps(dir, fs, &mut system_deps); + } let extra_sys_patterns: &[(&str, &[&str])] = &[ ("mysqlclient", &["libmysqlclient-dev"]), ("pycairo", &["libcairo2-dev"]), ("pdf2image", &["poppler-utils"]), ("pydub", &["ffmpeg"]), - ("pymovie", &["ffmpeg"]), + ( + "pymovie", + &[ + "ffmpeg", + "qt5-qmake", + "qtbase5-dev", + "qtbase5-dev-tools", + "qttools5-dev-tools", + "libqt5core5a", + "python3-pyqt5", + ], + ), + ("pillow", &["libjpeg-dev", "zlib1g-dev"]), + ("lxml", &["libxml2-dev", "libxslt1-dev"]), + ("cffi", &["libffi-dev"]), + ("cryptography", &["libssl-dev", "libffi-dev"]), + ("bcrypt", &["libffi-dev"]), + ("numpy", &["python3-dev"]), + ("scipy", &["python3-dev", "gfortran", "libopenblas-dev"]), + ("pandas", &["python3-dev"]), + ("grpcio", &["python3-dev"]), + ("greenlet", &["python3-dev"]), ]; let extra = detect_system_deps(&dep_refs, extra_sys_patterns); for dep in extra { @@ -114,12 +171,40 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt } } + // -- Celery detection -- + let has_celery = dep_refs.contains(&"celery"); + + // -- Whitenoise detection -- + let has_whitenoise = dep_refs.contains(&"whitenoise"); + + // Build backend → system deps for compilation + match build_backend.as_deref() { + Some("maturin") => { + for dep in &["rustc", "cargo"] { + if !system_deps.contains(&dep.to_string()) { + system_deps.push(dep.to_string()); + } + } + } + Some("scikit-build" | "meson") => { + for dep in &["cmake", "gcc", "g++", "python3-dev"] { + if !system_deps.contains(&dep.to_string()) { + system_deps.push(dep.to_string()); + } + } + } + _ => {} + } + // -- Language config -- let language_config = LanguageConfig::Python(PythonConfig { - wsgi_app: None, - asgi_app: None, + wsgi_app, + asgi_app, has_manage_py: files.manage_py, main_file, + has_celery, + has_whitenoise, + build_backend, }); Some(DirContext { @@ -131,6 +216,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt language_config: Some(language_config), output_dir: None, commands, + healthcheck: None, env: Vec::new(), system_deps, }) @@ -371,6 +457,7 @@ fn infer_start_command( deps: &[&str], framework: Option<&str>, main_file: Option<&str>, + wsgi_app: Option<&str>, ) -> Option { let has_gunicorn = deps.contains(&"gunicorn"); let has_uvicorn = deps.contains(&"uvicorn"); @@ -381,7 +468,15 @@ fn infer_start_command( .unwrap_or("main"); match framework { - Some("django") if has_gunicorn => Some("gunicorn".into()), + Some("django") if has_gunicorn => { + // Use detected WSGI_APPLICATION if available for proper gunicorn binding + match wsgi_app { + Some(app) => Some(format!( + "gunicorn --bind 0.0.0.0:${{PORT:-8000}} {app}" + )), + None => Some("gunicorn".into()), + } + } Some("django") => Some("python manage.py runserver".into()), Some("fasthtml") | Some("fastapi") if has_uvicorn && has_main => Some(format!( "uvicorn {module}:app --host 0.0.0.0 --port ${{PORT:-8000}}" @@ -406,6 +501,172 @@ fn infer_start_command( } } +/// Detect Django WSGI_APPLICATION and ASGI_APPLICATION from settings files. +/// Scans known locations: `settings.py`, `/settings.py`, `config/settings.py`. +fn detect_django_apps(dir: &Path, fs: &dyn FileSystem) -> (Option, Option) { + // Common Django settings file locations + let candidates = [ + dir.join("settings.py"), + dir.join("config").join("settings.py"), + ]; + + // Also check /settings.py for any immediate subdirectory + let mut all_candidates: Vec = candidates.to_vec(); + if let Ok(entries) = fs.read_dir(dir) { + for entry in &entries { + if entry.is_dir { + all_candidates.push(dir.join(&entry.name).join("settings.py")); + } + } + } + + for path in &all_candidates { + if let Some(content) = read_text(fs, path) { + let wsgi = extract_django_setting(&content, "WSGI_APPLICATION"); + let asgi = extract_django_setting(&content, "ASGI_APPLICATION"); + if wsgi.is_some() || asgi.is_some() { + return (wsgi, asgi); + } + } + } + + (None, None) +} + +/// Scan Django settings files for database backend declarations and add system deps. +fn detect_django_db_deps(dir: &Path, fs: &dyn FileSystem, system_deps: &mut Vec) { + let mut all_candidates = vec![ + dir.join("settings.py"), + dir.join("config").join("settings.py"), + ]; + if let Ok(entries) = fs.read_dir(dir) { + for entry in &entries { + if entry.is_dir { + all_candidates.push(dir.join(&entry.name).join("settings.py")); + } + } + } + + for path in &all_candidates { + if let Some(content) = read_text(fs, path) { + if content.contains("django.db.backends.postgresql") { + if !system_deps.contains(&"libpq-dev".to_string()) { + system_deps.push("libpq-dev".to_string()); + } + } + if content.contains("django.db.backends.mysql") { + if !system_deps.contains(&"libmysqlclient-dev".to_string()) { + system_deps.push("libmysqlclient-dev".to_string()); + } + } + if content.contains("django.db.backends.sqlite3") { + if !system_deps.contains(&"libsqlite3-dev".to_string()) { + system_deps.push("libsqlite3-dev".to_string()); + } + } + } + } +} + +/// Extract a Django string setting like `WSGI_APPLICATION = "myapp.wsgi.application"`. +fn extract_django_setting(content: &str, setting: &str) -> Option { + for line in content.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix(setting) { + let rest = rest.trim_start(); + if let Some(rest) = rest.strip_prefix('=') { + let val = rest.trim(); + // Strip quotes: "value" or 'value' + let val = val + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .or_else(|| { + val.strip_prefix('\'') + .and_then(|v| v.strip_suffix('\'')) + }); + if let Some(v) = val { + if !v.is_empty() { + return Some(v.to_string()); + } + } + } + } + } + None +} + +/// Determine the install command based on package manager and build backend. +/// +/// When a build backend exists (pyproject.toml with [build-system]), the project +/// itself is a package that needs to be installed — not just its dependencies. +/// - poetry/pdm/uv already handle this (their `install` installs the project too). +/// - pip needs `pip install .` instead of `pip install -r requirements.txt`. +/// - pipenv needs `pipenv install` (handles pyproject.toml automatically). +fn infer_python_install( + pm: &str, + build_backend: Option<&str>, + files: &DirFiles, +) -> Option { + match pm { + "poetry" => Some("poetry install".into()), + "uv" => Some("uv sync".into()), + "pdm" => Some("pdm install".into()), + "pipenv" => Some("pipenv install".into()), + "pip" => { + if build_backend.is_some() { + // Project has a build backend — install the package (which also installs deps) + Some("pip install .".into()) + } else if files.requirements_txt { + Some("pip install -r requirements.txt".into()) + } else if files.setup_py || files.setup_cfg { + Some("pip install .".into()) + } else { + None + } + } + _ => None, + } +} + +/// Determine the build command based on package manager and build backend. +/// +/// Projects with a build backend produce a distributable artifact (wheel/sdist). +/// - maturin: compiles Rust extensions → `maturin build` +/// - poetry: `poetry build` +/// - pdm: `pdm build` +/// - pip with any build backend: `python -m build` (PEP 517 standard) +fn infer_python_build(pm: &str, build_backend: Option<&str>) -> Option { + let backend = build_backend?; + + match backend { + "maturin" => Some("maturin build --release".into()), + _ => match pm { + "poetry" => Some("poetry build".into()), + "pdm" => Some("pdm build".into()), + _ => Some("python -m build".into()), + }, + } +} + +/// Normalize a PEP 517 build-backend string to a short name. +/// e.g. "setuptools.build_meta" → "setuptools", "hatchling.build" → "hatchling" +fn normalize_build_backend(backend: &str) -> String { + // The build-backend is a dotted module path; the first component is the package name + let name = backend.split('.').next().unwrap_or(backend); + match name { + "setuptools" => "setuptools", + "hatchling" => "hatchling", + "flit_core" | "flit" => "flit", + "pdm" => "pdm", + "maturin" => "maturin", + "poetry" => "poetry", + "scikit_build_core" | "scikit-build-core" => "scikit-build", + "mesonpy" | "meson" => "meson", + other => other, + } + .to_string() +} + /// Infer a dev command with reload/debug flags. fn infer_python_dev_command( deps: &[&str], diff --git a/src/signals/package/ruby.rs b/src/signals/package/ruby.rs index 6fe57c3..2e001ee 100644 --- a/src/signals/package/ruby.rs +++ b/src/signals/package/ruby.rs @@ -9,8 +9,9 @@ use regex::Regex; use super::DirFiles; use crate::fs::FileSystem; use crate::signals::package::common::{ - detect_framework, detect_system_deps, dir_string, extract_ruby_constraint, install_command, - parse_mise_toml, parse_tool_versions, read_text, strip_version_prefix, + detect_framework, detect_system_deps, dir_string, extract_ruby_constraint, + extract_semver_version, install_command, parse_mise_toml, parse_tool_versions, read_text, + strip_version_prefix, }; use crate::types::{ Commands, DirContext, Language, LanguageConfig, PackageManagerInfo, RubyConfig, RuntimeInfo, @@ -40,7 +41,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt let runtime = RuntimeInfo { name: "ruby".into(), - version, + version: version.clone(), source, }; @@ -65,7 +66,10 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt ]; let framework = detect_framework(&dep_refs, framework_patterns).map(String::from); - // -- Language config (needed for commands) -- + // -- App server -- + let app_server = detect_app_server(&dep_refs); + + // -- Language config -- let asset_pipeline = detect_asset_pipeline(&dep_refs); // -- Commands -- @@ -90,28 +94,60 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt let sys_patterns: &[(&str, &[&str])] = &[ ("pg", &["libpq-dev"]), ("mysql2", &["libmysqlclient-dev"]), + ("sqlite3", &["libsqlite3-dev"]), ("nokogiri", &["libxml2-dev", "libxslt-dev"]), ("rmagick", &["libmagickwand-dev"]), ("magick", &["libmagickwand-dev"]), ("libvips", &["libvips-dev"]), + ("mini_racer", &["libv8-dev"]), + ("grpc", &["build-essential"]), + ("ffi", &["libffi-dev"]), ( "charlock_holmes", &["libicu-dev", "libxml2-dev", "libxslt-dev"], ), ]; - let system_deps = detect_system_deps(&dep_refs, sys_patterns); + let mut system_deps = detect_system_deps(&dep_refs, sys_patterns); + + // Every Ruby app needs libyaml and jemalloc + for dep in &["libyaml-dev", "libjemalloc2"] { + if !system_deps.contains(&dep.to_string()) { + system_deps.push(dep.to_string()); + } + } + + // YJIT in Ruby 3.2+ needs rustc + cargo at build time + if needs_yjit_deps(version.as_deref()) { + for dep in &["rustc", "cargo"] { + if !system_deps.contains(&dep.to_string()) { + system_deps.push(dep.to_string()); + } + } + } + let needs_node = dep_refs.iter().any(|d| { *d == "webpacker" || *d == "jsbundling-rails" || *d == "cssbundling-rails" || *d == "execjs" }); let has_bootsnap = dep_refs.contains(&"bootsnap"); + let has_sidekiq = dep_refs.contains(&"sidekiq"); + let has_good_job = dep_refs.contains(&"good_job"); let language_config = LanguageConfig::Ruby(RubyConfig { asset_pipeline, needs_node, has_bootsnap, + app_server, + has_sidekiq, + has_good_job, }); + // -- Health check -- + let healthcheck = match framework.as_deref() { + Some("rails") => Some("/up".into()), + _ => None, + }; + Some(DirContext { dir: dir_string(dir), language: Some(Language::Ruby), @@ -121,6 +157,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt language_config: Some(language_config), output_dir: None, commands, + healthcheck, env: Vec::new(), system_deps, }) @@ -293,3 +330,30 @@ fn detect_asset_pipeline(deps: &[&str]) -> Option { } None } + +/// Detect the app server gem. +fn detect_app_server(deps: &[&str]) -> Option { + let servers: &[(&str, &str)] = &[ + ("puma", "puma"), + ("unicorn", "unicorn"), + ("passenger", "passenger"), + ("thin", "thin"), + ("falcon", "falcon"), + ]; + for (gem, name) in servers { + if deps.contains(gem) { + return Some(name.to_string()); + } + } + None +} + +/// Ruby 3.2+ ships with YJIT which needs rustc+cargo to compile. +fn needs_yjit_deps(version: Option<&str>) -> bool { + let Some(v) = version else { return false }; + let v = extract_semver_version(v).unwrap_or_else(|| v.to_string()); + let mut parts = v.split('.'); + let major: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + let minor: u32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + major > 3 || (major == 3 && minor >= 2) +} diff --git a/src/signals/package/rust_lang.rs b/src/signals/package/rust_lang.rs index ff11bbe..585a3e0 100644 --- a/src/signals/package/rust_lang.rs +++ b/src/signals/package/rust_lang.rs @@ -122,6 +122,7 @@ pub(super) fn generate(dir: &Path, files: &DirFiles, fs: &dyn FileSystem) -> Opt language_config: Some(language_config), output_dir: None, commands, + healthcheck: None, env, system_deps, }) diff --git a/src/types.rs b/src/types.rs index 2a383b6..2c15293 100644 --- a/src/types.rs +++ b/src/types.rs @@ -136,6 +136,9 @@ impl Service { } } } + if self.healthcheck.is_none() { + self.healthcheck.clone_from(&ctx.healthcheck); + } merge_env_vars(&mut self.env, &ctx.env); merge_string_vecs(&mut self.system_deps, &ctx.system_deps); } @@ -260,6 +263,7 @@ impl Runtime { start: self.start.clone(), dev: self.dev.clone(), }, + healthcheck: None, env: Vec::new(), system_deps: Vec::new(), } @@ -336,6 +340,7 @@ pub enum LanguageConfig { pub struct NodeConfig { pub corepack: bool, pub has_puppeteer: bool, + pub has_playwright: bool, pub has_prisma: bool, pub has_sharp: bool, /// Framework builds to static output (Vite SPA, Astro static, CRA, Gatsby, Angular SPA) @@ -352,6 +357,13 @@ pub struct PythonConfig { /// Detected entry file: main.py, app.py, wsgi.py, asgi.py #[serde(skip_serializing_if = "Option::is_none")] pub main_file: Option, + /// Celery task queue detected — needs a separate worker process + pub has_celery: bool, + /// Django whitenoise detected — app serves its own static files + pub has_whitenoise: bool, + /// Build backend from pyproject.toml [build-system] (e.g. "setuptools", "hatchling", "maturin") + #[serde(skip_serializing_if = "Option::is_none")] + pub build_backend: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -377,8 +389,11 @@ pub struct RubyConfig { #[serde(skip_serializing_if = "Option::is_none")] pub asset_pipeline: Option, pub needs_node: bool, - /// bootsnap gem detected — enables boot-time caching pub has_bootsnap: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_server: Option, + pub has_sidekiq: bool, + pub has_good_job: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -405,6 +420,8 @@ pub struct PhpConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JavaConfig { pub build_tool_wrapper: bool, + pub has_flyway: bool, + pub has_liquibase: bool, } // -- Commands -- @@ -593,6 +610,8 @@ pub struct DirContext { #[serde(skip_serializing_if = "Option::is_none")] pub output_dir: Option, pub commands: Commands, + #[serde(skip_serializing_if = "Option::is_none")] + pub healthcheck: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub env: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -622,6 +641,9 @@ impl DirContext { self.output_dir.clone_from(&other.output_dir); } self.commands.fill_from(&other.commands); + if self.healthcheck.is_none() { + self.healthcheck.clone_from(&other.healthcheck); + } merge_env_vars(&mut self.env, &other.env); merge_string_vecs(&mut self.system_deps, &other.system_deps); } diff --git a/tests/integration.rs b/tests/integration.rs index 9e0d8cd..e5c371e 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -264,6 +264,16 @@ fn pnpm_monorepo_turborepo() { assert_eq!(web.framework(), Some("next")); assert_eq!(web.start(), Some("npm run start")); + // Orchestrator-driven commands: turbo wraps build and dev + assert_eq!(api.build(), Some("turbo run build --filter=@myapp/api")); + assert_eq!(api.dev(), Some("turbo run dev --filter=@myapp/api")); + assert_eq!(web.build(), Some("turbo run build --filter=@myapp/web")); + assert_eq!(web.dev(), Some("turbo run dev --filter=@myapp/web")); + + // start commands are NOT rewritten — they run per-package at runtime + assert_eq!(api.start(), Some("npm run start")); + assert_eq!(web.start(), Some("npm run start")); + // Monorepo detected let mono = disc.monorepo.as_ref().unwrap(); assert_eq!(mono.monorepo_type, "pnpm"); @@ -275,6 +285,30 @@ fn pnpm_monorepo_turborepo() { assert!(mono.packages.contains_key("shared")); } +// -- Scenario 5b: Nx Monorepo -- + +#[test] +fn nx_monorepo_commands() { + let disc = discover(&[ + ("package.json", r#"{"workspaces": ["apps/*"]}"#), + ("nx.json", r#"{"targetDefaults": {"build": {}}}"#), + ("package-lock.json", ""), + ( + "apps/api/package.json", + r#"{ + "name": "@myapp/api", + "scripts": { "start": "node index.js", "build": "tsc", "dev": "tsx watch" }, + "dependencies": { "express": "4" } + }"#, + ), + ]); + + let api = disc.services.iter().find(|s| s.dir == "apps/api").unwrap(); + assert_eq!(api.build(), Some("nx run @myapp/api:build")); + assert_eq!(api.dev(), Some("nx run @myapp/api:dev")); + assert_eq!(api.start(), Some("npm run start")); // unchanged +} + // -- Scenario 6: .tool-versions Co-located with Package -- #[test]