Skip to main content

spec_core/
passport.rs

1//! Passport generation for spec units.
2//!
3//! A passport is a static knowledge artifact derived from a LoadedSpec. One
4//! `.spec.passport.json` file is emitted per unit, co-located with its
5//! `.unit.spec` source file. Passports are derived artifacts (gitignored) and
6//! are written atomically only after all generation succeeds.
7
8use 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/// A single contract input parameter in the passport JSON.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct PassportInput {
19    pub name: String,
20    #[serde(rename = "type")]
21    pub type_: String,
22}
23
24/// Contract section of the passport.
25#[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/// A local test entry in the passport.
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct PassportLocalTest {
38    pub id: String,
39    pub expect: String,
40}
41
42/// Observed runtime result for one declared local test.
43#[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/// Observed runtime evidence captured from the last `spec test` run.
52#[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/// The full passport document for one unit.
60#[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
75/// Build a Passport from a LoadedSpec.
76///
77/// `generated_at` is injected so all passports in one run share an identical
78/// timestamp (batch consistency).
79pub fn build_passport(spec: &LoadedSpec, generated_at: &str) -> Passport {
80    build_passport_with_evidence(spec, generated_at, None)
81}
82
83/// Build a Passport from a LoadedSpec and optional observed evidence.
84pub 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
131/// Return the passport file path for a given source `.unit.spec` path.
132///
133/// Example: `units/pricing/apply_tax.unit.spec` →
134///          `units/pricing/apply_tax.spec.passport.json`
135pub 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
165/// Serialize a Passport to pretty-printed JSON and write it atomically
166/// co-located with the source `.unit.spec` file.
167pub 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
175/// Emit `**/*.spec.passport.json` to `<spec_root>/.gitignore` if not already
176/// present. Creates the file if it does not exist; appends if the entry is
177/// missing. Safe to call on every generate run (idempotent).
178pub 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    // Check for the entry on any line (trim trailing whitespace per line).
189    if existing.lines().any(|l| l.trim_end() == ENTRY) {
190        return Ok(());
191    }
192
193    // Append the entry, ensuring a leading newline if the file is non-empty and
194    // doesn't already end with a newline.
195    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
206/// Return an RFC 3339 UTC timestamp for the current moment (second precision).
207///
208/// Uses only `std::time`; no external crate dependency required.
209/// Output format: `YYYY-MM-DDTHH:MM:SSZ`
210pub 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
219/// Convert a Unix timestamp (seconds since epoch) to (year, month, day, hour,
220/// minute, second) using the proleptic Gregorian calendar.
221///
222/// Algorithm: Richards (2013), "Calendrical Calculations" variant — integer
223/// arithmetic only, handles leap years including 100/400-year rules.
224fn 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; // days since 1970-01-01
229
230    // Shift epoch to 1 March 0000 (simplifies leap-year arithmetic).
231    // 719468 = days from 0000-03-01 to 1970-01-01
232    let z = days + 719_468;
233    let era = z / 146_097; // 400-year era
234    let doe = z - era * 146_097; // day of era [0, 146096]
235    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // year of era [0, 399]
236    let y = yoe + era * 400; // year (March-based)
237    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365]
238    let mp = (5 * doy + 2) / 153; // month of year (March = 0)
239    let d = doy - (153 * mp + 2) / 5 + 1; // day [1, 31]
240    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // month [1, 12]
241    let y = if m <= 2 { y + 1 } else { y }; // adjust year for Jan/Feb
242
243    (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"); // default
346        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(); // create source file so parent exists
405
406        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        // Must match YYYY-MM-DDTHH:MM:SSZ
527        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        // Unix epoch = 1970-01-01T00:00:00Z
539        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        // 2026-04-04T12:34:56Z
546        // Days from epoch to 2026-04-04: calculate manually
547        // 2026-04-04 = epoch + 20547 days + 45296 seconds
548        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        // File without trailing newline
585        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}