use crate::step_test::StepTest;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_with::{DisplayFromStr, PickFirst, serde_as};
use std::{fmt, fmt::Display, path::PathBuf, str::FromStr};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(untagged)]
pub enum Pattern {
Regex {
_type: String,
pattern: String,
},
Globs(Vec<String>),
}
impl Pattern {
pub fn is_empty(&self) -> bool {
match self {
Pattern::Regex { .. } => false,
Pattern::Globs(globs) => globs.is_empty(),
}
}
}
impl<'de> Deserialize<'de> for Pattern {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
use serde_json::Value;
let value = Value::deserialize(deserializer)?;
if let Value::Object(ref map) = value
&& let Some(Value::String(type_str)) = map.get("_type")
&& type_str == "regex"
&& let Some(Value::String(pattern)) = map.get("pattern")
{
return Ok(Pattern::Regex {
_type: "regex".to_string(),
pattern: pattern.clone(),
});
}
if let Value::String(s) = value {
return Ok(Pattern::Globs(vec![s]));
}
if let Value::Array(arr) = value {
let globs: Result<Vec<String>, _> = arr
.into_iter()
.map(|v| {
if let Value::String(s) = v {
Ok(s)
} else {
Err(D::Error::custom("array elements must be strings"))
}
})
.collect();
return Ok(Pattern::Globs(globs?));
}
Err(D::Error::custom(
"expected regex object, string, or array of strings",
))
}
}
#[serde_as]
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(debug_assertions, serde(deny_unknown_fields))]
pub struct Step {
#[serde(skip_serializing_if = "Option::is_none")]
pub _type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub name: String,
pub profiles: Option<Vec<String>>,
#[serde(default)]
pub glob: Option<Pattern>,
#[serde(default)]
pub types: Option<Vec<String>>,
#[serde(default)]
pub interactive: bool,
pub stdin: Option<String>,
#[serde(default)]
pub required: Vec<String>,
pub depends: Vec<String>,
#[serde_as(as = "Option<PickFirst<(_, DisplayFromStr)>>")]
pub shell: Option<Script>,
#[serde_as(as = "Option<PickFirst<(_, DisplayFromStr)>>")]
pub check: Option<Script>,
#[serde_as(as = "Option<PickFirst<(_, DisplayFromStr)>>")]
pub check_list_files: Option<Script>,
#[serde_as(as = "Option<PickFirst<(_, DisplayFromStr)>>")]
pub check_diff: Option<Script>,
#[serde_as(as = "Option<PickFirst<(_, DisplayFromStr)>>")]
pub fix: Option<Script>,
pub workspace_indicator: Option<String>,
pub prefix: Option<String>,
pub dir: Option<String>,
#[serde(rename = "condition")]
pub job_condition: Option<String>,
pub step_condition: Option<String>,
#[serde(default)]
pub check_first: bool,
#[serde(default)]
pub batch: bool,
#[serde(default)]
pub stomp: bool,
pub env: IndexMap<String, String>,
pub stage: Option<Vec<String>>,
pub exclude: Option<Pattern>,
#[serde(default)]
pub exclusive: bool,
#[serde(default)]
pub allow_binary: bool,
#[serde(default)]
pub allow_symlinks: bool,
pub root: Option<PathBuf>,
#[serde(default)]
pub hide: bool,
#[serde(default)]
pub tests: IndexMap<String, StepTest>,
#[serde(default)]
pub output_summary: OutputSummary,
}
impl fmt::Display for Step {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunType {
Check,
Fix,
}
impl RunType {
pub fn as_str(self) -> &'static str {
match self {
RunType::Check => "check",
RunType::Fix => "fix",
}
}
}
impl fmt::Display for RunType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OutputSummary {
#[default]
Stderr,
Stdout,
Combined,
Hide,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde_as]
pub struct Script {
pub linux: Option<String>,
pub macos: Option<String>,
pub windows: Option<String>,
pub other: Option<String>,
}
impl FromStr for Script {
type Err = eyre::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self {
linux: None,
macos: None,
windows: None,
other: Some(s.to_string()),
})
}
}
impl Display for Script {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let other = self.other.as_deref().unwrap_or_default();
if cfg!(target_os = "macos") {
write!(f, "{}", self.macos.as_deref().unwrap_or(other))
} else if cfg!(target_os = "linux") {
write!(f, "{}", self.linux.as_deref().unwrap_or(other))
} else if cfg!(target_os = "windows") {
write!(f, "{}", self.windows.as_deref().unwrap_or(other))
} else {
write!(f, "{other}")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_script_empty_windows_command() {
let script = Script {
linux: Some("linux_cmd".to_string()),
macos: Some("macos_cmd".to_string()),
windows: Some("".to_string()),
other: None,
};
#[cfg(target_os = "windows")]
{
assert_eq!(script.to_string(), "");
assert!(script.to_string().trim().is_empty());
}
#[cfg(not(target_os = "windows"))]
{
assert!(!script.to_string().is_empty());
}
}
#[test]
fn test_script_none_windows_command_with_other() {
let script = Script {
linux: None,
macos: None,
windows: None,
other: Some("fallback_cmd".to_string()),
};
assert_eq!(script.to_string(), "fallback_cmd");
}
#[test]
fn test_script_all_none_produces_empty() {
let script = Script {
linux: None,
macos: None,
windows: None,
other: None,
};
assert_eq!(script.to_string(), "");
assert!(script.to_string().trim().is_empty());
}
}