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