use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use anyhow::Result;
use crate::types::{Language, ProjectProfile};
const HELP_TIMEOUT: Duration = Duration::from_secs(5);
const README_HEAD_LINES: usize = 500;
pub fn introspect(root: &Path) -> Result<ProjectProfile> {
anyhow::ensure!(root.is_dir(), "{} is not a directory", root.display());
let language = detect_language(root);
let manifest_name = project_manifest_name(root, language);
let repo_url = detect_repo_url(root);
let license = detect_license(root).or_else(|| manifest_license(root, language));
let description_hint = read_readme_hint(root);
let (has_cli, cli_command, cli_help_output) = detect_cli(root, language, manifest_name.clone());
let name = manifest_name
.or_else(|| repo_url_name(&repo_url))
.unwrap_or_else(|| {
root.file_name().map_or_else(
|| "unknown-tool".to_string(),
|n| n.to_string_lossy().to_string(),
)
});
Ok(ProjectProfile {
name,
language,
has_cli,
cli_command,
cli_help_output,
repo_url,
license,
description_hint,
})
}
pub fn detect_language(root: &Path) -> Language {
if root.join("Cargo.toml").exists() {
Language::Rust
} else if root.join("package.json").exists() {
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("Gemfile").exists() || has_gemspec(root) {
Language::Ruby
} else {
Language::Unknown
}
}
fn has_gemspec(root: &Path) -> bool {
fs::read_dir(root).is_ok_and(|entries| {
entries
.flatten()
.any(|e| e.path().extension().and_then(|x| x.to_str()) == Some("gemspec"))
})
}
fn project_manifest_name(root: &Path, language: Language) -> Option<String> {
match language {
Language::Rust => {
let raw = fs::read_to_string(root.join("Cargo.toml")).ok()?;
let pkg_line = raw
.lines()
.skip_while(|l| !l.trim_start().starts_with("[package]"))
.skip(1)
.take_while(|l| !l.trim_start().starts_with('['))
.find(|l| l.trim_start().starts_with("name ="))?;
Some(extract_toml_string_value(pkg_line)?)
}
Language::Node => {
let raw = fs::read_to_string(root.join("package.json")).ok()?;
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
v.get("name")?
.as_str()
.map(std::string::ToString::to_string)
}
Language::Python => {
if let Ok(raw) = fs::read_to_string(root.join("pyproject.toml")) {
if let Ok(v) = toml::from_str::<toml::Value>(&raw) {
if let Some(name) = v
.get("project")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
{
return Some(name.to_string());
}
}
}
None
}
Language::Go => {
let raw = fs::read_to_string(root.join("go.mod")).ok()?;
let module_line = raw
.lines()
.find(|l| l.trim_start().starts_with("module "))?;
let path = module_line
.trim()
.strip_prefix("module ")
.map(|s| s.trim().to_string())?;
let last = path.rsplit('/').next()?.to_string();
Some(last)
}
Language::Ruby => {
if let Ok(entries) = fs::read_dir(root) {
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) == Some("gemspec") {
if let Ok(raw) = fs::read_to_string(&p) {
if let Some(line) = raw
.lines()
.find(|l| l.contains("spec.name") || l.contains(".name ="))
{
if let Some(name) = extract_ruby_string_value(line) {
return Some(name);
}
}
}
}
}
}
None
}
Language::Unknown => None,
}
}
fn detect_cli(
root: &Path,
language: Language,
name: Option<String>,
) -> (bool, Option<Vec<String>>, Option<String>) {
let Some(name) = name else {
return (false, None, None);
};
let Some(candidate) = primary_cli_candidate(root, language, &name) else {
return (false, None, None);
};
let mut command = candidate.argv.clone();
command.push("--help".to_string());
let mut cmd = Command::new(&candidate.argv[0]);
for arg in &candidate.argv[1..] {
cmd.arg(arg);
}
cmd.arg("--help")
.current_dir(&candidate.spawn_cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
match spawn_with_timeout(&mut cmd, HELP_TIMEOUT) {
SpawnOutcome::RanClean(output) => (true, Some(command), Some(output)),
SpawnOutcome::RanNonZero(_output) => (true, Some(command), None),
SpawnOutcome::TimedOut => (true, Some(command), None),
SpawnOutcome::NotFound => (false, None, None),
}
}
#[derive(Debug, Clone)]
struct CliCandidate {
argv: Vec<String>,
spawn_cwd: PathBuf,
}
fn primary_cli_candidate(root: &Path, language: Language, name: &str) -> Option<CliCandidate> {
match language {
Language::Rust => rust_cli_candidate(root, name),
Language::Node => node_cli_candidate(root, name),
Language::Go => go_cli_candidate(root, name),
Language::Python => python_cli_candidate(root, name),
Language::Ruby => ruby_cli_candidate(root, name),
Language::Unknown => which_on_path(name).map(|_| CliCandidate {
argv: vec![name.to_string()],
spawn_cwd: root.to_path_buf(),
}),
}
}
fn rust_cli_candidate(root: &Path, name: &str) -> Option<CliCandidate> {
for profile in &["release", "debug"] {
let p = root.join("target").join(profile).join(name);
if p.exists() {
let abs = std::fs::canonicalize(&p)
.ok()
.and_then(|c| c.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| p.to_string_lossy().to_string());
return Some(CliCandidate {
argv: vec![abs],
spawn_cwd: root.to_path_buf(),
});
}
}
which_on_path(name).map(|_| CliCandidate {
argv: vec![name.to_string()],
spawn_cwd: root.to_path_buf(),
})
}
fn node_cli_candidate(root: &Path, name: &str) -> Option<CliCandidate> {
let node = which_on_path("node")?;
let node_bin = node.to_string_lossy().to_string();
let raw = fs::read_to_string(root.join("package.json")).ok()?;
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
let bin = v.get("bin")?;
let script = match bin {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Object(map) => {
map.get(name)
.and_then(|v| v.as_str())
.or_else(|| map.iter().next().and_then(|(_, v)| v.as_str()))?
.to_string()
}
_ => return None,
};
if script.trim().is_empty() {
return None;
}
let script_path = root.join(&script);
let abs_script = std::fs::canonicalize(&script_path)
.ok()
.and_then(|c| c.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| script_path.to_string_lossy().to_string());
Some(CliCandidate {
argv: vec![node_bin, abs_script],
spawn_cwd: root.to_path_buf(),
})
}
fn go_cli_candidate(root: &Path, _name: &str) -> Option<CliCandidate> {
which_on_path("go")?;
if !has_go_main(root) {
return None;
}
Some(CliCandidate {
argv: vec!["go".to_string(), "run".to_string(), ".".to_string()],
spawn_cwd: root.to_path_buf(),
})
}
fn has_go_main(root: &Path) -> bool {
let Ok(entries) = fs::read_dir(root) else {
return false;
};
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("go") {
continue;
}
if p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with("_test.go"))
{
continue;
}
if let Ok(raw) = fs::read_to_string(&p) {
if raw.lines().any(|l| l.trim() == "package main") {
return true;
}
}
}
false
}
fn python_cli_candidate(root: &Path, name: &str) -> Option<CliCandidate> {
let python = which_on_path("python")
.or_else(|| which_on_path("python3"))
.map(|p| p.to_string_lossy().to_string())?;
if let Some(pkg) = python_script_package(root, name) {
if root.join(&pkg).is_dir() {
return Some(CliCandidate {
argv: vec![python, "-m".to_string(), pkg],
spawn_cwd: root.to_path_buf(),
});
}
}
if which_on_path(name).is_some() {
return Some(CliCandidate {
argv: vec![name.to_string()],
spawn_cwd: root.to_path_buf(),
});
}
None
}
fn python_script_package(root: &Path, name: &str) -> Option<String> {
let raw = fs::read_to_string(root.join("pyproject.toml")).ok()?;
let v: toml::Value = toml::from_str(&raw).ok()?;
let scripts = v
.get("project")
.and_then(|p| p.get("scripts"))?
.as_table()?;
let target = scripts.get(name)?.as_str()?;
let module = target.split(':').next()?.trim();
module.split('.').next().map(|s| s.to_string())
}
fn ruby_cli_candidate(root: &Path, name: &str) -> Option<CliCandidate> {
which_on_path("ruby")
.or_else(|| which_on_path("bundle"))
.map(|b| b.to_string_lossy().to_string())?;
for dir in &["exe", "bin"] {
let p = root.join(dir).join(name);
if p.is_file() {
let abs = std::fs::canonicalize(&p)
.ok()
.and_then(|c| c.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| p.to_string_lossy().to_string());
return Some(CliCandidate {
argv: vec!["ruby".to_string(), abs],
spawn_cwd: root.to_path_buf(),
});
}
}
None
}
enum SpawnOutcome {
RanClean(String),
RanNonZero(String),
TimedOut,
NotFound,
}
fn spawn_with_timeout(cmd: &mut Command, timeout: Duration) -> SpawnOutcome {
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return SpawnOutcome::NotFound,
Err(_) => return SpawnOutcome::NotFound,
};
let deadline = std::time::Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => {
let output = child.wait_with_output().ok().map(|o| {
String::from_utf8_lossy(&o.stdout).to_string()
+ String::from_utf8_lossy(&o.stderr).as_ref()
});
let out = output.unwrap_or_default();
return if status.success() {
SpawnOutcome::RanClean(out)
} else {
SpawnOutcome::RanNonZero(out)
};
}
Ok(None) => {
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
return SpawnOutcome::TimedOut;
}
std::thread::sleep(Duration::from_millis(20));
}
Err(_) => return SpawnOutcome::TimedOut,
}
}
}
fn which_on_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn detect_repo_url(root: &Path) -> Option<String> {
let out = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn detect_license(root: &Path) -> Option<String> {
for filename in &["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"] {
let p = root.join(filename);
if let Ok(raw) = fs::read_to_string(&p) {
let head = raw.split('\n').take(3).collect::<Vec<_>>().join("\n");
let lower = head.to_lowercase();
if lower.contains("mit license") || lower.contains("permission is hereby granted") {
return Some("MIT".to_string());
}
if lower.contains("apache license") {
return Some("Apache-2.0".to_string());
}
if lower.contains("bsd 3-clause") || lower.contains("neither the name") {
return Some("BSD-3-Clause".to_string());
}
if lower.contains("gnu general public license") {
return Some("GPL-3.0".to_string());
}
}
}
None
}
fn manifest_license(root: &Path, language: Language) -> Option<String> {
match language {
Language::Node => {
let raw = fs::read_to_string(root.join("package.json")).ok()?;
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
v.get("license")?
.as_str()
.map(std::string::ToString::to_string)
}
Language::Rust => {
let raw = fs::read_to_string(root.join("Cargo.toml")).ok()?;
let line = raw
.lines()
.skip_while(|l| !l.trim_start().starts_with("[package]"))
.skip(1)
.take_while(|l| !l.trim_start().starts_with('['))
.find(|l| l.trim_start().starts_with("license ="))?;
extract_toml_string_value(line)
}
_ => None,
}
}
fn read_readme_hint(root: &Path) -> Option<String> {
for filename in &["README.md", "README", "readme.md"] {
let p = root.join(filename);
if let Ok(raw) = fs::read_to_string(&p) {
let head: String = raw
.lines()
.take(README_HEAD_LINES)
.collect::<Vec<_>>()
.join("\n");
let paragraph = head
.lines()
.skip_while(|l| {
let t = l.trim();
t.is_empty() || t.starts_with('#') || t.starts_with('!')
})
.take_while(|l| !l.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
let trimmed = paragraph.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
fn repo_url_name(repo_url: &Option<String>) -> Option<String> {
let url = repo_url.as_ref()?;
let last = url.rsplit('/').next()?.trim_end();
let stem = last.strip_suffix(".git").unwrap_or(last);
Some(stem.to_string())
}
fn extract_toml_string_value(line: &str) -> Option<String> {
let after = line.split('=').nth(1)?.trim();
let stripped = after
.trim_start_matches('"')
.trim_start_matches('\'')
.trim_end_matches('"')
.trim_end_matches('\'')
.trim();
Some(stripped.to_string())
}
fn extract_ruby_string_value(line: &str) -> Option<String> {
let after = line.split('=').nth(1)?.trim();
let s = after.trim_start_matches(['"', '\'']);
let s = s.split(['"', '\'']).next()?.trim();
Some(s.to_string())
}
#[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,
repo_url: None,
license: None,
description_hint: None,
}
}
}
#[cfg(test)]
mod candidate_tests {
use super::*;
use crate::types::Language;
fn scratch_root(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-test-{}-{}", 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 {
let p = root.join(rel);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&p, contents).unwrap();
}
root
}
fn cleanup(root: &Path) {
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn node_cli_detected_via_bin_absolute_argv() {
if which_on_path("node").is_none() {
let root = scratch_root(&[
("package.json", r#"{"bin":{"sample-node":"./bin/cli.js"}}"#),
("bin/cli.js", "#!/usr/bin/env node\nconsole.log('x')\n"),
]);
assert!(primary_cli_candidate(&root, Language::Node, "sample-node").is_none());
cleanup(&root);
return;
}
let root = scratch_root(&[
("package.json", r#"{"bin":{"sample-node":"./bin/cli.js"}}"#),
("bin/cli.js", "#!/usr/bin/env node\nconsole.log('x')\n"),
]);
let cand = primary_cli_candidate(&root, Language::Node, "sample-node").unwrap();
assert_eq!(cand.argv.len(), 2, "argv should be [node, <abs script>]");
assert!(cand.argv[0].ends_with("node"), "got: {:?}", cand.argv);
let script = &cand.argv[1];
assert!(
Path::new(script).is_absolute() && script.ends_with("bin/cli.js"),
"expected absolute script path, got {}",
script
);
assert_eq!(cand.spawn_cwd, root);
cleanup(&root);
}
#[test]
fn node_cli_string_bin_form() {
if which_on_path("node").is_none() {
return;
}
let root = scratch_root(&[
("package.json", r#"{"bin":"./cli.js"}"#),
("cli.js", "console.log('x')\n"),
]);
let cand = primary_cli_candidate(&root, Language::Node, "anything").unwrap();
assert_eq!(cand.argv.len(), 2);
assert!(cand.argv[1].ends_with("cli.js"));
cleanup(&root);
}
#[test]
fn go_candidate_none_when_go_missing() {
if which_on_path("go").is_some() {
return;
}
let root = scratch_root(&[("main.go", "package main\nfunc main(){}\n")]);
assert!(primary_cli_candidate(&root, Language::Go, "sample-go").is_none());
cleanup(&root);
}
#[test]
fn go_candidate_uses_run_dot_when_go_present() {
if which_on_path("go").is_none() {
return;
}
let root = scratch_root(&[("main.go", "package main\nfunc main(){}\n")]);
let cand = primary_cli_candidate(&root, Language::Go, "sample-go").unwrap();
assert_eq!(cand.argv, vec!["go", "run", "."]);
assert_eq!(cand.spawn_cwd, root);
cleanup(&root);
}
#[test]
fn go_candidate_none_without_package_main() {
if which_on_path("go").is_none() {
return;
}
let root = scratch_root(&[("main.go", "package foo\nfunc main(){}\n")]);
assert!(primary_cli_candidate(&root, Language::Go, "sample-go").is_none());
cleanup(&root);
}
#[test]
fn python_candidate_uses_m_module_when_importable() {
if which_on_path("python")
.or_else(|| which_on_path("python3"))
.is_none()
{
return;
}
let root = scratch_root(&[
(
"pyproject.toml",
"[project]\nname = \"sample-python\"\n[project.scripts]\nsample-python = \"sample_python.cli:main\"\n",
),
("sample_python/__init__.py", ""),
("sample_python/cli.py", "def main(): pass\n"),
]);
let cand = primary_cli_candidate(&root, Language::Python, "sample-python").unwrap();
assert_eq!(cand.argv.len(), 3, "got: {:?}", cand.argv);
assert!(
cand.argv[0].ends_with("python"),
"expected python interpreter, got {}",
cand.argv[0]
);
assert_eq!(cand.argv[1], "-m");
assert_eq!(cand.argv[2], "sample_python");
assert_eq!(cand.spawn_cwd, root);
cleanup(&root);
}
#[test]
fn ruby_candidate_none_without_runtime() {
if which_on_path("ruby")
.or_else(|| which_on_path("bundle"))
.is_some()
{
return;
}
let root = scratch_root(&[("Gemfile", "source \"https://rubygems.org\"\n")]);
assert!(primary_cli_candidate(&root, Language::Ruby, "sample-ruby").is_none());
cleanup(&root);
}
#[test]
fn rust_candidate_fallback_to_path_probe() {
let root = scratch_root(&[("Cargo.toml", "[package]\nname = \"totally-fake-bin-xyz\"\n")]);
let cand = primary_cli_candidate(&root, Language::Rust, "totally-fake-bin-xyz");
assert!(cand.is_none());
cleanup(&root);
}
}