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;
#[cfg(test)]
pub(crate) use cli_candidates::which_on_path;
pub(crate) use manifest::{project_manifest_version, select_csproj};
pub(crate) use repo::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 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_help = d.subcommand_help;
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,
has_cli,
cli_command,
cli_help_output,
cli_subcommand_help,
diag,
repo_url,
license,
version,
authors,
description_hint,
})
}
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()
{
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 {
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); "
+ "language detected as Unknown",
);
Language::Unknown
}
}
#[cfg(test)]
impl ProjectProfile {
pub fn test_default() -> Self {
Self {
name: "test-tool".to_string(),
language: Language::Unknown,
has_cli: false,
cli_command: None,
cli_help_output: None,
cli_subcommand_help: 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 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:?}");
}
}
}