1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct PassportInput {
20 pub name: String,
21 #[serde(rename = "type")]
22 pub type_: String,
23}
24
25#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct PassportLocalTest {
39 pub id: String,
40 pub expect: String,
41}
42
43#[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#[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#[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
82pub fn build_passport(spec: &LoadedSpec, generated_at: &str) -> Passport {
87 build_passport_with_evidence(spec, generated_at, None, None)
88}
89
90pub 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
140pub 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
151pub 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
185pub 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
201pub 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
221pub 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 if existing.lines().any(|l| l.trim_end() == ENTRY) {
236 return Ok(());
237 }
238
239 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
252pub 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
265fn 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; let z = days + 719_468;
279 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)
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"); 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(); 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 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 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 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 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}