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 sha2::{Digest, Sha256};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17/// A single contract input parameter in the passport JSON.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct PassportInput {
20    pub name: String,
21    #[serde(rename = "type")]
22    pub type_: String,
23}
24
25/// Contract section of the passport.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27pub struct PassportContract {
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub inputs: Vec<PassportInput>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub returns: Option<String>,
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub invariants: Vec<String>,
34}
35
36/// A local test entry in the passport.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct PassportLocalTest {
39    pub id: String,
40    pub expect: String,
41}
42
43/// Observed runtime result for one declared local test.
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45pub struct PassportTestResult {
46    pub id: String,
47    pub status: String,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub reason: Option<String>,
50}
51
52/// Observed runtime evidence captured from the last `spec test` run.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54pub struct PassportEvidence {
55    pub build_status: String,
56    pub test_results: Vec<PassportTestResult>,
57    pub observed_at: String,
58}
59
60/// The full passport document for one unit.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62pub struct Passport {
63    pub spec_version: String,
64    pub id: String,
65    pub intent: String,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub contract: Option<PassportContract>,
68    pub deps: Vec<String>,
69    pub local_tests: Vec<PassportLocalTest>,
70    pub generated_at: String,
71    pub source_file: String,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub evidence: Option<PassportEvidence>,
74    #[serde(
75        default,
76        skip_serializing_if = "Option::is_none",
77        deserialize_with = "deserialize_contract_hash"
78    )]
79    pub contract_hash: Option<String>,
80}
81
82/// Build a Passport from a LoadedSpec.
83///
84/// `generated_at` is injected so all passports in one run share an identical
85/// timestamp (batch consistency).
86pub fn build_passport(spec: &LoadedSpec, generated_at: &str) -> Passport {
87    build_passport_with_evidence(spec, generated_at, None, None)
88}
89
90/// Build a Passport from a LoadedSpec and optional observed evidence.
91pub fn build_passport_with_evidence(
92    spec: &LoadedSpec,
93    generated_at: &str,
94    evidence: Option<PassportEvidence>,
95    contract_hash: Option<String>,
96) -> Passport {
97    let contract = spec.spec.contract.as_ref().map(|c| PassportContract {
98        inputs: c
99            .inputs
100            .as_ref()
101            .map(|m| {
102                m.iter()
103                    .map(|(name, type_str)| PassportInput {
104                        name: name.clone(),
105                        type_: type_str.clone(),
106                    })
107                    .collect()
108            })
109            .unwrap_or_default(),
110        returns: c.returns.clone(),
111        invariants: c.invariants.clone(),
112    });
113
114    Passport {
115        spec_version: spec
116            .spec
117            .spec_version
118            .clone()
119            .unwrap_or_else(|| AUTHORED_SPEC_VERSION.to_string()),
120        id: spec.spec.id.clone(),
121        intent: spec.spec.intent.why.clone(),
122        contract,
123        deps: spec.spec.deps.clone(),
124        local_tests: spec
125            .spec
126            .local_tests
127            .iter()
128            .map(|t| PassportLocalTest {
129                id: t.id.clone(),
130                expect: t.expect.clone(),
131            })
132            .collect(),
133        generated_at: generated_at.to_string(),
134        source_file: spec.source.file_path.clone(),
135        evidence,
136        contract_hash,
137    }
138}
139
140/// Compute SHA-256 of the serialized contract field.
141///
142/// Returns `None` when the spec has no contract block.
143pub fn compute_contract_hash(spec: &LoadedSpec) -> Option<String> {
144    let contract = spec.spec.contract.as_ref()?;
145    let json = serde_json::to_string(contract)
146        .expect("contract serialization cannot fail for well-formed spec");
147    let hash = Sha256::digest(json.as_bytes());
148    Some(format!("sha256:{}", hex::encode(hash)))
149}
150
151/// Return the passport file path for a given source `.unit.spec` path.
152///
153/// Example: `units/pricing/apply_tax.unit.spec` →
154///          `units/pricing/apply_tax.spec.passport.json`
155pub fn passport_path_for(source_path: &Path) -> Result<PathBuf> {
156    let parent = source_path.parent().ok_or_else(|| SpecError::Generator {
157        message: format!(
158            "passport_path_for: cannot determine parent of {}",
159            source_path.display()
160        ),
161    })?;
162
163    let filename = source_path
164        .file_name()
165        .and_then(|n| n.to_str())
166        .ok_or_else(|| SpecError::Generator {
167            message: format!(
168                "passport_path_for: no filename in {}",
169                source_path.display()
170            ),
171        })?;
172
173    let stem = filename
174        .strip_suffix(".unit.spec")
175        .ok_or_else(|| SpecError::Generator {
176            message: format!(
177                "passport_path_for: path does not end with .unit.spec: {}",
178                source_path.display()
179            ),
180        })?;
181
182    Ok(parent.join(format!("{stem}.spec.passport.json")))
183}
184
185/// Read a passport for a given source `.unit.spec` path.
186///
187/// Returns `Ok(None)` when the passport file does not exist.
188/// Returns `Err` when the file exists but cannot be parsed.
189pub fn read_passport(source_path: &Path) -> Result<Option<Passport>> {
190    let passport_path = passport_path_for(source_path)?;
191
192    let content = match fs::read_to_string(&passport_path) {
193        Ok(content) => content,
194        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
195        Err(err) => return Err(err.into()),
196    };
197
198    Ok(Some(serde_json::from_str(&content)?))
199}
200
201/// Serialize a Passport to pretty-printed JSON and write it atomically
202/// co-located with the source `.unit.spec` file.
203pub fn write_passport(passport: &Passport, source_file_path: &Path) -> Result<()> {
204    let json = serde_json::to_string_pretty(passport).map_err(|e| SpecError::Generator {
205        message: format!("Failed to serialize passport for '{}': {e}", passport.id),
206    })?;
207    let passport_path = passport_path_for(source_file_path)?;
208    write_generated_file(&passport_path.display().to_string(), &json)
209}
210
211fn deserialize_contract_hash<'de, D>(
212    deserializer: D,
213) -> std::result::Result<Option<String>, D::Error>
214where
215    D: serde::Deserializer<'de>,
216{
217    let contract_hash = Option::<String>::deserialize(deserializer)?;
218    Ok(contract_hash.filter(|hash| hash.starts_with("sha256:")))
219}
220
221/// Emit `**/*.spec.passport.json` to `<spec_root>/.gitignore` if not already
222/// present. Creates the file if it does not exist; appends if the entry is
223/// missing. Safe to call on every generate run (idempotent).
224pub fn ensure_gitignore_entry(spec_root: &Path) -> Result<()> {
225    const ENTRY: &str = "**/*.spec.passport.json";
226    let gitignore_path = spec_root.join(".gitignore");
227
228    let existing = if gitignore_path.exists() {
229        fs::read_to_string(&gitignore_path)?
230    } else {
231        String::new()
232    };
233
234    // Check for the entry on any line (trim trailing whitespace per line).
235    if existing.lines().any(|l| l.trim_end() == ENTRY) {
236        return Ok(());
237    }
238
239    // Append the entry, ensuring a leading newline if the file is non-empty and
240    // doesn't already end with a newline.
241    let mut content = existing;
242    if !content.is_empty() && !content.ends_with('\n') {
243        content.push('\n');
244    }
245    content.push_str(ENTRY);
246    content.push('\n');
247
248    fs::write(&gitignore_path, content)?;
249    Ok(())
250}
251
252/// Return an RFC 3339 UTC timestamp for the current moment (second precision).
253///
254/// Uses only `std::time`; no external crate dependency required.
255/// Output format: `YYYY-MM-DDTHH:MM:SSZ`
256pub fn rfc3339_now() -> String {
257    let secs = SystemTime::now()
258        .duration_since(UNIX_EPOCH)
259        .unwrap_or_default()
260        .as_secs();
261    let (year, month, day, h, m, s) = secs_to_gregorian(secs);
262    format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
263}
264
265/// Convert a Unix timestamp (seconds since epoch) to (year, month, day, hour,
266/// minute, second) using the proleptic Gregorian calendar.
267///
268/// Algorithm: Richards (2013), "Calendrical Calculations" variant — integer
269/// arithmetic only, handles leap years including 100/400-year rules.
270fn secs_to_gregorian(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
271    let sec = (secs % 60) as u32;
272    let min = ((secs / 60) % 60) as u32;
273    let hour = ((secs / 3600) % 24) as u32;
274    let days = secs / 86400; // days since 1970-01-01
275
276    // Shift epoch to 1 March 0000 (simplifies leap-year arithmetic).
277    // 719468 = days from 0000-03-01 to 1970-01-01
278    let z = days + 719_468;
279    let era = z / 146_097; // 400-year era
280    let doe = z - era * 146_097; // day of era [0, 146096]
281    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // year of era [0, 399]
282    let y = yoe + era * 400; // year (March-based)
283    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365]
284    let mp = (5 * doy + 2) / 153; // month of year (March = 0)
285    let d = doy - (153 * mp + 2) / 5 + 1; // day [1, 31]
286    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // month [1, 12]
287    let y = if m <= 2 { y + 1 } else { y }; // adjust year for Jan/Feb
288
289    (y as u32, m as u32, d as u32, hour, min, sec)
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::types::{Body, Contract, Intent, LocalTest, SpecSource, SpecStruct};
296    use indexmap::IndexMap;
297    use tempfile::TempDir;
298
299    fn make_loaded_spec(
300        id: &str,
301        file_path: &str,
302        spec_version: Option<&str>,
303        contract: Option<Contract>,
304        deps: Vec<&str>,
305        local_tests: Vec<(&str, &str)>,
306    ) -> LoadedSpec {
307        LoadedSpec {
308            source: SpecSource {
309                file_path: file_path.to_string(),
310                id: id.to_string(),
311            },
312            spec: SpecStruct {
313                id: id.to_string(),
314                kind: "function".to_string(),
315                intent: Intent {
316                    why: format!("Why {id}"),
317                },
318                contract,
319                deps: deps.into_iter().map(String::from).collect(),
320                imports: vec![],
321                body: Body {
322                    rust: "{ 42 }".to_string(),
323                },
324                local_tests: local_tests
325                    .into_iter()
326                    .map(|(tid, exp)| LocalTest {
327                        id: tid.to_string(),
328                        expect: exp.to_string(),
329                    })
330                    .collect(),
331                links: None,
332                spec_version: spec_version.map(String::from),
333            },
334        }
335    }
336
337    #[test]
338    fn build_passport_full_contract() {
339        let mut inputs = IndexMap::new();
340        inputs.insert("subtotal".to_string(), "Decimal".to_string());
341        inputs.insert("rate".to_string(), "Decimal".to_string());
342        let contract = Contract {
343            inputs: Some(inputs),
344            returns: Some("Decimal".to_string()),
345            invariants: vec!["output >= subtotal".to_string()],
346        };
347
348        let spec = make_loaded_spec(
349            "pricing/apply_tax",
350            "units/pricing/apply_tax.unit.spec",
351            Some("0.3.0"),
352            Some(contract),
353            vec!["money/round"],
354            vec![("basic", "apply_tax(1,2) == 3")],
355        );
356        let passport = build_passport(&spec, "2026-04-04T00:00:00Z");
357
358        assert_eq!(passport.spec_version, "0.3.0");
359        assert_eq!(passport.id, "pricing/apply_tax");
360        assert_eq!(passport.intent, "Why pricing/apply_tax");
361        assert_eq!(passport.deps, vec!["money/round"]);
362        assert_eq!(passport.generated_at, "2026-04-04T00:00:00Z");
363        assert_eq!(passport.source_file, "units/pricing/apply_tax.unit.spec");
364        assert!(passport.contract_hash.is_none());
365
366        let c = passport.contract.unwrap();
367        assert_eq!(c.inputs.len(), 2);
368        assert_eq!(c.inputs[0].name, "subtotal");
369        assert_eq!(c.inputs[0].type_, "Decimal");
370        assert_eq!(c.inputs[1].name, "rate");
371        assert_eq!(c.inputs[1].type_, "Decimal");
372        assert_eq!(c.returns, Some("Decimal".to_string()));
373        assert_eq!(c.invariants, vec!["output >= subtotal"]);
374
375        assert_eq!(passport.local_tests.len(), 1);
376        assert_eq!(passport.local_tests[0].id, "basic");
377        assert!(passport.evidence.is_none());
378    }
379
380    #[test]
381    fn build_passport_no_contract() {
382        let spec = make_loaded_spec(
383            "money/round",
384            "units/money/round.unit.spec",
385            None,
386            None,
387            vec![],
388            vec![],
389        );
390        let passport = build_passport(&spec, "2026-04-04T00:00:00Z");
391        assert!(passport.contract.is_none());
392        assert_eq!(passport.spec_version, "0.3.0"); // default
393        assert!(passport.deps.is_empty());
394        assert!(passport.local_tests.is_empty());
395        assert!(passport.evidence.is_none());
396        assert!(passport.contract_hash.is_none());
397    }
398
399    #[test]
400    fn build_passport_uses_spec_version_from_unit() {
401        let spec = make_loaded_spec(
402            "money/round",
403            "units/money/round.unit.spec",
404            Some("0.3.0"),
405            None,
406            vec![],
407            vec![],
408        );
409        let passport = build_passport(&spec, "t");
410        assert_eq!(passport.spec_version, "0.3.0");
411    }
412
413    #[test]
414    fn build_passport_defaults_spec_version_when_absent() {
415        let spec = make_loaded_spec(
416            "money/round",
417            "units/money/round.unit.spec",
418            None,
419            None,
420            vec![],
421            vec![],
422        );
423        let passport = build_passport(&spec, "t");
424        assert_eq!(passport.spec_version, "0.3.0");
425    }
426
427    #[test]
428    fn passport_path_for_standard_unit() {
429        let p = passport_path_for(Path::new("units/pricing/apply_tax.unit.spec")).unwrap();
430        assert_eq!(
431            p,
432            PathBuf::from("units/pricing/apply_tax.spec.passport.json")
433        );
434    }
435
436    #[test]
437    fn passport_path_for_root_level_unit() {
438        let p = passport_path_for(Path::new("money/round.unit.spec")).unwrap();
439        assert_eq!(p, PathBuf::from("money/round.spec.passport.json"));
440    }
441
442    #[test]
443    fn passport_path_for_rejects_non_unit_spec() {
444        let result = passport_path_for(Path::new("units/pricing/apply_tax.rs"));
445        assert!(result.is_err());
446    }
447
448    #[test]
449    fn test_contract_hash_absent_for_no_contract() {
450        let spec = make_loaded_spec(
451            "money/round",
452            "units/money/round.unit.spec",
453            Some("0.3.0"),
454            None,
455            vec![],
456            vec![],
457        );
458
459        assert_eq!(compute_contract_hash(&spec), None);
460    }
461
462    #[test]
463    fn test_contract_hash_present_for_contract() {
464        let mut inputs = IndexMap::new();
465        inputs.insert("subtotal".to_string(), "Decimal".to_string());
466        inputs.insert("rate".to_string(), "Decimal".to_string());
467        let spec = make_loaded_spec(
468            "pricing/apply_tax",
469            "units/pricing/apply_tax.unit.spec",
470            Some("0.3.0"),
471            Some(Contract {
472                inputs: Some(inputs),
473                returns: Some("Decimal".to_string()),
474                invariants: vec!["output >= subtotal".to_string()],
475            }),
476            vec![],
477            vec![],
478        );
479
480        let expected = {
481            let contract = spec.spec.contract.as_ref().unwrap();
482            let json = serde_json::to_string(contract).unwrap();
483            let hash = Sha256::digest(json.as_bytes());
484            format!("sha256:{}", hex::encode(hash))
485        };
486
487        assert_eq!(compute_contract_hash(&spec), Some(expected));
488    }
489
490    #[test]
491    fn test_contract_hash_changes_on_input_reorder() {
492        let mut inputs_ab = IndexMap::new();
493        inputs_ab.insert("a".to_string(), "String".to_string());
494        inputs_ab.insert("b".to_string(), "String".to_string());
495
496        let mut inputs_ba = IndexMap::new();
497        inputs_ba.insert("b".to_string(), "String".to_string());
498        inputs_ba.insert("a".to_string(), "String".to_string());
499
500        let spec_ab = make_loaded_spec(
501            "example/alpha",
502            "units/example/alpha.unit.spec",
503            Some("0.3.0"),
504            Some(Contract {
505                inputs: Some(inputs_ab),
506                returns: Some("String".to_string()),
507                invariants: vec![],
508            }),
509            vec![],
510            vec![],
511        );
512        let spec_ba = make_loaded_spec(
513            "example/alpha",
514            "units/example/alpha.unit.spec",
515            Some("0.3.0"),
516            Some(Contract {
517                inputs: Some(inputs_ba),
518                returns: Some("String".to_string()),
519                invariants: vec![],
520            }),
521            vec![],
522            vec![],
523        );
524
525        assert_ne!(
526            compute_contract_hash(&spec_ab),
527            compute_contract_hash(&spec_ba)
528        );
529    }
530
531    #[test]
532    fn test_read_passport_returns_none_for_missing() {
533        let dir = TempDir::new().unwrap();
534        let source_path = dir.path().join("apply_tax.unit.spec");
535
536        let passport = read_passport(&source_path).unwrap();
537        assert!(passport.is_none());
538    }
539
540    #[test]
541    fn test_read_passport_returns_err_for_malformed() {
542        let dir = TempDir::new().unwrap();
543        let source_path = dir.path().join("apply_tax.unit.spec");
544        let passport_path = passport_path_for(&source_path).unwrap();
545        fs::write(&passport_path, "{not valid json").unwrap();
546
547        let result = read_passport(&source_path);
548        assert!(result.is_err());
549    }
550
551    #[test]
552    fn test_read_passport_discards_non_sha256_contract_hash() {
553        let dir = TempDir::new().unwrap();
554        let source_path = dir.path().join("apply_tax.unit.spec");
555        let passport_path = passport_path_for(&source_path).unwrap();
556        fs::write(
557            &passport_path,
558            r#"{
559  "spec_version": "0.3.0",
560  "id": "pricing/apply_tax",
561  "intent": "Why pricing/apply_tax",
562  "deps": [],
563  "local_tests": [],
564  "generated_at": "2026-04-04T00:00:00Z",
565  "source_file": "units/pricing/apply_tax.unit.spec",
566  "contract_hash": "deadbeef"
567}"#,
568        )
569        .unwrap();
570
571        let passport = read_passport(&source_path).unwrap().unwrap();
572        assert!(passport.contract_hash.is_none());
573    }
574
575    #[test]
576    fn test_read_passport_roundtrip() {
577        let dir = TempDir::new().unwrap();
578        let source_path = dir.path().join("apply_tax.unit.spec");
579        fs::write(&source_path, "").unwrap();
580
581        let mut inputs = IndexMap::new();
582        inputs.insert("subtotal".to_string(), "i32".to_string());
583        let spec = make_loaded_spec(
584            "pricing/apply_tax",
585            source_path.to_str().unwrap(),
586            Some("0.3.0"),
587            Some(Contract {
588                inputs: Some(inputs),
589                returns: Some("i32".to_string()),
590                invariants: vec!["output >= subtotal".to_string()],
591            }),
592            vec![],
593            vec![],
594        );
595        let passport = build_passport_with_evidence(
596            &spec,
597            "2026-04-04T00:00:00Z",
598            Some(PassportEvidence {
599                build_status: "pass".to_string(),
600                test_results: vec![],
601                observed_at: "2026-04-04T00:01:00Z".to_string(),
602            }),
603            compute_contract_hash(&spec),
604        );
605        write_passport(&passport, &source_path).unwrap();
606
607        let parsed = read_passport(&source_path).unwrap().unwrap();
608        assert_eq!(parsed, passport);
609    }
610
611    #[test]
612    fn write_passport_creates_valid_json() {
613        let dir = TempDir::new().unwrap();
614        let source_path = dir.path().join("apply_tax.unit.spec");
615        fs::write(&source_path, "").unwrap(); // create source file so parent exists
616
617        let spec = make_loaded_spec(
618            "pricing/apply_tax",
619            source_path.to_str().unwrap(),
620            Some("0.3.0"),
621            None,
622            vec![],
623            vec![],
624        );
625        let passport = build_passport(&spec, "2026-04-04T00:00:00Z");
626        write_passport(&passport, &source_path).unwrap();
627
628        let passport_path = dir.path().join("apply_tax.spec.passport.json");
629        assert!(passport_path.exists());
630
631        let content = fs::read_to_string(&passport_path).unwrap();
632        let parsed: Passport = serde_json::from_str(&content).unwrap();
633        assert_eq!(parsed.id, "pricing/apply_tax");
634        assert_eq!(parsed.generated_at, "2026-04-04T00:00:00Z");
635    }
636
637    #[test]
638    fn write_passport_round_trips_contract_with_omitted_empty_fields() {
639        let dir = TempDir::new().unwrap();
640        let source_path = dir.path().join("apply_tax.unit.spec");
641        fs::write(&source_path, "").unwrap();
642
643        let mut inputs = IndexMap::new();
644        inputs.insert("subtotal".to_string(), "i32".to_string());
645        let spec = make_loaded_spec(
646            "pricing/apply_tax",
647            source_path.to_str().unwrap(),
648            Some("0.3.0"),
649            Some(Contract {
650                inputs: Some(inputs),
651                returns: Some("i32".to_string()),
652                invariants: vec![],
653            }),
654            vec![],
655            vec![],
656        );
657        let passport = build_passport(&spec, "2026-04-04T00:00:00Z");
658        write_passport(&passport, &source_path).unwrap();
659
660        let content = fs::read_to_string(dir.path().join("apply_tax.spec.passport.json")).unwrap();
661        let parsed: Passport = serde_json::from_str(&content).unwrap();
662
663        assert_eq!(
664            parsed.contract.unwrap(),
665            PassportContract {
666                inputs: vec![PassportInput {
667                    name: "subtotal".to_string(),
668                    type_: "i32".to_string(),
669                }],
670                returns: Some("i32".to_string()),
671                invariants: vec![],
672            }
673        );
674    }
675
676    #[test]
677    fn build_passport_with_evidence_serializes_observed_results() {
678        let spec = make_loaded_spec(
679            "pricing/apply_tax",
680            "units/pricing/apply_tax.unit.spec",
681            Some("0.3.0"),
682            None,
683            vec![],
684            vec![("basic", "apply_tax(1,2) == 3")],
685        );
686        let passport = build_passport_with_evidence(
687            &spec,
688            "2026-04-04T00:00:00Z",
689            Some(PassportEvidence {
690                build_status: "pass".to_string(),
691                test_results: vec![PassportTestResult {
692                    id: "basic".to_string(),
693                    status: "pass".to_string(),
694                    reason: None,
695                }],
696                observed_at: "2026-04-04T00:01:00Z".to_string(),
697            }),
698            Some("sha256:abc123".to_string()),
699        );
700
701        assert_eq!(
702            passport.evidence,
703            Some(PassportEvidence {
704                build_status: "pass".to_string(),
705                test_results: vec![PassportTestResult {
706                    id: "basic".to_string(),
707                    status: "pass".to_string(),
708                    reason: None,
709                }],
710                observed_at: "2026-04-04T00:01:00Z".to_string(),
711            })
712        );
713        assert_eq!(passport.contract_hash, Some("sha256:abc123".to_string()));
714    }
715
716    #[test]
717    fn spec_generate_passport_has_no_evidence() {
718        let spec = make_loaded_spec(
719            "money/round",
720            "units/money/round.unit.spec",
721            Some("0.3.0"),
722            None,
723            vec![],
724            vec![],
725        );
726        let passport = build_passport(&spec, "2026-04-04T00:00:00Z");
727        let json = serde_json::to_string(&passport).unwrap();
728
729        assert!(passport.evidence.is_none());
730        assert!(passport.contract_hash.is_none());
731        assert!(
732            !json.contains("\"evidence\""),
733            "static passport should not serialize evidence: {json}"
734        );
735    }
736
737    #[test]
738    fn rfc3339_now_format() {
739        let ts = rfc3339_now();
740        // Must match YYYY-MM-DDTHH:MM:SSZ
741        assert_eq!(ts.len(), 20, "timestamp length should be 20: {ts}");
742        assert_eq!(&ts[4..5], "-");
743        assert_eq!(&ts[7..8], "-");
744        assert_eq!(&ts[10..11], "T");
745        assert_eq!(&ts[13..14], ":");
746        assert_eq!(&ts[16..17], ":");
747        assert_eq!(&ts[19..20], "Z");
748    }
749
750    #[test]
751    fn rfc3339_known_epoch() {
752        // Unix epoch = 1970-01-01T00:00:00Z
753        let (y, mo, d, h, m, s) = secs_to_gregorian(0);
754        assert_eq!((y, mo, d, h, m, s), (1970, 1, 1, 0, 0, 0));
755    }
756
757    #[test]
758    fn rfc3339_known_date() {
759        // 2026-04-04T12:34:56Z
760        // Days from epoch to 2026-04-04: calculate manually
761        // 2026-04-04 = epoch + 20547 days + 45296 seconds
762        let ts = 20547 * 86400 + 12 * 3600 + 34 * 60 + 56;
763        let (y, mo, d, h, m, s) = secs_to_gregorian(ts);
764        assert_eq!((y, mo, d, h, m, s), (2026, 4, 4, 12, 34, 56));
765    }
766
767    #[test]
768    fn ensure_gitignore_creates_file_when_absent() {
769        let dir = TempDir::new().unwrap();
770        ensure_gitignore_entry(dir.path()).unwrap();
771        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
772        assert!(content.contains("**/*.spec.passport.json"));
773    }
774
775    #[test]
776    fn ensure_gitignore_appends_when_entry_missing() {
777        let dir = TempDir::new().unwrap();
778        fs::write(dir.path().join(".gitignore"), "*.rs\n").unwrap();
779        ensure_gitignore_entry(dir.path()).unwrap();
780        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
781        assert!(content.contains("*.rs"));
782        assert!(content.contains("**/*.spec.passport.json"));
783    }
784
785    #[test]
786    fn ensure_gitignore_is_idempotent() {
787        let dir = TempDir::new().unwrap();
788        ensure_gitignore_entry(dir.path()).unwrap();
789        ensure_gitignore_entry(dir.path()).unwrap();
790        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
791        let count = content.matches("**/*.spec.passport.json").count();
792        assert_eq!(count, 1, "entry should appear exactly once");
793    }
794
795    #[test]
796    fn ensure_gitignore_no_trailing_newline_handled() {
797        let dir = TempDir::new().unwrap();
798        // File without trailing newline
799        fs::write(dir.path().join(".gitignore"), "*.rs").unwrap();
800        ensure_gitignore_entry(dir.path()).unwrap();
801        let content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
802        assert!(content.contains("*.rs\n**/*.spec.passport.json"));
803    }
804}