greentic_conformance/
pack_suite.rs

1use 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
11/// Adapter that knows how to interrogate a pack component at runtime.
12pub 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/// Configuration knobs for pack verification.
26#[derive(Clone)]
27pub struct PackSuiteOptions {
28    /// Optional manifest path override.
29    pub manifest_override: Option<PathBuf>,
30    /// Require at least one flow to be exported.
31    pub require_flows: bool,
32    /// Optional runtime adapter used to query the component for its exports.
33    pub runtime_adapter: Option<Arc<dyn PackRuntimeAdapter>>,
34    /// Fail verification if runtime exports diverge from the manifest.
35    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/// Manifest describing a Greentic pack component.
68#[derive(Debug, Clone, Deserialize, Serialize)]
69pub struct PackManifest {
70    pub signature: String,
71    #[serde(default)]
72    pub flows: Vec<PackExport>,
73}
74
75/// Basic metadata for a flow exported by the pack.
76#[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/// Result of verifying a pack component.
86#[derive(Debug, Clone)]
87pub struct PackReport {
88    pub manifest_path: PathBuf,
89    pub manifest: PackManifest,
90    pub runtime_flows: Option<Vec<String>>,
91}
92
93/// Primary entrypoint that verifies the pack exports using the default options.
94pub fn verify_pack_exports(component_path: &str) -> Result<PackReport> {
95    PackSuiteOptions::default().verify_pack_exports(component_path)
96}
97
98impl PackSuiteOptions {
99    /// Executes the pack export verification with custom options.
100    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    /// Convenience helper to configure a runtime adapter.
152    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    /// Convenience helper to override the manifest discovery path.
161    pub fn with_manifest_override(mut self, manifest: impl Into<PathBuf>) -> Self {
162        self.manifest_override = Some(manifest.into());
163        self
164    }
165
166    /// Allows diverging runtime exports without failing verification.
167    pub fn allow_runtime_mismatch(mut self) -> Self {
168        self.require_runtime_match = false;
169        self
170    }
171}
172
173/// Attempts to resolve the manifest associated with a component path.
174pub 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}