1use crate::generator::write_generated_file;
9use crate::types::LoadedSpec;
10use crate::{AUTHORED_SPEC_VERSION, Result, SpecError};
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::time::{SystemTime, UNIX_EPOCH};
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct PassportInput {
19 pub name: String,
20 #[serde(rename = "type")]
21 pub type_: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct PassportContract {
27 #[serde(default, skip_serializing_if = "Vec::is_empty")]
28 pub inputs: Vec<PassportInput>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub returns: Option<String>,
31 #[serde(default, skip_serializing_if = "Vec::is_empty")]
32 pub invariants: Vec<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct PassportLocalTest {
38 pub id: String,
39 pub expect: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44pub struct PassportTestResult {
45 pub id: String,
46 pub status: String,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub reason: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53pub struct PassportEvidence {
54 pub build_status: String,
55 pub test_results: Vec<PassportTestResult>,
56 pub observed_at: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub struct Passport {
62 pub spec_version: String,
63 pub id: String,
64 pub intent: String,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub contract: Option<PassportContract>,
67 pub deps: Vec<String>,
68 pub local_tests: Vec<PassportLocalTest>,
69 pub generated_at: String,
70 pub source_file: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub evidence: Option<PassportEvidence>,
73}
74
75pub fn build_passport(spec: &LoadedSpec, generated_at: &str) -> Passport {
80 build_passport_with_evidence(spec, generated_at, None)
81}
82
83pub fn build_passport_with_evidence(
85 spec: &LoadedSpec,
86 generated_at: &str,
87 evidence: Option<PassportEvidence>,
88) -> Passport {
89 let contract = spec.spec.contract.as_ref().map(|c| PassportContract {
90 inputs: c
91 .inputs
92 .as_ref()
93 .map(|m| {
94 m.iter()
95 .map(|(name, type_str)| PassportInput {
96 name: name.clone(),
97 type_: type_str.clone(),
98 })
99 .collect()
100 })
101 .unwrap_or_default(),
102 returns: c.returns.clone(),
103 invariants: c.invariants.clone(),
104 });
105
106 Passport {
107 spec_version: spec
108 .spec
109 .spec_version
110 .clone()
111 .unwrap_or_else(|| AUTHORED_SPEC_VERSION.to_string()),
112 id: spec.spec.id.clone(),
113 intent: spec.spec.intent.why.clone(),
114 contract,
115 deps: spec.spec.deps.clone(),
116 local_tests: spec
117 .spec
118 .local_tests
119 .iter()
120 .map(|t| PassportLocalTest {
121 id: t.id.clone(),
122 expect: t.expect.clone(),
123 })
124 .collect(),
125 generated_at: generated_at.to_string(),
126 source_file: spec.source.file_path.clone(),
127 evidence,
128 }
129}
130
131pub fn passport_path_for(source_path: &Path) -> Result<PathBuf> {
136 let parent = source_path.parent().ok_or_else(|| SpecError::Generator {
137 message: format!(
138 "passport_path_for: cannot determine parent of {}",
139 source_path.display()
140 ),
141 })?;
142
143 let filename = source_path
144 .file_name()
145 .and_then(|n| n.to_str())
146 .ok_or_else(|| SpecError::Generator {
147 message: format!(
148 "passport_path_for: no filename in {}",
149 source_path.display()
150 ),
151 })?;
152
153 let stem = filename
154 .strip_suffix(".unit.spec")
155 .ok_or_else(|| SpecError::Generator {
156 message: format!(
157 "passport_path_for: path does not end with .unit.spec: {}",
158 source_path.display()
159 ),
160 })?;
161
162 Ok(parent.join(format!("{stem}.spec.passport.json")))
163}
164
165pub fn write_passport(passport: &Passport, source_file_path: &Path) -> Result<()> {
168 let json = serde_json::to_string_pretty(passport).map_err(|e| SpecError::Generator {
169 message: format!("Failed to serialize passport for '{}': {e}", passport.id),
170 })?;
171 let passport_path = passport_path_for(source_file_path)?;
172 write_generated_file(&passport_path.display().to_string(), &json)
173}
174
175pub fn ensure_gitignore_entry(spec_root: &Path) -> Result<()> {
179 const ENTRY: &str = "**/*.spec.passport.json";
180 let gitignore_path = spec_root.join(".gitignore");
181
182 let existing = if gitignore_path.exists() {
183 fs::read_to_string(&gitignore_path)?
184 } else {
185 String::new()
186 };
187
188 if existing.lines().any(|l| l.trim_end() == ENTRY) {
190 return Ok(());
191 }
192
193 let mut content = existing;
196 if !content.is_empty() && !content.ends_with('\n') {
197 content.push('\n');
198 }
199 content.push_str(ENTRY);
200 content.push('\n');
201
202 fs::write(&gitignore_path, content)?;
203 Ok(())
204}
205
206pub fn rfc3339_now() -> String {
211 let secs = SystemTime::now()
212 .duration_since(UNIX_EPOCH)
213 .unwrap_or_default()
214 .as_secs();
215 let (year, month, day, h, m, s) = secs_to_gregorian(secs);
216 format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
217}
218
219fn secs_to_gregorian(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
225 let sec = (secs % 60) as u32;
226 let min = ((secs / 60) % 60) as u32;
227 let hour = ((secs / 3600) % 24) as u32;
228 let days = secs / 86400; let z = days + 719_468;
233 let era = z / 146_097; let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y }; (y as u32, m as u32, d as u32, hour, min, sec)
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use crate::types::{Body, Contract, Intent, LocalTest, SpecSource, SpecStruct};
250 use indexmap::IndexMap;
251 use tempfile::TempDir;
252
253 fn make_loaded_spec(
254 id: &str,
255 file_path: &str,
256 spec_version: Option<&str>,
257 contract: Option<Contract>,
258 deps: Vec<&str>,
259 local_tests: Vec<(&str, &str)>,
260 ) -> LoadedSpec {
261 LoadedSpec {
262 source: SpecSource {
263 file_path: file_path.to_string(),
264 id: id.to_string(),
265 },
266 spec: SpecStruct {
267 id: id.to_string(),
268 kind: "function".to_string(),
269 intent: Intent {
270 why: format!("Why {id}"),
271 },
272 contract,
273 deps: deps.into_iter().map(String::from).collect(),
274 imports: vec![],
275 body: Body {
276 rust: "{ 42 }".to_string(),
277 },
278 local_tests: local_tests
279 .into_iter()
280 .map(|(tid, exp)| LocalTest {
281 id: tid.to_string(),
282 expect: exp.to_string(),
283 })
284 .collect(),
285 links: None,
286 spec_version: spec_version.map(String::from),
287 },
288 }
289 }
290
291 #[test]
292 fn build_passport_full_contract() {
293 let mut inputs = IndexMap::new();
294 inputs.insert("subtotal".to_string(), "Decimal".to_string());
295 inputs.insert("rate".to_string(), "Decimal".to_string());
296 let contract = Contract {
297 inputs: Some(inputs),
298 returns: Some("Decimal".to_string()),
299 invariants: vec!["output >= subtotal".to_string()],
300 };
301
302 let spec = make_loaded_spec(
303 "pricing/apply_tax",
304 "units/pricing/apply_tax.unit.spec",
305 Some("0.3.0"),
306 Some(contract),
307 vec!["money/round"],
308 vec![("basic", "apply_tax(1,2) == 3")],
309 );
310 let passport = build_passport(&spec, "2026-04-04T00:00:00Z");
311
312 assert_eq!(passport.spec_version, "0.3.0");
313 assert_eq!(passport.id, "pricing/apply_tax");
314 assert_eq!(passport.intent, "Why pricing/apply_tax");
315 assert_eq!(passport.deps, vec!["money/round"]);
316 assert_eq!(passport.generated_at, "2026-04-04T00:00:00Z");
317 assert_eq!(passport.source_file, "units/pricing/apply_tax.unit.spec");
318
319 let c = passport.contract.unwrap();
320 assert_eq!(c.inputs.len(), 2);
321 assert_eq!(c.inputs[0].name, "subtotal");
322 assert_eq!(c.inputs[0].type_, "Decimal");
323 assert_eq!(c.inputs[1].name, "rate");
324 assert_eq!(c.inputs[1].type_, "Decimal");
325 assert_eq!(c.returns, Some("Decimal".to_string()));
326 assert_eq!(c.invariants, vec!["output >= subtotal"]);
327
328 assert_eq!(passport.local_tests.len(), 1);
329 assert_eq!(passport.local_tests[0].id, "basic");
330 assert!(passport.evidence.is_none());
331 }
332
333 #[test]
334 fn build_passport_no_contract() {
335 let spec = make_loaded_spec(
336 "money/round",
337 "units/money/round.unit.spec",
338 None,
339 None,
340 vec![],
341 vec![],
342 );
343 let passport = build_passport(&spec, "2026-04-04T00:00:00Z");
344 assert!(passport.contract.is_none());
345 assert_eq!(passport.spec_version, "0.3.0"); assert!(passport.deps.is_empty());
347 assert!(passport.local_tests.is_empty());
348 assert!(passport.evidence.is_none());
349 }
350
351 #[test]
352 fn build_passport_uses_spec_version_from_unit() {
353 let spec = make_loaded_spec(
354 "money/round",
355 "units/money/round.unit.spec",
356 Some("0.3.0"),
357 None,
358 vec![],
359 vec![],
360 );
361 let passport = build_passport(&spec, "t");
362 assert_eq!(passport.spec_version, "0.3.0");
363 }
364
365 #[test]
366 fn build_passport_defaults_spec_version_when_absent() {
367 let spec = make_loaded_spec(
368 "money/round",
369 "units/money/round.unit.spec",
370 None,
371 None,
372 vec![],
373 vec![],
374 );
375 let passport = build_passport(&spec, "t");
376 assert_eq!(passport.spec_version, "0.3.0");
377 }
378
379 #[test]
380 fn passport_path_for_standard_unit() {
381 let p = passport_path_for(Path::new("units/pricing/apply_tax.unit.spec")).unwrap();
382 assert_eq!(
383 p,
384 PathBuf::from("units/pricing/apply_tax.spec.passport.json")
385 );
386 }
387
388 #[test]
389 fn passport_path_for_root_level_unit() {
390 let p = passport_path_for(Path::new("money/round.unit.spec")).unwrap();
391 assert_eq!(p, PathBuf::from("money/round.spec.passport.json"));
392 }
393
394 #[test]
395 fn passport_path_for_rejects_non_unit_spec() {
396 let result = passport_path_for(Path::new("units/pricing/apply_tax.rs"));
397 assert!(result.is_err());
398 }
399
400 #[test]
401 fn write_passport_creates_valid_json() {
402 let dir = TempDir::new().unwrap();
403 let source_path = dir.path().join("apply_tax.unit.spec");
404 fs::write(&source_path, "").unwrap(); let spec = make_loaded_spec(
407 "pricing/apply_tax",
408 source_path.to_str().unwrap(),
409 Some("0.3.0"),
410 None,
411 vec![],
412 vec![],
413 );
414 let passport = build_passport(&spec, "2026-04-04T00:00:00Z");
415 write_passport(&passport, &source_path).unwrap();
416
417 let passport_path = dir.path().join("apply_tax.spec.passport.json");
418 assert!(passport_path.exists());
419
420 let content = fs::read_to_string(&passport_path).unwrap();
421 let parsed: Passport = serde_json::from_str(&content).unwrap();
422 assert_eq!(parsed.id, "pricing/apply_tax");
423 assert_eq!(parsed.generated_at, "2026-04-04T00:00:00Z");
424 }
425
426 #[test]
427 fn write_passport_round_trips_contract_with_omitted_empty_fields() {
428 let dir = TempDir::new().unwrap();
429 let source_path = dir.path().join("apply_tax.unit.spec");
430 fs::write(&source_path, "").unwrap();
431
432 let mut inputs = IndexMap::new();
433 inputs.insert("subtotal".to_string(), "i32".to_string());
434 let spec = make_loaded_spec(
435 "pricing/apply_tax",
436 source_path.to_str().unwrap(),
437 Some("0.3.0"),
438 Some(Contract {
439 inputs: Some(inputs),
440 returns: Some("i32".to_string()),
441 invariants: vec![],
442 }),
443 vec![],
444 vec![],
445 );
446 let passport = build_passport(&spec, "2026-04-04T00:00:00Z");
447 write_passport(&passport, &source_path).unwrap();
448
449 let content = fs::read_to_string(dir.path().join("apply_tax.spec.passport.json")).unwrap();
450 let parsed: Passport = serde_json::from_str(&content).unwrap();
451
452 assert_eq!(
453 parsed.contract.unwrap(),
454 PassportContract {
455 inputs: vec![PassportInput {
456 name: "subtotal".to_string(),
457 type_: "i32".to_string(),
458 }],
459 returns: Some("i32".to_string()),
460 invariants: vec![],
461 }
462 );
463 }
464
465 #[test]
466 fn build_passport_with_evidence_serializes_observed_results() {
467 let spec = make_loaded_spec(
468 "pricing/apply_tax",
469 "units/pricing/apply_tax.unit.spec",
470 Some("0.3.0"),
471 None,
472 vec![],
473 vec![("basic", "apply_tax(1,2) == 3")],
474 );
475 let passport = build_passport_with_evidence(
476 &spec,
477 "2026-04-04T00:00:00Z",
478 Some(PassportEvidence {
479 build_status: "pass".to_string(),
480 test_results: vec![PassportTestResult {
481 id: "basic".to_string(),
482 status: "pass".to_string(),
483 reason: None,
484 }],
485 observed_at: "2026-04-04T00:01:00Z".to_string(),
486 }),
487 );
488
489 assert_eq!(
490 passport.evidence,
491 Some(PassportEvidence {
492 build_status: "pass".to_string(),
493 test_results: vec![PassportTestResult {
494 id: "basic".to_string(),
495 status: "pass".to_string(),
496 reason: None,
497 }],
498 observed_at: "2026-04-04T00:01:00Z".to_string(),
499 })
500 );
501 }
502
503 #[test]
504 fn spec_generate_passport_has_no_evidence() {
505 let spec = make_loaded_spec(
506 "money/round",
507 "units/money/round.unit.spec",
508 Some("0.3.0"),
509 None,
510 vec![],
511 vec![],
512 );
513 let passport = build_passport(&spec, "2026-04-04T00:00:00Z");
514 let json = serde_json::to_string(&passport).unwrap();
515
516 assert!(passport.evidence.is_none());
517 assert!(
518 !json.contains("\"evidence\""),
519 "static passport should not serialize evidence: {json}"
520 );
521 }
522
523 #[test]
524 fn rfc3339_now_format() {
525 let ts = rfc3339_now();
526 assert_eq!(ts.len(), 20, "timestamp length should be 20: {ts}");
528 assert_eq!(&ts[4..5], "-");
529 assert_eq!(&ts[7..8], "-");
530 assert_eq!(&ts[10..11], "T");
531 assert_eq!(&ts[13..14], ":");
532 assert_eq!(&ts[16..17], ":");
533 assert_eq!(&ts[19..20], "Z");
534 }
535
536 #[test]
537 fn rfc3339_known_epoch() {
538 let (y, mo, d, h, m, s) = secs_to_gregorian(0);
540 assert_eq!((y, mo, d, h, m, s), (1970, 1, 1, 0, 0, 0));
541 }
542
543 #[test]
544 fn rfc3339_known_date() {
545 let ts = 20547 * 86400 + 12 * 3600 + 34 * 60 + 56;
549 let (y, mo, d, h, m, s) = secs_to_gregorian(ts);
550 assert_eq!((y, mo, d, h, m, s), (2026, 4, 4, 12, 34, 56));
551 }
552
553 #[test]
554 fn ensure_gitignore_creates_file_when_absent() {
555 let dir = TempDir::new().unwrap();
556 ensure_gitignore_entry(dir.path()).unwrap();
557 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
558 assert!(content.contains("**/*.spec.passport.json"));
559 }
560
561 #[test]
562 fn ensure_gitignore_appends_when_entry_missing() {
563 let dir = TempDir::new().unwrap();
564 fs::write(dir.path().join(".gitignore"), "*.rs\n").unwrap();
565 ensure_gitignore_entry(dir.path()).unwrap();
566 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
567 assert!(content.contains("*.rs"));
568 assert!(content.contains("**/*.spec.passport.json"));
569 }
570
571 #[test]
572 fn ensure_gitignore_is_idempotent() {
573 let dir = TempDir::new().unwrap();
574 ensure_gitignore_entry(dir.path()).unwrap();
575 ensure_gitignore_entry(dir.path()).unwrap();
576 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
577 let count = content.matches("**/*.spec.passport.json").count();
578 assert_eq!(count, 1, "entry should appear exactly once");
579 }
580
581 #[test]
582 fn ensure_gitignore_no_trailing_newline_handled() {
583 let dir = TempDir::new().unwrap();
584 fs::write(dir.path().join(".gitignore"), "*.rs").unwrap();
586 ensure_gitignore_entry(dir.path()).unwrap();
587 let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
588 assert!(content.contains("*.rs\n**/*.spec.passport.json"));
589 }
590}