use std::fs;
use std::path::{Path, PathBuf};
use crate::types::Language;
pub(crate) struct DetectCli {
pub(crate) has_cli: bool,
pub(crate) command: Option<Vec<String>>,
pub(crate) help_output: Option<String>,
pub(crate) subcommand_help: Vec<(String, String)>,
}
impl DetectCli {
pub(crate) fn none() -> Self {
Self {
has_cli: false,
command: None,
help_output: None,
subcommand_help: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct CliCandidate {
pub(crate) argv: Vec<String>,
pub(crate) spawn_cwd: PathBuf,
}
pub(crate) fn which_on_path(name: &str) -> Option<PathBuf> {
let exts: Vec<String> = std::env::var("PATHEXT")
.ok()
.map(|p| p.split(';').map(|s| s.to_string()).collect())
.unwrap_or_default();
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let bare = dir.join(name);
if bare.is_file() {
return Some(bare);
}
for ext in &exts {
let with_ext = match dir.join(format!("{name}{ext}")) {
p if p.is_file() => p,
_ => continue,
};
return Some(with_ext);
}
}
None
}
pub(crate) 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::Php => php_cli_candidate(root, name),
Language::Jvm => jvm_cli_candidate(root, name),
Language::CSharp => csharp_cli_candidate(root, name),
Language::Unknown => which_on_path(name).map(|_| CliCandidate {
argv: vec![name.to_string()],
spawn_cwd: root.to_path_buf(),
}),
}
}
fn cargo_bin_names(root: &Path) -> Vec<String> {
let Ok(raw) = fs::read_to_string(root.join("Cargo.toml")) else {
return Vec::new();
};
let Ok(v) = toml::from_str::<toml::Value>(&raw) else {
return Vec::new();
};
v.get("bin")
.and_then(|b| b.as_array())
.map(|arr| {
arr.iter()
.filter_map(|t| t.get("name").and_then(|n| n.as_str()).map(String::from))
.collect()
})
.unwrap_or_default()
}
fn rust_cli_candidate(root: &Path, name: &str) -> Option<CliCandidate> {
let suffix = if cfg!(windows) { ".exe" } else { "" };
let mut candidates: Vec<String> = cargo_bin_names(root);
if !candidates.iter().any(|c| c == name) {
candidates.push(name.to_string());
}
let probe_names: Vec<String> = candidates
.into_iter()
.map(|n| format!("{n}{suffix}"))
.collect();
for bin in &probe_names {
for profile in &["release", "debug"] {
let p = root.join("target").join(profile).join(bin);
if p.exists() {
let abs = canonicalize_for_argv(&p);
return Some(CliCandidate {
argv: vec![abs],
spawn_cwd: root.to_path_buf(),
});
}
}
}
for cand_name in cargo_bin_names(root) {
if cand_name == name {
continue;
}
if let Some(p) = which_on_path(&cand_name) {
return Some(CliCandidate {
argv: vec![p.to_string_lossy().to_string()],
spawn_cwd: root.to_path_buf(),
});
}
}
which_on_path(name).map(|p| CliCandidate {
argv: vec![p.to_string_lossy().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 = canonicalize_for_argv(&script_path);
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 let Some(script) = which_on_path(name) {
return Some(CliCandidate {
argv: vec![script.to_string_lossy().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> {
let ruby = 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 = canonicalize_for_argv(&p);
return Some(CliCandidate {
argv: vec![ruby.clone(), abs],
spawn_cwd: root.to_path_buf(),
});
}
}
None
}
fn php_cli_candidate(root: &Path, name: &str) -> Option<CliCandidate> {
let php = which_on_path("php")?;
let php_bin = php.to_string_lossy().to_string();
let raw = fs::read_to_string(root.join("composer.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(),
serde_json::Value::Array(arr) => arr.first()?.as_str()?.to_string(),
_ => return None,
};
if script.trim().is_empty() {
return None;
}
let script_path = root.join(&script);
let abs_script = canonicalize_for_argv(&script_path);
Some(CliCandidate {
argv: vec![php_bin, abs_script],
spawn_cwd: root.to_path_buf(),
})
}
fn jvm_cli_candidate(root: &Path, name: &str) -> Option<CliCandidate> {
let install_bin = root.join("build/install").join(name).join("bin").join(name);
if install_bin.exists() {
let abs = canonicalize_for_argv(&install_bin);
return Some(CliCandidate {
argv: vec![abs],
spawn_cwd: root.to_path_buf(),
});
}
let java = which_on_path("java")?;
let java_bin = java.to_string_lossy().to_string();
for dir in &["target", "build/libs"] {
if let Ok(entries) = fs::read_dir(root.join(dir)) {
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("jar") {
continue;
}
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
if stem.starts_with(name) {
let abs = canonicalize_for_argv(&p);
return Some(CliCandidate {
argv: vec![java_bin.clone(), "-jar".to_string(), abs],
spawn_cwd: root.to_path_buf(),
});
}
}
}
}
}
which_on_path(name).map(|p| CliCandidate {
argv: vec![p.to_string_lossy().to_string()],
spawn_cwd: root.to_path_buf(),
})
}
fn csharp_cli_candidate(root: &Path, _name: &str) -> Option<CliCandidate> {
which_on_path("dotnet")?;
let csproj = super::select_csproj(root)?;
let csproj_arg = csproj.to_string_lossy().to_string();
Some(CliCandidate {
argv: vec![
"dotnet".to_string(),
"run".to_string(),
"--project".to_string(),
csproj_arg,
"--".to_string(),
],
spawn_cwd: root.to_path_buf(),
})
}
fn canonicalize_for_argv(p: &Path) -> String {
let path = 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());
if cfg!(windows) && path.starts_with(r"\\?\") {
path[4..].to_string()
} else {
path
}
}