use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Detection {
pub languages: Vec<Language>,
pub workspace: Option<WorkspaceFlavor>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Language {
Rust,
Node,
Python,
Go,
Java,
}
impl Language {
fn ruleset(self) -> &'static str {
match self {
Self::Rust => "alint://bundled/rust@v1",
Self::Node => "alint://bundled/node@v1",
Self::Python => "alint://bundled/python@v1",
Self::Go => "alint://bundled/go@v1",
Self::Java => "alint://bundled/java@v1",
}
}
fn label(self) -> &'static str {
match self {
Self::Rust => "Rust",
Self::Node => "Node",
Self::Python => "Python",
Self::Go => "Go",
Self::Java => "Java",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceFlavor {
Cargo,
Pnpm,
Yarn,
}
impl WorkspaceFlavor {
fn ruleset(self) -> &'static str {
match self {
Self::Cargo => "alint://bundled/monorepo/cargo-workspace@v1",
Self::Pnpm => "alint://bundled/monorepo/pnpm-workspace@v1",
Self::Yarn => "alint://bundled/monorepo/yarn-workspace@v1",
}
}
fn label(self) -> &'static str {
match self {
Self::Cargo => "Cargo workspace",
Self::Pnpm => "pnpm workspace",
Self::Yarn => "Yarn / npm workspace",
}
}
}
pub fn detect(root: &Path, with_monorepo: bool) -> Detection {
let mut languages = Vec::new();
if root.join("Cargo.toml").is_file() {
languages.push(Language::Rust);
}
if root.join("package.json").is_file() {
languages.push(Language::Node);
}
if ["pyproject.toml", "setup.py", "setup.cfg"]
.iter()
.any(|f| root.join(f).is_file())
{
languages.push(Language::Python);
}
if root.join("go.mod").is_file() {
languages.push(Language::Go);
}
if ["pom.xml", "build.gradle", "build.gradle.kts"]
.iter()
.any(|f| root.join(f).is_file())
{
languages.push(Language::Java);
}
let workspace = if with_monorepo {
detect_workspace(root)
} else {
None
};
Detection {
languages,
workspace,
}
}
fn detect_workspace(root: &Path) -> Option<WorkspaceFlavor> {
if let Ok(content) = fs::read_to_string(root.join("Cargo.toml")) {
if content
.lines()
.any(|l| l.trim_start().starts_with("[workspace]"))
{
return Some(WorkspaceFlavor::Cargo);
}
}
if root.join("pnpm-workspace.yaml").is_file() || root.join("pnpm-workspace.yml").is_file() {
return Some(WorkspaceFlavor::Pnpm);
}
if let Ok(content) = fs::read_to_string(root.join("package.json")) {
if content.contains("\"workspaces\"") {
return Some(WorkspaceFlavor::Yarn);
}
}
None
}
pub fn render(detection: &Detection) -> String {
let mut out = String::with_capacity(512);
out.push_str("# Generated by `alint init`. Adjust as needed.\n");
let summary = render_summary(detection);
if !summary.is_empty() {
out.push_str("# Detected: ");
out.push_str(&summary);
out.push_str(".\n");
}
out.push_str("# Run `alint check` to lint, `alint fix` to apply auto-fixable rules.\n");
out.push_str("# See https://alint.org for the full ruleset reference.\n");
out.push('\n');
out.push_str("version: 1\n");
if detection.workspace.is_some() {
out.push_str("nested_configs: true\n");
}
out.push('\n');
out.push_str("extends:\n");
out.push_str(" - alint://bundled/oss-baseline@v1\n");
for lang in &detection.languages {
out.push_str(" - ");
out.push_str(lang.ruleset());
out.push('\n');
}
if let Some(flavor) = detection.workspace {
out.push_str(" - alint://bundled/monorepo@v1\n");
out.push_str(" - ");
out.push_str(flavor.ruleset());
out.push('\n');
}
out
}
pub fn render_summary(detection: &Detection) -> String {
let mut parts: Vec<&str> = detection.languages.iter().map(|l| l.label()).collect();
if let Some(flavor) = detection.workspace {
parts.push(flavor.label());
}
parts.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
fn td() -> tempfile::TempDir {
tempfile::Builder::new()
.prefix("alint-init-")
.tempdir()
.unwrap()
}
fn touch(root: &Path, rel: &str, content: &str) {
let path = root.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
#[test]
fn detects_no_languages_in_empty_dir() {
let tmp = td();
let det = detect(tmp.path(), false);
assert!(det.languages.is_empty());
assert_eq!(det.workspace, None);
}
#[test]
fn detects_rust_via_cargo_toml() {
let tmp = td();
touch(tmp.path(), "Cargo.toml", "[package]\nname = \"x\"\n");
let det = detect(tmp.path(), false);
assert_eq!(det.languages, vec![Language::Rust]);
}
#[test]
fn detects_node_via_package_json() {
let tmp = td();
touch(tmp.path(), "package.json", "{\"name\":\"x\"}\n");
let det = detect(tmp.path(), false);
assert_eq!(det.languages, vec![Language::Node]);
}
#[test]
fn detects_python_via_any_of_three_manifests() {
for manifest in ["pyproject.toml", "setup.py", "setup.cfg"] {
let tmp = td();
touch(tmp.path(), manifest, "");
let det = detect(tmp.path(), false);
assert_eq!(
det.languages,
vec![Language::Python],
"manifest: {manifest}"
);
}
}
#[test]
fn detects_go_via_go_mod() {
let tmp = td();
touch(tmp.path(), "go.mod", "module example.com/x\n");
let det = detect(tmp.path(), false);
assert_eq!(det.languages, vec![Language::Go]);
}
#[test]
fn detects_java_via_pom_or_gradle() {
for manifest in ["pom.xml", "build.gradle", "build.gradle.kts"] {
let tmp = td();
touch(tmp.path(), manifest, "");
let det = detect(tmp.path(), false);
assert_eq!(det.languages, vec![Language::Java], "manifest: {manifest}");
}
}
#[test]
fn detects_polyglot_repos() {
let tmp = td();
touch(tmp.path(), "Cargo.toml", "[package]\nname = \"x\"\n");
touch(tmp.path(), "package.json", "{}");
let det = detect(tmp.path(), false);
assert_eq!(det.languages, vec![Language::Rust, Language::Node]);
}
#[test]
fn workspace_detection_off_by_default() {
let tmp = td();
touch(tmp.path(), "Cargo.toml", "[workspace]\nmembers = []\n");
let det = detect(tmp.path(), false);
assert_eq!(det.workspace, None);
}
#[test]
fn detects_cargo_workspace_with_monorepo_flag() {
let tmp = td();
touch(
tmp.path(),
"Cargo.toml",
"[workspace]\nmembers = [\"crates/*\"]\n",
);
let det = detect(tmp.path(), true);
assert_eq!(det.workspace, Some(WorkspaceFlavor::Cargo));
assert_eq!(det.languages, vec![Language::Rust]);
}
#[test]
fn cargo_without_workspace_table_is_not_a_workspace() {
let tmp = td();
touch(tmp.path(), "Cargo.toml", "[package]\nname = \"x\"\n");
let det = detect(tmp.path(), true);
assert_eq!(det.workspace, None);
}
#[test]
fn detects_pnpm_workspace() {
for f in ["pnpm-workspace.yaml", "pnpm-workspace.yml"] {
let tmp = td();
touch(tmp.path(), "package.json", "{}");
touch(tmp.path(), f, "packages:\n - 'packages/*'\n");
let det = detect(tmp.path(), true);
assert_eq!(det.workspace, Some(WorkspaceFlavor::Pnpm), "file: {f}");
}
}
#[test]
fn detects_yarn_workspace_via_package_json_workspaces_field() {
let tmp = td();
touch(
tmp.path(),
"package.json",
"{\"name\":\"root\",\"workspaces\":[\"packages/*\"]}\n",
);
let det = detect(tmp.path(), true);
assert_eq!(det.workspace, Some(WorkspaceFlavor::Yarn));
}
#[test]
fn cargo_workspace_takes_precedence_over_yarn_when_both_match() {
let tmp = td();
touch(
tmp.path(),
"Cargo.toml",
"[workspace]\nmembers = [\"crates/*\"]\n",
);
touch(
tmp.path(),
"package.json",
"{\"workspaces\":[\"packages/*\"]}\n",
);
let det = detect(tmp.path(), true);
assert_eq!(det.workspace, Some(WorkspaceFlavor::Cargo));
}
#[test]
fn render_minimal_repo_extends_only_oss_baseline() {
let det = Detection::default();
let out = render(&det);
assert!(out.contains("alint://bundled/oss-baseline@v1"));
assert!(!out.contains("rust@v1"));
assert!(!out.contains("monorepo@v1"));
assert!(!out.contains("nested_configs"));
}
#[test]
fn render_polyglot_extends_each_language() {
let det = Detection {
languages: vec![Language::Rust, Language::Node],
workspace: None,
};
let out = render(&det);
assert!(out.contains("rust@v1"));
assert!(out.contains("node@v1"));
assert!(!out.contains("monorepo@v1"));
}
#[test]
fn render_workspace_adds_monorepo_overlay_and_nested_configs() {
let det = Detection {
languages: vec![Language::Rust],
workspace: Some(WorkspaceFlavor::Cargo),
};
let out = render(&det);
assert!(out.contains("nested_configs: true"));
assert!(out.contains("alint://bundled/monorepo@v1"));
assert!(out.contains("alint://bundled/monorepo/cargo-workspace@v1"));
}
#[test]
fn render_includes_detection_summary_in_header() {
let det = Detection {
languages: vec![Language::Rust],
workspace: Some(WorkspaceFlavor::Cargo),
};
let out = render(&det);
assert!(out.contains("# Detected: Rust, Cargo workspace."));
}
#[test]
fn render_omits_detection_line_for_empty_detection() {
let det = Detection::default();
let out = render(&det);
assert!(!out.contains("# Detected:"));
}
}