use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PlanDigest(String);
impl PlanDigest {
pub fn new(hex: impl Into<String>) -> Self {
Self(hex.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlannedValue {
Plain(String),
Redacted {
kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
fingerprint: Option<String>,
},
}
impl PlannedValue {
pub fn redacted(kind: impl Into<String>, fingerprint: Option<String>) -> Self {
Self::Redacted {
kind: kind.into(),
fingerprint,
}
}
pub fn display(&self) -> String {
match self {
Self::Plain(s) => s.clone(),
Self::Redacted { kind, .. } => format!("<{kind}>"),
}
}
pub fn is_redacted(&self) -> bool {
matches!(self, Self::Redacted { .. })
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Plan {
pub rendered: String,
pub statement_kind: String,
pub commands: Vec<PlannedCommand>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub free_variables: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bound_variables: Vec<String>,
}
pub const PLAN_RENDER_LIMIT: usize = 8 * 1024;
impl Plan {
pub fn new(
rendered: impl Into<String>,
statement_kind: impl Into<String>,
commands: Vec<PlannedCommand>,
) -> Self {
Self {
rendered: rendered.into(),
statement_kind: statement_kind.into(),
commands,
free_variables: Vec::new(),
bound_variables: Vec::new(),
}
}
pub fn with_variables(mut self, free: Vec<String>, bound: Vec<String>) -> Self {
self.free_variables = free;
self.bound_variables = bound;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlannedCommand {
pub name: String,
pub args: Vec<PlannedValue>,
pub redirects: Vec<PlannedRedirect>,
pub background: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub heredocs: Vec<PlannedHeredoc>,
}
impl PlannedCommand {
pub fn new(
name: impl Into<String>,
args: Vec<PlannedValue>,
redirects: Vec<PlannedRedirect>,
background: bool,
) -> Self {
Self {
name: name.into(),
args,
redirects,
background,
heredocs: Vec::new(),
}
}
pub fn with_heredocs(mut self, heredocs: Vec<PlannedHeredoc>) -> Self {
self.heredocs = heredocs;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlannedHeredoc {
pub index: usize,
pub delimiter: String,
pub literal: bool,
pub strip_tabs: bool,
pub body: PlannedValue,
pub body_offset: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub free_variables: Vec<String>,
}
impl PlannedHeredoc {
pub fn new(
index: usize,
delimiter: impl Into<String>,
literal: bool,
strip_tabs: bool,
body: PlannedValue,
body_offset: usize,
) -> Self {
Self {
index,
delimiter: delimiter.into(),
literal,
strip_tabs,
body,
body_offset,
free_variables: Vec::new(),
}
}
pub fn with_free_variables(mut self, free: Vec<String>) -> Self {
self.free_variables = free;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct FragmentAddr {
pub statement: usize,
pub heredoc: usize,
}
impl FragmentAddr {
pub fn new(statement: usize, heredoc: usize) -> Self {
Self { statement, heredoc }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Expansion {
Complete(String),
Blocked {
holes: Vec<Hole>,
},
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Hole {
pub source: String,
pub plans: Vec<Plan>,
}
impl Hole {
pub fn new(source: impl Into<String>, plans: Vec<Plan>) -> Self {
Self {
source: source.into(),
plans,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlannedRedirect {
pub kind: String,
pub target: PlannedValue,
}
impl PlannedRedirect {
pub fn new(kind: impl Into<String>, target: PlannedValue) -> Self {
Self {
kind: kind.into(),
target,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_plan() -> Plan {
Plan::new(
"cargo build > ${LOG}",
"command",
vec![PlannedCommand::new(
"cargo",
vec![PlannedValue::Plain("build".to_string())],
vec![PlannedRedirect::new(">", PlannedValue::Plain("${LOG}".to_string()))],
false,
)],
)
}
#[test]
fn a_plan_round_trips_with_every_planned_field() {
let plan = sample_plan();
let json = serde_json::to_value(&plan).expect("serialize");
let back: Plan = serde_json::from_value(json).expect("deserialize");
assert_eq!(plan, back);
assert_eq!(back.commands[0].redirects[0].kind, ">");
assert_eq!(
back.commands[0].redirects[0].target,
PlannedValue::Plain("${LOG}".to_string())
);
}
#[test]
fn variables_default_to_empty_and_survive_a_round_trip() {
let bare = sample_plan();
assert!(bare.free_variables.is_empty());
assert!(bare.bound_variables.is_empty());
let plan = sample_plan()
.with_variables(vec!["LOG".to_string()], vec!["OUT".to_string()]);
let json = serde_json::to_value(&plan).expect("serialize");
let back: Plan = serde_json::from_value(json).expect("deserialize");
assert_eq!(back.free_variables, vec!["LOG".to_string()]);
assert_eq!(back.bound_variables, vec!["OUT".to_string()]);
}
#[test]
fn a_redacted_value_keeps_no_text() {
let json = serde_json::to_value(PlannedValue::redacted("confirm-key", None))
.expect("serialize");
assert!(
!json.to_string().contains("secret"),
"a redacted value must not carry text: {json}"
);
}
#[test]
fn a_plan_digest_round_trips() {
let digest = PlanDigest::new("abc123");
let json = serde_json::to_string(&digest).expect("serialize");
let back: PlanDigest = serde_json::from_str(&json).expect("deserialize");
assert_eq!(digest, back);
}
}