1use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use crate::error::SendraError;
10use crate::request::Request;
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
37#[serde(deny_unknown_fields)]
38pub struct Collection {
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub name: Option<String>,
41 pub requests: Vec<Request>,
42}
43
44impl Collection {
45 pub fn get(&self, name: &str) -> Result<&Request, SendraError> {
51 self.requests
52 .iter()
53 .find(|request| request.name.as_deref() == Some(name))
54 .ok_or_else(|| SendraError::RequestNotFound {
55 name: name.to_string(),
56 available: self.names(),
57 })
58 }
59
60 pub fn names(&self) -> Vec<String> {
62 self.requests
63 .iter()
64 .filter_map(|request| request.name.clone())
65 .collect()
66 }
67
68 fn validate(&self) -> Result<(), SendraError> {
77 let invalid = |reason: String| Err(SendraError::InvalidCollection { reason });
78
79 if self.requests.is_empty() {
80 return invalid("`requests` is empty".to_string());
81 }
82
83 let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
84 for (index, request) in self.requests.iter().enumerate() {
85 let Some(name) = request.name.as_deref() else {
86 return invalid(format!(
87 "request {} ({}) has no `name`; every request in a collection needs one to be selectable",
88 index + 1,
89 request.label()
90 ));
91 };
92 if let Some(first) = seen.insert(name, index + 1) {
93 return invalid(format!(
94 "two requests are named `{name}` (numbers {first} and {}); names must be unique",
95 index + 1
96 ));
97 }
98 if let Err(SendraError::InvalidRequest { reason }) = request.validate() {
104 return invalid(format!("request {} ({name}): {reason}", index + 1));
105 }
106 }
107
108 Ok(())
109 }
110}
111
112#[allow(clippy::large_enum_variant)]
133#[derive(Debug, Clone, PartialEq)]
134pub enum Document {
135 Single(Request),
136 Collection(Collection),
137}
138
139impl Document {
140 pub fn from_yaml_str(yaml: &str) -> Result<Self, SendraError> {
142 Self::parse(yaml, SendraError::ParseStr)
143 }
144
145 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SendraError> {
147 let path = path.as_ref();
148 let raw = std::fs::read_to_string(path).map_err(|source| SendraError::Io {
149 path: path.to_path_buf(),
150 source,
151 })?;
152 Self::parse(&raw, |source| SendraError::Parse {
153 path: path.to_path_buf(),
154 source,
155 })
156 }
157
158 fn parse(
161 yaml: &str,
162 wrap: impl Fn(serde_yaml::Error) -> SendraError,
163 ) -> Result<Self, SendraError> {
164 let probe: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(&wrap)?;
167 let is_collection = probe
168 .as_mapping()
169 .is_some_and(|mapping| mapping.contains_key("requests"));
170
171 if is_collection {
172 let collection: Collection = serde_yaml::from_str(yaml).map_err(&wrap)?;
173 collection.validate()?;
174 Ok(Document::Collection(collection))
175 } else {
176 let request: Request = serde_yaml::from_str(yaml).map_err(&wrap)?;
177 request.validate()?;
178 Ok(Document::Single(request))
179 }
180 }
181
182 pub fn requests(&self) -> &[Request] {
186 match self {
187 Document::Single(request) => std::slice::from_ref(request),
188 Document::Collection(collection) => &collection.requests,
189 }
190 }
191
192 pub fn get(&self, name: &str) -> Result<&Request, SendraError> {
198 match self {
199 Document::Single(_) => Err(SendraError::NotACollection {
200 name: name.to_string(),
201 }),
202 Document::Collection(collection) => collection.get(name),
203 }
204 }
205
206 pub fn validate(&self) -> Result<(), SendraError> {
223 match self {
224 Document::Single(request) => request.validate(),
225 Document::Collection(collection) => collection.validate(),
226 }
227 }
228
229 pub fn to_yaml_string(&self) -> Result<String, SendraError> {
249 match self {
250 Document::Single(request) => serde_yaml::to_string(request),
251 Document::Collection(collection) => serde_yaml::to_string(collection),
252 }
253 .map_err(SendraError::Serialize)
254 }
255
256 pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SendraError> {
292 self.validate()?;
293
294 let path = path.as_ref();
295 let yaml = self.to_yaml_string()?;
296 let temp_path = unique_temp_path(path);
297
298 std::fs::write(&temp_path, yaml.as_bytes()).map_err(|source| SendraError::SaveIo {
299 path: path.to_path_buf(),
300 source,
301 })?;
302
303 std::fs::rename(&temp_path, path).map_err(|source| {
304 let _ = std::fs::remove_file(&temp_path);
305 SendraError::SaveIo {
306 path: path.to_path_buf(),
307 source,
308 }
309 })
310 }
311}
312
313pub(crate) fn unique_temp_path(target: &Path) -> PathBuf {
325 use std::sync::atomic::{AtomicU64, Ordering};
326 static COUNTER: AtomicU64 = AtomicU64::new(0);
327
328 let dir = target
329 .parent()
330 .filter(|dir| !dir.as_os_str().is_empty())
331 .unwrap_or_else(|| Path::new("."));
332 let file_name = target
333 .file_name()
334 .and_then(|name| name.to_str())
335 .unwrap_or("document.yaml");
336 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
337
338 dir.join(format!(
339 ".{file_name}.sendra-tmp-{}-{unique}",
340 std::process::id()
341 ))
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::request::Method;
348
349 const COLLECTION: &str = "\
352name: Example API
353requests:
354 - name: Zeta
355 method: GET
356 url: https://api.example.com/zeta
357 headers:
358 Accept: application/json
359 - name: Alpha
360 method: POST
361 url: https://api.example.com/alpha
362 body: '{}'
363 - name: Middle
364 method: DELETE
365 url: https://api.example.com/middle
366";
367
368 #[test]
369 fn parses_a_collection_and_keeps_file_order() {
370 let document = Document::from_yaml_str(COLLECTION).expect("valid collection should parse");
371
372 let Document::Collection(collection) = &document else {
373 panic!("a top-level `requests` key means a collection, got {document:?}");
374 };
375 assert_eq!(collection.name.as_deref(), Some("Example API"));
376 assert_eq!(collection.names(), vec!["Zeta", "Alpha", "Middle"]);
378 assert_eq!(collection.requests[1].method, Method::Post);
379 assert_eq!(collection.requests[1].body.as_deref(), Some("{}"));
380 }
381
382 #[test]
383 fn a_file_without_a_requests_key_is_still_a_single_request() {
384 let document =
385 Document::from_yaml_str("name: Get user\nmethod: GET\nurl: https://example.com\n")
386 .expect("the existing single-request shape must keep parsing");
387
388 match document {
389 Document::Single(request) => assert_eq!(request.label(), "Get user"),
390 other => panic!("expected Single, got {other:?}"),
391 }
392 }
393
394 #[test]
395 fn a_single_request_runs_as_a_one_element_document() {
396 let document = Document::from_yaml_str("method: GET\nurl: https://example.com\n").unwrap();
397 assert_eq!(document.requests().len(), 1);
398 assert_eq!(document.requests()[0].url, "https://example.com");
399 }
400
401 #[test]
402 fn collection_requests_are_returned_in_file_order() {
403 let document = Document::from_yaml_str(COLLECTION).unwrap();
404 let urls: Vec<&str> = document
405 .requests()
406 .iter()
407 .map(|request| request.url.as_str())
408 .collect();
409 assert_eq!(
410 urls,
411 vec![
412 "https://api.example.com/zeta",
413 "https://api.example.com/alpha",
414 "https://api.example.com/middle",
415 ]
416 );
417 }
418
419 #[test]
420 fn looks_a_request_up_by_name() {
421 let document = Document::from_yaml_str(COLLECTION).unwrap();
422 let request = document.get("Alpha").expect("`Alpha` is in the collection");
423 assert_eq!(request.method, Method::Post);
424 assert_eq!(request.url, "https://api.example.com/alpha");
425 }
426
427 #[test]
428 fn an_unknown_name_is_a_typed_error_listing_what_is_available() {
429 let document = Document::from_yaml_str(COLLECTION).unwrap();
430 let err = document
431 .get("Beta")
432 .expect_err("`Beta` is not in the collection");
433
434 match err {
435 SendraError::RequestNotFound { name, available } => {
436 assert_eq!(name, "Beta");
437 assert_eq!(available, vec!["Zeta", "Alpha", "Middle"]);
438 }
439 other => panic!("expected RequestNotFound, got {other:?}"),
440 }
441 let message = document.get("Beta").unwrap_err().to_string();
443 assert!(message.contains("Zeta, Alpha, Middle"), "got {message}");
444 }
445
446 #[test]
447 fn asking_a_single_request_file_for_a_name_says_so() {
448 let document = Document::from_yaml_str("method: GET\nurl: https://example.com\n").unwrap();
449 let err = document.get("Alpha").expect_err("no names to select from");
450 assert!(
451 matches!(err, SendraError::NotACollection { .. }),
452 "got {err:?}"
453 );
454 }
455
456 #[test]
457 fn a_request_in_a_collection_must_be_named() {
458 let err =
459 Document::from_yaml_str("requests:\n - method: GET\n url: https://example.com\n")
460 .expect_err("an unnamed request cannot be selected, so it is rejected");
461 assert!(
462 matches!(err, SendraError::InvalidCollection { .. }),
463 "got {err:?}"
464 );
465 }
466
467 #[test]
468 fn duplicate_names_in_a_collection_are_rejected() {
469 let yaml = "\
470requests:
471 - name: Same
472 method: GET
473 url: https://example.com/a
474 - name: Same
475 method: GET
476 url: https://example.com/b
477";
478 let err = Document::from_yaml_str(yaml).expect_err("duplicate names are ambiguous");
479 match err {
480 SendraError::InvalidCollection { reason } => {
481 assert!(reason.contains("Same"), "got {reason}")
482 }
483 other => panic!("expected InvalidCollection, got {other:?}"),
484 }
485 }
486
487 #[test]
488 fn an_empty_collection_is_rejected() {
489 let err = Document::from_yaml_str("requests: []\n").expect_err("nothing to run");
490 assert!(
491 matches!(err, SendraError::InvalidCollection { .. }),
492 "got {err:?}"
493 );
494 }
495
496 #[test]
497 fn unknown_keys_in_a_collection_are_rejected() {
498 let yaml = "\
499requests:
500 - name: One
501 method: GET
502 url: https://example.com
503enviroment: staging
504";
505 let err = Document::from_yaml_str(yaml).expect_err("a typo must not be silently ignored");
506 assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
507 }
508
509 #[test]
510 fn the_shipped_example_files_parse() {
511 for name in [
513 "get-request.yaml",
514 "post-request.yaml",
515 "collection.yaml",
516 "mixed-status-collection.yaml",
517 "environment-request.yaml",
520 "assertions.yaml",
521 "richer-assertions.yaml",
522 "test-collection.yaml",
523 "scripted-request.yaml",
524 "capture-chain.yaml",
525 "capture-header-status.yaml",
526 "repeated-headers.yaml",
527 "structured-bodies.yaml",
528 "query-params.yaml",
529 "auth.yaml",
530 "oauth.yaml",
531 ] {
532 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
533 .join("..")
534 .join("examples")
535 .join(name);
536 Document::from_path(&path).unwrap_or_else(|e| panic!("{name} should parse: {e}"));
537 }
538 }
539
540 #[test]
541 fn missing_collection_file_is_an_io_error_carrying_the_path() {
542 let err = Document::from_path("does/not/exist.yaml").expect_err("missing file must error");
543 match err {
544 SendraError::Io { path, .. } => assert_eq!(path, Path::new("does/not/exist.yaml")),
545 other => panic!("expected Io, got {other:?}"),
546 }
547 }
548
549 #[test]
552 fn to_yaml_string_round_trips_a_collection_with_every_nested_shape() {
553 let yaml = "\
554name: test
555requests:
556 - name: One
557 method: POST
558 url: https://example.com
559 headers:
560 X-Test: abc
561 body: '{}'
562 auth:
563 bearer: secret-token
564 assertions:
565 status: 200
566 json:
567 $.ok: true
568 capture:
569 id: $.id
570 trace:
571 header: X-Trace-Id
572";
573 let document = Document::from_yaml_str(yaml).unwrap();
574
575 let serialized = document
576 .to_yaml_string()
577 .expect("a valid document always serializes");
578 let round_tripped =
579 Document::from_yaml_str(&serialized).expect("what was just serialized must reparse");
580
581 assert_eq!(
582 round_tripped, document,
583 "round-tripping through to_yaml_string must not lose or change anything"
584 );
585 }
586
587 #[test]
588 fn to_yaml_string_serializes_a_single_request_as_a_bare_request_not_wrapped() {
589 let yaml = "method: GET\nurl: https://example.com\n";
590 let document = Document::from_yaml_str(yaml).unwrap();
591
592 let serialized = document.to_yaml_string().unwrap();
593
594 assert_eq!(Document::from_yaml_str(&serialized).unwrap(), document);
595 assert!(
599 !serialized.contains("Single") && !serialized.contains("Collection"),
600 "a Document must serialize as whichever bare shape it holds, not tagged with its \
601 own variant name: got {serialized}"
602 );
603 }
604
605 #[test]
606 fn document_validate_accepts_a_valid_single_and_a_valid_collection() {
607 let single = Document::from_yaml_str("method: GET\nurl: https://example.com\n").unwrap();
608 single
609 .validate()
610 .expect("a real, already-parsed Single document must validate");
611
612 let collection = Document::from_yaml_str(
613 "requests:\n - name: One\n method: GET\n url: https://example.com\n",
614 )
615 .unwrap();
616 collection
617 .validate()
618 .expect("a real, already-parsed Collection document must validate");
619 }
620
621 #[test]
622 fn document_validate_rejects_a_collection_with_an_unnamed_request() {
623 let request = Request::from_yaml_str("method: GET\nurl: https://example.com\n").unwrap();
628 let document = Document::Collection(Collection {
629 name: None,
630 requests: vec![request],
631 });
632
633 let err = document
634 .validate()
635 .expect_err("an unnamed request in a collection is invalid");
636 assert!(
637 matches!(err, SendraError::InvalidCollection { .. }),
638 "got {err:?}"
639 );
640 }
641
642 #[test]
643 fn save_to_path_refuses_to_write_an_invalid_document_and_touches_nothing() {
644 let dir = tempfile::tempdir().expect("a temp dir for this test");
645 let path = dir.path().join("collection.yaml");
646 let request = Request::from_yaml_str("method: GET\nurl: https://example.com\n").unwrap();
647 let invalid = Document::Collection(Collection {
648 name: None,
649 requests: vec![request], });
651
652 let err = invalid
653 .save_to_path(&path)
654 .expect_err("an invalid document must never be written");
655 assert!(
656 matches!(err, SendraError::InvalidCollection { .. }),
657 "got {err:?}"
658 );
659
660 assert!(
661 !path.exists(),
662 "nothing should be written for a document that fails validation"
663 );
664 let entries: Vec<_> = std::fs::read_dir(dir.path()).unwrap().collect();
665 assert!(
666 entries.is_empty(),
667 "no temp file should be created either, since validation happens before the write: \
668 {entries:?}"
669 );
670 }
671
672 #[test]
673 fn save_to_path_writes_the_document_and_a_reload_from_disk_matches() {
674 let dir = tempfile::tempdir().expect("a temp dir for this test");
675 let path = dir.path().join("collection.yaml");
676 let document = Document::from_yaml_str("method: GET\nurl: https://example.com\n").unwrap();
677
678 document
679 .save_to_path(&path)
680 .expect("saving into a writable directory must succeed");
681
682 let reloaded = Document::from_path(&path).expect("the saved file must parse back");
683 assert_eq!(reloaded, document);
684 }
685
686 #[test]
687 fn save_to_path_leaves_no_temp_file_behind_on_success() {
688 let dir = tempfile::tempdir().expect("a temp dir for this test");
689 let path = dir.path().join("collection.yaml");
690 let document = Document::from_yaml_str("method: GET\nurl: https://example.com\n").unwrap();
691
692 document.save_to_path(&path).unwrap();
693
694 let entries: Vec<_> = std::fs::read_dir(dir.path())
695 .unwrap()
696 .map(|entry| entry.unwrap().file_name())
697 .collect();
698 assert_eq!(
699 entries,
700 vec![std::ffi::OsString::from("collection.yaml")],
701 "no stray temp file should remain after a successful save: {entries:?}"
702 );
703 }
704
705 #[test]
706 fn save_to_path_fails_without_touching_anything_when_the_parent_is_not_a_directory() {
707 let dir = tempfile::tempdir().expect("a temp dir for this test");
713 let blocking_file = dir.path().join("not-a-directory");
714 std::fs::write(&blocking_file, "just a file").unwrap();
715 let path = blocking_file.join("collection.yaml");
716
717 let document = Document::from_yaml_str("method: GET\nurl: https://example.com\n").unwrap();
718 let err = document
719 .save_to_path(&path)
720 .expect_err("a non-directory parent must fail the write");
721 assert!(matches!(err, SendraError::SaveIo { .. }), "got {err:?}");
722
723 assert_eq!(
724 std::fs::read_to_string(&blocking_file).unwrap(),
725 "just a file",
726 "the unrelated file the failure was caused by must be untouched"
727 );
728 }
729
730 #[test]
731 fn save_to_path_fails_without_corrupting_an_existing_directory_at_the_target() {
732 let dir = tempfile::tempdir().expect("a temp dir for this test");
740 let path = dir.path().join("collection.yaml");
741 std::fs::create_dir(&path).unwrap();
742
743 let document = Document::from_yaml_str("method: GET\nurl: https://example.com\n").unwrap();
744 let err = document
745 .save_to_path(&path)
746 .expect_err("renaming a file over an existing directory must fail");
747 assert!(matches!(err, SendraError::SaveIo { .. }), "got {err:?}");
748
749 assert!(
750 path.is_dir(),
751 "the original directory at the target path must be left exactly as it was"
752 );
753 let entries: Vec<_> = std::fs::read_dir(dir.path())
754 .unwrap()
755 .map(|entry| entry.unwrap().file_name())
756 .collect();
757 assert_eq!(
758 entries,
759 vec![std::ffi::OsString::from("collection.yaml")],
760 "no leftover temp file should remain after a failed rename: {entries:?}"
761 );
762 }
763
764 #[test]
776 #[cfg(windows)]
777 fn a_write_denied_target_directory_leaves_the_original_file_completely_untouched() {
778 use std::process::Command;
779
780 let dir = tempfile::tempdir().expect("a temp dir for this test");
781 let path = dir.path().join("collection.yaml");
782 let original = "method: GET\nurl: https://example.com/original\n";
783 std::fs::write(&path, original).unwrap();
784
785 let user = std::env::var("USERNAME").expect("USERNAME must be set on Windows");
786 let deny = Command::new("icacls")
787 .arg(dir.path())
788 .arg("/deny")
789 .arg(format!("{user}:(OI)(CI)W"))
790 .status()
791 .expect("icacls must be available on Windows");
792 assert!(
793 deny.success(),
794 "icacls /deny must succeed to set up this test"
795 );
796
797 let new_document =
798 Document::from_yaml_str("method: POST\nurl: https://example.com/new\n").unwrap();
799 let result = new_document.save_to_path(&path);
800
801 let restore = Command::new("icacls")
804 .arg(dir.path())
805 .arg("/remove:d")
806 .arg(&user)
807 .status()
808 .expect("icacls must be available on Windows");
809 assert!(
810 restore.success(),
811 "icacls /remove:d must succeed to clean this test up"
812 );
813
814 assert!(
815 result.is_err(),
816 "a write-denied directory must fail the save rather than silently succeeding"
817 );
818 assert_eq!(
819 std::fs::read_to_string(&path).unwrap(),
820 original,
821 "the original file must be completely unchanged after the failed save"
822 );
823 }
824}