use serde::{Deserialize, Serialize};
use crate::models::{
AnnotationOverrides, Confidence, FlagSource, ScannedArg, ScannedCLITool, ScannedFlag,
ToolVariant, ValueType,
};
pub const OVERLAY_SCHEMA_VERSION: &str = "1.0";
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub struct Platform(String);
impl Platform {
pub fn new(raw: &str) -> Self {
Self(raw.trim().to_ascii_lowercase())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<String> for Platform {
fn from(raw: String) -> Self {
Platform::new(&raw)
}
}
impl From<&str> for Platform {
fn from(raw: &str) -> Self {
Platform::new(raw)
}
}
impl From<Platform> for String {
fn from(platform: Platform) -> Self {
platform.0
}
}
impl std::fmt::Display for Platform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OverlayMode {
Authoritative,
#[default]
Merge,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProbeExpectation {
Success,
Failure,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProbeMatcher {
pub args: Vec<String>,
pub expect: ProbeExpectation,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_contains: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProbeOutcome {
pub args: Vec<String>,
pub succeeded: bool,
pub output: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayMatch {
#[serde(default)]
pub platform: Vec<Platform>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub probe: Option<ProbeMatcher>,
#[serde(default)]
pub binary_globs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_range: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum MatchStrength {
Platform,
PlatformAndGlob,
Probe,
Explicit,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayFlag {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub long: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub short: Option<String>,
#[serde(rename = "type", default)]
pub value_type: ValueType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_name: Option<String>,
#[serde(default)]
pub description: String,
#[serde(default)]
pub conflicts_with: Vec<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub repeatable: bool,
#[serde(default)]
pub long_running: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enum_values: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayArg {
pub name: String,
#[serde(rename = "type", default)]
pub value_type: ValueType,
#[serde(default)]
pub description: String,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub variadic: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EvidenceSource {
ManPage,
Help,
VendorDocs,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OverlayProvenance {
pub platform: Platform,
pub tool_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub package: Option<String>,
pub source: EvidenceSource,
pub checked_on: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub notes: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolOverlay {
pub schema_version: String,
pub command: String,
pub variant: ToolVariant,
#[serde(rename = "match", default)]
pub match_rules: OverlayMatch,
#[serde(default)]
pub mode: OverlayMode,
#[serde(default)]
pub confidence: Confidence,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<OverlayProvenance>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub notes: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub flags: Vec<OverlayFlag>,
#[serde(default)]
pub positional_args: Vec<OverlayArg>,
#[serde(default)]
pub annotations: AnnotationOverrides,
}
impl Default for ToolOverlay {
fn default() -> Self {
Self {
schema_version: OVERLAY_SCHEMA_VERSION.to_string(),
command: String::new(),
variant: ToolVariant::default(),
match_rules: OverlayMatch::default(),
mode: OverlayMode::default(),
confidence: Confidence::default(),
provenance: None,
notes: String::new(),
description: String::new(),
flags: Vec::new(),
positional_args: Vec::new(),
annotations: AnnotationOverrides::default(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct MatchContext {
pub command: String,
pub variant: ToolVariant,
pub platform: Option<Platform>,
pub binary_path: String,
pub version: Option<String>,
pub probes: Vec<ProbeOutcome>,
}
impl ToolOverlay {
pub fn id(&self) -> String {
format!("{}@{}", self.command, self.variant.as_str())
}
pub fn is_supported_version(&self) -> bool {
self.schema_version == OVERLAY_SCHEMA_VERSION
}
pub fn evaluate(&self, context: &MatchContext) -> Option<MatchStrength> {
if self.command != context.command || self.variant != context.variant {
return None;
}
if !self.version_matches(context.version.as_deref()) {
return None;
}
if !self.platform_matches(context.platform.as_ref()) {
return None;
}
if let Some(ref probe) = self.match_rules.probe {
return probe_satisfied(probe, &context.probes).then_some(MatchStrength::Probe);
}
if !self.match_rules.binary_globs.is_empty() {
let matched = self
.match_rules
.binary_globs
.iter()
.any(|pattern| glob_matches(pattern, &context.binary_path));
return matched.then_some(MatchStrength::PlatformAndGlob);
}
Some(MatchStrength::Platform)
}
fn platform_matches(&self, platform: Option<&Platform>) -> bool {
if self.match_rules.platform.is_empty() {
return true;
}
platform.is_some_and(|current| self.match_rules.platform.contains(current))
}
fn version_matches(&self, version: Option<&str>) -> bool {
match self.match_rules.version_range.as_deref() {
None => true,
Some(range) => version_in_range(version, range),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum OverlayDefect {
#[error("schema_version is '{found}', but this build supports '{expected}'")]
UnsupportedVersion { found: String, expected: String },
#[error("command must not be empty")]
EmptyCommand,
#[error("command '{0}' looks like a path; overlays are keyed by bare command name")]
CommandIsPath(String),
#[error(
"confidence 'verified' requires a 'provenance' block recording platform, \
tool_version, source and checked_on"
)]
VerifiedWithoutProvenance,
#[error("provenance.tool_version must not be empty")]
EmptyToolVersion,
#[error("provenance.checked_on '{0}' is not an ISO-8601 calendar date (YYYY-MM-DD)")]
MalformedCheckedOn(String),
#[error("provenance.source is 'vendor-docs', which requires a 'reference' citation")]
VendorDocsWithoutReference,
#[error("flag #{index} declares neither 'long' nor 'short'")]
FlagWithoutName { index: usize },
#[error("flag long form '{0}' must start with '--'")]
MalformedLong(String),
#[error("flag short form '{0}' must be a single dash followed by at least one character; a '--long' form belongs in the long field")]
MalformedShort(String),
#[error("flag '{0}' is declared more than once")]
DuplicateFlag(String),
#[error("flag '{0}' has type 'enum' but declares no enum_values")]
EnumWithoutValues(String),
#[error("flag '{flag}' conflicts_with '{other}', which this overlay never declares")]
UnknownConflict { flag: String, other: String },
#[error("positional arg #{0} has an empty name")]
UnnamedArg(usize),
}
pub fn validate_overlay(overlay: &ToolOverlay) -> Vec<OverlayDefect> {
let mut defects = Vec::new();
if !overlay.is_supported_version() {
defects.push(OverlayDefect::UnsupportedVersion {
found: overlay.schema_version.clone(),
expected: OVERLAY_SCHEMA_VERSION.to_string(),
});
}
if overlay.command.trim().is_empty() {
defects.push(OverlayDefect::EmptyCommand);
} else if overlay.command.contains('/') || overlay.command.contains('\\') {
defects.push(OverlayDefect::CommandIsPath(overlay.command.clone()));
}
validate_provenance(overlay, &mut defects);
validate_flags(&overlay.flags, &mut defects);
for (index, arg) in overlay.positional_args.iter().enumerate() {
if arg.name.trim().is_empty() {
defects.push(OverlayDefect::UnnamedArg(index));
}
}
defects
}
fn validate_provenance(overlay: &ToolOverlay, defects: &mut Vec<OverlayDefect>) {
let Some(ref provenance) = overlay.provenance else {
if overlay.confidence == Confidence::Verified {
defects.push(OverlayDefect::VerifiedWithoutProvenance);
}
return;
};
if provenance.tool_version.trim().is_empty() {
defects.push(OverlayDefect::EmptyToolVersion);
}
if !is_iso_date(&provenance.checked_on) {
defects.push(OverlayDefect::MalformedCheckedOn(
provenance.checked_on.clone(),
));
}
let uncited = provenance
.reference
.as_deref()
.is_none_or(|text| text.trim().is_empty());
if provenance.source == EvidenceSource::VendorDocs && uncited {
defects.push(OverlayDefect::VendorDocsWithoutReference);
}
}
fn validate_flags(flags: &[OverlayFlag], defects: &mut Vec<OverlayDefect>) {
let mut seen: Vec<String> = Vec::new();
for (index, flag) in flags.iter().enumerate() {
if flag.long.is_none() && flag.short.is_none() {
defects.push(OverlayDefect::FlagWithoutName { index });
}
if let Some(ref long) = flag.long {
if !long.starts_with("--") || long.chars().count() < 3 {
defects.push(OverlayDefect::MalformedLong(long.clone()));
}
}
if let Some(ref short) = flag.short {
if !short.starts_with('-') || short.starts_with("--") || short.chars().count() < 2 {
defects.push(OverlayDefect::MalformedShort(short.clone()));
}
}
for name in flag.names() {
if seen.contains(&name) {
defects.push(OverlayDefect::DuplicateFlag(name.clone()));
}
seen.push(name);
}
if flag.value_type == ValueType::Enum && flag.enum_values.as_ref().is_none_or(Vec::is_empty)
{
defects.push(OverlayDefect::EnumWithoutValues(flag.display_name()));
}
}
for flag in flags {
for other in &flag.conflicts_with {
if !seen.iter().any(|name| name == other) {
defects.push(OverlayDefect::UnknownConflict {
flag: flag.display_name(),
other: other.clone(),
});
}
}
}
}
fn is_iso_date(text: &str) -> bool {
let parts: Vec<&str> = text.split('-').collect();
let [year, month, day] = parts[..] else {
return false;
};
let numeric = |part: &str, width: usize| -> Option<u32> {
(part.len() == width && part.chars().all(|c| c.is_ascii_digit()))
.then(|| part.parse().ok())
.flatten()
};
let (Some(year), Some(month), Some(day)) =
(numeric(year, 4), numeric(month, 2), numeric(day, 2))
else {
return false;
};
year >= 1970 && (1..=12).contains(&month) && (1..=31).contains(&day)
}
fn probe_satisfied(matcher: &ProbeMatcher, outcomes: &[ProbeOutcome]) -> bool {
let Some(outcome) = outcomes.iter().find(|o| o.args == matcher.args) else {
return false;
};
let expectation_met = match matcher.expect {
ProbeExpectation::Success => outcome.succeeded,
ProbeExpectation::Failure => !outcome.succeeded,
};
if !expectation_met {
return false;
}
match matcher.output_contains {
None => true,
Some(ref needle) => outcome.output.contains(needle.as_str()),
}
}
fn glob_matches(pattern: &str, path: &str) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let path: Vec<char> = path.chars().collect();
let (mut p, mut t) = (0_usize, 0_usize);
let (mut star, mut backtrack) = (None, 0_usize);
while t < path.len() {
if p < pattern.len() && (pattern[p] == '?' || pattern[p] == path[t]) {
p += 1;
t += 1;
} else if p < pattern.len() && pattern[p] == '*' {
star = Some(p);
backtrack = t;
p += 1;
} else if let Some(star_index) = star {
p = star_index + 1;
backtrack += 1;
t = backtrack;
} else {
return false;
}
}
pattern[p..].iter().all(|c| *c == '*')
}
fn version_in_range(version: Option<&str>, range: &str) -> bool {
let range = range.trim();
if range.is_empty() || range == "*" {
return true;
}
let Some(version) = version else {
return false;
};
range.split(',').all(|constraint| {
let constraint = constraint.trim();
let (op, bound) = split_constraint(constraint);
match compare_versions(version, bound) {
std::cmp::Ordering::Less => matches!(op, "<" | "<="),
std::cmp::Ordering::Equal => matches!(op, "=" | "==" | ">=" | "<="),
std::cmp::Ordering::Greater => matches!(op, ">" | ">="),
}
})
}
fn split_constraint(constraint: &str) -> (&str, &str) {
for op in [">=", "<=", "==", ">", "<", "="] {
if let Some(bound) = constraint.strip_prefix(op) {
return (op, bound.trim());
}
}
("=", constraint)
}
fn compare_versions(left: &str, right: &str) -> std::cmp::Ordering {
let parse = |text: &str| -> Vec<u64> {
text.split('.')
.map(|part| {
let digits: String = part.chars().take_while(char::is_ascii_digit).collect();
digits.parse::<u64>().unwrap_or(0)
})
.collect()
};
let (left, right) = (parse(left), parse(right));
let width = left.len().max(right.len());
for index in 0..width {
let l = left.get(index).copied().unwrap_or(0);
let r = right.get(index).copied().unwrap_or(0);
if l != r {
return l.cmp(&r);
}
}
std::cmp::Ordering::Equal
}
impl OverlayFlag {
pub fn names(&self) -> Vec<String> {
self.long.iter().chain(self.short.iter()).cloned().collect()
}
pub fn display_name(&self) -> String {
self.names().first().cloned().unwrap_or_default()
}
fn to_scanned(&self, confidence: Confidence) -> ScannedFlag {
ScannedFlag {
long_name: self.long.clone(),
short_name: self.short.clone(),
description: self.description.clone(),
value_type: self.value_type,
required: self.required,
default: self.default.clone(),
enum_values: self.enum_values.clone(),
repeatable: self.repeatable,
value_name: self.value_name.clone(),
long_running: self.long_running,
conflicts_with: self.conflicts_with.clone(),
sources: vec![FlagSource::Overlay],
confidence,
}
}
}
impl OverlayArg {
fn to_scanned(&self) -> ScannedArg {
ScannedArg {
name: self.name.clone(),
description: self.description.clone(),
value_type: self.value_type,
required: self.required,
variadic: self.variadic,
}
}
}
fn same_flag(left: &ScannedFlag, right: &ScannedFlag) -> bool {
let long_matches = left.long_name.is_some() && left.long_name == right.long_name;
let short_matches = left.short_name.is_some() && left.short_name == right.short_name;
long_matches || short_matches
}
pub fn apply_overlay(tool: &mut ScannedCLITool, overlay: &ToolOverlay) {
let confidence = overlay.confidence;
let curated: Vec<ScannedFlag> = overlay
.flags
.iter()
.map(|flag| flag.to_scanned(confidence))
.collect();
match overlay.mode {
OverlayMode::Authoritative => {
tool.global_flags = curated;
tool.positional_args = overlay
.positional_args
.iter()
.map(OverlayArg::to_scanned)
.collect();
tool.subcommands.clear();
}
OverlayMode::Merge => {
merge_flags(&mut tool.global_flags, curated);
merge_positional_args(&mut tool.positional_args, &overlay.positional_args);
}
}
if !overlay.description.is_empty() {
tool.description.clone_from(&overlay.description);
}
if !overlay.annotations.is_empty() {
tool.annotation_overrides = overlay.annotations.clone();
}
tool.overlay = Some(overlay.id());
}
fn merge_flags(target: &mut Vec<ScannedFlag>, curated: Vec<ScannedFlag>) {
for flag in curated {
match target
.iter_mut()
.find(|existing| same_flag(existing, &flag))
{
Some(existing) => *existing = merged_flag(existing, flag),
None => target.push(flag),
}
}
}
fn merged_flag(existing: &ScannedFlag, curated: ScannedFlag) -> ScannedFlag {
let mut merged = curated;
merged.long_name = merged.long_name.or_else(|| existing.long_name.clone());
merged.short_name = merged.short_name.or_else(|| existing.short_name.clone());
if merged.description.is_empty() {
merged.description.clone_from(&existing.description);
}
let mut sources = existing.sources.clone();
if !sources.contains(&FlagSource::Overlay) {
sources.push(FlagSource::Overlay);
}
merged.confidence = merged.confidence.max(Confidence::from_sources(&sources));
merged.sources = sources;
merged
}
fn merge_positional_args(target: &mut Vec<ScannedArg>, curated: &[OverlayArg]) {
for arg in curated {
let replacement = arg.to_scanned();
match target.iter_mut().find(|existing| existing.name == arg.name) {
Some(existing) => *existing = replacement,
None => target.push(replacement),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn overlay(mode: OverlayMode) -> ToolOverlay {
ToolOverlay {
schema_version: OVERLAY_SCHEMA_VERSION.to_string(),
command: "ls".to_string(),
variant: ToolVariant::Bsd,
match_rules: OverlayMatch::default(),
mode,
confidence: Confidence::Verified,
description: "List directory contents.".to_string(),
flags: vec![OverlayFlag {
short: Some("-l".to_string()),
value_type: ValueType::Boolean,
description: "List in long format.".to_string(),
conflicts_with: vec!["-1".to_string()],
..Default::default()
}],
positional_args: vec![OverlayArg {
name: "file".to_string(),
value_type: ValueType::Path,
variadic: true,
..Default::default()
}],
annotations: AnnotationOverrides {
readonly: Some(true),
..Default::default()
},
provenance: Some(OverlayProvenance {
platform: Platform::new("macos"),
tool_version: "unknown".to_string(),
package: None,
source: EvidenceSource::ManPage,
checked_on: "2026-07-27".to_string(),
command: Some("man ls".to_string()),
environment: None,
reference: None,
notes: String::new(),
}),
notes: String::new(),
}
}
fn scanned_flag(short: Option<&str>, long: Option<&str>, description: &str) -> ScannedFlag {
let mut flag = ScannedFlag {
short_name: short.map(str::to_string),
long_name: long.map(str::to_string),
description: description.to_string(),
..Default::default()
};
flag.add_source(FlagSource::Help);
flag
}
fn context(variant: ToolVariant, platform: Platform, path: &str) -> MatchContext {
MatchContext {
command: "ls".to_string(),
variant,
platform: Some(platform),
binary_path: path.to_string(),
..Default::default()
}
}
#[test]
fn test_overlay_id_combines_command_and_variant() {
assert_eq!(overlay(OverlayMode::Merge).id(), "ls@bsd");
}
#[test]
fn test_evaluate_rejects_other_command() {
let overlay = overlay(OverlayMode::Merge);
let mut ctx = context(ToolVariant::Bsd, Platform::new("macos"), "/bin/ls");
ctx.command = "cat".to_string();
assert_eq!(overlay.evaluate(&ctx), None);
}
#[test]
fn test_evaluate_rejects_other_variant() {
let overlay = overlay(OverlayMode::Merge);
let ctx = context(ToolVariant::Gnu, Platform::new("macos"), "/bin/ls");
assert_eq!(overlay.evaluate(&ctx), None);
}
#[test]
fn test_evaluate_platform_only_is_weakest_match() {
let mut overlay = overlay(OverlayMode::Merge);
overlay.match_rules.platform = vec![Platform::new("macos")];
let ctx = context(ToolVariant::Bsd, Platform::new("macos"), "/bin/ls");
assert_eq!(overlay.evaluate(&ctx), Some(MatchStrength::Platform));
}
#[test]
fn test_evaluate_rejects_wrong_platform() {
let mut overlay = overlay(OverlayMode::Merge);
overlay.match_rules.platform = vec![Platform::new("macos"), Platform::new("freebsd")];
let ctx = context(ToolVariant::Bsd, Platform::new("linux"), "/bin/ls");
assert_eq!(overlay.evaluate(&ctx), None);
}
#[test]
fn test_evaluate_glob_outranks_platform_alone() {
let mut overlay = overlay(OverlayMode::Merge);
overlay.match_rules.platform = vec![Platform::new("macos")];
overlay.match_rules.binary_globs = vec!["/bin/*".to_string()];
let ctx = context(ToolVariant::Bsd, Platform::new("macos"), "/bin/ls");
assert_eq!(overlay.evaluate(&ctx), Some(MatchStrength::PlatformAndGlob));
assert!(MatchStrength::PlatformAndGlob > MatchStrength::Platform);
}
#[test]
fn test_evaluate_rejects_unmatched_glob() {
let mut overlay = overlay(OverlayMode::Merge);
overlay.match_rules.binary_globs = vec!["/bin/ls".to_string()];
let ctx = context(
ToolVariant::Bsd,
Platform::new("macos"),
"/opt/homebrew/bin/ls",
);
assert_eq!(overlay.evaluate(&ctx), None);
}
#[test]
fn test_evaluate_probe_outranks_glob() {
let mut overlay = overlay(OverlayMode::Merge);
overlay.match_rules.binary_globs = vec!["/bin/ls".to_string()];
overlay.match_rules.probe = Some(ProbeMatcher {
args: vec!["--version".to_string()],
expect: ProbeExpectation::Failure,
output_contains: None,
});
let mut ctx = context(ToolVariant::Bsd, Platform::new("macos"), "/bin/ls");
ctx.probes = vec![ProbeOutcome {
args: vec!["--version".to_string()],
succeeded: false,
output: "ls: unrecognized option `--version'".to_string(),
}];
assert_eq!(overlay.evaluate(&ctx), Some(MatchStrength::Probe));
assert!(MatchStrength::Probe > MatchStrength::PlatformAndGlob);
}
#[test]
fn test_evaluate_declared_probe_that_fails_rejects_outright() {
let mut overlay = overlay(OverlayMode::Merge);
overlay.match_rules.binary_globs = vec!["/bin/ls".to_string()];
overlay.match_rules.probe = Some(ProbeMatcher {
args: vec!["--version".to_string()],
expect: ProbeExpectation::Failure,
output_contains: None,
});
let mut ctx = context(ToolVariant::Bsd, Platform::new("macos"), "/bin/ls");
ctx.probes = vec![ProbeOutcome {
args: vec!["--version".to_string()],
succeeded: true,
output: "ls (GNU coreutils) 9.4".to_string(),
}];
assert_eq!(overlay.evaluate(&ctx), None);
}
#[test]
fn test_evaluate_probe_requires_output_substring() {
let mut overlay = overlay(OverlayMode::Merge);
overlay.variant = ToolVariant::Gnu;
overlay.match_rules.probe = Some(ProbeMatcher {
args: vec!["--version".to_string()],
expect: ProbeExpectation::Success,
output_contains: Some("GNU coreutils".to_string()),
});
let mut ctx = context(ToolVariant::Gnu, Platform::new("linux"), "/usr/bin/ls");
ctx.probes = vec![ProbeOutcome {
args: vec!["--version".to_string()],
succeeded: true,
output: "ls 1.2.3".to_string(),
}];
assert_eq!(overlay.evaluate(&ctx), None);
ctx.probes[0].output = "ls (GNU coreutils) 9.4".to_string();
assert_eq!(overlay.evaluate(&ctx), Some(MatchStrength::Probe));
}
#[test]
fn test_evaluate_probe_absent_outcome_rejects() {
let mut overlay = overlay(OverlayMode::Merge);
overlay.match_rules.probe = Some(ProbeMatcher {
args: vec!["--version".to_string()],
expect: ProbeExpectation::Failure,
output_contains: None,
});
let ctx = context(ToolVariant::Bsd, Platform::new("macos"), "/bin/ls");
assert_eq!(overlay.evaluate(&ctx), None);
}
#[test]
fn test_evaluate_respects_version_range() {
let mut overlay = overlay(OverlayMode::Merge);
overlay.match_rules.version_range = Some(">=9.0".to_string());
let mut ctx = context(ToolVariant::Bsd, Platform::new("macos"), "/bin/ls");
ctx.version = Some("8.32".to_string());
assert_eq!(overlay.evaluate(&ctx), None);
ctx.version = Some("9.4".to_string());
assert_eq!(overlay.evaluate(&ctx), Some(MatchStrength::Platform));
}
#[test]
fn test_glob_matches_literal_and_wildcards() {
assert!(glob_matches("/bin/ls", "/bin/ls"));
assert!(glob_matches("/bin/*", "/bin/ls"));
assert!(glob_matches("*/bin/ls", "/opt/homebrew/bin/ls"));
assert!(glob_matches("/bin/l?", "/bin/ls"));
assert!(!glob_matches("/bin/ls", "/usr/bin/ls"));
assert!(!glob_matches("/bin/l?", "/bin/lsx"));
}
#[test]
fn test_version_in_range_handles_operators() {
assert!(version_in_range(Some("9.4"), "*"));
assert!(version_in_range(Some("9.4"), ">=9.0"));
assert!(version_in_range(Some("9.0"), ">=9.0"));
assert!(!version_in_range(Some("8.32"), ">=9.0"));
assert!(version_in_range(Some("9.4"), ">=8.0,<10"));
assert!(!version_in_range(Some("10.1"), ">=8.0,<10"));
assert!(version_in_range(Some("9.4"), "9.4"));
}
#[test]
fn test_version_in_range_missing_version_only_matches_wildcard() {
assert!(version_in_range(None, "*"));
assert!(!version_in_range(None, ">=9.0"));
}
#[test]
fn test_apply_overlay_authoritative_replaces_scan_surface() {
let mut tool = ScannedCLITool {
name: "ls".to_string(),
description: "ls: unrecognized option".to_string(),
global_flags: vec![scanned_flag(Some("-Z"), None, "bogus")],
subcommands: vec![],
..Default::default()
};
apply_overlay(&mut tool, &overlay(OverlayMode::Authoritative));
assert_eq!(tool.global_flags.len(), 1);
assert_eq!(tool.global_flags[0].short_name.as_deref(), Some("-l"));
assert_eq!(tool.global_flags[0].confidence, Confidence::Verified);
assert_eq!(tool.global_flags[0].sources, vec![FlagSource::Overlay]);
assert_eq!(tool.global_flags[0].conflicts_with, vec!["-1".to_string()]);
assert_eq!(tool.positional_args.len(), 1);
assert_eq!(tool.description, "List directory contents.");
assert_eq!(tool.overlay.as_deref(), Some("ls@bsd"));
assert_eq!(tool.annotation_overrides.readonly, Some(true));
}
#[test]
fn test_apply_overlay_merge_keeps_scanned_flags() {
let mut tool = ScannedCLITool {
name: "ls".to_string(),
global_flags: vec![scanned_flag(Some("-a"), Some("--all"), "Include hidden")],
..Default::default()
};
apply_overlay(&mut tool, &overlay(OverlayMode::Merge));
assert_eq!(
tool.global_flags.len(),
2,
"scanned flag must survive merge"
);
assert_eq!(tool.global_flags[0].short_name.as_deref(), Some("-a"));
assert_eq!(tool.global_flags[1].short_name.as_deref(), Some("-l"));
}
#[test]
fn test_apply_overlay_merge_overrides_matching_flag() {
let mut tool = ScannedCLITool {
name: "ls".to_string(),
global_flags: vec![scanned_flag(Some("-l"), Some("--long"), "long")],
..Default::default()
};
apply_overlay(&mut tool, &overlay(OverlayMode::Merge));
assert_eq!(tool.global_flags.len(), 1);
let flag = &tool.global_flags[0];
assert_eq!(flag.description, "List in long format.");
assert_eq!(
flag.long_name.as_deref(),
Some("--long"),
"an alias the overlay did not restate must be kept"
);
assert_eq!(flag.confidence, Confidence::Verified);
assert_eq!(flag.sources, vec![FlagSource::Help, FlagSource::Overlay]);
}
#[test]
fn test_apply_overlay_authoritative_clears_scanned_subcommands() {
let mut tool = ScannedCLITool {
name: "ls".to_string(),
subcommands: vec![crate::models::ScannedCommand {
name: "bogus".to_string(),
full_command: "ls bogus".to_string(),
description: String::new(),
flags: vec![],
positional_args: vec![],
subcommands: vec![],
examples: vec![],
help_format: crate::models::HelpFormat::Unknown,
structured_output: crate::models::StructuredOutputInfo::default(),
raw_help: String::new(),
}],
..Default::default()
};
apply_overlay(&mut tool, &overlay(OverlayMode::Authoritative));
assert!(tool.subcommands.is_empty());
}
#[test]
fn test_apply_overlay_empty_description_keeps_scanned_one() {
let mut overlay = overlay(OverlayMode::Merge);
overlay.description = String::new();
let mut tool = ScannedCLITool {
name: "ls".to_string(),
description: "Scanned description".to_string(),
..Default::default()
};
apply_overlay(&mut tool, &overlay);
assert_eq!(tool.description, "Scanned description");
}
#[test]
fn test_overlay_round_trips_through_json() {
let original = overlay(OverlayMode::Authoritative);
let encoded = serde_json::to_string(&original).unwrap();
let decoded: ToolOverlay = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded.id(), "ls@bsd");
assert_eq!(decoded.mode, OverlayMode::Authoritative);
assert!(decoded.is_supported_version());
}
#[test]
fn test_overlay_flag_long_running_reaches_the_scanned_flag() {
let mut overlay = overlay(OverlayMode::Authoritative);
overlay.command = "tail".to_string();
overlay.flags = vec![
OverlayFlag {
short: Some("-f".to_string()),
long_running: true,
description: "Follow the file.".to_string(),
..Default::default()
},
OverlayFlag {
short: Some("-n".to_string()),
description: "Number of lines.".to_string(),
..Default::default()
},
];
let mut tool = ScannedCLITool {
name: "tail".to_string(),
..Default::default()
};
apply_overlay(&mut tool, &overlay);
assert!(tool.global_flags[0].long_running);
assert!(
!tool.global_flags[1].long_running,
"an ordinary flag must not inherit the claim"
);
}
#[test]
fn test_overlay_flag_long_running_defaults_to_false_when_absent() {
let document = r#"{ "short": "-n", "type": "integer" }"#;
let flag: OverlayFlag = serde_json::from_str(document).unwrap();
assert!(!flag.long_running);
}
#[test]
fn test_overlay_provenance_package_round_trips() {
let document = r#"{
"platform": "linux",
"tool_version": "9.7",
"package": "coreutils",
"source": "help",
"checked_on": "2026-07-27"
}"#;
let provenance: OverlayProvenance = serde_json::from_str(document).unwrap();
assert_eq!(provenance.package.as_deref(), Some("coreutils"));
let encoded = serde_json::to_string(&provenance).unwrap();
assert!(encoded.contains("\"package\":\"coreutils\""));
}
const SCHEMA: &str = include_str!("../../schemas/tool-overlay.schema.json");
fn schema_enum(pointer: &str) -> Vec<String> {
let schema: serde_json::Value = serde_json::from_str(SCHEMA).unwrap();
schema
.pointer(pointer)
.unwrap_or_else(|| panic!("missing {pointer}"))
.as_array()
.unwrap()
.iter()
.map(|value| value.as_str().unwrap().to_string())
.collect()
}
#[test]
fn test_schema_file_is_valid_json_and_pins_the_supported_version() {
let schema: serde_json::Value = serde_json::from_str(SCHEMA).unwrap();
assert_eq!(
schema["properties"]["schema_version"]["const"],
OVERLAY_SCHEMA_VERSION
);
}
#[test]
fn test_schema_variant_enum_matches_rust_variants() {
let mut declared = schema_enum("/$defs/variant/enum");
declared.sort();
let mut actual: Vec<String> = [
ToolVariant::Bsd,
ToolVariant::Gnu,
ToolVariant::Apple,
ToolVariant::Busybox,
ToolVariant::Unknown,
]
.iter()
.map(|v| v.as_str().to_string())
.collect();
actual.sort();
assert_eq!(declared, actual);
}
#[test]
fn test_schema_platform_is_an_open_string_with_examples() {
let schema: serde_json::Value = serde_json::from_str(SCHEMA).unwrap();
let platform = schema.pointer("/$defs/platform").expect("missing platform");
assert_eq!(platform["type"], "string");
assert!(
platform.get("enum").is_none(),
"platform must stay an open string; `enum` closes it again"
);
let examples = schema_enum("/$defs/platform/examples");
for expected in ["macos", "linux", "freebsd", "openbsd", "netbsd"] {
assert!(
examples.iter().any(|value| value == expected),
"schema examples should mention {expected}"
);
}
}
#[test]
fn test_schema_platform_examples_are_all_normalized() {
for example in schema_enum("/$defs/platform/examples") {
assert_eq!(Platform::new(&example).as_str(), example);
}
}
#[test]
fn test_schema_value_type_enum_matches_rust_value_types() {
let mut declared = schema_enum("/$defs/valueType/enum");
declared.sort();
let mut actual: Vec<String> = [
ValueType::String,
ValueType::Integer,
ValueType::Float,
ValueType::Boolean,
ValueType::Path,
ValueType::Enum,
ValueType::Url,
ValueType::Unknown,
]
.iter()
.map(|t| {
serde_json::to_value(t)
.unwrap()
.as_str()
.unwrap()
.to_string()
})
.collect();
actual.sort();
assert_eq!(declared, actual);
}
#[test]
fn test_schema_confidence_enum_matches_rust_confidence() {
let mut declared = schema_enum("/properties/confidence/enum");
declared.sort();
let mut actual: Vec<String> = [
Confidence::Low,
Confidence::Medium,
Confidence::High,
Confidence::Verified,
]
.iter()
.map(|c| {
serde_json::to_value(c)
.unwrap()
.as_str()
.unwrap()
.to_string()
})
.collect();
actual.sort();
assert_eq!(declared, actual);
}
#[test]
fn test_overlay_rejects_unknown_schema_version() {
let mut overlay = overlay(OverlayMode::Merge);
overlay.schema_version = "99.0".to_string();
assert!(!overlay.is_supported_version());
}
#[test]
fn test_validate_overlay_accepts_multi_character_short_form() {
let mut overlay = overlay(OverlayMode::Authoritative);
overlay.flags = vec![OverlayFlag {
short: Some("-maxdepth".to_string()),
value_type: ValueType::Integer,
description: "Descend at most n directory levels.".to_string(),
..Default::default()
}];
assert!(validate_overlay(&overlay).is_empty());
}
#[test]
fn test_validate_overlay_rejects_bare_dash_and_undashed_short_form() {
for bad in ["-", "maxdepth", "--verbose"] {
let mut overlay = overlay(OverlayMode::Authoritative);
overlay.flags = vec![OverlayFlag {
short: Some(bad.to_string()),
value_type: ValueType::Boolean,
..Default::default()
}];
let defects = validate_overlay(&overlay);
assert!(
defects.iter().any(
|defect| matches!(defect, OverlayDefect::MalformedShort(name) if name == bad)
),
"'{bad}' must be reported as a malformed short form, got {defects:?}"
);
}
}
}