1use crate::error::{ObserverError, ObserverResult};
5use crate::cmake::ProductStageCmakeModel;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeSet;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct ProductDefinition {
11 pub k: String,
12 pub v: String,
13 pub certification_rule: ProductCertificationRule,
14 pub product_id: String,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub product_label: Option<String>,
17 pub stages: Vec<ProductStage>,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ProductCertificationRule {
23 AllPass,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct ProductStage {
28 pub stage_id: String,
29 #[serde(default = "default_required")]
30 pub required: bool,
31 pub runner: ProductRunner,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(tag = "k", rename_all = "snake_case")]
36pub enum ProductRunner {
37 ObserverSuite(ProductStageObserverSuite),
38 ObserverCmakeModel(ProductStageCmakeModel),
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct ProductStageObserverSuite {
43 pub cwd: String,
44 pub suite: String,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub inventory: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub config: Option<String>,
49 pub surface: ProductStageSurface,
50 pub mode: ProductStageRunMode,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub filter: Option<String>,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum ProductStageSurface {
58 Simple,
59 Full,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum ProductStageRunMode {
65 Default,
66 Golden,
67}
68
69impl ProductDefinition {
70 pub fn parse(source: &str) -> ObserverResult<Self> {
71 let product: Self = serde_json::from_str(source)
72 .map_err(|error| ObserverError::ProductParse(error.to_string()))?;
73 product.validate()?;
74 Ok(product)
75 }
76
77 pub fn validate(&self) -> ObserverResult<()> {
78 if self.k != "observer_product" {
79 return Err(ObserverError::ProductParse(format!(
80 "expected `k` = `observer_product`, found `{}`",
81 self.k
82 )));
83 }
84 if self.v != "0" {
85 return Err(ObserverError::ProductParse(format!(
86 "expected `v` = `0`, found `{}`",
87 self.v
88 )));
89 }
90 if self.product_id.trim().is_empty() {
91 return Err(ObserverError::ProductParse(
92 "product_id must be non-empty".to_owned(),
93 ));
94 }
95 if let Some(label) = &self.product_label {
96 if label.trim().is_empty() {
97 return Err(ObserverError::ProductParse(
98 "product_label must be non-empty when present".to_owned(),
99 ));
100 }
101 }
102 if self.stages.is_empty() {
103 return Err(ObserverError::ProductParse(
104 "product definition must declare at least one stage".to_owned(),
105 ));
106 }
107
108 let mut stage_ids = BTreeSet::new();
109 for stage in &self.stages {
110 stage.validate()?;
111 if !stage_ids.insert(stage.stage_id.clone()) {
112 return Err(ObserverError::ProductParse(format!(
113 "duplicate stage_id `{}`",
114 stage.stage_id
115 )));
116 }
117 }
118
119 Ok(())
120 }
121}
122
123impl ProductStage {
124 fn validate(&self) -> ObserverResult<()> {
125 if self.stage_id.trim().is_empty() {
126 return Err(ObserverError::ProductParse(
127 "stage_id must be non-empty".to_owned(),
128 ));
129 }
130 match &self.runner {
131 ProductRunner::ObserverSuite(runner) => runner.validate(),
132 ProductRunner::ObserverCmakeModel(runner) => runner.validate(),
133 }
134 }
135}
136
137impl ProductStageObserverSuite {
138 fn validate(&self) -> ObserverResult<()> {
139 validate_normalized_relative_path(&self.cwd, "runner.cwd", true)?;
140 validate_normalized_relative_path(&self.suite, "runner.suite", false)?;
141 if matches!(self.surface, ProductStageSurface::Simple) && self.inventory.is_none() {
142 return Err(ObserverError::ProductParse(
143 "runner.inventory is required when runner.surface = `simple`".to_owned(),
144 ));
145 }
146 if let Some(inventory) = &self.inventory {
147 validate_normalized_relative_path(inventory, "runner.inventory", false)?;
148 }
149 if let Some(config) = &self.config {
150 validate_normalized_relative_path(config, "runner.config", false)?;
151 }
152 if let Some(filter) = &self.filter {
153 if filter.trim().is_empty() {
154 return Err(ObserverError::ProductParse(
155 "runner.filter must be non-empty when present".to_owned(),
156 ));
157 }
158 }
159 Ok(())
160 }
161}
162
163fn default_required() -> bool {
164 true
165}
166
167fn validate_normalized_relative_path(path: &str, field: &str, allow_dot: bool) -> ObserverResult<()> {
168 if path.is_empty() {
169 return Err(ObserverError::ProductParse(format!("{field} must be non-empty")));
170 }
171 if allow_dot && path == "." {
172 return Ok(());
173 }
174 if path.starts_with('/') || path.starts_with('~') || path.contains('\\') {
175 return Err(ObserverError::ProductParse(format!(
176 "{field} must be a normalized relative path"
177 )));
178 }
179 for segment in path.split('/') {
180 if segment.is_empty() || segment == "." || segment == ".." {
181 return Err(ObserverError::ProductParse(format!(
182 "{field} must be a normalized relative path"
183 )));
184 }
185 }
186 Ok(())
187}