use std::{ffi::OsString, io, process::Command, sync::Arc};
use camino::Utf8PathBuf;
use cap_std::fs_utf8::Dir;
use minijinja::{
Error, ErrorKind,
value::{Value, ValueKind},
};
use tempfile::{Builder, NamedTempFile};
use crate::localization::{self, keys};
use crate::stdlib::DEFAULT_COMMAND_TEMP_DIR;
use super::error::CommandFailure;
#[derive(Clone)]
pub(crate) struct CommandConfig {
pub(crate) max_capture_bytes: u64,
pub(crate) max_stream_bytes: u64,
workspace_root: Arc<Dir>,
workspace_root_path: Option<Arc<Utf8PathBuf>>,
temp_relative: Utf8PathBuf,
command_path_override: Option<OsString>,
}
pub(crate) struct CommandConfigInit {
pub(crate) max_capture_bytes: u64,
pub(crate) max_stream_bytes: u64,
pub(crate) workspace_root: Arc<Dir>,
pub(crate) workspace_root_path: Option<Arc<Utf8PathBuf>>,
pub(crate) command_path_override: Option<OsString>,
}
impl CommandConfig {
pub(crate) fn new(init: CommandConfigInit) -> Self {
Self {
max_capture_bytes: init.max_capture_bytes,
max_stream_bytes: init.max_stream_bytes,
workspace_root: init.workspace_root,
workspace_root_path: init.workspace_root_path,
temp_relative: Utf8PathBuf::from(DEFAULT_COMMAND_TEMP_DIR),
command_path_override: init.command_path_override,
}
}
pub(super) fn configure_environment(&self, command: &mut Command) {
if let Some(path) = &self.command_path_override {
command.env("PATH", path);
}
}
pub(super) const fn has_command_path_override(&self) -> bool {
self.command_path_override.is_some()
}
pub(super) fn create_tempfile(&self, label: &str) -> io::Result<NamedTempFile> {
let Some(root_path) = &self.workspace_root_path else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
localization::message(keys::COMMAND_TEMPFILE_ROOT_REQUIRED).to_string(),
));
};
self.workspace_root.create_dir_all(&self.temp_relative)?;
let dir_path = root_path.join(&self.temp_relative);
let prefix = sanitize_label(label);
Builder::new()
.prefix(&prefix)
.suffix(".tmp")
.tempfile_in(dir_path.as_std_path())
.map_err(|err| {
io::Error::new(
err.kind(),
localization::message(keys::COMMAND_TEMPFILE_CREATE_FAILED)
.with_arg("label", label)
.with_arg("details", err.to_string())
.to_string(),
)
})
}
}
fn sanitize_label(label: &str) -> String {
let mut sanitized = String::with_capacity(label.len());
for ch in label.chars() {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
sanitized.push(ch);
} else {
sanitized.push('-');
}
}
if sanitized.is_empty() {
sanitized.push('t');
}
sanitized
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum OutputMode {
Capture,
Tempfile,
}
impl OutputMode {
pub(super) const fn label_key(self) -> &'static str {
match self {
Self::Capture => keys::COMMAND_OUTPUT_MODE_CAPTURE,
Self::Tempfile => keys::COMMAND_OUTPUT_MODE_STREAMING,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum OutputStream {
Stdout,
Stderr,
}
impl OutputStream {
pub(super) const fn label_key(self) -> &'static str {
match self {
Self::Stdout => keys::COMMAND_OUTPUT_STREAM_STDOUT,
Self::Stderr => keys::COMMAND_OUTPUT_STREAM_STDERR,
}
}
pub(super) const fn tempfile_label(self) -> &'static str {
match self {
Self::Stdout => "stdout",
Self::Stderr => "stderr",
}
}
pub(super) const fn empty_tempfile_label(self) -> &'static str {
match self {
Self::Stdout => "stdout-empty",
Self::Stderr => "stderr-empty",
}
}
}
#[derive(Clone, Copy)]
pub(super) struct PipeSpec {
stream: OutputStream,
mode: OutputMode,
limit: u64,
}
impl PipeSpec {
pub(super) const fn new(stream: OutputStream, mode: OutputMode, limit: u64) -> Self {
Self {
stream,
mode,
limit,
}
}
pub(super) const fn stream(self) -> OutputStream {
self.stream
}
pub(super) const fn mode(self) -> OutputMode {
self.mode
}
pub(super) const fn limit(self) -> u64 {
self.limit
}
pub(super) const fn into_limit(self) -> PipeLimit {
PipeLimit {
spec: self,
consumed: 0,
}
}
}
pub(super) struct PipeLimit {
spec: PipeSpec,
consumed: u64,
}
impl PipeLimit {
pub(super) fn record(&mut self, read: usize) -> Result<(), CommandFailure> {
let bytes = read_size_to_u64(read);
let new_total = add_saturating(self.consumed, bytes);
if new_total > self.spec.limit() {
return Err(CommandFailure::OutputLimit {
stream: self.spec.stream(),
mode: self.spec.mode(),
limit: self.spec.limit(),
});
}
self.consumed = new_total;
Ok(())
}
}
fn read_size_to_u64(read: usize) -> u64 {
u64::try_from(read).unwrap_or(u64::MAX)
}
const fn add_saturating(current: u64, delta: u64) -> u64 {
current.saturating_add(delta)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct CommandOptions {
stdout_mode: OutputMode,
}
impl CommandOptions {
pub(super) fn from_value(options: Option<Value>) -> Result<Self, Error> {
let Some(raw) = options else {
return Ok(Self::default());
};
if raw.is_undefined() {
return Ok(Self::default());
}
match raw.kind() {
ValueKind::String => {
let Some(text) = raw.as_str() else {
return Err(Error::new(
ErrorKind::InvalidOperation,
localization::message(keys::COMMAND_OPTIONS_INVALID_UTF8).to_string(),
));
};
Self::from_mode_str(text)
}
ValueKind::Map | ValueKind::Plain => {
let mode_value = raw.get_attr("mode")?;
if mode_value.is_undefined() {
return Ok(Self::default());
}
let Some(mode) = mode_value.as_str() else {
return Err(Error::new(
ErrorKind::InvalidOperation,
localization::message(keys::COMMAND_OPTION_MODE_NOT_STRING).to_string(),
));
};
Self::from_mode_str(mode)
}
_ => Err(Error::new(
ErrorKind::InvalidOperation,
localization::message(keys::COMMAND_OPTIONS_INVALID_TYPE).to_string(),
)),
}
}
fn from_mode_str(mode: &str) -> Result<Self, Error> {
match mode {
"capture" => Ok(Self {
stdout_mode: OutputMode::Capture,
}),
"tempfile" | "stream" | "streaming" => Ok(Self {
stdout_mode: OutputMode::Tempfile,
}),
other => Err(Error::new(
ErrorKind::InvalidOperation,
localization::message(keys::COMMAND_OUTPUT_MODE_UNSUPPORTED)
.with_arg("mode", other)
.to_string(),
)),
}
}
pub(super) const fn stdout_mode(self) -> OutputMode {
self.stdout_mode
}
}
impl Default for CommandOptions {
fn default() -> Self {
Self {
stdout_mode: OutputMode::Capture,
}
}
}
#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;