use serde::Deserialize;
use serde::de::Error as _;
use super::contract::{
ArgvSlot, CommandLineContract, CommandParameterContract, DeclaredCommandContract,
EnvBindingContract,
};
use super::template::{FillPiece, FillTemplate};
impl<'de> Deserialize<'de> for DeclaredCommandContract {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
let has_program = value.get("program").is_some();
let has_lines = value.get("lines").is_some();
if has_program && has_lines {
return Err(D::Error::custom(
"this declared command carries both `program` (the prior archive form) and \
`lines` (the current form); no emitter writes both, and reading either one \
would silently discard the other, so the entry is refused rather than guessed",
));
}
if has_program {
let prior = PriorForm::deserialize(&value).map_err(D::Error::custom)?;
return Ok(prior.translated());
}
CurrentForm::deserialize(&value)
.map_err(D::Error::custom)
.map(CurrentForm::into_contract)
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CurrentForm {
name: String,
#[serde(default)]
parameters: Vec<CommandParameterContract>,
lines: Vec<CommandLineContract>,
#[serde(default)]
env: Vec<EnvBindingContract>,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
prior_form_refusal: Option<String>,
}
impl CurrentForm {
fn into_contract(self) -> DeclaredCommandContract {
DeclaredCommandContract {
name: self.name,
parameters: self.parameters,
lines: self.lines,
env: self.env,
cwd: self.cwd,
prior_form_refusal: self.prior_form_refusal,
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PriorParameter {
name: String,
list: bool,
#[serde(default)]
default: Option<FillTemplate>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PriorEnv {
name: String,
value: FillTemplate,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PriorForm {
name: String,
#[serde(default)]
parameters: Vec<PriorParameter>,
program: Vec<String>,
#[serde(default)]
args: Vec<ArgvSlot>,
#[serde(default)]
env: Vec<PriorEnv>,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
hardened_path: Option<FillTemplate>,
#[serde(default)]
timeout_ms: Option<i64>,
#[serde(default)]
timeout_owner: Option<String>,
}
impl PriorForm {
fn translated(self) -> DeclaredCommandContract {
let Self {
name,
parameters,
program,
args,
env,
cwd,
hardened_path,
timeout_ms,
timeout_owner,
} = self;
if let Some(timeout_ms) = timeout_ms {
tracing::warn!(
operation = "prior_form_command_read",
command = %name,
timeout_ms,
owner = timeout_owner.as_deref().unwrap_or("unstated"),
"this declared command was deployed with a time limit of its own, and a command \
no longer carries one: the limit is gone and the command now runs for as long \
as it takes. Bound the work with the workflow's activity timeout instead, then \
redeploy the document"
);
}
let mut untranslated = Untranslated {
command: &name,
first: None,
};
let mut slots: Vec<ArgvSlot> = program
.into_iter()
.map(|word| ArgvSlot {
fill: FillTemplate::literal(word.clone()),
label: word,
admits_leading_dash: true,
})
.collect();
slots.extend(args);
let parameters = translate_parameters(parameters, &mut untranslated);
let translated_env = translate_env(env, hardened_path, &mut untranslated);
let refusal = untranslated.first;
DeclaredCommandContract {
name,
parameters,
lines: vec![CommandLineContract { slots }],
env: translated_env,
cwd,
prior_form_refusal: refusal,
}
}
}
fn translate_parameters(
parameters: Vec<PriorParameter>,
untranslated: &mut Untranslated<'_>,
) -> Vec<CommandParameterContract> {
parameters
.into_iter()
.map(|parameter| {
if parameter.list {
untranslated.record(
format!("the list parameter `{}`", parameter.name),
"under the old spelling this parameter took several values at once and each \
became its own word on the command line; a value now becomes exactly one \
word, so there is no command line left to build",
);
}
let default = match parameter.default.as_ref().map(literal_text) {
None => None,
Some(Some(literal)) => Some(literal),
Some(None) => {
untranslated.record(
format!("the default of parameter `{}`", parameter.name),
"under the old spelling this parameter's default was built from the \
values of other parameters; a default is plain text now, so there is no \
value left for it to fall back on",
);
None
}
};
CommandParameterContract {
name: parameter.name,
default,
}
})
.collect()
}
fn translate_env(
env: Vec<PriorEnv>,
hardened_path: Option<FillTemplate>,
untranslated: &mut Untranslated<'_>,
) -> Vec<EnvBindingContract> {
let mut translated: Vec<EnvBindingContract> = Vec::new();
for binding in env {
match literal_text(&binding.value) {
Some(value) => translated.push(EnvBindingContract {
name: binding.name,
value,
}),
None => untranslated.record(
format!(
"an environment binding for `{}` whose value interpolates a parameter",
binding.name
),
"under the old spelling this variable's value was built from the values passed \
to the command at run time; an exported value is plain document text now, with \
no parameters in scope",
),
}
}
if let Some(path) = hardened_path {
match literal_text(&path) {
Some(value) => translated.push(EnvBindingContract {
name: "PATH".to_owned(),
value,
}),
None => untranslated.record(
"an environment binding for `PATH` whose value interpolates a parameter".to_owned(),
"under the old spelling this command replaced the executable search path with a \
value built from the values passed to it at run time; an exported value is \
plain document text now, with no parameters in scope",
),
}
}
translated
}
struct Untranslated<'a> {
command: &'a str,
first: Option<String>,
}
impl Untranslated<'_> {
fn record(&mut self, construct: String, meant: &str) {
tracing::warn!(
operation = "prior_form_command_read",
command = %self.command,
construct = %construct,
"this declared command was deployed before command declarations changed shape, and \
it carries {construct}: {meant}. The archive still opens and lists everywhere, but \
running this command will refuse until you redeploy the document under the current \
spelling"
);
if self.first.is_none() {
self.first = Some(construct);
}
}
}
fn literal_text(template: &FillTemplate) -> Option<String> {
let mut text = String::new();
for piece in &template.pieces {
match piece {
FillPiece::Literal { text: literal } => text.push_str(literal),
FillPiece::Hole { .. } => return None,
}
}
Some(text)
}