use crate::node::{CommandNode, Example, Flag, Positional, ValueKind};
use crate::provenance::{Provenance, Source};
use serde::Serialize;
pub fn to_snapshot(node: &CommandNode) -> NodeSnapshot {
NodeSnapshot::from(node)
}
fn round_confidence(c: f32) -> f32 {
(c * 100.0).round() / 100.0
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ProvenanceSnapshot {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f32>,
}
impl From<&Provenance> for ProvenanceSnapshot {
fn from(p: &Provenance) -> Self {
ProvenanceSnapshot {
sources: p.sources.iter().map(Source::label).collect(),
confidence: p.confidence.map(round_confidence),
}
}
}
fn is_no_value(v: &ValueKind) -> bool {
matches!(v, ValueKind::None)
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct FlagSnapshot {
#[serde(skip_serializing_if = "Option::is_none")]
pub short: Option<char>,
#[serde(skip_serializing_if = "Option::is_none")]
pub long: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value_name: Option<String>,
#[serde(skip_serializing_if = "is_no_value")]
pub value_kind: ValueKind,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub choices: Vec<String>,
#[serde(skip_serializing_if = "is_false")]
pub repeatable: bool,
#[serde(skip_serializing_if = "is_false")]
pub required: bool,
#[serde(skip_serializing_if = "is_false")]
pub negatable: bool,
#[serde(skip_serializing_if = "is_false")]
pub hidden: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub deprecated: Option<String>,
#[serde(skip_serializing_if = "is_false")]
pub inherited: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env_var: Option<String>,
pub provenance: ProvenanceSnapshot,
}
impl From<&Flag> for FlagSnapshot {
fn from(f: &Flag) -> Self {
FlagSnapshot {
short: f.short,
long: f.long.clone(),
value_name: f.value_name.clone(),
value_kind: f.value_kind,
choices: f.choices.iter().map(|t| t.as_str().to_string()).collect(),
repeatable: f.repeatable,
required: f.required,
negatable: f.negatable,
hidden: f.hidden,
deprecated: f.deprecated.as_ref().map(|t| t.as_str().to_string()),
inherited: f.inherited,
group: f.group.clone(),
description: f.description.as_ref().map(|t| t.as_str().to_string()),
default: f.default.as_ref().map(|t| t.as_str().to_string()),
env_var: f.env_var.clone(),
provenance: ProvenanceSnapshot::from(&f.provenance),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PositionalSnapshot {
pub name: String,
#[serde(skip_serializing_if = "is_false")]
pub required: bool,
#[serde(skip_serializing_if = "is_false")]
pub variadic: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub provenance: ProvenanceSnapshot,
}
impl From<&Positional> for PositionalSnapshot {
fn from(p: &Positional) -> Self {
PositionalSnapshot {
name: p.name.clone(),
required: p.required,
variadic: p.variadic,
description: p.description.as_ref().map(|t| t.as_str().to_string()),
provenance: ProvenanceSnapshot::from(&p.provenance),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ExampleSnapshot {
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub explanation: Option<String>,
}
impl From<&Example> for ExampleSnapshot {
fn from(e: &Example) -> Self {
ExampleSnapshot {
command: e.command.as_str().to_string(),
explanation: e.explanation.as_ref().map(|t| t.as_str().to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct NodeSnapshot {
pub name: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub usage: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub positionals: Vec<PositionalSnapshot>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<FlagSnapshot>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<ExampleSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deprecated: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detected_framework: Option<String>,
pub provenance: ProvenanceSnapshot,
#[serde(skip_serializing_if = "is_false")]
pub hidden: bool,
#[serde(skip_serializing_if = "is_false")]
pub children_filled: bool,
#[serde(skip_serializing_if = "is_false")]
pub heading_attested: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub unparsed: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub subcommands: Vec<NodeSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confession: Option<ConfessionSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ConfessionSnapshot {
pub word: String,
pub flag: String,
pub followed: bool,
}
impl From<&crate::node::Confession> for ConfessionSnapshot {
fn from(c: &crate::node::Confession) -> Self {
ConfessionSnapshot {
word: c.word.clone(),
flag: c.flag.clone(),
followed: c.followed,
}
}
}
impl From<&CommandNode> for NodeSnapshot {
fn from(n: &CommandNode) -> Self {
NodeSnapshot {
name: n.name.clone(),
aliases: n.aliases.clone(),
summary: n.summary.as_ref().map(|t| t.as_str().to_string()),
description: n.description.as_ref().map(|t| t.as_str().to_string()),
usage: n.usage.iter().map(|t| t.as_str().to_string()).collect(),
positionals: n.positionals.iter().map(PositionalSnapshot::from).collect(),
flags: n.flags.iter().map(FlagSnapshot::from).collect(),
examples: n.examples.iter().map(ExampleSnapshot::from).collect(),
group: n.group.clone(),
deprecated: n.deprecated.as_ref().map(|t| t.as_str().to_string()),
detected_framework: n.detected_framework.clone(),
provenance: ProvenanceSnapshot::from(&n.provenance),
hidden: n.hidden,
children_filled: n.children_filled,
heading_attested: n.heading_attested,
unparsed: n.unparsed.iter().map(|t| t.as_str().to_string()).collect(),
subcommands: n.subcommands.iter().map(NodeSnapshot::from).collect(),
confession: n.confession.as_ref().map(ConfessionSnapshot::from),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provenance::Provenance;
use crate::text::Text;
fn node_with_confidence(confidence: f32) -> CommandNode {
let mut n = CommandNode::new(
"tool",
Provenance::with_confidence(Source::HelpText, confidence),
);
n.summary = Some(Text::sanitize("does a thing"));
n.flags.push(Flag::long(
"verbose",
Provenance::with_confidence(Source::HelpText, confidence),
));
n
}
fn render(node: &CommandNode) -> String {
serde_yaml::to_string(&to_snapshot(node)).expect("snapshot serializes")
}
#[test]
fn serializing_the_same_node_twice_is_identical() {
let node = node_with_confidence(0.8734);
assert_eq!(render(&node), render(&node));
}
#[test]
fn confidence_rounding_absorbs_noise_but_not_real_change() {
let base = render(&node_with_confidence(0.821));
let wobble = render(&node_with_confidence(0.8199999));
assert_eq!(
base, wobble,
"a sub-hundredth confidence wobble must not change the snapshot"
);
assert!(base.contains("0.82"), "rounded value must still appear");
let changed = render(&node_with_confidence(0.75));
assert_ne!(
base, changed,
"a genuine confidence change must still move the snapshot"
);
assert!(changed.contains("0.75"));
}
#[test]
fn subcommand_order_is_preserved_not_sorted() {
let mut root = CommandNode::new("git", Provenance::single(Source::HelpText));
for name in ["zebra", "apple", "mango"] {
root.subcommands
.push(CommandNode::new(name, Provenance::single(Source::HelpText)));
}
let out = render(&root);
let zebra = out.find("zebra").expect("zebra present");
let apple = out.find("apple").expect("apple present");
let mango = out.find("mango").expect("mango present");
assert!(
zebra < apple && apple < mango,
"subcommand order must be preserved exactly as built, got: {out}"
);
}
#[test]
fn empty_and_none_fields_are_omitted() {
let node = CommandNode::new("bare", Provenance::single(Source::HelpText));
let out = render(&node);
assert!(!out.contains("aliases"), "empty Vec must be omitted");
assert!(!out.contains("summary"), "None Option must be omitted");
assert!(!out.contains("subcommands"), "empty Vec must be omitted");
assert!(!out.contains("flags"), "empty Vec must be omitted");
}
#[test]
fn a_field_losing_its_value_still_shows_up_as_a_removed_key() {
let mut with_summary = CommandNode::new("t", Provenance::single(Source::HelpText));
with_summary.summary = Some(Text::sanitize("hi"));
let without_summary = CommandNode::new("t", Provenance::single(Source::HelpText));
assert!(render(&with_summary).contains("summary"));
assert!(!render(&without_summary).contains("summary"));
}
#[test]
fn snapshot_of_a_representative_synthetic_tree() {
let mut root =
CommandNode::new("git", Provenance::with_confidence(Source::HelpText, 0.9123));
root.summary = Some(Text::sanitize("the stupid content tracker"));
let mut commit = CommandNode::new("commit", Provenance::single(Source::HelpText));
commit.summary = Some(Text::sanitize("Record changes to the repository"));
commit.flags.push({
let mut f = Flag::long("amend", Provenance::single(Source::HelpText));
f.description = Some(Text::sanitize("amend the previous commit"));
f
});
let mut status = CommandNode::new("status", Provenance::single(Source::HelpText));
status.summary = Some(Text::sanitize("Show the working tree status"));
root.subcommands.push(commit);
root.subcommands.push(status);
insta::assert_yaml_snapshot!(to_snapshot(&root));
}
}