pub mod action;
pub mod error;
pub mod predicate;
pub mod validate;
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub use action::{
Action, EnvArgs, EnvScope, ExecSpec, MkdirArgs, RequireSpec, RmdirArgs, SymlinkArgs,
SymlinkKind, UnlinkArgs, WhenSpec, VALID_ACTION_KEYS,
};
pub use error::{PackParseError, MAX_REQUIRE_DEPTH};
pub use predicate::{Combiner, ExecOnFail, OsKind, Predicate, RequireOnFail};
pub use validate::{run_all, PackValidationError, Validator};
pub const SUPPORTED_SCHEMA_VERSION: &str = "1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct SchemaVersion(String);
impl SchemaVersion {
#[must_use]
pub fn current() -> Self {
Self(SUPPORTED_SCHEMA_VERSION.to_string())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for SchemaVersion {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = serde_yaml::Value::deserialize(deserializer)?;
let got = match &raw {
serde_yaml::Value::String(s) => s.clone(),
serde_yaml::Value::Number(n) => {
return Err(serde::de::Error::custom(format!(
"schema_version must be the quoted string \"1\", got bare number {n} \
(quote it as \"{n}\")"
)));
}
other => {
return Err(serde::de::Error::custom(format!(
"schema_version must be the quoted string \"1\", got {other:?}"
)));
}
};
if got == SUPPORTED_SCHEMA_VERSION {
Ok(Self(got))
} else {
Err(serde::de::Error::custom(format!(
"unsupported pack schema_version {got:?}: this grex build only understands \"1\""
)))
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PackType {
Meta,
Declarative,
Scripted,
}
impl PackType {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Meta => "meta",
Self::Declarative => "declarative",
Self::Scripted => "scripted",
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChildRef {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
pub r#ref: Option<String>,
}
impl ChildRef {
#[must_use]
pub fn effective_path(&self) -> String {
if let Some(p) = &self.path {
return p.clone();
}
let url = self.url.trim_end_matches('/');
let tail = url.rsplit_once('/').map_or(url, |(_, t)| t);
tail.strip_suffix(".git").unwrap_or(tail).to_string()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackManifest {
pub schema_version: SchemaVersion,
pub name: String,
pub r#type: PackType,
pub version: Option<String>,
pub depends_on: Vec<String>,
pub children: Vec<ChildRef>,
pub actions: Vec<Action>,
pub teardown: Option<Vec<Action>>,
pub extensions: BTreeMap<String, serde_yaml::Value>,
}
impl PackManifest {
pub fn iter_all_symlinks(&self) -> impl Iterator<Item = (usize, &SymlinkArgs)> {
self.actions.iter().flat_map(Action::iter_symlinks).enumerate()
}
pub fn validate_plan(&self) -> Result<(), Vec<PackValidationError>> {
let errs = validate::run_all(self);
if errs.is_empty() {
Ok(())
} else {
Err(errs)
}
}
}
pub fn parse(yaml: &str) -> Result<PackManifest, PackParseError> {
reject_yaml_aliases(yaml)?;
let mapping = parse_root_mapping(yaml)?;
let extensions = segregate_extensions(&mapping)?;
let schema_version = parse_schema_version(&mapping)?;
let name = parse_name(&mapping)?;
let r#type = parse_type(&mapping)?;
let version = parse_version(&mapping);
let depends_on = parse_depends_on(&mapping)?;
let children = parse_children(&mapping)?;
let actions = Action::parse_list(mapping.get(s("actions")))?;
let teardown = parse_teardown(&mapping)?;
Ok(PackManifest {
schema_version,
name,
r#type,
version,
depends_on,
children,
actions,
teardown,
extensions,
})
}
const KNOWN_TOP_LEVEL_KEYS: &[&str] =
&["schema_version", "name", "type", "version", "depends_on", "children", "actions", "teardown"];
fn parse_root_mapping(yaml: &str) -> Result<serde_yaml::Mapping, PackParseError> {
let root: serde_yaml::Value = serde_yaml::from_str(yaml)?;
match root {
serde_yaml::Value::Mapping(m) => Ok(m),
serde_yaml::Value::Null => Err(PackParseError::InvalidName { got: String::new() }),
other => Err(PackParseError::InvalidPredicate {
detail: format!("pack.yaml root must be a mapping, got {other:?}"),
}),
}
}
fn segregate_extensions(
mapping: &serde_yaml::Mapping,
) -> Result<BTreeMap<String, serde_yaml::Value>, PackParseError> {
let mut extensions: BTreeMap<String, serde_yaml::Value> = BTreeMap::new();
for (k, v) in mapping.iter() {
let Some(key) = k.as_str() else {
return Err(PackParseError::UnknownTopLevelKey { key: format!("{k:?}") });
};
if KNOWN_TOP_LEVEL_KEYS.contains(&key) {
continue;
}
if key.starts_with("x-") {
extensions.insert(key.to_string(), v.clone());
continue;
}
return Err(PackParseError::UnknownTopLevelKey { key: key.to_string() });
}
Ok(extensions)
}
fn parse_schema_version(mapping: &serde_yaml::Mapping) -> Result<SchemaVersion, PackParseError> {
match mapping.get(s("schema_version")) {
Some(v) => match serde_yaml::from_value::<SchemaVersion>(v.clone()) {
Ok(sv) => Ok(sv),
Err(e) => {
if matches!(v, serde_yaml::Value::String(_)) {
Err(PackParseError::InvalidSchemaVersion { got: render_scalar(v) })
} else {
Err(PackParseError::Inner(e))
}
}
},
None => Err(PackParseError::InvalidSchemaVersion { got: "<missing>".to_string() }),
}
}
fn parse_name(mapping: &serde_yaml::Mapping) -> Result<String, PackParseError> {
let name = match mapping.get(s("name")) {
Some(v) => v
.as_str()
.map(str::to_owned)
.ok_or_else(|| PackParseError::InvalidName { got: render_scalar(v) })?,
None => return Err(PackParseError::InvalidName { got: "<missing>".to_string() }),
};
if !is_valid_pack_name(&name) {
return Err(PackParseError::InvalidName { got: name });
}
Ok(name)
}
fn parse_type(mapping: &serde_yaml::Mapping) -> Result<PackType, PackParseError> {
match mapping.get(s("type")) {
Some(v) => Ok(serde_yaml::from_value(v.clone())?),
None => Err(PackParseError::UnknownTopLevelKey {
key: "<missing required field `type`>".to_string(),
}),
}
}
fn parse_version(mapping: &serde_yaml::Mapping) -> Option<String> {
match mapping.get(s("version")) {
Some(v) if v.is_null() => None,
Some(v) => Some(v.as_str().map(str::to_owned).unwrap_or_else(|| render_scalar(v))),
None => None,
}
}
fn parse_depends_on(mapping: &serde_yaml::Mapping) -> Result<Vec<String>, PackParseError> {
match mapping.get(s("depends_on")) {
Some(v) if v.is_null() => Ok(Vec::new()),
Some(v) => Ok(serde_yaml::from_value(v.clone())?),
None => Ok(Vec::new()),
}
}
fn parse_children(mapping: &serde_yaml::Mapping) -> Result<Vec<ChildRef>, PackParseError> {
match mapping.get(s("children")) {
Some(v) if v.is_null() => Ok(Vec::new()),
Some(v) => Ok(serde_yaml::from_value(v.clone())?),
None => Ok(Vec::new()),
}
}
fn parse_teardown(mapping: &serde_yaml::Mapping) -> Result<Option<Vec<Action>>, PackParseError> {
match mapping.get(s("teardown")) {
None => Ok(None),
Some(v) if v.is_null() => Ok(None),
Some(v) => Ok(Some(Action::parse_list(Some(v))?)),
}
}
fn s(key: &str) -> serde_yaml::Value {
serde_yaml::Value::String(key.to_string())
}
fn render_scalar(v: &serde_yaml::Value) -> String {
match v {
serde_yaml::Value::String(s) => s.clone(),
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::Bool(b) => b.to_string(),
serde_yaml::Value::Null => "null".to_string(),
other => format!("{other:?}"),
}
}
fn is_valid_pack_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_lowercase() {
return false;
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn reject_yaml_aliases(yaml: &str) -> Result<(), PackParseError> {
let mut in_single = false;
let mut in_double = false;
let mut prev: char = '\n';
for ch in yaml.chars() {
match ch {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single && prev != '\\' => in_double = !in_double,
'&' | '*' if !in_single && !in_double => {
if matches!(prev, ' ' | '\t' | '\n' | ':' | '-' | '[' | ',' | '{') {
return Err(PackParseError::YamlAliasRejected);
}
}
_ => {}
}
prev = ch;
}
Ok(())
}