use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Detected {
pub ecosystem: &'static str,
pub name: String,
pub name_source: &'static str,
}
struct Rule {
ecosystem: &'static str,
matches: fn(&Path) -> bool,
}
fn has(dir: &Path, name: &str) -> bool {
dir.join(name).is_file()
}
fn has_suffix(dir: &Path, suffix: &str) -> bool {
std::fs::read_dir(dir)
.ok()
.map(|entries| entries.flatten().any(|e| {
e.file_name().to_str()
.map(|n| n.ends_with(suffix))
.unwrap_or(false)
}))
.unwrap_or(false)
}
fn cargo_is_workspace(dir: &Path) -> bool {
let cargo = dir.join("Cargo.toml");
if !cargo.is_file() { return false; }
std::fs::read_to_string(&cargo)
.map(|s| s.lines().any(|l| l.trim_start().starts_with("[workspace]")))
.unwrap_or(false)
}
const RULES: &[Rule] = &[
Rule { ecosystem: "rust-workspace", matches: cargo_is_workspace },
Rule { ecosystem: "rust-single-crate", matches: |d| has(d, "Cargo.toml") },
Rule { ecosystem: "js-pnpm", matches: |d| has(d, "pnpm-workspace.yaml") },
Rule { ecosystem: "js-deno", matches: |d| has(d, "deno.json") || has(d, "deno.jsonc") },
Rule { ecosystem: "npm", matches: |d| has(d, "package.json") },
Rule { ecosystem: "python-pipenv", matches: |d| has(d, "Pipfile") },
Rule { ecosystem: "python-conda", matches: |d| has(d, "meta.yaml") },
Rule { ecosystem: "python-pdm", matches: |d| has(d, "pdm.lock") },
Rule { ecosystem: "python", matches: |d| has(d, "pyproject.toml") || has(d, "setup.py") },
Rule { ecosystem: "helm", matches: |d| has(d, "Chart.yaml") },
Rule { ecosystem: "github-action", matches: |d| has(d, "action.yml") || has(d, "action.yaml") },
Rule { ecosystem: "go", matches: |d| has(d, "go.mod") },
Rule { ecosystem: "zig", matches: |d| has(d, "build.zig") || has(d, "build.zig.zon") },
Rule { ecosystem: "fortran-fpm", matches: |d| has(d, "fpm.toml") },
Rule { ecosystem: "java-gradle-kts", matches: |d| has(d, "build.gradle.kts") || has(d, "settings.gradle.kts") },
Rule { ecosystem: "java-maven", matches: |d| has(d, "pom.xml") },
Rule { ecosystem: "scala-sbt", matches: |d| has(d, "build.sbt") },
Rule { ecosystem: "clojure-deps", matches: |d| has(d, "deps.edn") },
Rule { ecosystem: "dotnet-csproj", matches: |d| has_suffix(d, ".csproj") },
Rule { ecosystem: "swift-spm", matches: |d| has(d, "Package.swift") },
Rule { ecosystem: "ocaml-dune", matches: |d| has(d, "dune-project") },
Rule { ecosystem: "haskell-cabal", matches: |d| has_suffix(d, ".cabal") },
Rule { ecosystem: "gleam", matches: |d| has(d, "gleam.toml") },
Rule { ecosystem: "racket-info", matches: |d| has(d, "info.rkt") },
Rule { ecosystem: "elixir-mix", matches: |d| has(d, "mix.exs") },
Rule { ecosystem: "ruby-gem", matches: |d| has_suffix(d, ".gemspec") },
Rule { ecosystem: "lua-rockspec", matches: |d| has_suffix(d, ".rockspec") },
Rule { ecosystem: "nim-nimble", matches: |d| has_suffix(d, ".nimble") },
Rule { ecosystem: "crystal", matches: |d| has(d, "shard.yml") },
Rule { ecosystem: "dart", matches: |d| has(d, "pubspec.yaml") },
Rule { ecosystem: "composer", matches: |d| has(d, "composer.json") },
Rule { ecosystem: "julia", matches: |d| has(d, "Project.toml") },
Rule { ecosystem: "r-description", matches: |d| has(d, "DESCRIPTION") },
Rule { ecosystem: "ada-alire", matches: |d| {
std::fs::read_dir(d).ok().map(|es| es.flatten().any(|e|
e.file_name().to_str().map(|n|
n.starts_with("alire-") && n.ends_with(".toml")
).unwrap_or(false)
)).unwrap_or(false)
}},
Rule { ecosystem: "cpp-conan", matches: |d| has(d, "conanfile.py") || has(d, "conanfile.txt") },
Rule { ecosystem: "cpp-vcpkg", matches: |d| has(d, "vcpkg.json") },
Rule { ecosystem: "cpp-meson", matches: |d| has(d, "meson.build") },
Rule { ecosystem: "cpp-cmake", matches: |d| has(d, "CMakeLists.txt") },
Rule { ecosystem: "nix-flake", matches: |d| has(d, "flake.nix") },
Rule { ecosystem: "tlisp-library", matches: |d| {
std::fs::read_dir(d).ok().map(|es| es.flatten().any(|e|
e.file_name().to_str().map(|n| n.ends_with(".tlisp")).unwrap_or(false)
)).unwrap_or(false)
}},
];
pub fn detect(dir: &Path) -> Option<Detected> {
if !dir.is_dir() { return None; }
for rule in RULES {
if (rule.matches)(dir) {
let (name, source) = detect_name(dir, rule.ecosystem);
return Some(Detected {
ecosystem: rule.ecosystem,
name,
name_source: source,
});
}
}
None
}
pub fn detect_github_url(slug: &str) -> Option<Detected> {
detect_github_url_with(slug, &crate::github_client::GhCliClient)
}
pub fn detect_github_url_with(
slug: &str,
client: &dyn crate::github_client::GithubClient,
) -> Option<Detected> {
let names = client.list_root_filenames(slug)?;
let has_name = |n: &str| names.iter().any(|f| f == n);
let has_suffix = |s: &str| names.iter().any(|f| f.ends_with(s));
let has_alire = || names.iter().any(|f|
f.starts_with("alire-") && f.ends_with(".toml"));
let eco: Option<&'static str> = if has_name("Cargo.toml") {
let is_ws = client.fetch_file_text(slug, "Cargo.toml")
.map(|s| s.contains("[workspace]"))
.unwrap_or(false);
Some(if is_ws { "rust-workspace" } else { "rust-single-crate" })
} else if has_name("pnpm-workspace.yaml") { Some("js-pnpm") }
else if has_name("deno.json") || has_name("deno.jsonc") { Some("js-deno") }
else if has_name("package.json") { Some("npm") }
else if has_name("Pipfile") { Some("python-pipenv") }
else if has_name("meta.yaml") { Some("python-conda") }
else if has_name("pdm.lock") { Some("python-pdm") }
else if has_name("pyproject.toml") || has_name("setup.py") { Some("python") }
else if has_name("Chart.yaml") { Some("helm") }
else if has_name("action.yml") || has_name("action.yaml") { Some("github-action") }
else if has_name("go.mod") { Some("go") }
else if has_name("build.zig") || has_name("build.zig.zon") { Some("zig") }
else if has_name("fpm.toml") { Some("fortran-fpm") }
else if has_name("build.gradle.kts") || has_name("settings.gradle.kts") {
Some("java-gradle-kts")
}
else if has_name("pom.xml") { Some("java-maven") }
else if has_name("build.sbt") { Some("scala-sbt") }
else if has_name("deps.edn") { Some("clojure-deps") }
else if has_suffix(".csproj") { Some("dotnet-csproj") }
else if has_name("Package.swift") { Some("swift-spm") }
else if has_name("dune-project") { Some("ocaml-dune") }
else if has_suffix(".cabal") { Some("haskell-cabal") }
else if has_name("gleam.toml") { Some("gleam") }
else if has_name("info.rkt") { Some("racket-info") }
else if has_name("mix.exs") { Some("elixir-mix") }
else if has_suffix(".gemspec") { Some("ruby-gem") }
else if has_suffix(".rockspec") { Some("lua-rockspec") }
else if has_suffix(".nimble") { Some("nim-nimble") }
else if has_name("shard.yml") { Some("crystal") }
else if has_name("pubspec.yaml") { Some("dart") }
else if has_name("composer.json") { Some("composer") }
else if has_name("Project.toml") { Some("julia") }
else if has_name("DESCRIPTION") { Some("r-description") }
else if has_alire() { Some("ada-alire") }
else if has_name("conanfile.py") || has_name("conanfile.txt") { Some("cpp-conan") }
else if has_name("vcpkg.json") { Some("cpp-vcpkg") }
else if has_name("meson.build") { Some("cpp-meson") }
else if has_name("CMakeLists.txt") { Some("cpp-cmake") }
else { None };
let eco = eco?;
let name = slug.rsplit('/').next().unwrap_or(slug).to_string();
Some(Detected { ecosystem: eco, name, name_source: "github-slug" })
}
fn detect_name(dir: &Path, eco: &str) -> (String, &'static str) {
if let Some(n) = read_manifest_name(dir, eco) {
return (n, "manifest");
}
let basename = dir.file_name()
.and_then(|s| s.to_str())
.map(String::from)
.unwrap_or_else(|| "unknown".to_string());
(basename, "dir-basename")
}
fn read_manifest_name(dir: &Path, eco: &str) -> Option<String> {
let try_toml_name = |path: &Path, header: &str| -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
let mut in_section = false;
for line in text.lines() {
let trim = line.trim();
if trim.starts_with('[') {
in_section = trim == header;
continue;
}
if in_section && trim.starts_with("name") {
let after_eq = trim.split_once('=')?.1.trim();
let stripped = after_eq.trim_matches('"').trim_matches('\'');
return Some(stripped.to_string());
}
}
None
};
let try_json_name = |path: &Path| -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
for line in text.lines() {
let trim = line.trim();
if let Some(rest) = trim.strip_prefix("\"name\"") {
let val = rest.trim_start_matches(':').trim();
let stripped = val.trim_end_matches(',').trim().trim_matches('"');
return Some(stripped.to_string());
}
}
None
};
match eco {
"rust-single-crate" | "rust-workspace" => try_toml_name(&dir.join("Cargo.toml"), "[package]")
.or_else(|| try_toml_name(&dir.join("Cargo.toml"), "[workspace.package]")),
"npm" | "js-pnpm" => try_json_name(&dir.join("package.json")),
"js-deno" => try_json_name(&dir.join("deno.json"))
.or_else(|| try_json_name(&dir.join("deno.jsonc"))),
"python" | "python-pdm" => try_toml_name(&dir.join("pyproject.toml"), "[project]"),
"go" => {
let text = std::fs::read_to_string(dir.join("go.mod")).ok()?;
let line = text.lines().find(|l| l.starts_with("module "))?;
line.trim_start_matches("module ").trim().rsplit('/').next().map(String::from)
}
"fortran-fpm" => try_toml_name(&dir.join("fpm.toml"), ""),
"gleam" => try_toml_name(&dir.join("gleam.toml"), ""),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mk_dir(files: &[(&str, &str)]) -> tempdir::TempDir {
let tmp = tempdir::TempDir::new("discover").expect("tempdir");
for (name, body) in files {
std::fs::write(tmp.path().join(name), body).expect("write fixture");
}
tmp
}
#[test]
fn detects_rust_single_crate_with_name() {
let dir = mk_dir(&[("Cargo.toml", "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n")]);
let det = detect(dir.path()).expect("must detect");
assert_eq!(det.ecosystem, "rust-single-crate");
assert_eq!(det.name, "demo");
assert_eq!(det.name_source, "manifest");
}
#[test]
fn workspace_beats_single_crate() {
let dir = mk_dir(&[("Cargo.toml", "[workspace]\nmembers = []\n")]);
let det = detect(dir.path()).expect("must detect");
assert_eq!(det.ecosystem, "rust-workspace");
}
#[test]
fn pnpm_beats_npm() {
let dir = mk_dir(&[
("package.json", "{\"name\":\"thing\"}"),
("pnpm-workspace.yaml", "packages:\n - 'packages/*'\n"),
]);
assert_eq!(detect(dir.path()).unwrap().ecosystem, "js-pnpm");
}
#[test]
fn pipenv_beats_python() {
let dir = mk_dir(&[("pyproject.toml", "[project]\nname = \"x\"\n"), ("Pipfile", "")]);
assert_eq!(detect(dir.path()).unwrap().ecosystem, "python-pipenv");
}
#[test]
fn detects_helm() {
let dir = mk_dir(&[("Chart.yaml", "apiVersion: v2\nname: x\n")]);
assert_eq!(detect(dir.path()).unwrap().ecosystem, "helm");
}
#[test]
fn detects_github_action() {
let dir = mk_dir(&[("action.yml", "name: x\nruns: { using: composite }\n")]);
assert_eq!(detect(dir.path()).unwrap().ecosystem, "github-action");
}
#[test]
fn detects_swift_spm() {
let dir = mk_dir(&[("Package.swift", "// swift-tools-version:5.9\n")]);
assert_eq!(detect(dir.path()).unwrap().ecosystem, "swift-spm");
}
#[test]
fn detects_ruby_gem_by_suffix() {
let dir = mk_dir(&[("demo.gemspec", "Gem::Specification.new {}\n")]);
assert_eq!(detect(dir.path()).unwrap().ecosystem, "ruby-gem");
}
#[test]
fn detects_csproj_by_suffix() {
let dir = mk_dir(&[("Demo.csproj", "<Project Sdk=\"x\" />\n")]);
assert_eq!(detect(dir.path()).unwrap().ecosystem, "dotnet-csproj");
}
#[test]
fn empty_dir_returns_none() {
let dir = mk_dir(&[]);
assert!(detect(dir.path()).is_none());
}
#[test]
fn name_falls_back_to_basename_when_manifest_unparseable() {
let dir = mk_dir(&[("Chart.yaml", "apiVersion: v2\n")]);
let det = detect(dir.path()).unwrap();
assert_eq!(det.ecosystem, "helm");
assert_eq!(det.name_source, "dir-basename");
}
#[test]
fn go_name_is_last_segment_of_module_path() {
let dir = mk_dir(&[("go.mod", "module github.com/pleme-io/thing\n\ngo 1.22\n")]);
let det = detect(dir.path()).unwrap();
assert_eq!(det.ecosystem, "go");
assert_eq!(det.name, "thing");
}
use crate::github_client::MockClient;
fn assert_url_detect(files: &[&str], expected_eco: &str) {
let client = MockClient::new().with_files(files.iter().copied());
let det = detect_github_url_with("any/repo", &client)
.unwrap_or_else(|| panic!("expected detection from {files:?}"));
assert_eq!(det.ecosystem, expected_eco,
"files {files:?} should detect as {expected_eco}, got {}", det.ecosystem);
assert_eq!(det.name_source, "github-slug");
}
#[test]
fn url_mode_detects_rust_single_crate() {
assert_url_detect(&["Cargo.toml", "src"], "rust-single-crate");
}
#[test]
fn url_mode_detects_rust_workspace_via_content_peek() {
let client = MockClient::new()
.with_files(["Cargo.toml"])
.with_file_content("o/r", "Cargo.toml", "[workspace]\nmembers = []\n");
let det = detect_github_url_with("o/r", &client).unwrap();
assert_eq!(det.ecosystem, "rust-workspace");
}
#[test]
fn url_mode_pnpm_beats_npm() {
assert_url_detect(&["package.json", "pnpm-workspace.yaml"], "js-pnpm");
}
#[test]
fn url_mode_pipenv_beats_python() {
assert_url_detect(&["pyproject.toml", "Pipfile"], "python-pipenv");
}
#[test]
fn url_mode_matrix_covers_each_unique_signal() {
let matrix: &[(&[&str], &str)] = &[
(&["package.json"], "npm"),
(&["deno.json"], "js-deno"),
(&["pyproject.toml"], "python"),
(&["meta.yaml"], "python-conda"),
(&["pdm.lock"], "python-pdm"),
(&["Chart.yaml"], "helm"),
(&["action.yml"], "github-action"),
(&["go.mod"], "go"),
(&["build.zig"], "zig"),
(&["fpm.toml"], "fortran-fpm"),
(&["build.gradle.kts"], "java-gradle-kts"),
(&["pom.xml"], "java-maven"),
(&["build.sbt"], "scala-sbt"),
(&["deps.edn"], "clojure-deps"),
(&["Demo.csproj"], "dotnet-csproj"),
(&["Package.swift"], "swift-spm"),
(&["dune-project"], "ocaml-dune"),
(&["foo.cabal"], "haskell-cabal"),
(&["gleam.toml"], "gleam"),
(&["info.rkt"], "racket-info"),
(&["mix.exs"], "elixir-mix"),
(&["demo.gemspec"], "ruby-gem"),
(&["demo-1.0.rockspec"], "lua-rockspec"),
(&["demo.nimble"], "nim-nimble"),
(&["shard.yml"], "crystal"),
(&["pubspec.yaml"], "dart"),
(&["composer.json"], "composer"),
(&["Project.toml"], "julia"),
(&["DESCRIPTION"], "r-description"),
(&["alire-demo.toml"], "ada-alire"),
(&["conanfile.py"], "cpp-conan"),
(&["vcpkg.json"], "cpp-vcpkg"),
(&["meson.build"], "cpp-meson"),
(&["CMakeLists.txt"], "cpp-cmake"),
];
let mut failures: Vec<String> = vec![];
for (files, expected) in matrix {
let client = MockClient::new().with_files(files.iter().copied());
match detect_github_url_with("o/r", &client) {
Some(d) if d.ecosystem == *expected => {}
Some(d) => failures.push(format!(
"{files:?} → got {}, expected {expected}", d.ecosystem
)),
None => failures.push(format!(
"{files:?} → no detection (expected {expected})"
)),
}
}
assert!(failures.is_empty(),
"{} URL-mode signals broken:\n - {}",
failures.len(), failures.join("\n - "));
}
}