use std::collections::BTreeMap;
use std::path::PathBuf;
use aion_package::{ArgumentValue, DeclaredCommandContract, RenderedCommand};
use tokio::process::Command;
use super::action::{ShellOutcome, trim_trailing_newline};
use super::exit::Ending;
use super::failure::{BodySite, ending_permits_retry, spawn_failure, unreadable_ending_clause};
use super::world::place_in_declared_world;
use crate::activity::ActivityFailure;
use crate::command_transcript::CommandTranscript;
use crate::context::ActivityContext;
use crate::process::{CancellableCommandOutput, run_cancellable_command};
#[derive(Debug, Clone)]
pub struct DeclaredCommandAction {
contract: DeclaredCommandContract,
working_directory: Option<PathBuf>,
}
impl DeclaredCommandAction {
#[must_use]
pub const fn new(contract: DeclaredCommandContract) -> Self {
Self {
contract,
working_directory: None,
}
}
#[must_use]
pub fn declared_working_directory(&self) -> Option<&str> {
self.contract.cwd.as_deref()
}
#[must_use]
pub fn with_working_directory(mut self, directory: impl Into<PathBuf>) -> Self {
self.working_directory = Some(directory.into());
self
}
#[must_use]
pub fn name(&self) -> &str {
&self.contract.name
}
pub fn render(
&self,
arguments: &BTreeMap<String, serde_json::Value>,
) -> Result<RenderedCommand, ActivityFailure> {
let mut supplied = BTreeMap::new();
for name in self.contract.parameter_names() {
if let Some(value) = arguments.get(name) {
supplied.insert(
name.to_owned(),
ArgumentValue::from_json(name, value)
.map_err(|error| ActivityFailure::terminal(error.to_string()))?,
);
}
}
self.contract
.render(&supplied)
.map_err(|error| ActivityFailure::terminal(error.to_string()))
}
pub async fn run(
&self,
arguments: &BTreeMap<String, serde_json::Value>,
context: &ActivityContext,
) -> Result<ShellOutcome, ActivityFailure> {
let rendered = self.render(arguments)?;
if rendered.argv_lines.is_empty() {
return Err(ActivityFailure::terminal(format!(
"command `{}` states no line to run, so nothing was executed; a command that ran \
nothing cannot be reported as having succeeded. Write the lines this command is \
meant to run underneath its header, then deploy the document again",
self.contract.name
)));
}
let total = rendered.argv_lines.len();
let mut stdout = String::new();
let mut stderr = String::new();
for (position, argv) in rendered.argv_lines.iter().enumerate() {
if context.is_cancelled() {
return Err(self.cancelled_between_lines(position, total, argv, &stdout, &stderr));
}
let site = BodySite {
command: &self.contract.name,
line: position + 1,
total,
};
let (program, rest) = argv.split_first().ok_or_else(|| {
ActivityFailure::terminal(format!(
"command `{name}`'s line {line} of {total} names no program to run, so \
nothing on that line could be executed; the deployed document is defective \
and no attempt at it can succeed",
name = self.contract.name,
line = site.line,
))
})?;
let mut command = Command::new(program);
command.args(rest);
place_in_declared_world(
&mut command,
rendered
.env
.iter()
.map(|(name, value)| (name.as_str(), value.as_str())),
);
if let Some(directory) = &self.working_directory {
command.current_dir(directory);
}
let transcript = CommandTranscript::for_body_line(context, site.line, total);
match run_cancellable_command(command, context.cancelled(), &transcript).await {
Ok(CancellableCommandOutput::Completed(output)) => {
let earlier_stderr_ends = stderr.len();
let line_stderr = String::from_utf8_lossy(&output.stderr).into_owned();
stdout.push_str(&String::from_utf8_lossy(&output.stdout));
stderr.push_str(&line_stderr);
let ending = Ending::of(output.status);
if !ending.succeeded() {
return Err(self.line_failure(
program,
site,
ending,
&trim_trailing_newline(&stdout),
&trim_trailing_newline(&line_stderr),
&trim_trailing_newline(&stderr[..earlier_stderr_ends]),
));
}
}
Ok(CancellableCommandOutput::Cancelled) => {
return Err(self.cancelled_mid_line(program, site, &stdout, &stderr));
}
Err(error) => return Err(spawn_failure(program, Some(site), &error)),
}
}
Ok(ShellOutcome {
exit_code: 0,
stdout: trim_trailing_newline(&stdout),
stderr: trim_trailing_newline(&stderr),
})
}
fn line_failure(
&self,
program: &str,
site: BodySite<'_>,
ending: Ending,
stdout: &str,
line_stderr: &str,
earlier_stderr: &str,
) -> ActivityFailure {
let mut sentences = vec![format!(
"command `{name}` stopped at line {line} of {total}: `{program}` {ended}{unreadable}",
name = self.contract.name,
line = site.line,
total = site.total,
ended = ending.described(),
unreadable = unreadable_ending_clause(ending),
)];
sentences.push(if line_stderr.is_empty() {
"That line wrote nothing to standard error".to_owned()
} else {
format!("That line wrote to standard error: {line_stderr}")
});
if site.line > 1 {
let before = lines_phrase(site.line - 1, "before it");
sentences.push(if earlier_stderr.is_empty() {
format!("The {before} wrote nothing to standard error")
} else {
format!("The {before} wrote to standard error: {earlier_stderr}")
});
}
sentences.push(if stdout.is_empty() {
"The command had printed nothing before it stopped".to_owned()
} else {
format!("What the command had printed before it stopped: {stdout}")
});
let message = sentences.join(". ");
if ending_permits_retry(ending) {
ActivityFailure::retryable(message)
} else {
ActivityFailure::terminal(message)
}
}
fn cancelled_between_lines(
&self,
position: usize,
total: usize,
argv: &[String],
stdout: &str,
stderr: &str,
) -> ActivityFailure {
let next = argv.first().map_or_else(
|| "the next line".to_owned(),
|program| format!("`{program}`"),
);
ActivityFailure::terminal(format!(
"command `{name}` was cancelled after {ran} of its {total} lines had run, so {next} \
and everything after it never started. {story}",
name = self.contract.name,
ran = position,
story = finished_lines_story(position, stdout, stderr),
))
}
fn cancelled_mid_line(
&self,
program: &str,
site: BodySite<'_>,
stdout: &str,
stderr: &str,
) -> ActivityFailure {
ActivityFailure::terminal(format!(
"command `{name}` was cancelled while line {line} of {total} (`{program}`) was \
running: that line's process group was terminated and proven gone, so nothing it \
started is still running, and no later line ran. {story}. The cancelled line's own \
output is not captured here — it was streamed line by line as it was written",
name = self.contract.name,
line = site.line,
total = site.total,
story = finished_lines_story(site.line - 1, stdout, stderr),
))
}
}
fn lines_phrase(count: usize, relation: &str) -> String {
if count == 1 {
format!("line {relation}")
} else {
format!("{count} lines {relation}")
}
}
fn finished_lines_story(finished: usize, stdout: &str, stderr: &str) -> String {
if finished == 0 {
return "No line of the body had finished".to_owned();
}
let printed = trim_trailing_newline(stdout);
let wrote = trim_trailing_newline(stderr);
let subject = lines_phrase(finished, "that had finished");
let pronoun = if finished == 1 {
"That line".to_owned()
} else {
format!("Those {finished} lines")
};
let printed_sentence = if printed.is_empty() {
format!("The {subject} printed nothing")
} else {
format!("What the {subject} printed: {printed}")
};
let wrote_sentence = if wrote.is_empty() {
format!("{pronoun} wrote nothing to standard error")
} else {
format!("{pronoun} wrote to standard error: {wrote}")
};
format!("{printed_sentence}. {wrote_sentence}")
}
pub fn shape_command_result(
action: &str,
capture: aion_package::contract::CommandBodyCapture,
outcome: ShellOutcome,
) -> Result<serde_json::Value, ActivityFailure> {
match capture {
aion_package::contract::CommandBodyCapture::Text => {
Ok(serde_json::Value::String(outcome.stdout))
}
aion_package::contract::CommandBodyCapture::Json => serde_json::from_str(&outcome.stdout)
.map_err(|error| {
ActivityFailure::terminal(format!(
"action `{action}` declares a `runs json command` body and its command \
printed output that is not valid JSON: {error}"
))
}),
}
}
#[cfg(test)]
#[path = "declared_tests.rs"]
mod tests;