greentic_conformance/
pack_suite.rs1use std::{
2 collections::HashSet,
3 fs,
4 path::{Path, PathBuf},
5 sync::Arc,
6};
7
8use anyhow::{bail, Context, Result};
9use serde::{Deserialize, Serialize};
10
11pub trait PackRuntimeAdapter: Send + Sync {
13 fn list_flows(&self, component_path: &Path) -> Result<Vec<String>>;
14}
15
16impl<T> PackRuntimeAdapter for T
17where
18 T: Fn(&Path) -> Result<Vec<String>> + Send + Sync,
19{
20 fn list_flows(&self, component_path: &Path) -> Result<Vec<String>> {
21 (self)(component_path)
22 }
23}
24
25#[derive(Clone)]
27pub struct PackSuiteOptions {
28 pub manifest_override: Option<PathBuf>,
30 pub require_flows: bool,
32 pub runtime_adapter: Option<Arc<dyn PackRuntimeAdapter>>,
34 pub require_runtime_match: bool,
36}
37
38impl Default for PackSuiteOptions {
39 fn default() -> Self {
40 Self {
41 manifest_override: None,
42 require_flows: true,
43 runtime_adapter: None,
44 require_runtime_match: true,
45 }
46 }
47}
48
49impl std::fmt::Debug for PackSuiteOptions {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 f.debug_struct("PackSuiteOptions")
52 .field("manifest_override", &self.manifest_override)
53 .field("require_flows", &self.require_flows)
54 .field(
55 "runtime_adapter",
56 if self.runtime_adapter.is_some() {
57 &"Some(<runtime adapter>)"
58 } else {
59 &"None"
60 },
61 )
62 .field("require_runtime_match", &self.require_runtime_match)
63 .finish()
64 }
65}
66
67#[derive(Debug, Clone, Deserialize, Serialize)]
69pub struct PackManifest {
70 pub signature: String,
71 #[serde(default)]
72 pub flows: Vec<PackExport>,
73}
74
75#[derive(Debug, Clone, Deserialize, Serialize)]
77pub struct PackExport {
78 pub id: String,
79 #[serde(default)]
80 pub summary: Option<String>,
81 #[serde(default)]
82 pub schema: Option<serde_json::Value>,
83}
84
85#[derive(Debug, Clone)]
87pub struct PackReport {
88 pub manifest_path: PathBuf,
89 pub manifest: PackManifest,
90 pub runtime_flows: Option<Vec<String>>,
91}
92
93pub fn verify_pack_exports(component_path: &str) -> Result<PackReport> {
95 PackSuiteOptions::default().verify_pack_exports(component_path)
96}
97
98impl PackSuiteOptions {
99 pub fn verify_pack_exports(&self, component_path: impl AsRef<Path>) -> Result<PackReport> {
101 let component_path = component_path.as_ref();
102 if !component_path.exists() {
103 bail!(
104 "component path '{}' does not exist",
105 component_path.display()
106 );
107 }
108
109 let manifest_path = resolve_pack_manifest(component_path, self)?;
110 let manifest_data = fs::read_to_string(&manifest_path)
111 .with_context(|| format!("failed to read manifest {}", manifest_path.display()))?;
112
113 let manifest: PackManifest = if manifest_path
114 .extension()
115 .is_some_and(|ext| ext == "yaml" || ext == "yml")
116 {
117 serde_yaml::from_str(&manifest_data).with_context(|| {
118 format!("manifest {} is not valid YAML", manifest_path.display())
119 })?
120 } else {
121 serde_json::from_str(&manifest_data).with_context(|| {
122 format!("manifest {} is not valid JSON", manifest_path.display())
123 })?
124 };
125
126 validate_manifest(&manifest, self.require_flows)
127 .with_context(|| format!("manifest {}", manifest_path.display()))?;
128
129 let runtime_flows = if let Some(adapter) = &self.runtime_adapter {
130 let flows = adapter.list_flows(component_path).with_context(|| {
131 format!(
132 "runtime interrogation failed for {}",
133 component_path.display()
134 )
135 })?;
136 if self.require_runtime_match {
137 ensure_runtime_matches_manifest(&manifest, &flows)?;
138 }
139 Some(flows)
140 } else {
141 None
142 };
143
144 Ok(PackReport {
145 manifest_path,
146 manifest,
147 runtime_flows,
148 })
149 }
150
151 pub fn with_runtime_adapter<A>(mut self, adapter: A) -> Self
153 where
154 A: PackRuntimeAdapter + 'static,
155 {
156 self.runtime_adapter = Some(Arc::new(adapter));
157 self
158 }
159
160 pub fn with_manifest_override(mut self, manifest: impl Into<PathBuf>) -> Self {
162 self.manifest_override = Some(manifest.into());
163 self
164 }
165
166 pub fn allow_runtime_mismatch(mut self) -> Self {
168 self.require_runtime_match = false;
169 self
170 }
171}
172
173pub fn resolve_pack_manifest(component_path: &Path, options: &PackSuiteOptions) -> Result<PathBuf> {
175 if let Some(override_path) = &options.manifest_override {
176 if !override_path.exists() {
177 bail!(
178 "provided manifest override '{}' does not exist",
179 override_path.display()
180 );
181 }
182 return Ok(override_path.clone());
183 }
184
185 if let Ok(env_override) = std::env::var("GREENTIC_PACK_MANIFEST") {
186 let env_path = PathBuf::from(env_override);
187 if env_path.exists() {
188 return Ok(env_path);
189 }
190 }
191
192 if component_path.is_file() {
193 if component_path
194 .extension()
195 .is_some_and(|ext| ext == "json" || ext == "yaml" || ext == "yml")
196 {
197 return Ok(component_path.to_path_buf());
198 }
199 let mut candidates = Vec::new();
200 candidates.push(component_path.with_extension("json"));
201 candidates.push(component_path.with_extension("yaml"));
202 candidates.push(component_path.with_extension("yml"));
203
204 if let Some(found) = candidates.into_iter().find(|p| p.exists()) {
205 return Ok(found);
206 }
207 }
208
209 let lookup_dir = if component_path.is_file() {
210 component_path
211 .parent()
212 .map(Path::to_path_buf)
213 .unwrap_or_else(|| PathBuf::from("."))
214 } else {
215 component_path.to_path_buf()
216 };
217
218 let mut candidates = Vec::new();
219 for name in [
220 "pack.manifest.json",
221 "pack.manifest.yaml",
222 "pack.manifest.yml",
223 "pack.json",
224 "pack.yaml",
225 "pack.yml",
226 ] {
227 candidates.push(lookup_dir.join(name));
228 }
229
230 if let Some(found) = candidates.into_iter().find(|p| p.exists()) {
231 return Ok(found);
232 }
233
234 bail!(
235 "unable to locate a manifest for '{}' –\
236 expected one of pack.manifest.(json|yaml) near the component",
237 component_path.display()
238 );
239}
240
241fn validate_manifest(manifest: &PackManifest, require_flows: bool) -> Result<()> {
242 if manifest.signature.trim().is_empty() {
243 bail!("pack signature must be a non-empty string");
244 }
245
246 if require_flows && manifest.flows.is_empty() {
247 bail!("pack must export at least one flow");
248 }
249
250 let mut seen_ids = HashSet::new();
251 for export in &manifest.flows {
252 if export.id.trim().is_empty() {
253 bail!("found flow with an empty id");
254 }
255 if !seen_ids.insert(export.id.clone()) {
256 bail!("duplicate flow id '{}'", export.id);
257 }
258 if let Some(schema) = &export.schema {
259 if !schema.is_object() {
260 bail!(
261 "flow '{}' schema must be a JSON object when provided",
262 export.id
263 );
264 }
265 }
266 }
267
268 Ok(())
269}
270
271fn ensure_runtime_matches_manifest(
272 manifest: &PackManifest,
273 runtime_flows: &[String],
274) -> Result<()> {
275 let manifest_set: HashSet<_> = manifest.flows.iter().map(|flow| flow.id.as_str()).collect();
276 let runtime_set: HashSet<_> = runtime_flows.iter().map(|id| id.as_str()).collect();
277
278 let missing_from_runtime: Vec<_> = manifest_set.difference(&runtime_set).cloned().collect();
279 let missing_from_manifest: Vec<_> = runtime_set.difference(&manifest_set).cloned().collect();
280
281 if !missing_from_runtime.is_empty() || !missing_from_manifest.is_empty() {
282 bail!(
283 "runtime exports do not align with manifest\nmissing in runtime: {:?}\nmissing in manifest: {:?}",
284 missing_from_runtime,
285 missing_from_manifest
286 );
287 }
288
289 Ok(())
290}