Skip to main content

sendra_core/
collection.rs

1//! [`Collection`] and [`Document`]: a named group of requests in one YAML
2//! file, and the two shapes a Sendra file can hold.
3
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use crate::error::SendraError;
10use crate::request::Request;
11
12/// A named group of requests living in one YAML file.
13///
14/// ```text
15/// name: Example API        # optional, a label for the collection as a whole
16/// requests:
17///   - name: List users     # required inside a collection: it is the selector
18///     method: GET
19///     url: https://api.example.com/users
20///   - name: Create user
21///     method: POST
22///     url: https://api.example.com/users
23///     body: '{"name": "ada"}'
24/// ```
25///
26/// `requests` is a *list*, not a map of name-to-request, for two reasons.
27/// First, each entry is then exactly a single-request file: a request can be
28/// lifted into a collection (or pulled back out into its own file) verbatim,
29/// with its `name` staying a field instead of becoming a key. There is one
30/// request shape in Sendra, not two. Second, a list preserves file order,
31/// which is the order `sendra run <file>` sends them in; the map types serde
32/// reaches for either sort the entries (`BTreeMap`) or need a dependency
33/// (`IndexMap`) to avoid it. Lookup by name is then a linear scan, which costs
34/// nothing at the sizes a hand-written collection reaches.
35#[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    /// Look a request up by its `name`.
46    ///
47    /// Errors with [`SendraError::RequestNotFound`], which carries the names
48    /// that do exist, rather than returning a bare `Option` — a missing name
49    /// is a user-facing mistake worth a good message everywhere it happens.
50    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    /// The name of every request, in file order.
61    pub fn names(&self) -> Vec<String> {
62        self.requests
63            .iter()
64            .filter_map(|request| request.name.clone())
65            .collect()
66    }
67
68    /// Rules the `Deserialize` impl cannot express: at least one request,
69    /// every request named, no name used twice.
70    ///
71    /// `name` stays `Option` on [`Request`] because a standalone request file
72    /// genuinely does not need one, so the requirement is enforced here, at
73    /// parse time — a collection that cannot be addressed by name is a broken
74    /// file, and finding that out before the first request goes over the wire
75    /// beats finding out halfway through a run.
76    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            // Wrapped into `InvalidCollection`, with which request it was,
99            // the same way the duplicate-name error above is — a standalone
100            // request file raises `InvalidRequest` directly, but inside a
101            // collection this is still a fact about *the file*, so it gets
102            // the file-level error with request-level context added.
103            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/// What one Sendra YAML file can hold: a single request, or a collection.
113///
114/// The two shapes are told apart by **the presence of a top-level `requests`
115/// key**. A mapping with `requests` is a [`Collection`]; anything else is
116/// parsed as a single [`Request`]. The discriminator is in the file itself, so
117/// no new extension and no CLI flag are needed, and it cannot be ambiguous:
118/// [`Request`] rejects unknown top-level keys, so a single-request file could
119/// never have carried a `requests` key to begin with.
120///
121/// Detection is a separate pass over the YAML rather than a
122/// `#[serde(untagged)]` enum on purpose. An untagged enum collapses every
123/// failure into "data did not match any variant" with no position; picking the
124/// target first and then deserializing the original text keeps serde's real
125/// error message, line and column included.
126///
127/// The `Single` variant is not boxed, though it is several times the size of
128/// `Collection`. A `Document` is built once per invocation and read from where
129/// it sits — the requests are borrowed out of it, never moved through it — so
130/// the indirection would buy nothing and would cost every caller a deref to
131/// reach a request that is right there.
132#[allow(clippy::large_enum_variant)]
133#[derive(Debug, Clone, PartialEq)]
134pub enum Document {
135    Single(Request),
136    Collection(Collection),
137}
138
139impl Document {
140    /// Parse a request or a collection from a YAML string.
141    pub fn from_yaml_str(yaml: &str) -> Result<Self, SendraError> {
142        Self::parse(yaml, SendraError::ParseStr)
143    }
144
145    /// Read and parse a request or a collection from a YAML file on disk.
146    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    /// Shared body of the two constructors; `wrap` supplies the error variant
159    /// that says where the YAML came from.
160    fn parse(
161        yaml: &str,
162        wrap: impl Fn(serde_yaml::Error) -> SendraError,
163    ) -> Result<Self, SendraError> {
164        // First pass: shape detection only. Cheap, and it means the second
165        // pass parses the original text and so reports real positions.
166        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    /// Every request the document holds, in file order — one for a single
183    /// request, all of them for a collection. This is what `sendra run <file>`
184    /// with no name sends.
185    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    /// Look up one request by name.
193    ///
194    /// Asking a single-request file for a name is its own error rather than a
195    /// "not found": the file has no names to choose between, and saying so is
196    /// more useful than listing an empty set.
197    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    /// Every rule `Deserialize` cannot express, checked directly rather than
207    /// only ever at parse time: a single request's own `Request::validate`
208    /// (at most one body source, `auth` exclusivity, ...), or, for a
209    /// collection, `Collection::validate` (non-empty, every request named,
210    /// no name used twice) plus that same per-request check for each one.
211    ///
212    /// `from_yaml_str`/`from_path` already run this before ever handing a
213    /// `Document` back, so a `Document` that came from a real file is always
214    /// already valid — this exists for the other direction: a `Document`
215    /// built or mutated in memory (a front-end applying an edit, say) can
216    /// check *before* [`save_to_path`](Self::save_to_path) writes it, rather
217    /// than only discovering it was invalid the next time something tries to
218    /// load it back. `save_to_path` calls this itself for exactly that
219    /// reason — this is exposed as its own method mainly so a caller can ask
220    /// the question earlier, e.g. to show a validation message before ever
221    /// attempting a write.
222    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    /// Serializes this document back to YAML, exactly the shape
230    /// [`from_yaml_str`](Self::from_yaml_str)/[`from_path`](Self::from_path)
231    /// parse: a bare [`Request`] for `Single`, a [`Collection`] for
232    /// `Collection`.
233    ///
234    /// **Not a derived `Serialize` impl on `Document` itself.** `Document`
235    /// deliberately has no `#[derive(Serialize)]` (nor a hand-written
236    /// externally-tagged one): serde's default representation for an enum
237    /// like this one wraps the output in a `Single:`/`Collection:` key
238    /// (`!Single ...` in YAML's own tag syntax, depending on the
239    /// representation), which is not a shape `from_yaml_str`'s own shape
240    /// detection — "a top-level `requests` key means a collection, anything
241    /// else is a single request" (see this type's own doc comment) — was ever
242    /// written to expect. Serializing whichever variant is actually held,
243    /// unwrapped, is what keeps
244    /// `Document::from_yaml_str(&doc.to_yaml_string()?)` equal to `doc` for
245    /// every real collection or request file — round-tripping through the
246    /// same shape a hand-written file already has, not a new one only this
247    /// method would produce.
248    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    /// Writes this document back to `path`, atomically: the new content is
257    /// written to a sibling temp file in the same directory first, then
258    /// [`std::fs::rename`]d over `path` — never written in place — so a
259    /// crash or a killed process mid-write can never leave `path` holding a
260    /// truncated or half-written file. A rename onto an existing file is
261    /// atomic on the same volume on both POSIX (`rename(2)`) and Windows
262    /// (`std::fs::rename` there is implemented as `MoveFileExW` with
263    /// `MOVEFILE_REPLACE_EXISTING`) — the two platforms sendra-tui ships
264    /// on — so `path` is always either its old content in full or its new
265    /// content in full, never a mix of both, no matter when the process is
266    /// interrupted.
267    ///
268    /// The temp file is created in the *same directory* as `path`, not the
269    /// system temp directory: a rename across filesystems/mount points is not
270    /// atomic (POSIX `rename(2)` fails outright with `EXDEV`), so the temp
271    /// file has to already live on whatever volume `path` is on for the final
272    /// rename to be the one atomic operation this whole guarantee rests on.
273    ///
274    /// If either the initial write or the rename fails, `path` is left
275    /// completely untouched (the failure can only ever happen to the temp
276    /// file, before `path` itself is touched at all) and the temp file is
277    /// removed on a best-effort basis rather than left behind as a stray
278    /// dotfile — the original error is what gets returned either way, not
279    /// whatever the cleanup did.
280    ///
281    /// **Refuses to write an invalid document at all** — [`validate`](Self::validate)
282    /// is checked first, before the temp file is even created. Without this,
283    /// an in-memory edit that left the document invalid (a collection request
284    /// edited down to an empty `name`, say) would still write out a file that
285    /// parses back as YAML but fails `Collection::validate` the very next
286    /// time anything loads it — a real file that looks saved but is
287    /// silently broken. Catching it here means the caller learns about it
288    /// immediately, through the same `Result` a disk-level failure already
289    /// comes back through, rather than the next `Document::from_path` call
290    /// discovering it days later.
291    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
313/// A path, next to `target`, that nothing else is using — what
314/// [`Document::save_to_path`] writes the new content to before renaming it
315/// over `target`. Named with a leading dot (hidden on Unix, and merely
316/// unusual rather than special on Windows) and a `sendra-tmp-` marker so a
317/// stray one left behind by a process that was killed between the write and
318/// the rename reads as obviously disposable rather than a mystery file.
319///
320/// Unique per call within one process via a process-wide counter — `target`'s
321/// own name plus the process id alone would collide if `save_to_path` were
322/// ever called twice for the same path in quick succession (e.g. two rapid
323/// saves) inside the same process.
324pub(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    /// Three requests, in a deliberately non-alphabetical order so the
350    /// file-order assertions below mean something.
351    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        // File order, not alphabetical: the run order is the author's order.
377        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        // The message is what a user actually sees, so pin it too.
442        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        // The examples are documentation; a broken one is a broken doc.
512        for name in [
513            "get-request.yaml",
514            "post-request.yaml",
515            "collection.yaml",
516            "mixed-status-collection.yaml",
517            // Parses like any other request file: the `{{...}}` in it is a
518            // string value, and substitution is a separate pass afterwards.
519            "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    // --- to_yaml_string / save_to_path --------------------------------------
550
551    #[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        // The regression this guards against: a derived `Serialize` on
596        // `Document` itself would wrap the output in a `Single:` key, which
597        // `from_yaml_str`'s own shape detection was never written to expect.
598        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        // Built directly rather than through `from_yaml_str`, which would
624        // already reject this at parse time — `validate` has to be checked
625        // independently, since it exists precisely for a `Document` that
626        // didn't come from a file (an in-memory edit, say).
627        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], // unnamed -- invalid inside a collection
650        });
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        // A real, deterministic write-phase failure (before `path` is ever
708        // touched): the temp file's own write fails because its parent
709        // component names a plain file, not a directory — reproducible on
710        // both POSIX (`ENOTDIR`) and Windows without needing OS-specific
711        // permission setup.
712        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        // A different, later failure point than the previous test: the temp
733        // file's own write succeeds (its parent — `dir` — is a real,
734        // writable directory), and the failure is specifically the final
735        // rename, which always fails on both POSIX (`EISDIR`) and Windows
736        // when the destination is an existing directory. This proves the
737        // target is left alone even when the new content was already written
738        // somewhere, not just when nothing was ever written at all.
739        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    /// The one failure mode the two tests above can't reach: a write refused
765    /// purely by filesystem permissions rather than by the path shape.
766    /// Windows-only because `std::fs::Permissions::set_readonly` on a
767    /// *directory* is cosmetic there and does not actually block file
768    /// creation inside it — reproducing a genuinely write-denied directory
769    /// needs a real ACL deny via `icacls`, which only exists on Windows. The
770    /// POSIX equivalent (`set_permissions` clearing the write bit on the
771    /// directory) is not exercised here since this workspace's dev/CI
772    /// environment for this crate is Windows; the *mechanism* being proved —
773    /// a failed write leaves the original file completely untouched — is
774    /// already covered cross-platform by the two tests above.
775    #[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        // Restore permissions before asserting anything, so a failing
802        // assertion never leaves the temp directory locked for cleanup.
803        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}