insta/
snapshot.rs

1use crate::{
2    content::{self, json, yaml, Content},
3    elog,
4    utils::style,
5};
6use once_cell::sync::Lazy;
7use std::env;
8use std::error::Error;
9use std::fs;
10use std::io::{BufRead, BufReader, Write};
11use std::path::{Path, PathBuf};
12use std::rc::Rc;
13use std::time::{SystemTime, UNIX_EPOCH};
14use std::{borrow::Cow, fmt};
15
16static RUN_ID: Lazy<String> = Lazy::new(|| {
17    if let Ok(run_id) = env::var("NEXTEST_RUN_ID") {
18        run_id
19    } else {
20        let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
21        format!("{}-{}", d.as_secs(), d.subsec_nanos())
22    }
23});
24
25/// Holds a pending inline snapshot loaded from a json file or read from an assert
26/// macro (doesn't write to the rust file, which is done by `cargo-insta`)
27#[derive(Debug)]
28pub struct PendingInlineSnapshot {
29    pub run_id: String,
30    pub line: u32,
31    pub new: Option<Snapshot>,
32    pub old: Option<Snapshot>,
33}
34
35impl PendingInlineSnapshot {
36    pub fn new(new: Option<Snapshot>, old: Option<Snapshot>, line: u32) -> PendingInlineSnapshot {
37        PendingInlineSnapshot {
38            new,
39            old,
40            line,
41            run_id: RUN_ID.clone(),
42        }
43    }
44
45    #[cfg(feature = "_cargo_insta_internal")]
46    pub fn load_batch(p: &Path) -> Result<Vec<PendingInlineSnapshot>, Box<dyn Error>> {
47        let contents =
48            fs::read_to_string(p).map_err(|e| content::Error::FileIo(e, p.to_path_buf()))?;
49
50        let mut rv: Vec<Self> = contents
51            .lines()
52            .map(|line| {
53                let value = yaml::parse_str(line, p)?;
54                Self::from_content(value)
55            })
56            .collect::<Result<_, Box<dyn Error>>>()?;
57
58        // remove all but the last run
59        if let Some(last_run_id) = rv.last().map(|x| x.run_id.clone()) {
60            rv.retain(|x| x.run_id == last_run_id);
61        }
62
63        Ok(rv)
64    }
65
66    #[cfg(feature = "_cargo_insta_internal")]
67    pub fn save_batch(p: &Path, batch: &[PendingInlineSnapshot]) -> Result<(), Box<dyn Error>> {
68        fs::remove_file(p).ok();
69        for snap in batch {
70            snap.save(p)?;
71        }
72        Ok(())
73    }
74
75    pub fn save(&self, p: &Path) -> Result<(), Box<dyn Error>> {
76        let mut f = fs::OpenOptions::new().create(true).append(true).open(p)?;
77        let mut s = json::to_string(&self.as_content());
78        s.push('\n');
79        f.write_all(s.as_bytes())?;
80        Ok(())
81    }
82
83    #[cfg(feature = "_cargo_insta_internal")]
84    fn from_content(content: Content) -> Result<PendingInlineSnapshot, Box<dyn Error>> {
85        if let Content::Map(map) = content {
86            let mut run_id = None;
87            let mut line = None;
88            let mut old = None;
89            let mut new = None;
90
91            for (key, value) in map.into_iter() {
92                match key.as_str() {
93                    Some("run_id") => run_id = value.as_str().map(|x| x.to_string()),
94                    Some("line") => line = value.as_u64().map(|x| x as u32),
95                    Some("old") if !value.is_nil() => {
96                        old = Some(Snapshot::from_content(value, TextSnapshotKind::Inline)?)
97                    }
98                    Some("new") if !value.is_nil() => {
99                        new = Some(Snapshot::from_content(value, TextSnapshotKind::Inline)?)
100                    }
101                    _ => {}
102                }
103            }
104
105            Ok(PendingInlineSnapshot {
106                run_id: run_id.ok_or(content::Error::MissingField)?,
107                line: line.ok_or(content::Error::MissingField)?,
108                new,
109                old,
110            })
111        } else {
112            Err(content::Error::UnexpectedDataType.into())
113        }
114    }
115
116    fn as_content(&self) -> Content {
117        let fields = vec![
118            ("run_id", Content::from(self.run_id.as_str())),
119            ("line", Content::from(self.line)),
120            (
121                "new",
122                match &self.new {
123                    Some(snap) => snap.as_content(),
124                    None => Content::None,
125                },
126            ),
127            (
128                "old",
129                match &self.old {
130                    Some(snap) => snap.as_content(),
131                    None => Content::None,
132                },
133            ),
134        ];
135
136        Content::Struct("PendingInlineSnapshot", fields)
137    }
138}
139
140#[derive(Debug, Clone, PartialEq)]
141pub enum SnapshotKind {
142    Text,
143    Binary { extension: String },
144}
145
146impl Default for SnapshotKind {
147    fn default() -> Self {
148        SnapshotKind::Text
149    }
150}
151
152/// Snapshot metadata information.
153#[derive(Debug, Default, Clone, PartialEq)]
154pub struct MetaData {
155    /// The source file (relative to workspace root).
156    pub(crate) source: Option<String>,
157    /// The source line, if available. This is used by pending snapshots, but trimmed
158    /// before writing to the final `.snap` files in [`MetaData::trim_for_persistence`].
159    pub(crate) assertion_line: Option<u32>,
160    /// Optional human readable (non formatted) snapshot description.
161    pub(crate) description: Option<String>,
162    /// Optionally the expression that created the snapshot.
163    pub(crate) expression: Option<String>,
164    /// An optional arbitrary structured info object.
165    pub(crate) info: Option<Content>,
166    /// Reference to the input file.
167    pub(crate) input_file: Option<String>,
168    /// The type of the snapshot (string or binary).
169    pub(crate) snapshot_kind: SnapshotKind,
170}
171
172impl MetaData {
173    /// Returns the absolute source path.
174    pub fn source(&self) -> Option<&str> {
175        self.source.as_deref()
176    }
177
178    /// Returns the assertion line.
179    pub fn assertion_line(&self) -> Option<u32> {
180        self.assertion_line
181    }
182
183    /// Returns the expression that created the snapshot.
184    pub fn expression(&self) -> Option<&str> {
185        self.expression.as_deref()
186    }
187
188    /// Returns the description that created the snapshot.
189    pub fn description(&self) -> Option<&str> {
190        self.description.as_deref().filter(|x| !x.is_empty())
191    }
192
193    /// Returns the embedded info.
194    #[doc(hidden)]
195    pub fn private_info(&self) -> Option<&Content> {
196        self.info.as_ref()
197    }
198
199    /// Returns the relative source path.
200    pub fn get_relative_source(&self, base: &Path) -> Option<PathBuf> {
201        self.source.as_ref().map(|source| {
202            base.join(source)
203                .canonicalize()
204                .ok()
205                .and_then(|s| s.strip_prefix(base).ok().map(|x| x.to_path_buf()))
206                .unwrap_or_else(|| base.to_path_buf())
207        })
208    }
209
210    /// Returns the input file reference.
211    pub fn input_file(&self) -> Option<&str> {
212        self.input_file.as_deref()
213    }
214
215    fn from_content(content: Content) -> Result<MetaData, Box<dyn Error>> {
216        if let Content::Map(map) = content {
217            let mut source = None;
218            let mut assertion_line = None;
219            let mut description = None;
220            let mut expression = None;
221            let mut info = None;
222            let mut input_file = None;
223            let mut snapshot_type = TmpSnapshotKind::Text;
224            let mut extension = None;
225
226            enum TmpSnapshotKind {
227                Text,
228                Binary,
229            }
230
231            for (key, value) in map.into_iter() {
232                match key.as_str() {
233                    Some("source") => source = value.as_str().map(|x| x.to_string()),
234                    Some("assertion_line") => assertion_line = value.as_u64().map(|x| x as u32),
235                    Some("description") => description = value.as_str().map(Into::into),
236                    Some("expression") => expression = value.as_str().map(Into::into),
237                    Some("info") if !value.is_nil() => info = Some(value),
238                    Some("input_file") => input_file = value.as_str().map(Into::into),
239                    Some("snapshot_kind") => {
240                        snapshot_type = match value.as_str() {
241                            Some("binary") => TmpSnapshotKind::Binary,
242                            _ => TmpSnapshotKind::Text,
243                        }
244                    }
245                    Some("extension") => {
246                        extension = value.as_str().map(Into::into);
247                    }
248                    _ => {}
249                }
250            }
251
252            Ok(MetaData {
253                source,
254                assertion_line,
255                description,
256                expression,
257                info,
258                input_file,
259                snapshot_kind: match snapshot_type {
260                    TmpSnapshotKind::Text => SnapshotKind::Text,
261                    TmpSnapshotKind::Binary => SnapshotKind::Binary {
262                        extension: extension.ok_or(content::Error::MissingField)?,
263                    },
264                },
265            })
266        } else {
267            Err(content::Error::UnexpectedDataType.into())
268        }
269    }
270
271    fn as_content(&self) -> Content {
272        let mut fields = Vec::new();
273        if let Some(source) = self.source.as_deref() {
274            fields.push(("source", Content::from(source)));
275        }
276        if let Some(line) = self.assertion_line {
277            fields.push(("assertion_line", Content::from(line)));
278        }
279        if let Some(description) = self.description.as_deref() {
280            fields.push(("description", Content::from(description)));
281        }
282        if let Some(expression) = self.expression.as_deref() {
283            fields.push(("expression", Content::from(expression)));
284        }
285        if let Some(info) = &self.info {
286            fields.push(("info", info.to_owned()));
287        }
288        if let Some(input_file) = self.input_file.as_deref() {
289            fields.push(("input_file", Content::from(input_file)));
290        }
291
292        match self.snapshot_kind {
293            SnapshotKind::Text => {}
294            SnapshotKind::Binary { ref extension } => {
295                fields.push(("extension", Content::from(extension.clone())));
296                fields.push(("snapshot_kind", Content::from("binary")));
297            }
298        }
299
300        Content::Struct("MetaData", fields)
301    }
302
303    /// Trims the metadata of fields that we don't save to `.snap` files (those
304    /// we only use for display while reviewing)
305    fn trim_for_persistence(&self) -> Cow<'_, MetaData> {
306        // TODO: in order for `--require-full-match` to work on inline snapshots
307        // without cargo-insta, we need to trim all fields if there's an inline
308        // snapshot. But we don't know that from here (notably
309        // `self.input_file.is_none()` is not a correct approach). Given that
310        // `--require-full-match` is experimental and we're working on making
311        // inline & file snapshots more coherent, I'm leaving this as is for
312        // now.
313        if self.assertion_line.is_some() {
314            let mut rv = self.clone();
315            rv.assertion_line = None;
316            Cow::Owned(rv)
317        } else {
318            Cow::Borrowed(self)
319        }
320    }
321}
322
323#[derive(Debug, PartialEq, Eq, Clone, Copy)]
324pub enum TextSnapshotKind {
325    Inline,
326    File,
327}
328
329/// A helper to work with file snapshots.
330#[derive(Debug, Clone)]
331pub struct Snapshot {
332    module_name: String,
333    snapshot_name: Option<String>,
334    metadata: MetaData,
335    snapshot: SnapshotContents,
336}
337
338impl Snapshot {
339    /// Loads a snapshot from a file.
340    pub fn from_file(p: &Path) -> Result<Snapshot, Box<dyn Error>> {
341        let mut f = BufReader::new(fs::File::open(p)?);
342        let mut buf = String::new();
343
344        f.read_line(&mut buf)?;
345
346        // yaml format
347        let metadata = if buf.trim_end() == "---" {
348            loop {
349                let read = f.read_line(&mut buf)?;
350                if read == 0 {
351                    break;
352                }
353                if buf[buf.len() - read..].trim_end() == "---" {
354                    buf.truncate(buf.len() - read);
355                    break;
356                }
357            }
358            let content = yaml::parse_str(&buf, p)?;
359            MetaData::from_content(content)?
360        // legacy format
361        // (but not viable to move into `match_legacy` given it's more than
362        // just the snapshot value itself...)
363        } else {
364            let mut rv = MetaData::default();
365            loop {
366                buf.clear();
367                let read = f.read_line(&mut buf)?;
368                if read == 0 || buf.trim_end().is_empty() {
369                    buf.truncate(buf.len() - read);
370                    break;
371                }
372                let mut iter = buf.splitn(2, ':');
373                if let Some(key) = iter.next() {
374                    if let Some(value) = iter.next() {
375                        let value = value.trim();
376                        match key.to_lowercase().as_str() {
377                            "expression" => rv.expression = Some(value.to_string()),
378                            "source" => rv.source = Some(value.into()),
379                            _ => {}
380                        }
381                    }
382                }
383            }
384            elog!("A snapshot uses a legacy snapshot format; please update it to the new format with `cargo insta test --force-update-snapshots --accept`.\nSnapshot is at: {}", p.to_string_lossy());
385            rv
386        };
387
388        let contents = match metadata.snapshot_kind {
389            SnapshotKind::Text => {
390                buf.clear();
391                for (idx, line) in f.lines().enumerate() {
392                    let line = line?;
393                    if idx > 0 {
394                        buf.push('\n');
395                    }
396                    buf.push_str(&line);
397                }
398
399                TextSnapshotContents {
400                    contents: buf,
401                    kind: TextSnapshotKind::File,
402                }
403                .into()
404            }
405            SnapshotKind::Binary { ref extension } => {
406                let path = build_binary_path(extension, p);
407                let contents = fs::read(path)?;
408
409                SnapshotContents::Binary(Rc::new(contents))
410            }
411        };
412
413        let (snapshot_name, module_name) = names_of_path(p);
414
415        Ok(Snapshot::from_components(
416            module_name,
417            Some(snapshot_name),
418            metadata,
419            contents,
420        ))
421    }
422
423    pub(crate) fn from_components(
424        module_name: String,
425        snapshot_name: Option<String>,
426        metadata: MetaData,
427        snapshot: SnapshotContents,
428    ) -> Snapshot {
429        Snapshot {
430            module_name,
431            snapshot_name,
432            metadata,
433            snapshot,
434        }
435    }
436
437    #[cfg(feature = "_cargo_insta_internal")]
438    fn from_content(content: Content, kind: TextSnapshotKind) -> Result<Snapshot, Box<dyn Error>> {
439        if let Content::Map(map) = content {
440            let mut module_name = None;
441            let mut snapshot_name = None;
442            let mut metadata = None;
443            let mut snapshot = None;
444
445            for (key, value) in map.into_iter() {
446                match key.as_str() {
447                    Some("module_name") => module_name = value.as_str().map(|x| x.to_string()),
448                    Some("snapshot_name") => snapshot_name = value.as_str().map(|x| x.to_string()),
449                    Some("metadata") => metadata = Some(MetaData::from_content(value)?),
450                    Some("snapshot") => {
451                        snapshot = Some(
452                            TextSnapshotContents {
453                                contents: value
454                                    .as_str()
455                                    .ok_or(content::Error::UnexpectedDataType)?
456                                    .to_string(),
457                                kind,
458                            }
459                            .into(),
460                        );
461                    }
462                    _ => {}
463                }
464            }
465
466            Ok(Snapshot {
467                module_name: module_name.ok_or(content::Error::MissingField)?,
468                snapshot_name,
469                metadata: metadata.ok_or(content::Error::MissingField)?,
470                snapshot: snapshot.ok_or(content::Error::MissingField)?,
471            })
472        } else {
473            Err(content::Error::UnexpectedDataType.into())
474        }
475    }
476
477    fn as_content(&self) -> Content {
478        let mut fields = vec![("module_name", Content::from(self.module_name.as_str()))];
479        // Note this is currently never used, since this method is only used for
480        // inline snapshots
481        if let Some(name) = self.snapshot_name.as_deref() {
482            fields.push(("snapshot_name", Content::from(name)));
483        }
484        fields.push(("metadata", self.metadata.as_content()));
485
486        if let SnapshotContents::Text(ref content) = self.snapshot {
487            fields.push(("snapshot", Content::from(content.to_string())));
488        }
489
490        Content::Struct("Content", fields)
491    }
492
493    /// Returns the module name.
494    pub fn module_name(&self) -> &str {
495        &self.module_name
496    }
497
498    /// Returns the snapshot name.
499    pub fn snapshot_name(&self) -> Option<&str> {
500        self.snapshot_name.as_deref()
501    }
502
503    /// The metadata in the snapshot.
504    pub fn metadata(&self) -> &MetaData {
505        &self.metadata
506    }
507
508    /// The snapshot contents
509    pub fn contents(&self) -> &SnapshotContents {
510        &self.snapshot
511    }
512
513    /// Snapshot contents match another snapshot's.
514    pub fn matches(&self, other: &Self) -> bool {
515        self.contents() == other.contents()
516            // For binary snapshots the extension also need to be the same:
517            && self.metadata.snapshot_kind == other.metadata.snapshot_kind
518    }
519
520    /// Both the exact snapshot contents and the persisted metadata match another snapshot's.
521    // (could rename to `matches_exact` for consistency, after some current
522    // pending merge requests are merged)
523    pub fn matches_fully(&self, other: &Self) -> bool {
524        match (self.contents(), other.contents()) {
525            (SnapshotContents::Text(self_contents), SnapshotContents::Text(other_contents)) => {
526                // Note that we previously would match the exact values of the
527                // unnormalized text. But that's too strict — it means we can
528                // never match a snapshot that has leading/trailing whitespace.
529                // So instead we check it matches on the latest format.
530                // Generally those should be the same — latest should be doing
531                // the minimum normalization; if they diverge we could update
532                // this to be stricter.
533                //
534                // (I think to do this perfectly, we'd want to match the
535                // _reference_ value unnormalized, but the _generated_ value
536                // normalized. That way, we can get the But at the moment we
537                // don't distinguish between which is which in our data
538                // structures.)
539                let contents_match_exact = self_contents.matches_latest(other_contents);
540                match self_contents.kind {
541                    TextSnapshotKind::File => {
542                        self.metadata.trim_for_persistence()
543                            == other.metadata.trim_for_persistence()
544                            && contents_match_exact
545                    }
546                    TextSnapshotKind::Inline => contents_match_exact,
547                }
548            }
549            _ => self.matches(other),
550        }
551    }
552
553    fn serialize_snapshot(&self, md: &MetaData) -> String {
554        let mut buf = yaml::to_string(&md.as_content());
555        buf.push_str("---\n");
556
557        if let SnapshotContents::Text(ref contents) = self.snapshot {
558            buf.push_str(&contents.to_string());
559            buf.push('\n');
560        }
561
562        buf
563    }
564
565    // We take `md` as an argument here because the calling methods want to
566    // adjust it; e.g. removing volatile fields when writing to the final
567    // `.snap` file.
568    fn save_with_metadata(&self, path: &Path, md: &MetaData) -> Result<(), Box<dyn Error>> {
569        if let Some(folder) = path.parent() {
570            fs::create_dir_all(folder)?;
571        }
572
573        let serialized_snapshot = self.serialize_snapshot(md);
574        fs::write(path, serialized_snapshot)
575            .map_err(|e| content::Error::FileIo(e, path.to_path_buf()))?;
576
577        if let SnapshotContents::Binary(ref contents) = self.snapshot {
578            fs::write(self.build_binary_path(path).unwrap(), &**contents)
579                .map_err(|e| content::Error::FileIo(e, path.to_path_buf()))?;
580        }
581
582        Ok(())
583    }
584
585    pub fn build_binary_path(&self, path: impl Into<PathBuf>) -> Option<PathBuf> {
586        if let SnapshotKind::Binary { ref extension } = self.metadata.snapshot_kind {
587            Some(build_binary_path(extension, path))
588        } else {
589            None
590        }
591    }
592
593    /// Saves the snapshot.
594    #[doc(hidden)]
595    pub fn save(&self, path: &Path) -> Result<(), Box<dyn Error>> {
596        self.save_with_metadata(path, &self.metadata.trim_for_persistence())
597    }
598
599    /// Same as [`Self::save`] but instead of writing a normal snapshot file this will write
600    /// a `.snap.new` file with additional information.
601    ///
602    /// The path of the new snapshot file is returned.
603    pub(crate) fn save_new(&self, path: &Path) -> Result<PathBuf, Box<dyn Error>> {
604        // TODO: should we be the actual extension here rather than defaulting
605        // to the standard `.snap`?
606        let new_path = path.to_path_buf().with_extension("snap.new");
607        self.save_with_metadata(&new_path, &self.metadata)?;
608        Ok(new_path)
609    }
610}
611
612/// The contents of a Snapshot
613#[derive(Debug, Clone)]
614pub enum SnapshotContents {
615    Text(TextSnapshotContents),
616
617    // This is in an `Rc` because we need to be able to clone this struct cheaply and the contents
618    // of the `Vec` could be rather large. The reason it's not an `Rc<[u8]>` is because creating one
619    // of those would require re-allocating because of the additional size needed for the reference
620    // count.
621    Binary(Rc<Vec<u8>>),
622}
623
624// Could be Cow, but I think limited savings
625#[derive(Debug, PartialEq, Eq, Clone)]
626pub struct TextSnapshotContents {
627    contents: String,
628    pub kind: TextSnapshotKind,
629}
630
631impl From<TextSnapshotContents> for SnapshotContents {
632    fn from(value: TextSnapshotContents) -> Self {
633        SnapshotContents::Text(value)
634    }
635}
636
637impl SnapshotContents {
638    pub fn is_binary(&self) -> bool {
639        matches!(self, SnapshotContents::Binary(_))
640    }
641}
642
643impl TextSnapshotContents {
644    pub fn new(contents: String, kind: TextSnapshotKind) -> TextSnapshotContents {
645        // We could store a normalized version of the string as part of `new`;
646        // it would avoid allocating a new `String` when we get the normalized
647        // versions, which we may do a few times. (We want to store the
648        // unnormalized version because it allows us to use `matches_fully`.)
649        TextSnapshotContents { contents, kind }
650    }
651
652    /// Snapshot matches based on the latest format.
653    pub fn matches_latest(&self, other: &Self) -> bool {
654        self.to_string() == other.to_string()
655    }
656
657    pub fn matches_legacy(&self, other: &Self) -> bool {
658        fn as_str_legacy(sc: &TextSnapshotContents) -> String {
659            let out = sc.to_string();
660            // Legacy inline snapshots have `---` at the start, so this strips that if
661            // it exists.
662            let out = match out.strip_prefix("---\n") {
663                Some(old_snapshot) => old_snapshot.to_string(),
664                None => out,
665            };
666            match sc.kind {
667                TextSnapshotKind::Inline => legacy_inline_normalize(&out),
668                TextSnapshotKind::File => out,
669            }
670        }
671        as_str_legacy(self) == as_str_legacy(other)
672    }
673
674    fn normalize(&self) -> String {
675        let kind_specific_normalization = match self.kind {
676            TextSnapshotKind::Inline => normalize_inline_snapshot(&self.contents),
677            TextSnapshotKind::File => self.contents.clone(),
678        };
679        // Then this we do for both kinds
680        let out = kind_specific_normalization
681            .trim_start_matches(['\r', '\n'])
682            .trim_end();
683        out.replace("\r\n", "\n")
684    }
685
686    /// Returns the string literal, including `#` delimiters, to insert into a
687    /// Rust source file.
688    pub fn to_inline(&self, indentation: &str) -> String {
689        let contents = self.normalize();
690        let mut out = String::new();
691
692        // Some characters can't be escaped in a raw string literal, so we need
693        // to escape the string if it contains them. We prefer escaping control
694        // characters except for newlines, tabs, and ESC.
695        let has_control_chars = contents
696            .chars()
697            .any(|c| c.is_control() && !['\n', '\t', '\x1b'].contains(&c));
698
699        // We prefer raw strings for strings containing a quote or an escape
700        // character, and for strings containing newlines (which reduces diffs).
701        // We can't use raw strings for some control characters.
702        if !has_control_chars && contents.contains(['\\', '"', '\n']) {
703            out.push('r');
704        }
705
706        let delimiter = "#".repeat(required_hashes(&contents));
707
708        out.push_str(&delimiter);
709
710        // If there are control characters, then we have to just use a simple
711        // string with unicode escapes from the debug output. We don't attempt
712        // block mode (though not impossible to do so).
713        if has_control_chars {
714            out.push_str(format!("{:?}", contents).as_str());
715        } else {
716            out.push('"');
717            // if we have more than one line we want to change into the block
718            // representation mode
719            if contents.contains('\n') {
720                out.extend(
721                    contents
722                        .lines()
723                        // Adds an additional newline at the start of multiline
724                        // string (not sure this is the clearest way of representing
725                        // it, but it works...)
726                        .map(|l| {
727                            format!(
728                                "\n{i}{l}",
729                                i = if l.is_empty() { "" } else { indentation },
730                                l = l
731                            )
732                        })
733                        // `lines` removes the final line ending — add back. Include
734                        // indentation so the closing delimited aligns with the full string.
735                        .chain(Some(format!("\n{}", indentation))),
736                );
737            } else {
738                out.push_str(contents.as_str());
739            }
740            out.push('"');
741        }
742
743        out.push_str(&delimiter);
744        out
745    }
746}
747
748impl fmt::Display for TextSnapshotContents {
749    /// Returns the snapshot contents as a normalized string (for example,
750    /// removing surrounding whitespace)
751    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
752        write!(f, "{}", self.normalize())
753    }
754}
755
756impl PartialEq for SnapshotContents {
757    fn eq(&self, other: &Self) -> bool {
758        match (self, other) {
759            (SnapshotContents::Text(this), SnapshotContents::Text(other)) => {
760                // Ideally match on current rules, but otherwise fall back to legacy rules
761                if this.matches_latest(other) {
762                    true
763                } else if this.matches_legacy(other) {
764                    elog!("{} {}\n{}",style("Snapshot test passes but the existing value is in a legacy format. Please run `cargo insta test --force-update-snapshots` to update to a newer format.").yellow().bold(),"Snapshot contents:", this.to_string());
765                    true
766                } else {
767                    false
768                }
769            }
770            (SnapshotContents::Binary(this), SnapshotContents::Binary(other)) => this == other,
771            _ => false,
772        }
773    }
774}
775
776fn build_binary_path(extension: &str, path: impl Into<PathBuf>) -> PathBuf {
777    let path = path.into();
778    let mut new_extension = path.extension().unwrap().to_os_string();
779    new_extension.push(".");
780    new_extension.push(extension);
781
782    path.with_extension(new_extension)
783}
784
785/// The number of `#` we need to surround a raw string literal with.
786fn required_hashes(text: &str) -> usize {
787    let splits = text.split('"');
788    if splits.clone().count() <= 1 {
789        return 0;
790    }
791
792    splits
793        .map(|s| s.chars().take_while(|&c| c == '#').count() + 1)
794        .max()
795        .unwrap()
796}
797
798#[test]
799fn test_required_hashes() {
800    assert_snapshot!(required_hashes(""), @"0");
801    assert_snapshot!(required_hashes("Hello, world!"), @"0");
802    assert_snapshot!(required_hashes("\"\""), @"1");
803    assert_snapshot!(required_hashes("##"), @"0");
804    assert_snapshot!(required_hashes("\"#\"#"), @"2");
805    assert_snapshot!(required_hashes(r##""#"##), @"2");
806    assert_snapshot!(required_hashes(r######"foo ""##### bar "###" baz"######), @"6");
807    assert_snapshot!(required_hashes("\"\"\""), @"1");
808    assert_snapshot!(required_hashes("####"), @"0");
809    assert_snapshot!(required_hashes(r###"\"\"##\"\""###), @"3");
810    assert_snapshot!(required_hashes(r###"r"#"Raw string"#""###), @"2");
811}
812
813fn leading_space(value: &str) -> String {
814    value
815        .chars()
816        .take_while(|x| x.is_whitespace())
817        .collect::<String>()
818}
819
820fn min_indentation(snapshot: &str) -> String {
821    let lines = snapshot.trim_end().lines();
822
823    if lines.clone().count() <= 1 {
824        // not a multi-line string
825        return "".into();
826    }
827
828    lines
829        .filter(|l| !l.is_empty())
830        .map(leading_space)
831        .min_by(|a, b| a.len().cmp(&b.len()))
832        .unwrap_or("".into())
833}
834
835/// Removes excess indentation, and changes newlines to \n.
836fn normalize_inline_snapshot(snapshot: &str) -> String {
837    let indentation = min_indentation(snapshot);
838    snapshot
839        .lines()
840        .map(|l| l.get(indentation.len()..).unwrap_or(""))
841        .collect::<Vec<&str>>()
842        .join("\n")
843}
844
845/// Extracts the module and snapshot name from a snapshot path
846fn names_of_path(path: &Path) -> (String, String) {
847    // The final part of the snapshot file name is the test name; the
848    // initial parts are the module name
849    let parts: Vec<&str> = path
850        .file_stem()
851        .unwrap()
852        .to_str()
853        .unwrap_or("")
854        .rsplitn(2, "__")
855        .collect();
856
857    match parts.as_slice() {
858        [snapshot_name, module_name] => (snapshot_name.to_string(), module_name.to_string()),
859        [snapshot_name] => (snapshot_name.to_string(), String::new()),
860        _ => (String::new(), "<unknown>".to_string()),
861    }
862}
863
864#[test]
865fn test_names_of_path() {
866    assert_debug_snapshot!(
867        names_of_path(Path::new("/src/snapshots/insta_tests__tests__name_foo.snap")), @r###"
868    (
869        "name_foo",
870        "insta_tests__tests",
871    )
872    "###
873    );
874    assert_debug_snapshot!(
875        names_of_path(Path::new("/src/snapshots/name_foo.snap")), @r###"
876    (
877        "name_foo",
878        "",
879    )
880    "###
881    );
882    assert_debug_snapshot!(
883        names_of_path(Path::new("foo/src/snapshots/go1.20.5.snap")), @r###"
884    (
885        "go1.20.5",
886        "",
887    )
888    "###
889    );
890}
891
892/// legacy format - retain so old snapshots still work
893fn legacy_inline_normalize(frozen_value: &str) -> String {
894    if !frozen_value.trim_start().starts_with('⋮') {
895        return frozen_value.to_string();
896    }
897    let mut buf = String::new();
898    let mut line_iter = frozen_value.lines();
899    let mut indentation = 0;
900
901    for line in &mut line_iter {
902        let line_trimmed = line.trim_start();
903        if line_trimmed.is_empty() {
904            continue;
905        }
906        indentation = line.len() - line_trimmed.len();
907        // 3 because '⋮' is three utf-8 bytes long
908        buf.push_str(&line_trimmed[3..]);
909        buf.push('\n');
910        break;
911    }
912
913    for line in &mut line_iter {
914        if let Some(prefix) = line.get(..indentation) {
915            if !prefix.trim().is_empty() {
916                return "".to_string();
917            }
918        }
919        if let Some(remainder) = line.get(indentation..) {
920            if let Some(rest) = remainder.strip_prefix('⋮') {
921                buf.push_str(rest);
922                buf.push('\n');
923            } else if remainder.trim().is_empty() {
924                continue;
925            } else {
926                return "".to_string();
927            }
928        }
929    }
930
931    buf.trim_end().to_string()
932}
933
934#[test]
935fn test_snapshot_contents() {
936    use similar_asserts::assert_eq;
937    let snapshot_contents =
938        TextSnapshotContents::new("testing".to_string(), TextSnapshotKind::Inline);
939    assert_eq!(snapshot_contents.to_inline(""), r#""testing""#);
940
941    let t = &"
942a
943b"[1..];
944    assert_eq!(
945        TextSnapshotContents::new(t.to_string(), TextSnapshotKind::Inline).to_inline(""),
946        r##"r"
947a
948b
949""##
950    );
951
952    assert_eq!(
953        TextSnapshotContents::new("a\nb".to_string(), TextSnapshotKind::Inline).to_inline("    "),
954        r##"r"
955    a
956    b
957    ""##
958    );
959
960    assert_eq!(
961        TextSnapshotContents::new("\n    a\n    b".to_string(), TextSnapshotKind::Inline)
962            .to_inline(""),
963        r##"r"
964a
965b
966""##
967    );
968
969    assert_eq!(
970        TextSnapshotContents::new("\na\n\nb".to_string(), TextSnapshotKind::Inline)
971            .to_inline("    "),
972        r##"r"
973    a
974
975    b
976    ""##
977    );
978
979    assert_eq!(
980        TextSnapshotContents::new("\n    ab\n".to_string(), TextSnapshotKind::Inline).to_inline(""),
981        r##""ab""##
982    );
983
984    assert_eq!(
985        TextSnapshotContents::new("ab".to_string(), TextSnapshotKind::Inline).to_inline(""),
986        r#""ab""#
987    );
988
989    // Test control and special characters
990    assert_eq!(
991        TextSnapshotContents::new("a\tb".to_string(), TextSnapshotKind::Inline).to_inline(""),
992        r##""a	b""##
993    );
994
995    assert_eq!(
996        TextSnapshotContents::new("a\t\nb".to_string(), TextSnapshotKind::Inline).to_inline(""),
997        r##"r"
998a	
999b
1000""##
1001    );
1002
1003    assert_eq!(
1004        TextSnapshotContents::new("a\rb".to_string(), TextSnapshotKind::Inline).to_inline(""),
1005        r##""a\rb""##
1006    );
1007
1008    assert_eq!(
1009        TextSnapshotContents::new("a\0b".to_string(), TextSnapshotKind::Inline).to_inline(""),
1010        // Nul byte is printed as `\0` in Rust string literals
1011        r##""a\0b""##
1012    );
1013
1014    assert_eq!(
1015        TextSnapshotContents::new("a\u{FFFD}b".to_string(), TextSnapshotKind::Inline).to_inline(""),
1016        // Replacement character is returned as the character in literals
1017        r##""a�b""##
1018    );
1019}
1020
1021#[test]
1022fn test_snapshot_contents_hashes() {
1023    assert_eq!(
1024        TextSnapshotContents::new("a###b".to_string(), TextSnapshotKind::Inline).to_inline(""),
1025        r#""a###b""#
1026    );
1027
1028    assert_eq!(
1029        TextSnapshotContents::new("a\n\\###b".to_string(), TextSnapshotKind::Inline).to_inline(""),
1030        r#####"r"
1031a
1032\###b
1033""#####
1034    );
1035}
1036
1037#[test]
1038fn test_normalize_inline_snapshot() {
1039    use similar_asserts::assert_eq;
1040    // here we do exact matching (rather than `assert_snapshot`)
1041    // to ensure we're not incorporating the modifications this library makes
1042    assert_eq!(
1043        normalize_inline_snapshot(
1044            r#"
1045   1
1046   2
1047   "#,
1048        ),
1049        r###"
10501
10512
1052"###
1053    );
1054
1055    assert_eq!(
1056        normalize_inline_snapshot(
1057            r#"
1058            1
1059    2"#
1060        ),
1061        r###"
1062        1
10632"###
1064    );
1065
1066    assert_eq!(
1067        normalize_inline_snapshot(
1068            r#"
1069            1
1070            2
1071    "#
1072        ),
1073        r###"
10741
10752
1076"###
1077    );
1078
1079    assert_eq!(
1080        normalize_inline_snapshot(
1081            r#"
1082   1
1083   2
1084"#
1085        ),
1086        r###"
10871
10882"###
1089    );
1090
1091    assert_eq!(
1092        normalize_inline_snapshot(
1093            r#"
1094        a
1095    "#
1096        ),
1097        "
1098a
1099"
1100    );
1101
1102    assert_eq!(normalize_inline_snapshot(""), "");
1103
1104    assert_eq!(
1105        normalize_inline_snapshot(
1106            r#"
1107    a
1108    b
1109c
1110    "#
1111        ),
1112        r###"
1113    a
1114    b
1115c
1116    "###
1117    );
1118
1119    assert_eq!(
1120        normalize_inline_snapshot(
1121            r#"
1122a
1123    "#
1124        ),
1125        "
1126a
1127    "
1128    );
1129
1130    assert_eq!(
1131        normalize_inline_snapshot(
1132            "
1133    a"
1134        ),
1135        "
1136a"
1137    );
1138
1139    assert_eq!(
1140        normalize_inline_snapshot(
1141            r#"a
1142  a"#
1143        ),
1144        r###"a
1145  a"###
1146    );
1147
1148    assert_eq!(
1149        normalize_inline_snapshot(
1150            r#"
1151			1
1152	2"#
1153        ),
1154        r###"
1155		1
11562"###
1157    );
1158
1159    assert_eq!(
1160        normalize_inline_snapshot(
1161            r#"
1162	  	  1
1163	  	  2
1164    "#
1165        ),
1166        r###"
11671
11682
1169"###
1170    );
1171}
1172
1173#[test]
1174fn test_min_indentation() {
1175    use similar_asserts::assert_eq;
1176    let t = r#"
1177   1
1178   2
1179    "#;
1180    assert_eq!(min_indentation(t), "   ".to_string());
1181
1182    let t = r#"
1183            1
1184    2"#;
1185    assert_eq!(min_indentation(t), "    ".to_string());
1186
1187    let t = r#"
1188            1
1189            2
1190    "#;
1191    assert_eq!(min_indentation(t), "            ".to_string());
1192
1193    let t = r#"
1194   1
1195   2
1196"#;
1197    assert_eq!(min_indentation(t), "   ".to_string());
1198
1199    let t = r#"
1200        a
1201    "#;
1202    assert_eq!(min_indentation(t), "        ".to_string());
1203
1204    let t = "";
1205    assert_eq!(min_indentation(t), "".to_string());
1206
1207    let t = r#"
1208    a
1209    b
1210c
1211    "#;
1212    assert_eq!(min_indentation(t), "".to_string());
1213
1214    let t = r#"
1215a
1216    "#;
1217    assert_eq!(min_indentation(t), "".to_string());
1218
1219    let t = "
1220    a";
1221    assert_eq!(min_indentation(t), "    ".to_string());
1222
1223    let t = r#"a
1224  a"#;
1225    assert_eq!(min_indentation(t), "".to_string());
1226
1227    let t = r#"
1228 	1
1229 	2
1230    "#;
1231    assert_eq!(min_indentation(t), " 	".to_string());
1232
1233    let t = r#"
1234  	  	  	1
1235  	2"#;
1236    assert_eq!(min_indentation(t), "  	".to_string());
1237
1238    let t = r#"
1239			1
1240	2"#;
1241    assert_eq!(min_indentation(t), "	".to_string());
1242}
1243
1244#[test]
1245fn test_inline_snapshot_value_newline() {
1246    // https://github.com/mitsuhiko/insta/issues/39
1247    assert_eq!(normalize_inline_snapshot("\n"), "");
1248}
1249
1250#[test]
1251fn test_parse_yaml_error() {
1252    use std::env::temp_dir;
1253    let mut temp = temp_dir();
1254    temp.push("bad.yaml");
1255    let mut f = fs::File::create(temp.clone()).unwrap();
1256
1257    let invalid = r#"---
1258    This is invalid yaml:
1259     {
1260        {
1261    ---
1262    "#;
1263
1264    f.write_all(invalid.as_bytes()).unwrap();
1265
1266    let error = format!("{}", Snapshot::from_file(temp.as_path()).unwrap_err());
1267    assert!(error.contains("Failed parsing the YAML from"));
1268    assert!(error.contains("bad.yaml"));
1269}
1270
1271/// Check that snapshots don't take ownership of the value
1272#[test]
1273fn test_ownership() {
1274    // Range is non-copy
1275    use std::ops::Range;
1276    let r = Range { start: 0, end: 10 };
1277    assert_debug_snapshot!(r, @"0..10");
1278    assert_debug_snapshot!(r, @"0..10");
1279}
1280
1281#[test]
1282fn test_empty_lines() {
1283    assert_snapshot!(r#"single line should fit on a single line"#, @"single line should fit on a single line");
1284    assert_snapshot!(r#"single line should fit on a single line, even if it's really really really really really really really really really long"#, @"single line should fit on a single line, even if it's really really really really really really really really really long");
1285
1286    assert_snapshot!(r#"multiline content starting on first line
1287
1288    final line
1289    "#, @r###"
1290    multiline content starting on first line
1291
1292        final line
1293
1294    "###);
1295
1296    assert_snapshot!(r#"
1297    multiline content starting on second line
1298
1299    final line
1300    "#, @r###"
1301
1302        multiline content starting on second line
1303
1304        final line
1305
1306    "###);
1307}