use crate::pillars::{parse_pillar_tags_from_scenario_tags, Pillar};
use crate::workspace::mock_environment::MockEnvironmentName;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PromotionEntityType {
Scenario,
Persona,
Config,
}
impl std::fmt::Display for PromotionEntityType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PromotionEntityType::Scenario => write!(f, "scenario"),
PromotionEntityType::Persona => write!(f, "persona"),
PromotionEntityType::Config => write!(f, "config"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromotionRequest {
pub entity_type: PromotionEntityType,
pub entity_id: String,
pub entity_version: Option<String>,
pub workspace_id: String,
pub from_environment: MockEnvironmentName,
pub to_environment: MockEnvironmentName,
pub requires_approval: bool,
pub approval_required_reason: Option<String>,
pub comments: Option<String>,
#[serde(default)]
pub metadata: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScenarioPromotionRequest {
pub scenario_id: String,
pub scenario_version: String,
pub workspace_id: String,
pub from_environment: MockEnvironmentName,
pub to_environment: MockEnvironmentName,
pub requires_approval: bool,
pub approval_required_reason: Option<String>,
pub comments: Option<String>,
}
impl From<ScenarioPromotionRequest> for PromotionRequest {
fn from(req: ScenarioPromotionRequest) -> Self {
Self {
entity_type: PromotionEntityType::Scenario,
entity_id: req.scenario_id,
entity_version: Some(req.scenario_version),
workspace_id: req.workspace_id,
from_environment: req.from_environment,
to_environment: req.to_environment,
requires_approval: req.requires_approval,
approval_required_reason: req.approval_required_reason,
comments: req.comments,
metadata: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScenarioPromotionResult {
pub promotion_id: String,
pub success: bool,
pub message: String,
pub requires_approval: bool,
pub status: PromotionStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PromotionStatus {
Pending,
Approved,
Rejected,
Completed,
Failed,
}
impl std::fmt::Display for PromotionStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PromotionStatus::Pending => write!(f, "pending"),
PromotionStatus::Approved => write!(f, "approved"),
PromotionStatus::Rejected => write!(f, "rejected"),
PromotionStatus::Completed => write!(f, "completed"),
PromotionStatus::Failed => write!(f, "failed"),
}
}
}
pub struct ScenarioPromotionWorkflow;
impl ScenarioPromotionWorkflow {
pub fn validate_promotion_path(
from: MockEnvironmentName,
to: MockEnvironmentName,
) -> Result<(), String> {
match (from, to) {
(MockEnvironmentName::Dev, MockEnvironmentName::Test) => Ok(()),
(MockEnvironmentName::Test, MockEnvironmentName::Prod) => Ok(()),
_ => Err(format!(
"Invalid promotion path: {} → {}. Valid paths are: dev → test, test → prod",
from.as_str(),
to.as_str()
)),
}
}
pub fn requires_approval(
scenario_tags: &[String],
target_environment: MockEnvironmentName,
approval_rules: &ApprovalRules,
) -> (bool, Option<String>) {
if target_environment == MockEnvironmentName::Prod && approval_rules.prod_requires_approval
{
return (true, Some("Production promotions require approval".to_string()));
}
let pillar_tags = parse_pillar_tags_from_scenario_tags(scenario_tags);
if !pillar_tags.is_empty() {
for pattern in &approval_rules.high_impact_pillar_patterns {
if Self::matches_pillar_pattern(&pillar_tags, pattern) {
let pillar_names: Vec<String> =
pillar_tags.iter().map(|p| p.display_name()).collect();
let reason = if target_environment == MockEnvironmentName::Prod {
format!(
"High-impact pillar tag combination {} requires approval for production",
pillar_names.join("")
)
} else if target_environment == MockEnvironmentName::Test
&& approval_rules.dev_to_test_requires_approval
{
format!(
"High-impact pillar tag combination {} requires approval for test environment",
pillar_names.join("")
)
} else {
format!(
"High-impact pillar tag combination {} requires approval",
pillar_names.join("")
)
};
return (true, Some(reason));
}
}
for required_pillar in &approval_rules.require_approval_pillars {
if pillar_tags.contains(required_pillar) {
let reason = if target_environment == MockEnvironmentName::Prod {
format!(
"Pillar tag {} requires approval for production",
required_pillar.display_name()
)
} else if target_environment == MockEnvironmentName::Test
&& approval_rules.dev_to_test_requires_approval
{
format!(
"Pillar tag {} requires approval for test environment",
required_pillar.display_name()
)
} else {
format!("Pillar tag {} requires approval", required_pillar.display_name())
};
return (true, Some(reason));
}
}
}
for tag in scenario_tags {
if approval_rules.high_impact_tags.contains(tag) {
let reason = if target_environment == MockEnvironmentName::Prod {
format!("High-impact scenario tag '{}' requires approval for production", tag)
} else if target_environment == MockEnvironmentName::Test
&& approval_rules.dev_to_test_requires_approval
{
format!(
"High-impact scenario tag '{}' requires approval for test environment",
tag
)
} else {
format!("High-impact scenario tag '{}' requires approval", tag)
};
return (true, Some(reason));
}
}
for rule in &approval_rules.custom_rules {
if rule.matches(scenario_tags, target_environment) {
return (true, Some(rule.reason.clone()));
}
}
if target_environment == MockEnvironmentName::Test
&& approval_rules.dev_to_test_requires_approval
&& !scenario_tags.is_empty()
{
for tag in scenario_tags {
if approval_rules.high_impact_tags.contains(tag) {
return (
true,
Some(format!(
"High-impact scenario tag '{}' requires approval for test environment",
tag
)),
);
}
}
}
(false, None)
}
fn matches_pillar_pattern(tags: &[Pillar], pattern: &[Pillar]) -> bool {
pattern.iter().all(|required_pillar| tags.contains(required_pillar))
}
pub fn next_environment(current: MockEnvironmentName) -> Option<MockEnvironmentName> {
current.next()
}
pub fn previous_environment(current: MockEnvironmentName) -> Option<MockEnvironmentName> {
current.previous()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRules {
pub high_impact_tags: Vec<String>,
#[serde(default)]
pub require_approval_pillars: Vec<Pillar>,
#[serde(default)]
pub high_impact_pillar_patterns: Vec<Vec<Pillar>>,
pub custom_rules: Vec<CustomApprovalRule>,
#[serde(default = "default_true")]
pub prod_requires_approval: bool,
#[serde(default = "default_false")]
pub dev_to_test_requires_approval: bool,
#[serde(default = "default_min_approvers")]
pub min_approvers: usize,
}
fn default_false() -> bool {
false
}
fn default_min_approvers() -> usize {
1
}
fn default_true() -> bool {
true
}
impl Default for ApprovalRules {
fn default() -> Self {
Self {
high_impact_tags: vec![
"auth".to_string(),
"billing".to_string(),
"payment".to_string(),
"high-impact".to_string(),
"security".to_string(),
"pii".to_string(),
],
require_approval_pillars: vec![
],
high_impact_pillar_patterns: vec![vec![
Pillar::Cloud,
Pillar::Contracts,
Pillar::Reality,
]],
custom_rules: Vec::new(),
prod_requires_approval: true,
dev_to_test_requires_approval: false,
min_approvers: 1,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomApprovalRule {
pub name: String,
pub matching_tags: Vec<String>,
pub environments: Vec<MockEnvironmentName>,
pub reason: String,
}
impl CustomApprovalRule {
pub fn matches(&self, scenario_tags: &[String], environment: MockEnvironmentName) -> bool {
if !self.environments.is_empty() && !self.environments.contains(&environment) {
return false;
}
for tag in scenario_tags {
if self.matching_tags.contains(tag) {
return true;
}
}
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromotionHistory {
pub entity_type: PromotionEntityType,
pub entity_id: String,
pub workspace_id: String,
pub promotions: Vec<PromotionHistoryEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromotionHistoryEntry {
pub promotion_id: String,
pub entity_type: PromotionEntityType,
pub entity_id: String,
pub entity_version: Option<String>,
pub from_environment: MockEnvironmentName,
pub to_environment: MockEnvironmentName,
pub promoted_by: String,
pub approved_by: Option<String>,
pub status: PromotionStatus,
pub timestamp: chrono::DateTime<chrono::Utc>,
pub comments: Option<String>,
pub pr_url: Option<String>,
#[serde(default)]
pub metadata: HashMap<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_promotion_path() {
assert!(ScenarioPromotionWorkflow::validate_promotion_path(
MockEnvironmentName::Dev,
MockEnvironmentName::Test
)
.is_ok());
assert!(ScenarioPromotionWorkflow::validate_promotion_path(
MockEnvironmentName::Test,
MockEnvironmentName::Prod
)
.is_ok());
assert!(ScenarioPromotionWorkflow::validate_promotion_path(
MockEnvironmentName::Dev,
MockEnvironmentName::Prod
)
.is_err());
}
#[test]
fn test_requires_approval() {
let rules = ApprovalRules::default();
let tags = vec!["auth".to_string()];
let (requires, reason) =
ScenarioPromotionWorkflow::requires_approval(&tags, MockEnvironmentName::Test, &rules);
assert!(requires);
assert!(reason.is_some());
let tags = vec!["normal".to_string()];
let (requires, _) =
ScenarioPromotionWorkflow::requires_approval(&tags, MockEnvironmentName::Test, &rules);
assert!(!requires);
}
#[test]
fn test_requires_approval_with_pillar_tags() {
let rules = ApprovalRules::default();
let tags = vec!["[Cloud][Contracts][Reality]".to_string()];
let (requires, reason) =
ScenarioPromotionWorkflow::requires_approval(&tags, MockEnvironmentName::Test, &rules);
assert!(requires, "Should require approval for Cloud+Contracts+Reality combination");
assert!(reason.is_some());
assert!(reason.unwrap().contains("pillar tag combination"));
let tags2 = vec!["[Cloud]".to_string()];
let (requires2, _) =
ScenarioPromotionWorkflow::requires_approval(&tags2, MockEnvironmentName::Test, &rules);
assert!(!requires2, "Single pillar tag should not require approval by default");
let tags3 = vec!["[Cloud][Contracts]".to_string()];
let (requires3, _) =
ScenarioPromotionWorkflow::requires_approval(&tags3, MockEnvironmentName::Test, &rules);
assert!(!requires3, "Partial pillar combination should not require approval");
}
#[test]
fn test_matches_pillar_pattern() {
use crate::pillars::Pillar;
let tags = vec![Pillar::Cloud, Pillar::Contracts, Pillar::Reality];
let pattern = vec![Pillar::Cloud, Pillar::Contracts, Pillar::Reality];
assert!(ScenarioPromotionWorkflow::matches_pillar_pattern(&tags, &pattern));
let tags2 = vec![
Pillar::Cloud,
Pillar::Contracts,
Pillar::Reality,
Pillar::Ai,
];
let pattern2 = vec![Pillar::Cloud, Pillar::Contracts];
assert!(ScenarioPromotionWorkflow::matches_pillar_pattern(&tags2, &pattern2));
let tags3 = vec![Pillar::Cloud, Pillar::Contracts];
let pattern3 = vec![Pillar::Cloud, Pillar::Contracts, Pillar::Reality];
assert!(!ScenarioPromotionWorkflow::matches_pillar_pattern(&tags3, &pattern3));
}
}