use std::fs;
use std::path::Path;
use std::process::{Command, Stdio};
use crate::spawn::{self, SpawnOutcome, HELP_TIMEOUT};
use crate::types::{DiagTrace, Language, SubcommandNode};
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_tree(candidate, &output);
DetectCli {
has_cli: true,
command: Some(command),
help_output: Some(output),
subcommand_tree: 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_tree: 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_tree: 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()
}
}
}
const MAX_SUBCOMMAND_DEPTH: usize = 4;
fn capture_subcommand_tree(candidate: &CliCandidate, top_level_help: &str) -> Vec<SubcommandNode> {
capture_children(candidate, &[], top_level_help, 0)
}
fn capture_children(
candidate: &CliCandidate,
prefix: &[String],
help_output: &str,
depth: usize,
) -> Vec<SubcommandNode> {
if depth >= MAX_SUBCOMMAND_DEPTH {
return Vec::new();
}
let subs = crate::verify::invocation::extract_subcommands(help_output);
let mut out = Vec::with_capacity(subs.len());
for sub in subs {
let help = spawn_subcommand_help(candidate, prefix, &sub);
let mut child_prefix = prefix.to_vec();
child_prefix.push(sub.clone());
let children = if help.is_empty() {
Vec::new()
} else {
capture_children(candidate, &child_prefix, &help, depth + 1)
};
out.push(SubcommandNode {
name: sub,
help,
children,
});
}
out
}
fn spawn_subcommand_help(candidate: &CliCandidate, prefix: &[String], sub: &str) -> String {
let mut cmd = Command::new(&candidate.argv[0]);
for arg in &candidate.argv[1..] {
cmd.arg(arg);
}
for p in prefix {
cmd.arg(p);
}
cmd.arg(sub)
.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(help) => help,
_ => String::new(),
}
}
#[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);
}
}