Skip to main content

spec_core/
export.rs

1//! JSON export support for loaded spec sets.
2//!
3//! The export bundle is a read-only artifact intended for downstream tooling.
4//! It includes authored unit metadata, any readable co-located passports,
5//! the dependency edge list, and structured warnings for skipped passports.
6
7use crate::AUTHORED_SPEC_VERSION;
8use crate::passport::{Passport, passport_path_for};
9use crate::types::{Contract, LoadedSpec, LocalTest};
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::Path;
13
14const EXPORT_SCHEMA_VERSION: &str = "1.0";
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct ExportBundle {
18    pub schema_version: String,
19    pub spec_version: String,
20    pub exported_at: String,
21    pub units: Vec<ExportUnit>,
22    pub passports: Vec<Passport>,
23    pub graph: ExportGraph,
24    pub warnings: Vec<ExportWarning>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28pub struct ExportUnit {
29    pub id: String,
30    pub intent: String,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub contract: Option<Contract>,
33    pub deps: Vec<String>,
34    pub local_tests: Vec<LocalTest>,
35    pub source_file: String,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub struct ExportGraph {
40    pub edges: Vec<ExportEdge>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
44pub struct ExportEdge {
45    pub from: String,
46    pub to: String,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub struct ExportWarning {
51    pub code: String,
52    pub spec_id: String,
53    pub passport_path: String,
54    pub message: String,
55}
56
57pub fn build_export_bundle(specs: &[LoadedSpec], exported_at: &str) -> ExportBundle {
58    let (passports, warnings) = load_passports_for_specs(specs);
59    let mut edges = specs
60        .iter()
61        .flat_map(|spec| {
62            spec.spec.deps.iter().map(|dep| ExportEdge {
63                from: spec.spec.id.clone(),
64                to: dep.clone(),
65            })
66        })
67        .collect::<Vec<_>>();
68    edges.sort();
69
70    ExportBundle {
71        schema_version: EXPORT_SCHEMA_VERSION.to_string(),
72        spec_version: AUTHORED_SPEC_VERSION.to_string(),
73        exported_at: exported_at.to_string(),
74        units: specs.iter().map(ExportUnit::from).collect(),
75        passports,
76        graph: ExportGraph { edges },
77        warnings,
78    }
79}
80
81pub fn load_passports_for_specs(specs: &[LoadedSpec]) -> (Vec<Passport>, Vec<ExportWarning>) {
82    let mut passports = Vec::new();
83    let mut warnings = Vec::new();
84
85    for spec in specs {
86        let source_path = Path::new(&spec.source.file_path);
87        let passport_path = match passport_path_for(source_path) {
88            Ok(path) => path,
89            Err(err) => {
90                warnings.push(ExportWarning {
91                    code: "passport_malformed".to_string(),
92                    spec_id: spec.spec.id.clone(),
93                    passport_path: source_path.display().to_string(),
94                    message: err.to_string(),
95                });
96                continue;
97            }
98        };
99
100        match fs::read_to_string(&passport_path) {
101            Ok(content) => match serde_json::from_str::<Passport>(&content) {
102                Ok(passport) => passports.push(passport),
103                Err(err) => warnings.push(ExportWarning {
104                    code: "passport_malformed".to_string(),
105                    spec_id: spec.spec.id.clone(),
106                    passport_path: passport_path.display().to_string(),
107                    message: format!("Failed to parse passport JSON: {err}"),
108                }),
109            },
110            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
111                warnings.push(ExportWarning {
112                    code: "passport_missing".to_string(),
113                    spec_id: spec.spec.id.clone(),
114                    passport_path: passport_path.display().to_string(),
115                    message: format!("Passport file not found: {}", passport_path.display()),
116                });
117            }
118            Err(err) => warnings.push(ExportWarning {
119                code: "passport_malformed".to_string(),
120                spec_id: spec.spec.id.clone(),
121                passport_path: passport_path.display().to_string(),
122                message: format!("Failed to read passport file: {err}"),
123            }),
124        }
125    }
126
127    (passports, warnings)
128}
129
130impl From<&LoadedSpec> for ExportUnit {
131    fn from(spec: &LoadedSpec) -> Self {
132        Self {
133            id: spec.spec.id.clone(),
134            intent: spec.spec.intent.why.clone(),
135            contract: spec.spec.contract.clone(),
136            deps: spec.spec.deps.clone(),
137            local_tests: spec.spec.local_tests.clone(),
138            source_file: spec.source.file_path.clone(),
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::passport::{
147        PassportEvidence, PassportTestResult, build_passport_with_evidence, write_passport,
148    };
149    use crate::types::{Body, Intent, SpecSource, SpecStruct};
150    use indexmap::IndexMap;
151    use tempfile::TempDir;
152
153    fn loaded_spec(dir: &TempDir, rel_path: &str, id: &str, deps: Vec<&str>) -> LoadedSpec {
154        let source_path = dir.path().join(rel_path);
155        if let Some(parent) = source_path.parent() {
156            fs::create_dir_all(parent).unwrap();
157        }
158        fs::write(&source_path, "placeholder").unwrap();
159
160        LoadedSpec {
161            source: SpecSource {
162                file_path: source_path.display().to_string(),
163                id: id.to_string(),
164            },
165            spec: SpecStruct {
166                id: id.to_string(),
167                kind: "function".to_string(),
168                intent: Intent {
169                    why: format!("Why {id}"),
170                },
171                contract: Some(Contract {
172                    inputs: Some(IndexMap::from([("value".to_string(), "i32".to_string())])),
173                    returns: Some("i32".to_string()),
174                    invariants: vec![],
175                }),
176                deps: deps.into_iter().map(str::to_string).collect(),
177                imports: vec![],
178                body: Body {
179                    rust: "{ value }".to_string(),
180                },
181                local_tests: vec![LocalTest {
182                    id: "basic".to_string(),
183                    expect: "true".to_string(),
184                }],
185                links: None,
186                spec_version: Some("9.9.9".to_string()),
187            },
188        }
189    }
190
191    #[test]
192    fn build_export_bundle_graph_edges_correct() {
193        let dir = TempDir::new().unwrap();
194        let spec_a = loaded_spec(
195            &dir,
196            "units/pricing/apply_tax.unit.spec",
197            "pricing/apply_tax",
198            vec!["money/round", "money/format"],
199        );
200        let spec_b = loaded_spec(&dir, "units/money/round.unit.spec", "money/round", vec![]);
201
202        let bundle = build_export_bundle(&[spec_a, spec_b], "2026-04-05T00:00:00Z");
203
204        assert_eq!(
205            bundle.graph.edges,
206            vec![
207                ExportEdge {
208                    from: "pricing/apply_tax".to_string(),
209                    to: "money/format".to_string(),
210                },
211                ExportEdge {
212                    from: "pricing/apply_tax".to_string(),
213                    to: "money/round".to_string(),
214                },
215            ]
216        );
217    }
218
219    #[test]
220    fn spec_export_schema_version_separate_from_spec_version() {
221        let dir = TempDir::new().unwrap();
222        let spec = loaded_spec(
223            &dir,
224            "units/pricing/apply_tax.unit.spec",
225            "pricing/apply_tax",
226            vec![],
227        );
228
229        let bundle = build_export_bundle(&[spec], "2026-04-05T00:00:00Z");
230
231        assert_eq!(bundle.schema_version, "1.0");
232        assert_eq!(bundle.spec_version, crate::AUTHORED_SPEC_VERSION);
233        assert_ne!(bundle.schema_version, bundle.spec_version);
234    }
235
236    #[test]
237    fn spec_export_malformed_passport_json_produces_warning_not_crash() {
238        let dir = TempDir::new().unwrap();
239        let spec = loaded_spec(
240            &dir,
241            "units/pricing/apply_tax.unit.spec",
242            "pricing/apply_tax",
243            vec![],
244        );
245        let passport_path = passport_path_for(Path::new(&spec.source.file_path)).unwrap();
246        fs::write(&passport_path, "{\"id\":").unwrap();
247
248        let (passports, warnings) = load_passports_for_specs(&[spec]);
249
250        assert!(passports.is_empty());
251        assert_eq!(warnings.len(), 1);
252        assert_eq!(warnings[0].code, "passport_malformed");
253        assert!(
254            warnings[0]
255                .message
256                .contains("Failed to parse passport JSON")
257        );
258    }
259
260    #[test]
261    fn build_export_bundle_is_deterministic_for_edges_and_warnings() {
262        let dir = TempDir::new().unwrap();
263        let spec_a = loaded_spec(
264            &dir,
265            "units/pricing/apply_tax.unit.spec",
266            "pricing/apply_tax",
267            vec!["money/round", "money/format"],
268        );
269        let spec_b = loaded_spec(
270            &dir,
271            "units/pricing/apply_discount.unit.spec",
272            "pricing/apply_discount",
273            vec!["money/round"],
274        );
275
276        let passport_b = build_passport_with_evidence(
277            &spec_b,
278            "2026-04-05T00:00:00Z",
279            Some(PassportEvidence {
280                build_status: "pass".to_string(),
281                test_results: vec![PassportTestResult {
282                    id: "basic".to_string(),
283                    status: "pass".to_string(),
284                    reason: None,
285                }],
286                observed_at: "2026-04-05T00:00:00Z".to_string(),
287            }),
288            None,
289        );
290        write_passport(&passport_b, Path::new(&spec_b.source.file_path)).unwrap();
291
292        let bundle = build_export_bundle(&[spec_a.clone(), spec_b.clone()], "2026-04-05T01:00:00Z");
293
294        assert_eq!(bundle.graph.edges[0].from, "pricing/apply_discount");
295        assert_eq!(bundle.graph.edges[0].to, "money/round");
296        assert_eq!(bundle.graph.edges[1].from, "pricing/apply_tax");
297        assert_eq!(bundle.graph.edges[1].to, "money/format");
298        assert_eq!(bundle.graph.edges[2].from, "pricing/apply_tax");
299        assert_eq!(bundle.graph.edges[2].to, "money/round");
300        assert_eq!(bundle.warnings.len(), 1);
301        assert_eq!(bundle.warnings[0].spec_id, spec_a.spec.id);
302        assert_eq!(bundle.warnings[0].code, "passport_missing");
303        assert_eq!(bundle.passports.len(), 1);
304        assert_eq!(bundle.passports[0].id, spec_b.spec.id);
305    }
306}