use std::fs;
use std::path::Path;
use std::process::{Command, Stdio};
use crate::spawn::{self, SpawnOutcome, HELP_TIMEOUT};
use crate::types::{DiagTrace, Language};
use super::cli_candidates::{primary_cli_candidate, CliCandidate, DetectCli};
use super::workspace::{
expand_workspace_members, is_cargo_workspace_only, is_npm_workspace_only, pyproject_has_tool,
};
pub(crate) fn walk_cargo_workspace(
root: &Path,
_name: &str,
diag: &mut DiagTrace,
) -> Option<DetectCli> {
let raw = fs::read_to_string(root.join("Cargo.toml")).ok()?;
let v = toml::from_str::<toml::Value>(&raw).ok()?;
let members = v.get("workspace")?.get("members")?.as_array()?;
let raw_list: Vec<String> = members
.iter()
.filter_map(|m| m.as_str().map(String::from))
.collect();
let expanded = expand_workspace_members(root, &raw_list);
diag.push(
"detect_cli.rust.workspace",
format!(
"Cargo workspace root — {} member(s) to probe",
expanded.len()
),
);
for member_rel in expanded {
let member_root = root.join(&member_rel);
if !member_root.join("Cargo.toml").is_file() {
diag.push(
"detect_cli.rust.workspace",
format!("member `{member_rel}` has no Cargo.toml — skipped"),
);
continue;
}
let manifest_name = fs::read_to_string(member_root.join("Cargo.toml"))
.ok()
.and_then(|r| toml::from_str::<toml::Value>(&r).ok())
.and_then(|v| {
v.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(String::from)
});
let Some(member_name) = manifest_name.or_else(|| {
member_root
.file_name()
.map(|f| f.to_string_lossy().into_owned())
}) else {
diag.push(
"detect_cli.rust.workspace",
format!("member `{member_rel}` has no name in manifest, skipping"),
);
continue;
};
match primary_cli_candidate(&member_root, Language::Rust, &member_name) {
Some(candidate) => {
diag.push(
"detect_cli.rust.workspace",
format!(
"member `{member_rel}` yielded candidate `{}`",
candidate.argv.join(" ")
),
);
return Some(spawn_candidate(&candidate, diag));
}
None => diag.push(
"detect_cli.rust.workspace",
format!("member `{member_rel}` (`{member_name}`): no built/installed artifact"),
),
}
}
diag.push(
"detect_cli.rust.workspace",
"no workspace member yielded a runnable CLI — has_cli=false \
(run `skillpack init` inside the member crate that ships the binary)"
.to_string(),
);
None
}
pub(crate) fn walk_npm_workspace(
root: &Path,
_name: &str,
diag: &mut DiagTrace,
) -> Option<DetectCli> {
let raw = fs::read_to_string(root.join("package.json")).ok()?;
let v = serde_json::from_str::<serde_json::Value>(&raw).ok()?;
let ws = v.get("workspaces")?;
let paths: Vec<String> = match ws {
serde_json::Value::String(s) => vec![s.clone()],
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|e| e.as_str().map(String::from))
.collect(),
_ => return None,
};
let expanded = expand_workspace_members(root, &paths);
diag.push(
"detect_cli.node.workspace",
format!("npm workspace root — {} member(s) to probe", expanded.len()),
);
for member_rel in expanded {
let member_root = root.join(&member_rel);
let pkg_json = member_root.join("package.json");
if !pkg_json.is_file() {
diag.push(
"detect_cli.node.workspace",
format!("member `{member_rel}` has no package.json — skipped"),
);
continue;
}
let Ok(mraw) = fs::read_to_string(&pkg_json) else {
continue;
};
let Ok(mv) = serde_json::from_str::<serde_json::Value>(&mraw) else {
continue;
};
let Some(member_name) = mv
.get("name")
.and_then(|n| n.as_str())
.map(String::from)
.or_else(|| {
member_root
.file_name()
.map(|f| f.to_string_lossy().into_owned())
})
else {
diag.push(
"detect_cli.node.workspace",
format!("member `{member_rel}` has no name in manifest, skipping"),
);
continue;
};
if mv.get("bin").is_none() {
diag.push(
"detect_cli.node.workspace",
format!("member `{member_rel}` (`{member_name}`): no `bin` field — skipped"),
);
continue;
}
match primary_cli_candidate(&member_root, Language::Node, &member_name) {
Some(candidate) => {
diag.push(
"detect_cli.node.workspace",
format!(
"member `{member_rel}` yielded candidate `{}`",
candidate.argv.join(" ")
),
);
return Some(spawn_candidate(&candidate, diag));
}
None => diag.push(
"detect_cli.node.workspace",
format!("member `{member_rel}` (`{member_name}`): candidate None (node missing?)"),
),
}
}
diag.push(
"detect_cli.node.workspace",
"no workspace member yielded a runnable CLI — has_cli=false \
(run `skillpack init` inside the member package that ships the bin)"
.to_string(),
);
None
}
pub(crate) 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"))
})
}
pub(crate) fn has_csproj(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("csproj"))
})
}
pub(crate) fn detect_cli(
root: &Path,
language: Language,
name: Option<String>,
diag: &mut DiagTrace,
) -> DetectCli {
let Some(name) = name else {
diag.push(
"detect_cli",
"no tool name derivable from the manifest or repo; ".to_string()
+ "cannot probe for a CLI without a name",
);
return DetectCli::none();
};
let Some(candidate) = primary_cli_candidate(root, language, &name) else {
if language == Language::Rust && is_cargo_workspace_only(root) {
if let Some(d) = walk_cargo_workspace(root, &name, diag) {
return d;
}
}
if language == Language::Node && is_npm_workspace_only(root) {
if let Some(d) = walk_npm_workspace(root, &name, diag) {
return d;
}
}
diag.push(
"detect_cli",
format!(
"primary_cli_candidate for language `{}` returned None — \
runtime may be missing, no build artifact present, or no bin \
entry point. Run `skillpack doctor --verbose` to see the raw \
profile; if this is a monorepo member, try running \
`skillpack init` inside the member directory.",
language.as_str()
),
);
if language == Language::Python
&& (root.join("uv.toml").exists()
|| pyproject_has_tool(root, "uv")
|| pyproject_has_tool(root, "poetry"))
{
diag.push(
"detect_cli.python",
"uv/poetry workspace detected; member walking not yet \
implemented — run `skillpack init` in the member package dir"
.to_string(),
);
}
return DetectCli::none();
};
spawn_candidate(&candidate, diag)
}
fn spawn_candidate(candidate: &CliCandidate, diag: &mut DiagTrace) -> DetectCli {
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::run(&mut cmd, HELP_TIMEOUT) {
SpawnOutcome::RanClean(output) => {
let subs = capture_subcommand_help(candidate, &output);
DetectCli {
has_cli: true,
command: Some(command),
help_output: Some(output),
subcommand_help: subs,
}
}
SpawnOutcome::RanNonZero => {
diag.push(
"detect_cli",
format!(
"`{} --help` exited non-zero; help output not captured",
command.join(" ")
),
);
DetectCli {
has_cli: true,
command: Some(command),
help_output: None,
subcommand_help: Vec::new(),
}
}
SpawnOutcome::TimedOut => {
diag.push(
"detect_cli",
format!(
"`{} --help` timed out after {HELP_TIMEOUT:?}",
command.join(" ")
),
);
DetectCli {
has_cli: true,
command: Some(command),
help_output: None,
subcommand_help: Vec::new(),
}
}
SpawnOutcome::NotFound => {
diag.push(
"detect_cli",
format!(
"spawn failed — `{}` binary not found on PATH",
command.first().unwrap_or(&candidate.argv[0])
),
);
DetectCli::none()
}
SpawnOutcome::SpawnFailed(_) => {
diag.push(
"detect_cli",
"spawn failed (permission-denied or OS error); treated as has_cli=false"
.to_string(),
);
DetectCli::none()
}
}
}
fn capture_subcommand_help(
candidate: &CliCandidate,
top_level_help: &str,
) -> Vec<(String, String)> {
let subs = crate::verify::invocation::extract_subcommands(top_level_help);
let mut out = Vec::with_capacity(subs.len());
for sub in subs {
let mut cmd = Command::new(&candidate.argv[0]);
for arg in &candidate.argv[1..] {
cmd.arg(arg);
}
cmd.arg(&sub)
.arg("--help")
.current_dir(&candidate.spawn_cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let SpawnOutcome::RanClean(help) = spawn::run(&mut cmd, HELP_TIMEOUT) {
out.push((sub, help));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn cleanup(root: &Path) {
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn walk_cargo_workspace_continues_past_no_artifact_member() {
let root = std::env::temp_dir().join(format!(
"skillpack-walk-cargo-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("members/m1")).unwrap();
std::fs::create_dir_all(root.join("members/m2")).unwrap();
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"members/m1\", \"members/m2\"]\n",
)
.unwrap();
std::fs::write(
root.join("members/m1/Cargo.toml"),
"[package]\nname = \"m1\"\n",
)
.unwrap();
std::fs::write(
root.join("members/m2/Cargo.toml"),
"[package]\nname = \"m2\"\n",
)
.unwrap();
let mut diag = DiagTrace::default();
let res = walk_cargo_workspace(&root, "ws", &mut diag);
assert!(res.is_none(), "no member has a built artifact → None");
let notes: Vec<&str> = diag.0.iter().map(|d| d.note.as_str()).collect();
assert!(
notes.iter().any(|n| n.contains("m1")),
"m1 probed: {notes:?}"
);
assert!(
notes.iter().any(|n| n.contains("m2")),
"m2 probed: {notes:?}"
);
cleanup(&root);
}
#[test]
fn walk_npm_workspace_continues_past_no_cli_member() {
let root = std::env::temp_dir().join(format!(
"skillpack-walk-npm-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("members/m1")).unwrap();
std::fs::create_dir_all(root.join("members/m2")).unwrap();
std::fs::write(
root.join("package.json"),
"{ \"workspaces\": [\"members/m1\", \"members/m2\"] }",
)
.unwrap();
std::fs::write(
root.join("members/m1/package.json"),
"{ \"name\": \"m1\", \"bin\": {} }",
)
.unwrap();
std::fs::write(
root.join("members/m2/package.json"),
"{ \"name\": \"m2\", \"bin\": {} }",
)
.unwrap();
let mut diag = DiagTrace::default();
let res = walk_npm_workspace(&root, "ws", &mut diag);
assert!(res.is_none(), "bin:{{}} → both candidate None → walk None");
let notes: Vec<&str> = diag.0.iter().map(|d| d.note.as_str()).collect();
assert!(
notes.iter().any(|n| n.contains("m1")),
"m1 probed: {notes:?}"
);
assert!(
notes.iter().any(|n| n.contains("m2")),
"m2 probed: {notes:?}"
);
cleanup(&root);
}
}