use std::path::Path;
use anyhow::Result;
use crate::types::{DiagTrace, Language, ProjectProfile};
mod cli_candidates;
mod cli_probe;
mod manifest;
mod repo;
mod workspace;
pub(crate) use cli_candidates::which_on_path;
pub(crate) use manifest::{project_manifest_version, select_csproj};
pub(crate) use repo::{normalize_git_url, urls_equivalent};
#[cfg(test)]
use std::path::PathBuf;
pub(crate) use workspace::{
first_cargo_member_name, first_npm_member_name, is_cargo_workspace_only, is_npm_workspace_only,
};
pub fn introspect(root: &Path) -> Result<ProjectProfile> {
anyhow::ensure!(root.is_dir(), "{} is not a directory", root.display());
let mut diag = DiagTrace::default();
let language = detect_language(root, &mut diag);
let secondary_languages: Vec<Language> = detect_all_languages(root)
.into_iter()
.filter(|l| *l != language)
.collect();
let mut manifest_name = manifest::project_manifest_name(root, language);
if manifest_name.is_none() {
if language == Language::Rust && is_cargo_workspace_only(root) {
manifest_name = first_cargo_member_name(root, &mut diag);
} else if language == Language::Node && is_npm_workspace_only(root) {
manifest_name = first_npm_member_name(root, &mut diag);
}
}
let repo_url = repo::detect_repo_url(root);
let license = repo::detect_license(root).or_else(|| manifest::manifest_license(root, language));
let version = manifest::project_manifest_version(root, language);
let authors =
manifest::project_manifest_authors(root, language).or_else(|| repo::detect_author(root));
let description_hint = repo::read_readme_hint(root);
let d = cli_probe::detect_cli(root, language, manifest_name.clone(), &mut diag);
let has_cli = d.has_cli;
let cli_command = d.command;
let cli_help_output = d.help_output;
let cli_subcommand_tree = d.subcommand_tree;
let name = manifest_name
.or_else(|| repo::repo_url_name(&repo_url))
.unwrap_or_else(|| {
std::fs::canonicalize(root)
.ok()
.and_then(|c| c.file_name().map(|n| n.to_string_lossy().to_string()))
.or_else(|| {
std::env::current_dir()
.ok()
.and_then(|c| c.file_name().map(|n| n.to_string_lossy().to_string()))
})
.unwrap_or_else(|| "unknown-tool".to_string())
});
Ok(ProjectProfile {
name,
language,
secondary_languages,
has_cli,
cli_command,
cli_help_output,
cli_subcommand_tree,
diag,
repo_url,
license,
version,
authors,
description_hint,
})
}
pub(crate) fn detect_all_languages(root: &Path) -> Vec<Language> {
let mut langs = Vec::new();
let signals: &[(Language, bool)] = &[
(Language::Rust, root.join("Cargo.toml").exists()),
(Language::Node, root.join("package.json").exists()),
(
Language::Python,
root.join("pyproject.toml").exists()
|| root.join("setup.py").exists()
|| root.join("setup.cfg").exists(),
),
(Language::Go, root.join("go.mod").exists()),
(Language::Php, root.join("composer.json").exists()),
(
Language::Jvm,
root.join("pom.xml").exists()
|| root.join("build.gradle").exists()
|| root.join("build.gradle.kts").exists(),
),
(Language::CSharp, cli_probe::has_csproj(root)),
(
Language::Ruby,
root.join("Gemfile").exists() || cli_probe::has_gemspec(root),
),
(
Language::Zig,
root.join("build.zig").exists() || root.join("build.zig.zon").exists(),
),
(Language::Swift, root.join("Package.swift").exists()),
(
Language::CCpp,
root.join("CMakeLists.txt").exists() || root.join("meson.build").exists(),
),
(Language::Elixir, root.join("mix.exs").exists()),
(
Language::Deno,
root.join("deno.json").exists() || root.join("deno.jsonc").exists(),
),
(
Language::Nix,
root.join("flake.nix").exists()
|| root.join("shell.nix").exists()
|| root.join("default.nix").exists(),
),
(Language::Dart, root.join("pubspec.yaml").exists()),
(
Language::Haskell,
root.join("stack.yaml").exists()
|| root.join("cabal.project").exists()
|| cli_probe::has_cabal_file(root),
),
];
for (lang, present) in signals {
if *present {
langs.push(*lang);
}
}
langs
}
pub(crate) fn detect_language(root: &Path, diag: &mut DiagTrace) -> Language {
if root.join("Cargo.toml").exists() {
let is_workspace_only = is_cargo_workspace_only(root);
if is_workspace_only {
diag.push(
"detect_language.rust",
"Cargo.toml found but it is workspace-only (no [package]); ".to_string()
+ "CLI detection will probe workspace members next",
);
}
Language::Rust
} else if root.join("package.json").exists() {
if is_npm_workspace_only(root) {
diag.push(
"detect_language.node",
"package.json found but it declares `workspaces` with no root bin; ".to_string()
+ "CLI detection will probe workspace packages next",
);
}
Language::Node
} else if root.join("pyproject.toml").exists()
|| root.join("setup.py").exists()
|| root.join("setup.cfg").exists()
{
Language::Python
} else if root.join("go.mod").exists() {
Language::Go
} else if root.join("composer.json").exists() {
Language::Php
} else if root.join("pom.xml").exists()
|| root.join("build.gradle").exists()
|| root.join("build.gradle.kts").exists()
{
Language::Jvm
} else if cli_probe::has_csproj(root) {
Language::CSharp
} else if root.join("Gemfile").exists() || cli_probe::has_gemspec(root) {
Language::Ruby
} else if root.join("build.zig").exists() || root.join("build.zig.zon").exists() {
Language::Zig
} else if root.join("Package.swift").exists() {
Language::Swift
} else if root.join("CMakeLists.txt").exists()
|| root.join("meson.build").exists()
|| root.join("Makefile").exists()
{
if !root.join("CMakeLists.txt").exists() && !root.join("meson.build").exists() {
diag.push(
"detect_language.c_cpp",
"Makefile found with no CMakeLists.txt/meson.build; assuming C/C++ \
(a weak signal — a Makefile-only project may be another language). \
Run `skillpack doctor` to confirm.",
);
}
Language::CCpp
} else if root.join("mix.exs").exists() {
Language::Elixir
} else if root.join("deno.json").exists() || root.join("deno.jsonc").exists() {
Language::Deno
} else if root.join("flake.nix").exists()
|| root.join("shell.nix").exists()
|| root.join("default.nix").exists()
{
Language::Nix
} else if root.join("pubspec.yaml").exists() {
Language::Dart
} else if root.join("stack.yaml").exists()
|| root.join("cabal.project").exists()
|| cli_probe::has_cabal_file(root)
{
Language::Haskell
} else {
diag.push(
"detect_language",
"no known manifest found (none of: Cargo.toml, package.json, ".to_string()
+ "pyproject.toml, setup.py, setup.cfg, go.mod, composer.json, "
+ "pom.xml, build.gradle, build.gradle.kts, Gemfile, *.gemspec, "
+ "*.csproj, build.zig, Package.swift, CMakeLists.txt, meson.build, Makefile, mix.exs, deno.json, "
+ "flake.nix, shell.nix, pubspec.yaml, stack.yaml, *.cabal); "
+ "language detected as Unknown",
);
Language::Unknown
}
}
#[cfg(test)]
impl ProjectProfile {
pub fn test_default() -> Self {
Self {
name: "test-tool".to_string(),
language: Language::Unknown,
secondary_languages: Vec::new(),
has_cli: false,
cli_command: None,
cli_help_output: None,
cli_subcommand_tree: Vec::new(),
diag: DiagTrace::default(),
repo_url: None,
license: None,
version: None,
authors: None,
description_hint: None,
}
}
}
#[cfg(test)]
mod parse_tests {
use super::*;
fn scratch(files: &[(&str, &str)]) -> PathBuf {
static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root = std::env::temp_dir()
.join(format!("skillpack-parse-{}-{}", std::process::id(), n))
.join("proj");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
for (rel, contents) in files {
std::fs::write(root.join(rel), contents).unwrap();
}
root
}
fn cleanup(root: &Path) {
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn unknown_root_dot_falls_back_to_canonicalized_dir_name() {
let root = scratch(&[("package.json", "{}")]);
let p = introspect(&root).unwrap();
assert_ne!(
p.name, "unknown-tool",
"a real dir must resolve to its tail, not the unknown-tool sentinel"
);
assert_eq!(p.name, "proj");
cleanup(&root);
}
#[test]
fn introspect_dot_yields_cwd_tail_not_unknown_tool() {
let p = introspect(Path::new(".")).unwrap();
assert_ne!(p.name, "unknown-tool");
let cwd_tail = std::env::current_dir()
.ok()
.and_then(|c| c.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_default();
assert_eq!(p.name, cwd_tail);
}
#[test]
fn detect_all_languages_finds_polyglot_monorepo() {
let root = scratch(&[
("Cargo.toml", "[package]\nname = \"x\"\n"),
("package.json", "{}"),
]);
let langs = detect_all_languages(&root);
assert_eq!(langs, vec![Language::Rust, Language::Node]);
cleanup(&root);
}
#[test]
fn detect_all_languages_empty_for_no_manifests() {
let root = scratch(&[]);
assert!(detect_all_languages(&root).is_empty());
cleanup(&root);
}
#[test]
fn detect_language_recognizes_nix_dart_haskell() {
let mut diag = DiagTrace::default();
let nix = scratch(&[("flake.nix", "{}")]);
assert_eq!(detect_language(&nix, &mut diag), Language::Nix);
cleanup(&nix);
let dart = scratch(&[("pubspec.yaml", "name: x")]);
assert_eq!(detect_language(&dart, &mut diag), Language::Dart);
cleanup(&dart);
let haskell = scratch(&[("stack.yaml", "resolver: lts")]);
assert_eq!(detect_language(&haskell, &mut diag), Language::Haskell);
cleanup(&haskell);
}
#[test]
fn detect_all_languages_includes_nix_dart_haskell() {
let root = scratch(&[
("flake.nix", "{}"),
("pubspec.yaml", "name: x"),
("stack.yaml", "resolver: lts"),
]);
let langs = detect_all_languages(&root);
assert!(langs.contains(&Language::Nix), "got: {langs:?}");
assert!(langs.contains(&Language::Dart), "got: {langs:?}");
assert!(langs.contains(&Language::Haskell), "got: {langs:?}");
cleanup(&root);
}
#[test]
fn which_on_path_returns_existing_file() {
let probe = if cfg!(windows) {
which_on_path("cmd")
} else {
which_on_path("ls")
};
if let Some(p) = probe {
assert!(p.is_file(), "which_on_path returned non-file: {p:?}");
}
}
}