1use std::collections::{HashMap, HashSet};
84use std::io;
85use std::path::{Path, PathBuf};
86
87use serde::{Deserialize, Serialize};
88
89use crate::apidoc::{ApidocDict, ApidocEntry};
90
91pub(crate) fn is_apidoc_debug_enabled() -> bool {
93 std::env::var("LIBPERL_MACROGEN_DEBUG_APIDOC")
94 .map(|v| !v.is_empty() && v != "0")
95 .unwrap_or(false)
96}
97
98pub(crate) fn cargo_warning(msg: &str) {
102 println!("cargo:warning={}", msg);
103 eprintln!("{}", msg);
104}
105
106#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct ApidocPatchFile {
109 #[serde(default = "default_schema_version")]
111 pub schema_version: u32,
112 #[serde(default)]
114 pub comment: Option<String>,
115 #[serde(default)]
117 pub patches: Vec<ApidocPatch>,
118}
119
120fn default_schema_version() -> u32 { 1 }
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ApidocPatch {
125 pub name: String,
127 pub kind: PatchKind,
129 #[serde(default)]
131 pub value: Option<String>,
132 #[serde(default)]
134 pub arg_index: Option<usize>,
135 #[serde(default)]
137 pub source_loc: Option<String>,
138 pub reason: String,
140 #[serde(default)]
142 pub upstream_status: Option<String>,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147pub enum PatchKind {
148 #[serde(rename = "return_type_override")]
150 ReturnTypeOverride,
151 #[serde(rename = "arg_type_override")]
153 ArgTypeOverride,
154 #[serde(rename = "skip_codegen")]
157 SkipCodegen,
158 #[serde(rename = "remove")]
162 Remove,
163 #[serde(rename = "add_decl")]
169 AddDecl,
170}
171
172#[derive(Debug, Clone)]
174pub struct AddDeclPatch {
175 pub entry: ApidocEntry,
178 pub reason: String,
180}
181
182#[derive(Debug, Default)]
184pub struct ApidocPatchSet {
185 pub return_overrides: HashMap<String, (String, String)>,
187 pub arg_overrides: HashMap<String, Vec<(usize, String, String)>>,
189 pub skip_codegen: HashMap<String, String>,
191 pub add_decls: HashMap<String, AddDeclPatch>,
193 pub removals: HashSet<String>,
198 pub source_paths: Vec<PathBuf>,
201}
202
203pub fn load_name_list<P: AsRef<Path>>(path: P) -> io::Result<Vec<String>> {
212 let content = std::fs::read_to_string(path.as_ref())?;
213 Ok(content
214 .lines()
215 .filter_map(|raw_line| {
216 let line = raw_line.split('#').next().unwrap_or("").trim();
217 if line.is_empty() {
218 None
219 } else {
220 Some(line.to_string())
221 }
222 })
223 .collect())
224}
225
226fn parse_add_decl_value(value: &str) -> Option<ApidocEntry> {
231 let trimmed = value.trim();
232 if !trimmed.contains('|') {
233 return None;
234 }
235 if trimmed.starts_with("=for apidoc") {
236 ApidocEntry::parse_apidoc_line(trimmed)
237 } else {
238 ApidocEntry::parse_line(trimmed)
239 }
240}
241
242impl ApidocPatchSet {
243 pub fn empty() -> Self { Self::default() }
244
245 pub fn load_json<P: AsRef<Path>>(path: P) -> io::Result<Self> {
247 let path_ref = path.as_ref();
248 let content = std::fs::read_to_string(path_ref)?;
249 let file: ApidocPatchFile = serde_json::from_str(&content).map_err(|e| {
250 io::Error::new(io::ErrorKind::InvalidData,
251 format!("apidoc patches JSON parse error: {}", e))
252 })?;
253 if file.schema_version != 1 {
254 return Err(io::Error::new(io::ErrorKind::InvalidData,
255 format!("unsupported apidoc patches schema_version: {}", file.schema_version)));
256 }
257 let mut set = Self::default();
258 set.source_paths.push(path_ref.to_path_buf());
259 for p in file.patches {
260 match p.kind {
261 PatchKind::ReturnTypeOverride => {
262 let v = p.value.clone().ok_or_else(|| io::Error::new(
263 io::ErrorKind::InvalidData,
264 format!("patch for {}: return_type_override requires `value`", p.name)))?;
265 set.return_overrides.insert(p.name, (v, p.reason));
266 }
267 PatchKind::ArgTypeOverride => {
268 let v = p.value.clone().ok_or_else(|| io::Error::new(
269 io::ErrorKind::InvalidData,
270 format!("patch for {}: arg_type_override requires `value`", p.name)))?;
271 let idx = p.arg_index.ok_or_else(|| io::Error::new(
272 io::ErrorKind::InvalidData,
273 format!("patch for {}: arg_type_override requires `arg_index`", p.name)))?;
274 set.arg_overrides.entry(p.name).or_default().push((idx, v, p.reason));
275 }
276 PatchKind::SkipCodegen => {
277 set.skip_codegen.insert(p.name, p.reason);
278 }
279 PatchKind::Remove => {
280 set.removals.insert(p.name);
283 }
284 PatchKind::AddDecl => {
285 let v = p.value.clone().ok_or_else(|| io::Error::new(
286 io::ErrorKind::InvalidData,
287 format!("patch for {}: add_decl requires `value` \
288 (apidoc line: flags|return_type|name|args...)", p.name)))?;
289 let mut entry = parse_add_decl_value(&v).ok_or_else(|| io::Error::new(
290 io::ErrorKind::InvalidData,
291 format!("patch for {}: add_decl `value` is not a parsable \
292 apidoc line: {:?}", p.name, v)))?;
293 if entry.name != p.name {
294 return Err(io::Error::new(io::ErrorKind::InvalidData,
295 format!("patch for {}: add_decl `value` declares a different \
296 name `{}`", p.name, entry.name)));
297 }
298 entry.source_file = Some(format!("{} (add_decl)", path_ref.display()));
299 set.add_decls.insert(p.name, AddDeclPatch { entry, reason: p.reason });
300 }
301 }
302 }
303 Ok(set)
304 }
305
306 pub fn load_for_perl_version<P: AsRef<Path>>(
309 apidoc_dir: P, major: u32, minor: u32,
310 ) -> io::Result<Self> {
311 let filename = format!("v{}.{}.patches.json", major, minor);
312 let path = apidoc_dir.as_ref().join(&filename);
313 if !path.exists() {
314 return Ok(Self::empty());
315 }
316 Self::load_json(&path)
317 }
318
319 pub fn load_for_apidoc_path<P: AsRef<Path>>(apidoc_path: P) -> io::Result<Self> {
332 let path_ref = apidoc_path.as_ref();
333 let dir = path_ref.parent().unwrap_or_else(|| Path::new("."));
334 let debug = is_apidoc_debug_enabled();
335
336 let mut set = Self::default();
337
338 if debug {
339 cargo_warning(&format!(
340 "[apidoc-patches] load_for_apidoc_path: apidoc_path={}, dir={}",
341 path_ref.display(), dir.display()
342 ));
343 }
344
345 let common_path = dir.join("common.patches.json");
347 if common_path.exists() {
348 let common = Self::load_json(&common_path)?;
349 if debug {
350 cargo_warning(&format!(
351 "[apidoc-patches] loaded common.patches.json: \
352 {} return_overrides, {} arg_overrides, {} skip_codegen, \
353 {} add_decls, {} removals",
354 common.return_overrides.len(),
355 common.arg_overrides.len(),
356 common.skip_codegen.len(),
357 common.add_decls.len(),
358 common.removals.len(),
359 ));
360 }
361 set.merge_overlay(common);
362 } else if debug {
363 cargo_warning(&format!(
364 "[apidoc-patches] common.patches.json NOT FOUND at {}",
365 common_path.display()
366 ));
367 }
368
369 let version_path = {
371 let stem = path_ref.file_stem()
372 .map(|s| s.to_string_lossy().into_owned())
373 .unwrap_or_else(String::new);
374 path_ref.with_file_name(format!("{}.patches.json", stem))
375 };
376 if version_path.exists() {
377 let version = Self::load_json(&version_path)?;
378 if debug {
379 cargo_warning(&format!(
380 "[apidoc-patches] loaded {}: \
381 {} return_overrides, {} skip_codegen, {} add_decls, {} removals",
382 version_path.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(),
383 version.return_overrides.len(),
384 version.skip_codegen.len(),
385 version.add_decls.len(),
386 version.removals.len(),
387 ));
388 }
389 for name in &version.removals {
391 set.return_overrides.remove(name);
392 set.arg_overrides.remove(name);
393 set.skip_codegen.remove(name);
394 set.add_decls.remove(name);
395 }
396 set.merge_overlay(version);
398 } else if debug {
399 cargo_warning(&format!(
400 "[apidoc-patches] {} NOT FOUND",
401 version_path.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
402 ));
403 }
404
405 Ok(set)
406 }
407
408 fn merge_overlay(&mut self, other: ApidocPatchSet) {
411 for (k, v) in other.return_overrides {
412 self.return_overrides.insert(k, v);
413 }
414 for (k, v) in other.arg_overrides {
415 self.arg_overrides.insert(k, v);
417 }
418 for (k, v) in other.skip_codegen {
419 self.skip_codegen.insert(k, v);
420 }
421 for (k, v) in other.add_decls {
422 self.add_decls.insert(k, v);
423 }
424 for name in other.removals {
425 self.removals.insert(name);
426 }
427 self.source_paths.extend(other.source_paths);
428 }
429
430 pub fn merge_skip_list<P: AsRef<Path>>(&mut self, path: P) -> io::Result<usize> {
441 let path_ref = path.as_ref();
442 let display_name = path_ref.file_name()
443 .map(|s| s.to_string_lossy().to_string())
444 .unwrap_or_else(|| path_ref.display().to_string());
445 let reason = format!("skip-list: {}", display_name);
446 let mut added = 0usize;
447 for name in load_name_list(path_ref)? {
448 if !self.skip_codegen.contains_key(&name) {
450 self.skip_codegen.insert(name, reason.clone());
451 added += 1;
452 }
453 }
454 Ok(added)
455 }
456
457 pub fn is_empty(&self) -> bool {
459 self.return_overrides.is_empty()
460 && self.arg_overrides.is_empty()
461 && self.skip_codegen.is_empty()
462 && self.add_decls.is_empty()
463 }
464
465 pub fn count(&self) -> usize {
467 self.return_overrides.len()
468 + self.arg_overrides.iter().map(|(_, v)| v.len()).sum::<usize>()
469 + self.skip_codegen.len()
470 + self.add_decls.len()
471 }
472
473 pub fn apply_to_apidoc(&self, dict: &mut ApidocDict) -> Vec<String> {
484 let debug = is_apidoc_debug_enabled();
485 let mut applied: Vec<String> = Vec::new();
486
487 if debug {
488 cargo_warning(&format!(
489 "[apidoc-patches] apply_to_apidoc: dict has {} entries; \
490 patches: {} return_overrides, {} arg_overrides, {} skip_codegen, \
491 {} add_decls",
492 dict.len(),
493 self.return_overrides.len(),
494 self.arg_overrides.len(),
495 self.skip_codegen.len(),
496 self.add_decls.len(),
497 ));
498 }
499
500 for (name, patch) in &self.add_decls {
503 if dict.get(name).is_none() {
504 dict.insert(name.clone(), patch.entry.clone());
505 applied.push(name.clone());
506 if debug {
507 cargo_warning(&format!(
508 "[apidoc-patches] add_decl ADDED `{}`: {} ({} args)",
509 name,
510 patch.entry.return_type.as_deref().unwrap_or("(none)"),
511 patch.entry.args.len(),
512 ));
513 }
514 } else if debug {
515 cargo_warning(&format!(
516 "[apidoc-patches] add_decl `{}`: already present in dict, no-op",
517 name
518 ));
519 }
520 }
521
522 for (name, (new_ty, _reason)) in &self.return_overrides {
523 if let Some(entry) = dict.get_mut(name) {
524 let old = entry.return_type.clone();
525 entry.return_type = Some(new_ty.clone());
526 applied.push(name.clone());
527 if debug {
528 cargo_warning(&format!(
529 "[apidoc-patches] return_type_override APPLIED `{}`: {} -> {}",
530 name,
531 old.as_deref().unwrap_or("(none)"),
532 new_ty,
533 ));
534 }
535 } else {
536 cargo_warning(&format!(
538 "[apidoc-patches] return_type_override MISS `{}`: \
539 target not found in apidoc dict (dict has {} entries) — \
540 codegen falls back to whatever else is inferred",
541 name, dict.len()
542 ));
543 }
544 }
545 for (name, list) in &self.arg_overrides {
546 if let Some(entry) = dict.get_mut(name) {
547 for (idx, new_ty, _reason) in list {
548 if let Some(arg) = entry.args.get_mut(*idx) {
549 arg.ty = new_ty.clone();
550 } else {
551 cargo_warning(&format!(
552 "[apidoc-patches] arg_type_override `{}` arg_index {} \
553 out of range (entry has {} args)",
554 name, idx, entry.args.len()
555 ));
556 }
557 }
558 applied.push(name.clone());
559 } else {
560 cargo_warning(&format!(
561 "[apidoc-patches] arg_type_override MISS `{}`: \
562 target not found in apidoc dict",
563 name
564 ));
565 }
566 }
567
568 if debug {
571 let interest_prefixes: Vec<&str> = self.return_overrides.keys()
572 .chain(self.skip_codegen.keys())
573 .map(|s| s.as_str())
574 .collect();
575 let mut prefixes_set: std::collections::HashSet<&str> = std::collections::HashSet::new();
576 for n in &interest_prefixes {
577 if let Some(idx) = n.find('_') {
579 prefixes_set.insert(&n[..idx + 1]);
580 }
581 }
582 for prefix in prefixes_set {
583 let matches: Vec<String> = dict.iter()
584 .filter(|(name, _)| name.starts_with(prefix))
585 .map(|(name, e)| format!("{}->{}", name, e.return_type.as_deref().unwrap_or("?")))
586 .collect();
587 cargo_warning(&format!(
588 "[apidoc-patches] dict entries with prefix `{}` ({} entries): {:?}",
589 prefix, matches.len(), matches
590 ));
591 }
592 }
593
594 applied
595 }
596
597 pub fn skip_reason(&self, name: &str) -> Option<&str> {
599 self.skip_codegen.get(name).map(|s| s.as_str())
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use std::fs;
607 use std::io::Write;
608 use tempfile::TempDir;
609
610 fn write_json(dir: &Path, name: &str, content: &str) -> PathBuf {
611 let path = dir.join(name);
612 let mut f = fs::File::create(&path).unwrap();
613 f.write_all(content.as_bytes()).unwrap();
614 path
615 }
616
617 const COMMON_PATCH: &str = r#"{
618 "schema_version": 1,
619 "patches": [
620 { "name": "RCPV_LEN", "kind": "return_type_override",
621 "value": "STRLEN", "reason": "common: wrong apidoc" },
622 { "name": "Perl_custom_op_xop", "kind": "skip_codegen",
623 "reason": "common: macro lacks aTHX_" }
624 ]
625 }"#;
626
627 #[test]
628 fn test_load_name_list_parses_comments_and_blanks() {
629 let tmp = TempDir::new().unwrap();
630 let path = write_json(
631 tmp.path(),
632 "require.txt",
633 "# header comment\nCvFILE\n\n CvROOT # trailing comment\n#\nCvSTART\n",
634 );
635 let names = load_name_list(&path).unwrap();
636 assert_eq!(names, vec!["CvFILE", "CvROOT", "CvSTART"]);
637 }
638
639 #[test]
640 fn test_load_common_only() {
641 let tmp = TempDir::new().unwrap();
642 write_json(tmp.path(), "common.patches.json", COMMON_PATCH);
643 let apidoc_path = tmp.path().join("v5.40.json");
644 let set = ApidocPatchSet::load_for_apidoc_path(&apidoc_path).unwrap();
647 assert_eq!(set.return_overrides.len(), 1);
648 assert_eq!(set.return_overrides["RCPV_LEN"].0, "STRLEN");
649 assert_eq!(set.skip_codegen.len(), 1);
650 assert!(set.skip_codegen.contains_key("Perl_custom_op_xop"));
651 assert_eq!(set.source_paths.len(), 1);
652 }
653
654 #[test]
655 fn test_version_overrides_common() {
656 let tmp = TempDir::new().unwrap();
657 write_json(tmp.path(), "common.patches.json", COMMON_PATCH);
658 let version_json = r#"{
660 "schema_version": 1,
661 "patches": [
662 { "name": "RCPV_LEN", "kind": "return_type_override",
663 "value": "Size_t", "reason": "v5.42: tweaked" }
664 ]
665 }"#;
666 write_json(tmp.path(), "v5.42.patches.json", version_json);
667 let apidoc_path = tmp.path().join("v5.42.json");
668
669 let set = ApidocPatchSet::load_for_apidoc_path(&apidoc_path).unwrap();
670 assert_eq!(set.return_overrides["RCPV_LEN"].0, "Size_t");
672 assert!(set.skip_codegen.contains_key("Perl_custom_op_xop"));
674 assert_eq!(set.source_paths.len(), 2);
676 }
677
678 #[test]
679 fn test_remove_kind_drops_common_entry() {
680 let tmp = TempDir::new().unwrap();
681 write_json(tmp.path(), "common.patches.json", COMMON_PATCH);
682 let version_json = r#"{
684 "schema_version": 1,
685 "patches": [
686 { "name": "Perl_custom_op_xop", "kind": "remove",
687 "reason": "fixed upstream in 5.42" }
688 ]
689 }"#;
690 write_json(tmp.path(), "v5.42.patches.json", version_json);
691 let apidoc_path = tmp.path().join("v5.42.json");
692
693 let set = ApidocPatchSet::load_for_apidoc_path(&apidoc_path).unwrap();
694 assert!(!set.skip_codegen.contains_key("Perl_custom_op_xop"));
696 assert!(set.return_overrides.contains_key("RCPV_LEN"));
698 assert!(set.removals.contains("Perl_custom_op_xop"));
700 }
701
702 #[test]
703 fn test_no_patches_files_returns_empty() {
704 let tmp = TempDir::new().unwrap();
705 let apidoc_path = tmp.path().join("v5.40.json");
706 let set = ApidocPatchSet::load_for_apidoc_path(&apidoc_path).unwrap();
707 assert!(set.is_empty());
708 assert_eq!(set.source_paths.len(), 0);
709 }
710
711 const ADD_DECL_PATCH: &str = r#"{
712 "schema_version": 1,
713 "patches": [
714 { "name": "AvARRAY", "kind": "add_decl",
715 "value": "Am|SV**|AvARRAY|AV* av",
716 "reason": "declaration added to headers in 5.38" },
717 { "name": "MUTABLE_PTR", "kind": "add_decl",
718 "value": "=for apidoc Am |void *|MUTABLE_PTR|void * p",
719 "reason": "prefix form should be accepted (copy-paste from header)" }
720 ]
721 }"#;
722
723 #[test]
724 fn test_add_decl_load() {
725 let tmp = TempDir::new().unwrap();
726 let path = write_json(tmp.path(), "common.patches.json", ADD_DECL_PATCH);
727 let set = ApidocPatchSet::load_json(&path).unwrap();
728 assert_eq!(set.add_decls.len(), 2);
729 assert_eq!(set.count(), 2);
730 assert!(!set.is_empty());
731
732 let av = &set.add_decls["AvARRAY"];
733 assert_eq!(av.entry.return_type.as_deref(), Some("SV**"));
734 assert_eq!(av.entry.args.len(), 1);
735 assert!(av.entry.flags.is_macro);
736 assert!(av.entry.source_file.as_deref().unwrap().contains("add_decl"));
737
738 let mp = &set.add_decls["MUTABLE_PTR"];
740 assert_eq!(mp.entry.return_type.as_deref(), Some("void *"));
741 }
742
743 #[test]
744 fn test_add_decl_applies_when_absent() {
745 let tmp = TempDir::new().unwrap();
746 let path = write_json(tmp.path(), "common.patches.json", ADD_DECL_PATCH);
747 let set = ApidocPatchSet::load_json(&path).unwrap();
748
749 let mut dict = ApidocDict::new();
750 let applied = set.apply_to_apidoc(&mut dict);
751 assert!(applied.contains(&"AvARRAY".to_string()));
752 assert_eq!(dict.get("AvARRAY").unwrap().return_type.as_deref(), Some("SV**"));
753 assert!(dict.get("MUTABLE_PTR").is_some());
754 }
755
756 #[test]
757 fn test_add_decl_noop_when_present() {
758 let tmp = TempDir::new().unwrap();
759 let path = write_json(tmp.path(), "common.patches.json", ADD_DECL_PATCH);
760 let set = ApidocPatchSet::load_json(&path).unwrap();
761
762 let mut dict = ApidocDict::new();
764 let native = ApidocEntry::parse_line("Cm|SSize_t|AvARRAY|AV* av").unwrap();
765 dict.insert("AvARRAY".to_string(), native);
766
767 let applied = set.apply_to_apidoc(&mut dict);
768 assert_eq!(dict.get("AvARRAY").unwrap().return_type.as_deref(), Some("SSize_t"));
770 assert!(!applied.contains(&"AvARRAY".to_string()));
771 assert!(applied.contains(&"MUTABLE_PTR".to_string()));
773 }
774
775 #[test]
776 fn test_add_decl_value_errors() {
777 let tmp = TempDir::new().unwrap();
778 let p1 = write_json(tmp.path(), "p1.patches.json", r#"{
780 "schema_version": 1,
781 "patches": [ { "name": "FOO", "kind": "add_decl", "reason": "r" } ]
782 }"#);
783 assert!(ApidocPatchSet::load_json(&p1).is_err());
784 let p2 = write_json(tmp.path(), "p2.patches.json", r#"{
786 "schema_version": 1,
787 "patches": [ { "name": "FOO", "kind": "add_decl",
788 "value": "FOO", "reason": "r" } ]
789 }"#);
790 assert!(ApidocPatchSet::load_json(&p2).is_err());
791 let p3 = write_json(tmp.path(), "p3.patches.json", r#"{
793 "schema_version": 1,
794 "patches": [ { "name": "FOO", "kind": "add_decl",
795 "value": "Am|int|BAR|int x", "reason": "r" } ]
796 }"#);
797 assert!(ApidocPatchSet::load_json(&p3).is_err());
798 }
799
800 #[test]
801 fn test_add_decl_removed_by_version() {
802 let tmp = TempDir::new().unwrap();
803 write_json(tmp.path(), "common.patches.json", ADD_DECL_PATCH);
804 let version_json = r#"{
805 "schema_version": 1,
806 "patches": [
807 { "name": "AvARRAY", "kind": "remove",
808 "reason": "this version must not inject AvARRAY" }
809 ]
810 }"#;
811 write_json(tmp.path(), "v5.42.patches.json", version_json);
812 let apidoc_path = tmp.path().join("v5.42.json");
813
814 let set = ApidocPatchSet::load_for_apidoc_path(&apidoc_path).unwrap();
815 assert!(!set.add_decls.contains_key("AvARRAY"));
816 assert!(set.add_decls.contains_key("MUTABLE_PTR"));
817 }
818
819 #[test]
820 fn test_add_decl_with_return_override() {
821 let tmp = TempDir::new().unwrap();
824 let json = r#"{
825 "schema_version": 1,
826 "patches": [
827 { "name": "AvARRAY", "kind": "add_decl",
828 "value": "Am|SV**|AvARRAY|AV* av", "reason": "r" },
829 { "name": "AvARRAY", "kind": "return_type_override",
830 "value": "SV *", "reason": "r" }
831 ]
832 }"#;
833 let path = write_json(tmp.path(), "common.patches.json", json);
834 let set = ApidocPatchSet::load_json(&path).unwrap();
835
836 let mut dict = ApidocDict::new();
837 set.apply_to_apidoc(&mut dict);
838 assert_eq!(dict.get("AvARRAY").unwrap().return_type.as_deref(), Some("SV *"));
839 }
840
841 #[test]
842 fn test_remove_only_in_singlefile_load_does_not_panic() {
843 let tmp = TempDir::new().unwrap();
846 let json = r#"{
847 "schema_version": 1,
848 "patches": [
849 { "name": "FOO", "kind": "remove", "reason": "test" }
850 ]
851 }"#;
852 let path = write_json(tmp.path(), "v5.42.patches.json", json);
853 let set = ApidocPatchSet::load_json(&path).unwrap();
854 assert!(set.return_overrides.is_empty());
855 assert!(set.skip_codegen.is_empty());
856 assert!(set.removals.contains("FOO"));
857 }
858}