1use 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}