use std::fs;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::Duration;
use anyhow::Result;
use crate::types::{DiagTrace, Language, ProjectProfile};
mod cli_candidates;
mod manifest;
#[cfg(test)]
pub(crate) use cli_candidates::which_on_path;
pub(crate) use cli_candidates::{primary_cli_candidate, CliCandidate, DetectCli};
pub(crate) use manifest::{project_manifest_version, select_csproj};
#[cfg(test)]
use std::path::PathBuf;
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 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 = detect_repo_url(root);
let license = 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);
let description_hint = read_readme_hint(root);
let d = 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_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 has_csproj(root) {
Language::CSharp
} else if root.join("Gemfile").exists() || has_gemspec(root) {
Language::Ruby
} 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); "
+ "language detected as Unknown",
);
Language::Unknown
}
}
fn is_cargo_workspace_only(root: &Path) -> bool {
let Ok(raw) = fs::read_to_string(root.join("Cargo.toml")) else {
return false;
};
let Ok(v) = toml::from_str::<toml::Value>(&raw) else {
return false;
};
v.get("workspace").is_some() && v.get("package").is_none()
}
fn is_npm_workspace_only(root: &Path) -> bool {
let Some(raw) = fs::read_to_string(root.join("package.json")).ok() else {
return false;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
return false;
};
v.get("workspaces").is_some() && v.get("bin").is_none()
}
fn pyproject_has_tool(root: &Path, name: &str) -> bool {
let Some(raw) = fs::read_to_string(root.join("pyproject.toml")).ok() else {
return false;
};
let Ok(v) = toml::from_str::<toml::Value>(&raw) else {
return false;
};
v.get("tool").and_then(|t| t.get(name)).is_some()
}
fn first_cargo_member_name(root: &Path, diag: &mut DiagTrace) -> Option<String> {
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()?;
for m in members {
let Some(rel) = m.as_str() else { continue };
let member_root = root.join(rel);
let name = fs::read_to_string(member_root.join("Cargo.toml"))
.ok()
.and_then(|r| toml::from_str::<toml::Value>(&r).ok())
.and_then(|mv| {
mv.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(String::from)
});
if let Some(n) = name {
diag.push(
"detect_language.rust.workspace",
format!("workspace member `{rel}` supplied tool name `{n}`"),
);
return Some(n);
}
}
diag.push(
"detect_language.rust.workspace",
"no workspace member has a [package].name — name fell back to dir tail".to_string(),
);
None
}
fn first_npm_member_name(root: &Path, diag: &mut DiagTrace) -> Option<String> {
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,
};
for rel in paths {
let pkg = root.join(&rel).join("package.json");
let name = fs::read_to_string(&pkg)
.ok()
.and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())
.and_then(|mv| mv.get("name").and_then(|n| n.as_str()).map(String::from));
if let Some(n) = name {
diag.push(
"detect_language.node.workspace",
format!("workspace member `{rel}` supplied tool name `{n}`"),
);
return Some(n);
}
}
diag.push(
"detect_language.node.workspace",
"no workspace member has a package.json `name` — name fell back to dir tail".to_string(),
);
None
}
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()?;
diag.push(
"detect_cli.rust.workspace",
format!(
"Cargo workspace root — {} member(s) to probe",
members.len()
),
);
for m in members {
let Some(member_rel) = m.as_str() else {
continue;
};
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
}
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,
};
diag.push(
"detect_cli.node.workspace",
format!("npm workspace root — {} member(s) to probe", paths.len()),
);
for member_rel in paths {
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
}
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 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"))
})
}
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_with_timeout(&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_with_timeout(&mut cmd, HELP_TIMEOUT) {
out.push((sub, help));
}
}
out
}
use crate::spawn::{self, SpawnOutcome, HELP_TIMEOUT};
fn spawn_with_timeout(cmd: &mut Command, timeout: Duration) -> SpawnOutcome {
spawn::run(cmd, timeout)
}
fn detect_repo_url(root: &Path) -> Option<String> {
let mut cmd = Command::new("git");
cmd.args(["remote", "get-url", "origin"]).current_dir(root);
match spawn_with_timeout(&mut cmd, Duration::from_secs(3)) {
SpawnOutcome::RanClean(out) => Some(out.trim().to_string()),
_ => None,
}
}
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 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())
}
#[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 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>]");
let node_stem = Path::new(&cand.argv[0])
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
assert!(
node_stem.eq_ignore_ascii_case("node"),
"got: {:?}",
cand.argv
);
let script = Path::new(&cand.argv[1]);
assert!(
script.is_absolute() && script.ends_with("bin/cli.js"),
"expected absolute script path, got {}",
cand.argv[1]
);
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);
let stem = Path::new(&cand.argv[0])
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
assert!(
stem.eq_ignore_ascii_case("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);
}
#[test]
fn rust_candidate_probes_bin_name_not_package_name() {
let root = scratch_root(&[(
"Cargo.toml",
"[package]\nname = \"fd-find\"\n[[bin]]\nname = \"fd\"\n",
)]);
let bin_dir = root.join("target").join("release");
std::fs::create_dir_all(&bin_dir).unwrap();
let bin_name = if cfg!(windows) { "fd.exe" } else { "fd" };
std::fs::write(bin_dir.join(bin_name), "#!/bin/sh\necho fd\n").unwrap();
let cand = primary_cli_candidate(&root, Language::Rust, "fd-find");
assert!(cand.is_some(), "expected [[bin]].name artifact probed");
let cand = cand.unwrap();
assert!(
cand.argv[0].ends_with(bin_name),
"expected argv to target [[bin]] artifact, got {}",
cand.argv[0]
);
assert!(!cand.argv[0].ends_with("fd-find"));
cleanup(&root);
}
#[test]
fn csharp_candidate_uses_dotnet_run_with_dash_dash_separator() {
if which_on_path("dotnet").is_none() {
return;
}
let csproj = r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
"#;
let root = scratch_root(&[("sample.csproj", csproj)]);
let cand = primary_cli_candidate(&root, Language::CSharp, "sample").unwrap();
assert_eq!(cand.argv[0], "dotnet");
assert_eq!(cand.argv[1], "run");
assert_eq!(cand.argv[2], "--project");
assert!(cand.argv[3].ends_with("sample.csproj"));
assert_eq!(cand.argv[4], "--");
assert_eq!(cand.spawn_cwd, root);
cleanup(&root);
}
}
#[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 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);
}
#[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:?}");
}
}
}