use indexmap::IndexMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Script {
pub interpreter: Option<String>,
pub env: IndexMap<String, String>,
pub secrets: Vec<String>,
pub content: ScriptContent,
pub cwd: Option<PathBuf>,
pub content_explicit: bool,
}
impl Serialize for Script {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
#[derive(Serialize)]
#[serde(untagged)]
enum RawScriptContent<'a> {
Command { content: &'a String },
Commands { content: &'a Vec<String> },
Path { file: &'a PathBuf },
}
#[derive(Serialize)]
#[serde(untagged)]
enum RawScript<'a> {
CommandOrPath(&'a String),
Commands(&'a Vec<String>),
Object {
#[serde(skip_serializing_if = "Option::is_none")]
interpreter: Option<&'a String>,
#[serde(skip_serializing_if = "IndexMap::is_empty")]
env: &'a IndexMap<String, String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
secrets: &'a Vec<String>,
#[serde(skip_serializing_if = "Option::is_none", flatten)]
content: Option<RawScriptContent<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
cwd: Option<&'a PathBuf>,
},
}
let only_content = self.interpreter.is_none()
&& self.env.is_empty()
&& self.secrets.is_empty()
&& self.cwd.is_none();
let raw_script = match &self.content {
ScriptContent::CommandOrPath(content) if only_content && !self.content_explicit => {
RawScript::CommandOrPath(content)
}
ScriptContent::Commands(content) if only_content && !self.content_explicit => {
RawScript::Commands(content)
}
_ => RawScript::Object {
interpreter: self.interpreter.as_ref(),
env: &self.env,
secrets: &self.secrets,
cwd: self.cwd.as_ref(),
content: match &self.content {
ScriptContent::Command(content) => Some(RawScriptContent::Command { content }),
ScriptContent::Commands(content) => {
Some(RawScriptContent::Commands { content })
}
ScriptContent::Path(file) => Some(RawScriptContent::Path { file }),
ScriptContent::Default => None,
ScriptContent::CommandOrPath(content) => {
Some(RawScriptContent::Command { content })
}
},
},
};
raw_script.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for Script {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum RawScriptContent {
Command { content: String },
Commands { content: Vec<String> },
Path { file: PathBuf },
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RawScript {
CommandOrPath(String),
Commands(Vec<String>),
Object {
#[serde(default)]
interpreter: Option<String>,
#[serde(default)]
env: IndexMap<String, String>,
#[serde(default)]
secrets: Vec<String>,
#[serde(default, flatten)]
content: Option<RawScriptContent>,
#[serde(default)]
cwd: Option<PathBuf>,
},
}
let raw_script = RawScript::deserialize(deserializer)?;
Ok(match raw_script {
RawScript::CommandOrPath(str) => ScriptContent::CommandOrPath(str).into(),
RawScript::Commands(commands) => ScriptContent::Commands(commands).into(),
RawScript::Object {
interpreter,
env,
secrets,
content,
cwd,
} => {
let content_explicit = content.is_some();
Self {
interpreter,
env,
secrets,
cwd,
content: match content {
Some(RawScriptContent::Command { content }) => {
ScriptContent::Command(content)
}
Some(RawScriptContent::Commands { content }) => {
ScriptContent::Commands(content)
}
Some(RawScriptContent::Path { file }) => ScriptContent::Path(file),
None => ScriptContent::Default,
},
content_explicit,
}
}
})
}
}
impl Script {
pub fn interpreter(&self) -> &str {
self.interpreter
.as_deref()
.unwrap_or(if cfg!(windows) { "cmd" } else { "bash" })
}
pub fn contents(&self) -> &ScriptContent {
&self.content
}
pub fn env(&self) -> &IndexMap<String, String> {
&self.env
}
pub fn secrets(&self) -> &[String] {
self.secrets.as_slice()
}
pub fn is_default(&self) -> bool {
self.content.is_default()
&& self.interpreter.is_none()
&& self.env.is_empty()
&& self.secrets.is_empty()
}
}
impl From<ScriptContent> for Script {
fn from(value: ScriptContent) -> Self {
Self {
interpreter: None,
env: Default::default(),
secrets: Default::default(),
content: value,
cwd: None,
content_explicit: false,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum ScriptContent {
#[default]
Default,
CommandOrPath(String),
Path(PathBuf),
Commands(Vec<String>),
Command(String),
}
impl ScriptContent {
pub const fn is_default(&self) -> bool {
matches!(self, Self::Default)
}
}
pub fn determine_interpreter_from_path(path: &Path) -> Option<String> {
path.extension()
.and_then(|s| s.to_str())
.map(|ext| ext.to_lowercase())
.and_then(|ext_lower| match ext_lower.as_str() {
"py" => Some("python".to_string()),
"rb" => Some("ruby".to_string()),
"js" => Some("nodejs".to_string()),
"pl" => Some("perl".to_string()),
"r" => Some("rscript".to_string()),
"sh" | "bash" => Some("bash".to_string()),
"bat" | "cmd" => Some("cmd".to_string()),
"ps1" => Some("powershell".to_string()),
"nu" => Some("nushell".to_string()),
_ => None,
})
}