use mandible_core::{CommandNode, Provenance};
use mandible_extract::{resolve_tool, PathSibling, ResolvedTool};
pub fn attach_path_siblings(root: &mut CommandNode, siblings: &[PathSibling]) {
if root.subcommands.is_empty() {
return;
}
for sibling in siblings {
let documented = root
.subcommands
.iter()
.any(|c| c.name == sibling.name || c.aliases.contains(&sibling.name));
if documented {
continue;
}
let mut node = CommandNode::new(sibling.name.clone(), Provenance::default());
node.discovered_binary = Some(sibling.binary.clone());
root.subcommands.push(node);
}
}
pub fn probe_target(
root: &CommandNode,
tool: &ResolvedTool,
path: &[String],
) -> (ResolvedTool, Vec<String>) {
for depth in (1..path.len()).rev() {
let Some(node) = mandible_core::resolve(root, &path[..=depth]) else {
continue;
};
let Some(binary) = node.discovered_binary.as_deref() else {
continue;
};
let mut rebased = vec![node.name.clone()];
rebased.extend_from_slice(&path[depth + 1..]);
return (resolve_tool(binary), rebased);
}
(tool.clone(), path.to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
use mandible_core::Source;
fn node(name: &str) -> CommandNode {
CommandNode::new(name, Provenance::single(Source::HelpText))
}
fn sibling(name: &str, binary: &str) -> PathSibling {
PathSibling {
name: name.to_string(),
binary: binary.to_string(),
}
}
fn path(segments: &[&str]) -> Vec<String> {
segments.iter().map(|s| s.to_string()).collect()
}
#[test]
fn an_undocumented_sibling_becomes_a_child_carrying_its_binary() {
let mut root = node("cargo");
root.subcommands.push(node("build"));
attach_path_siblings(&mut root, &[sibling("clippy", "cargo-clippy")]);
let clippy = root
.subcommands
.iter()
.find(|c| c.name == "clippy")
.expect("the sibling should have been attached");
assert_eq!(clippy.discovered_binary.as_deref(), Some("cargo-clippy"));
assert!(!clippy.heading_attested);
}
#[test]
fn a_documented_name_is_not_duplicated_by_the_convention() {
let mut root = node("git");
let mut push = node("push");
push.summary = Some(mandible_core::Text::sanitize("Update remote refs"));
root.subcommands.push(push);
attach_path_siblings(&mut root, &[sibling("push", "git-push")]);
assert_eq!(root.subcommands.len(), 1);
assert!(root.subcommands[0].discovered_binary.is_none());
assert!(root.subcommands[0].summary.is_some());
}
#[test]
fn a_parent_that_documents_no_subcommands_gets_no_convention_children() {
let mut root = node("dpkg");
attach_path_siblings(&mut root, &[sibling("deb", "dpkg-deb")]);
assert!(root.subcommands.is_empty());
}
#[test]
fn an_alias_of_a_documented_command_is_not_duplicated_either() {
let mut root = node("cargo");
let mut build = node("build");
build.aliases.push("b".to_string());
root.subcommands.push(build);
attach_path_siblings(&mut root, &[sibling("b", "cargo-b")]);
assert_eq!(root.subcommands.len(), 1);
}
#[test]
fn an_ordinary_node_is_probed_against_the_tool_at_its_own_path() {
let root = node("git");
let tool = resolve_tool("git");
let (target, probe_path) = probe_target(&root, &tool, &path(&["git", "rebase"]));
assert_eq!(target.name, "git");
assert_eq!(probe_path, path(&["git", "rebase"]));
}
#[test]
fn a_discovered_node_is_probed_as_its_own_binarys_root() {
let mut root = node("cargo");
root.subcommands.push(node("build"));
attach_path_siblings(&mut root, &[sibling("clippy", "cargo-clippy")]);
let tool = resolve_tool("cargo");
let (target, probe_path) = probe_target(&root, &tool, &path(&["cargo", "clippy"]));
assert_eq!(target.name, "cargo-clippy");
assert_eq!(
probe_path.len(),
1,
"words must be empty, i.e. a root probe: {probe_path:?}"
);
assert_eq!(probe_path[0], "clippy", "the node keeps its own name");
}
#[cfg(unix)]
static PATH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(unix)]
fn shim(dir: &std::path::Path, name: &str, body: &str) {
use std::os::unix::fs::PermissionsExt;
let file = dir.join(name);
std::fs::write(&file, format!("#!/bin/sh\ncat <<'EOF'\n{body}\nEOF\n")).unwrap();
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o755)).unwrap();
}
#[cfg(unix)]
#[test]
fn a_discovered_node_fills_from_its_own_binary_and_never_through_the_parent() {
let dir = tempfile::TempDir::new_in(".").unwrap();
shim(
dir.path(),
"mytool",
"Usage: mytool [OPTIONS] <COMMAND>\n\nCommands:\n local A command the parent documents\n\nOptions:\n --parent-only Only the parent documents this",
);
shim(
dir.path(),
"mytool-extra",
"Usage: mytool extra [OPTIONS]\n\nOptions:\n --only-in-extra Only this binary documents this",
);
let _guard = PATH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let original = std::env::var_os("PATH");
let mut dirs = vec![dir.path().to_path_buf()];
if let Some(existing) = &original {
dirs.extend(std::env::split_paths(existing));
}
std::env::set_var("PATH", std::env::join_paths(dirs).unwrap());
let tool = resolve_tool("mytool");
let runner = mandible_extract::Runner::new(mandible_extract::default_tiers());
let mut root = runner
.extract_full_for(&tool)
.root
.expect("the shim's own --help should extract");
assert!(
root.subcommands.iter().any(|c| c.name == "local"),
"the parent's own command list must be recovered first: {:?}",
root.subcommands.iter().map(|c| &c.name).collect::<Vec<_>>()
);
attach_path_siblings(
&mut root,
&mandible_extract::discover_path_siblings("mytool"),
);
let extra = root
.subcommands
.iter()
.find(|c| c.name == "extra")
.expect("mytool-extra should have been discovered")
.clone();
let tree_path = path(&["mytool", "extra"]);
let (target, probe_path) = probe_target(&root, &tool, &tree_path);
let filled = runner.fill_node(&target, &probe_path, extra.clone());
let through_parent = runner.fill_node(&tool, &tree_path, extra);
if let Some(original) = original {
std::env::set_var("PATH", original);
}
assert!(
filled
.node
.flags()
.any(|f| f.long() == Some("only-in-extra")),
"the discovered node must be filled from its own binary: {:?}",
filled.node.flags().map(|f| f.long()).collect::<Vec<_>>()
);
assert_eq!(
through_parent.node.flags().count(),
0,
"a word read off a filename must never be probed as the parent's argv"
);
}
#[test]
fn a_child_of_a_discovered_node_is_probed_against_the_same_binary() {
let mut root = node("git");
root.subcommands.push(node("commit"));
attach_path_siblings(&mut root, &[sibling("lfs", "git-lfs")]);
let lfs = root
.subcommands
.iter_mut()
.find(|c| c.name == "lfs")
.expect("attached");
lfs.subcommands.push(node("push"));
let tool = resolve_tool("git");
let (target, probe_path) = probe_target(&root, &tool, &path(&["git", "lfs", "push"]));
assert_eq!(target.name, "git-lfs");
assert_eq!(probe_path, path(&["lfs", "push"]));
}
}