Skip to main content

observer_core/
product.rs

1// SPDX-FileCopyrightText: 2026 Alexander R. Croft
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4use 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}