use serde::Deserialize;
use std::path::Path;
pub const POLICY_PATH: &str = ".car/production-gates.toml";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionClass {
Database,
Deployment,
ProductionWrite,
}
impl ActionClass {
fn as_str(self) -> &'static str {
match self {
Self::Database => "database",
Self::Deployment => "deployment",
Self::ProductionWrite => "production_write",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Gate {
pub name: String,
pub action: String,
pub check: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ProductionGates {
#[serde(default)]
pub gates: Vec<Gate>,
}
impl ProductionGates {
pub fn load(root: &Path) -> Result<Option<Self>, String> {
let path = root.join(POLICY_PATH);
if !path.exists() {
return Ok(None);
}
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
let parsed: Self = toml::from_str(&text)
.map_err(|e| format!("invalid mandatory-gate policy {}: {e}", path.display()))?;
for gate in &parsed.gates {
if gate.name.trim().is_empty() || gate.check.trim().is_empty() {
return Err(format!(
"invalid mandatory gate in {}: name and check are required",
path.display()
));
}
if !matches!(
gate.action.as_str(),
"database" | "deployment" | "production_write"
) {
return Err(format!(
"invalid mandatory gate '{}': unknown action class '{}'",
gate.name, gate.action
));
}
}
Ok(Some(parsed))
}
pub fn gate_for(&self, class: ActionClass) -> Option<&Gate> {
self.gates.iter().find(|gate| gate.action == class.as_str())
}
}
pub fn classify_command(command: &str) -> Option<ActionClass> {
let lower = command.to_ascii_lowercase();
let words: Vec<&str> = lower.split_whitespace().collect();
let database_client = words.iter().any(|word| {
matches!(
word.trim_matches(|c: char| !c.is_ascii_alphanumeric()),
"psql" | "sqlcmd" | "mysql" | "flyway" | "liquibase"
)
});
let migration = lower.contains("migration") || lower.contains("migrate");
if database_client || migration || lower.contains("az sql") {
return Some(ActionClass::Database);
}
if lower.contains("deploy")
|| lower.contains("kubectl apply")
|| lower.contains("az webapp")
|| lower.contains("pipeline run")
{
return Some(ActionClass::Deployment);
}
if (lower.contains("production") || lower.contains("prod"))
&& words.iter().any(|word| {
matches!(
word.trim_matches(|c: char| !c.is_ascii_alphanumeric()),
"write" | "update" | "delete" | "set" | "close" | "resolve"
)
})
{
return Some(ActionClass::ProductionWrite);
}
None
}
pub fn required_gate(root: &Path, command: &str) -> Result<Option<Gate>, String> {
let Some(class) = classify_command(command) else {
return Ok(None);
};
let policy = ProductionGates::load(root)?;
match (class, policy) {
(ActionClass::Database, None) => Err(format!(
"database action refused: required project gate {} is absent",
root.join(POLICY_PATH).display()
)),
(ActionClass::Database, Some(policy)) => {
policy.gate_for(class).cloned().map(Some).ok_or_else(|| {
"database action refused: no database mandatory gate is defined".into()
})
}
(_, Some(policy)) => Ok(policy.gate_for(class).cloned()),
(_, None) => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn database_actions_fail_closed_without_policy() {
let root = tempfile::tempdir().unwrap();
let err = required_gate(root.path(), "psql -f migrations/388.sql").unwrap_err();
assert!(err.contains("required project gate"));
assert_eq!(
required_gate(root.path(), "cargo test database").unwrap(),
None
);
}
#[test]
fn malformed_or_missing_database_gate_is_an_error() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join(".car")).unwrap();
std::fs::write(
root.path().join(POLICY_PATH),
"[[gates]]\nname='deploy'\naction='deployment'\ncheck='cargo test'\n",
)
.unwrap();
assert!(required_gate(root.path(), "sqlcmd -i change.sql").is_err());
}
#[test]
fn matching_gate_is_returned_verbatim() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join(".car")).unwrap();
std::fs::write(
root.path().join(POLICY_PATH),
"[[gates]]\nname='dba'\naction='database'\ncheck='./scripts/dba-gate.sh'\n",
)
.unwrap();
let gate = required_gate(root.path(), "./scripts/run-migration")
.unwrap()
.unwrap();
assert_eq!(gate.name, "dba");
}
}