Skip to main content

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        // Create parent directories if they don't exist (needed for INSTA_PENDING_DIR)
78        if let Some(parent) = p.parent() {
79            fs::create_dir_all(parent)?;
80        }
81        let mut f = fs::OpenOptions::new().create(true).append(true).open(p)?;
82        let mut s = json::to_string(&self.as_content());
83        s.push('\n');
84        f.write_all(s.as_bytes())?;
85        Ok(())
86    }
87
88    #[cfg(feature = "_cargo_insta_internal")]
89    fn from_content(content: Content) -> Result<PendingInlineSnapshot, Box<dyn Error>> {
90        if let Content::Map(map) = content {
91            let mut run_id = None;
92            let mut line = None;
93            let mut old = None;
94            let mut new = None;
95
96            for (key, value) in map.into_iter() {
97                match key.as_str() {
98                    Some("run_id") => run_id = value.as_str().map(|x| x.to_string()),
99                    Some("line") => line = value.as_u64().map(|x| x as u32),
100                    Some("old") if !value.is_nil() => {
101                        old = Some(Snapshot::from_content(value, TextSnapshotKind::Inline)?)
102                    }
103                    Some("new") if !value.is_nil() => {
104                        new = Some(Snapshot::from_content(value, TextSnapshotKind::Inline)?)
105                    }
106                    _ => {}
107                }
108            }
109
110            Ok(PendingInlineSnapshot {
111                run_id: run_id.ok_or(content::Error::MissingField)?,
112                line: line.ok_or(content::Error::MissingField)?,
113                new,
114                old,
115            })
116        } else {
117            Err(content::Error::UnexpectedDataType.into())
118        }
119    }
120
121    fn as_content(&self) -> Content {
122        let fields = vec![
123            ("run_id", Content::from(self.run_id.as_str())),
124            ("line", Content::from(self.line)),
125            (
126                "new",
127                match &self.new {
128                    Some(snap) => snap.as_content(),
129                    None => Content::None,
130                },
131            ),
132            (
133                "old",
134                match &self.old {
135                    Some(snap) => snap.as_content(),
136                    None => Content::None,
137                },
138            ),
139        ];
140
141        Content::Struct("PendingInlineSnapshot", fields)
142    }
143}
144
145#[derive(Debug, Clone, PartialEq, Default)]
146pub enum SnapshotKind {
147    #[default]
148    Text,
149    Binary {
150        extension: String,
151    },
152}
153
154/// Snapshot metadata information.
155#[derive(Debug, Default, Clone, PartialEq)]
156pub struct MetaData {
157    /// The source file (relative to workspace root).
158    pub(crate) source: Option<String>,
159    /// The source line, if available. This is used by pending snapshots, but trimmed
160    /// before writing to the final `.snap` files in [`MetaData::trim_for_persistence`].
161    pub(crate) assertion_line: Option<u32>,
162    /// Optional human readable (non formatted) snapshot description.
163    pub(crate) description: Option<String>,
164    /// Optionally the expression that created the snapshot.
165    pub(crate) expression: Option<String>,
166    /// An optional arbitrary structured info object.
167    pub(crate) info: Option<Content>,
168    /// Reference to the input file.
169    pub(crate) input_file: Option<String>,
170    /// The type of the snapshot (string or binary).
171    pub(crate) snapshot_kind: SnapshotKind,
172}
173
174impl MetaData {
175    /// Returns the absolute source path.
176    pub fn source(&self) -> Option<&str> {
177        self.source.as_deref()
178    }
179
180    /// Returns the assertion line.
181    pub fn assertion_line(&self) -> Option<u32> {
182        self.assertion_line
183    }
184
185    /// Returns the expression that created the snapshot.
186    pub fn expression(&self) -> Option<&str> {
187        self.expression.as_deref()
188    }
189
190    /// Returns the description that created the snapshot.
191    pub fn description(&self) -> Option<&str> {
192        self.description.as_deref().filter(|x| !x.is_empty())
193    }
194
195    /// Returns the embedded info.
196    #[doc(hidden)]
197    pub fn private_info(&self) -> Option<&Content> {
198        self.info.as_ref()
199    }
200
201    /// Returns the relative source path.
202    pub fn get_relative_source(&self, base: &Path) -> Option<PathBuf> {
203        self.source.as_ref().map(|source| {
204            base.join(source)
205                .canonicalize()
206                .ok()
207                .and_then(|s| s.strip_prefix(base).ok().map(|x| x.to_path_buf()))
208                .unwrap_or_else(|| base.to_path_buf())
209        })
210    }
211
212    /// Returns the input file reference.
213    pub fn input_file(&self) -> Option<&str> {
214        self.input_file.as_deref()
215    }
216
217    fn from_content(content: Content) -> Result<MetaData, Box<dyn Error>> {
218        if let Content::Map(map) = content {
219            let mut source = None;
220            let mut assertion_line = None;
221            let mut description = None;
222            let mut expression = None;
223            let mut info = None;
224            let mut input_file = None;
225            let mut snapshot_type = TmpSnapshotKind::Text;
226            let mut extension = None;
227
228            enum TmpSnapshotKind {
229                Text,
230                Binary,
231            }
232
233            for (key, value) in map.into_iter() {
234                match key.as_str() {
235                    Some("source") => source = value.as_str().map(|x| x.to_string()),
236                    Some("assertion_line") => assertion_line = value.as_u64().map(|x| x as u32),
237                    Some("description") => description = value.as_str().map(Into::into),
238                    Some("expression") => expression = value.as_str().map(Into::into),
239                    Some("info") if !value.is_nil() => info = Some(value),
240                    Some("input_file") => input_file = value.as_str().map(Into::into),
241                    Some("snapshot_kind") => {
242                        snapshot_type = match value.as_str() {
243                            Some("binary") => TmpSnapshotKind::Binary,
244                            _ => TmpSnapshotKind::Text,
245                        }
246                    }
247                    Some("extension") => {
248                        extension = value.as_str().map(Into::into);
249                    }
250                    _ => {}
251                }
252            }
253
254            Ok(MetaData {
255                source,
256                assertion_line,
257                description,
258                expression,
259                info,
260                input_file,
261                snapshot_kind: match snapshot_type {
262                    TmpSnapshotKind::Text => SnapshotKind::Text,
263                    TmpSnapshotKind::Binary => SnapshotKind::Binary {
264                        extension: extension.ok_or(content::Error::MissingField)?,
265                    },
266                },
267            })
268        } else {
269            Err(content::Error::UnexpectedDataType.into())
270        }
271    }
272
273    fn as_content(&self) -> Content {
274        let mut fields = Vec::new();
275        if let Some(source) = self.source.as_deref() {
276            fields.push(("source", Content::from(source)));
277        }
278        if let Some(line) = self.assertion_line {
279            fields.push(("assertion_line", Content::from(line)));
280        }
281        if let Some(description) = self.description.as_deref() {
282            fields.push(("description", Content::from(description)));
283        }
284        if let Some(expression) = self.expression.as_deref() {
285            fields.push(("expression", Content::from(expression)));
286        }
287        if let Some(info) = &self.info {
288            fields.push(("info", info.to_owned()));
289        }
290        if let Some(input_file) = self.input_file.as_deref() {
291            fields.push(("input_file", Content::from(input_file)));
292        }
293
294        match self.snapshot_kind {
295            SnapshotKind::Text => {}
296            SnapshotKind::Binary { ref extension } => {
297                fields.push(("extension", Content::from(extension.clone())));
298                fields.push(("snapshot_kind", Content::from("binary")));
299            }
300        }
301
302        Content::Struct("MetaData", fields)
303    }
304
305    /// Trims the metadata of fields that we don't save to `.snap` files (those
306    /// we only use for display while reviewing)
307    pub(crate) fn trim_for_persistence(&self) -> Cow<'_, MetaData> {
308        // TODO: in order for `--require-full-match` to work on inline snapshots
309        // without cargo-insta, we need to trim all fields if there's an inline
310        // snapshot. But we don't know that from here (notably
311        // `self.input_file.is_none()` is not a correct approach). Given that
312        // `--require-full-match` is experimental and we're working on making
313        // inline & file snapshots more coherent, I'm leaving this as is for
314        // now.
315        if self.assertion_line.is_some() {
316            let mut rv = self.clone();
317            rv.assertion_line = None;
318            Cow::Owned(rv)
319        } else {
320            Cow::Borrowed(self)
321        }
322    }
323}
324
325#[derive(Debug, PartialEq, Eq, Clone, Copy)]
326pub enum TextSnapshotKind {
327    Inline,
328    File,
329}
330
331/// A helper to work with file snapshots.
332#[derive(Debug, Clone)]
333pub struct Snapshot {
334    module_name: String,
335    snapshot_name: Option<String>,
336    pub(crate) metadata: MetaData,
337    snapshot: SnapshotContents,
338}
339
340impl Snapshot {
341    /// Loads a snapshot from a file.
342    pub fn from_file(p: &Path) -> Result<Snapshot, Box<dyn Error>> {
343        let mut f = BufReader::new(fs::File::open(p)?);
344        let mut buf = String::new();
345
346        f.read_line(&mut buf)?;
347
348        // yaml format
349        let metadata = if buf.trim_end() == "---" {
350            loop {
351                let read = f.read_line(&mut buf)?;
352                if read == 0 {
353                    break;
354                }
355                if buf[buf.len() - read..].trim_end() == "---" {
356                    buf.truncate(buf.len() - read);
357                    break;
358                }
359            }
360            let content = yaml::parse_str(&buf, p)?;
361            MetaData::from_content(content)?
362        // legacy format
363        // (but not viable to move into `match_legacy` given it's more than
364        // just the snapshot value itself...)
365        } else {
366            let mut rv = MetaData::default();
367            loop {
368                buf.clear();
369                let read = f.read_line(&mut buf)?;
370                if read == 0 || buf.trim_end().is_empty() {
371                    buf.truncate(buf.len() - read);
372                    break;
373                }
374                let mut iter = buf.splitn(2, ':');
375                if let Some(key) = iter.next() {
376                    if let Some(value) = iter.next() {
377                        let value = value.trim();
378                        match key.to_lowercase().as_str() {
379                            "expression" => rv.expression = Some(value.to_string()),
380                            "source" => rv.source = Some(value.into()),
381                            _ => {}
382                        }
383                    }
384                }
385            }
386            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());
387            rv
388        };
389
390        let contents = match metadata.snapshot_kind {
391            SnapshotKind::Text => {
392                buf.clear();
393                for (idx, line) in f.lines().enumerate() {
394                    let line = line?;
395                    if idx > 0 {
396                        buf.push('\n');
397                    }
398                    buf.push_str(&line);
399                }
400
401                TextSnapshotContents {
402                    contents: buf,
403                    kind: TextSnapshotKind::File,
404                }
405                .into()
406            }
407            SnapshotKind::Binary { ref extension } => {
408                let path = build_binary_path(extension, p);
409                let contents = fs::read(path)?;
410
411                SnapshotContents::Binary(Rc::new(contents))
412            }
413        };
414
415        let (snapshot_name, module_name) = names_of_path(p);
416
417        Ok(Snapshot::from_components(
418            module_name,
419            Some(snapshot_name),
420            metadata,
421            contents,
422        ))
423    }
424
425    pub(crate) fn from_components(
426        module_name: String,
427        snapshot_name: Option<String>,
428        metadata: MetaData,
429        snapshot: SnapshotContents,
430    ) -> Snapshot {
431        Snapshot {
432            module_name,
433            snapshot_name,
434            metadata,
435            snapshot,
436        }
437    }
438
439    #[cfg(feature = "_cargo_insta_internal")]
440    fn from_content(content: Content, kind: TextSnapshotKind) -> Result<Snapshot, Box<dyn Error>> {
441        if let Content::Map(map) = content {
442            let mut module_name = None;
443            let mut snapshot_name = None;
444            let mut metadata = None;
445            let mut snapshot = None;
446
447            for (key, value) in map.into_iter() {
448                match key.as_str() {
449                    Some("module_name") => module_name = value.as_str().map(|x| x.to_string()),
450                    Some("snapshot_name") => snapshot_name = value.as_str().map(|x| x.to_string()),
451                    Some("metadata") => metadata = Some(MetaData::from_content(value)?),
452                    Some("snapshot") => {
453                        snapshot = Some(
454                            TextSnapshotContents {
455                                contents: value
456                                    .as_str()
457                                    .ok_or(content::Error::UnexpectedDataType)?
458                                    .to_string(),
459                                kind,
460                            }
461                            .into(),
462                        );
463                    }
464                    _ => {}
465                }
466            }
467
468            Ok(Snapshot {
469                module_name: module_name.ok_or(content::Error::MissingField)?,
470                snapshot_name,
471                metadata: metadata.ok_or(content::Error::MissingField)?,
472                snapshot: snapshot.ok_or(content::Error::MissingField)?,
473            })
474        } else {
475            Err(content::Error::UnexpectedDataType.into())
476        }
477    }
478
479    fn as_content(&self) -> Content {
480        let mut fields = vec![("module_name", Content::from(self.module_name.as_str()))];
481        // Note this is currently never used, since this method is only used for
482        // inline snapshots
483        if let Some(name) = self.snapshot_name.as_deref() {
484            fields.push(("snapshot_name", Content::from(name)));
485        }
486        fields.push(("metadata", self.metadata.as_content()));
487
488        if let SnapshotContents::Text(ref content) = self.snapshot {
489            fields.push(("snapshot", Content::from(content.to_string())));
490        }
491
492        Content::Struct("Content", fields)
493    }
494
495    /// Returns the module name.
496    pub fn module_name(&self) -> &str {
497        &self.module_name
498    }
499
500    /// Returns the snapshot name.
501    pub fn snapshot_name(&self) -> Option<&str> {
502        self.snapshot_name.as_deref()
503    }
504
505    /// The metadata in the snapshot.
506    pub fn metadata(&self) -> &MetaData {
507        &self.metadata
508    }
509
510    /// The snapshot contents
511    pub fn contents(&self) -> &SnapshotContents {
512        &self.snapshot
513    }
514
515    /// Returns the text contents if this is a text snapshot.
516    pub fn as_text(&self) -> Option<&TextSnapshotContents> {
517        self.snapshot.as_text()
518    }
519
520    fn serialize_snapshot(&self, md: &MetaData) -> String {
521        let mut buf = yaml::to_string(&md.as_content());
522        buf.push_str("---\n");
523
524        if let SnapshotContents::Text(ref contents) = self.snapshot {
525            buf.push_str(&contents.to_string());
526            buf.push('\n');
527        }
528
529        buf
530    }
531
532    // We take `md` as an argument here because the calling methods want to
533    // adjust it; e.g. removing volatile fields when writing to the final
534    // `.snap` file.
535    fn save_with_metadata(&self, path: &Path, md: &MetaData) -> Result<(), Box<dyn Error>> {
536        if let Some(folder) = path.parent() {
537            fs::create_dir_all(folder)?;
538        }
539
540        let serialized_snapshot = self.serialize_snapshot(md);
541        fs::write(path, serialized_snapshot)
542            .map_err(|e| content::Error::FileIo(e, path.to_path_buf()))?;
543
544        if let SnapshotContents::Binary(ref contents) = self.snapshot {
545            fs::write(self.build_binary_path(path).unwrap(), &**contents)
546                .map_err(|e| content::Error::FileIo(e, path.to_path_buf()))?;
547        }
548
549        Ok(())
550    }
551
552    pub fn build_binary_path(&self, path: impl Into<PathBuf>) -> Option<PathBuf> {
553        if let SnapshotKind::Binary { ref extension } = self.metadata.snapshot_kind {
554            Some(build_binary_path(extension, path))
555        } else {
556            None
557        }
558    }
559
560    /// Saves the snapshot.
561    #[doc(hidden)]
562    pub fn save(&self, path: &Path) -> Result<(), Box<dyn Error>> {
563        self.save_with_metadata(path, &self.metadata.trim_for_persistence())
564    }
565
566    /// Same as [`Self::save`] but instead of writing a normal snapshot file this will write
567    /// a `.snap.new` file with additional information.
568    ///
569    /// The path of the new snapshot file is returned.
570    pub(crate) fn save_new(&self, path: &Path) -> Result<PathBuf, Box<dyn Error>> {
571        // TODO: should we be the actual extension here rather than defaulting
572        // to the standard `.snap`?
573        let new_path = path.to_path_buf().with_extension("snap.new");
574        self.save_with_metadata(&new_path, &self.metadata)?;
575        Ok(new_path)
576    }
577}
578
579/// The contents of a Snapshot
580#[derive(Debug, Clone)]
581pub enum SnapshotContents {
582    Text(TextSnapshotContents),
583
584    // This is in an `Rc` because we need to be able to clone this struct cheaply and the contents
585    // of the `Vec` could be rather large. The reason it's not an `Rc<[u8]>` is because creating one
586    // of those would require re-allocating because of the additional size needed for the reference
587    // count.
588    Binary(Rc<Vec<u8>>),
589}
590
591// Could be Cow, but I think limited savings
592#[derive(Debug, PartialEq, Eq, Clone)]
593pub struct TextSnapshotContents {
594    contents: String,
595    pub kind: TextSnapshotKind,
596}
597
598impl From<TextSnapshotContents> for SnapshotContents {
599    fn from(value: TextSnapshotContents) -> Self {
600        SnapshotContents::Text(value)
601    }
602}
603
604impl SnapshotContents {
605    pub fn is_binary(&self) -> bool {
606        matches!(self, SnapshotContents::Binary(_))
607    }
608
609    /// Returns the text contents if this is a text snapshot.
610    pub fn as_text(&self) -> Option<&TextSnapshotContents> {
611        match self {
612            SnapshotContents::Text(t) => Some(t),
613            SnapshotContents::Binary(_) => None,
614        }
615    }
616}
617
618impl TextSnapshotContents {
619    pub fn new(contents: String, kind: TextSnapshotKind) -> TextSnapshotContents {
620        // We could store a normalized version of the string as part of `new`;
621        // it would avoid allocating a new `String` when we get the normalized
622        // versions, which we may do a few times. (We want to store the
623        // unnormalized version because it allows us to use `matches_fully`.)
624        TextSnapshotContents { contents, kind }
625    }
626
627    /// Matches another snapshot without any normalization
628    pub fn matches_fully(&self, other: &TextSnapshotContents) -> bool {
629        self.contents == other.contents
630    }
631
632    /// Snapshot matches based on the latest format.
633    pub fn matches_latest(&self, other: &Self) -> bool {
634        self.to_string() == other.to_string()
635    }
636
637    pub fn matches_legacy(&self, other: &Self) -> bool {
638        fn as_str_legacy(sc: &TextSnapshotContents) -> String {
639            // First do the standard normalization
640            let out = sc.to_string();
641            // Legacy snapshots trim newlines at the start.
642            let out = out.trim_start_matches(['\r', '\n']);
643            // Legacy inline snapshots have `---` at the start, so this strips that if
644            // it exists.
645            let out = match out.strip_prefix("---\n") {
646                Some(old_snapshot) => old_snapshot,
647                None => out,
648            };
649            match sc.kind {
650                TextSnapshotKind::Inline => {
651                    let out = legacy_inline_normalize(out);
652                    // Handle old multiline format where single-line content was stored
653                    // with code indentation (e.g., @r"\n    content\n    "). After
654                    // from_inline_literal processing, this becomes "    content\n    "
655                    // with leading spaces from source code indentation.
656                    //
657                    // We detect this by checking:
658                    // 1. Raw contents contain a newline (came from multiline literal)
659                    // 2. After trimming, it's effectively single-line (the legacy pattern)
660                    //
661                    // This distinguishes:
662                    // - Legacy single-line in multiline: @r"\n    X\n    " → trim
663                    // - Modern single-line: @"    X" → don't trim (intentional spaces)
664                    // - True multiline: @r"\n    A\n    B\n" → don't trim (>1 line)
665                    //
666                    // See: https://github.com/mitsuhiko/insta/pull/819#issuecomment-3583709431
667                    let is_legacy_single_line_in_multiline =
668                        sc.contents.contains('\n') && sc.contents.trim_end().lines().count() <= 1;
669                    if is_legacy_single_line_in_multiline {
670                        out.trim_start().to_string()
671                    } else {
672                        out
673                    }
674                }
675                TextSnapshotKind::File => out.to_string(),
676            }
677        }
678        as_str_legacy(self) == as_str_legacy(other)
679    }
680
681    /// Convert a literal snapshot value (i.e. the string inside the quotes,
682    /// from a rust file) to the value we retain in the struct. This is a small
683    /// change to the value: we remove the leading newline and coerce newlines
684    /// to `\n`. Otherwise, the value is retained unnormalized (generally we
685    /// want to retain unnormalized values so we can run `matches_fully` on
686    /// them)
687    /// Returns true if the string contains control characters that require
688    /// escaped format (using Rust's `{:?}` Debug formatting).
689    ///
690    /// We exclude `\n`, `\t`, and `\x1b` from triggering escaped format:
691    /// - `\n` (newline): Can appear literally in block format strings
692    /// - `\t` (tab): Can appear literally in strings without escaping
693    /// - `\x1b` (ESC): Used for ANSI terminal colors; users prefer seeing
694    ///   the actual escape sequences rather than double-escaped output
695    ///
696    /// All other control characters (like `\r`, `\0`) trigger escaped format.
697    fn needs_escaped_format(contents: &str) -> bool {
698        contents
699            .chars()
700            .any(|c| c.is_control() && !['\n', '\t', '\x1b'].contains(&c))
701    }
702
703    pub(crate) fn from_inline_literal(contents: &str) -> Self {
704        // If it's a single line string, then we don't do anything.
705        if contents.trim_end().lines().count() <= 1 {
706            return Self::new(contents.trim_end().to_string(), TextSnapshotKind::Inline);
707        }
708
709        // Escaped format doesn't use a formatting newline, so return as-is.
710        // Block format uses a formatting newline that we strip below.
711        if Self::needs_escaped_format(contents) {
712            return Self::new(contents.trim_end().to_string(), TextSnapshotKind::Inline);
713        }
714
715        // If it's multiline block format, we trim the first line, which should be empty.
716        // (Possibly in the future we'll do the same for the final line too)
717        let lines = contents.lines().collect::<Vec<&str>>();
718        let (first, remainder) = lines.split_first().unwrap();
719        let snapshot = {
720            // If the first isn't empty, something is up — include the first line
721            // and print a warning.
722            if first != &"" {
723                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);
724                once(first)
725                    .chain(remainder.iter())
726                    .cloned()
727                    .collect::<Vec<&str>>()
728                    .join("\n")
729            } else {
730                remainder.join("\n")
731            }
732        };
733        Self::new(snapshot, TextSnapshotKind::Inline)
734    }
735
736    fn normalize(&self) -> String {
737        let kind_specific_normalization = match self.kind {
738            TextSnapshotKind::Inline => normalize_inline(&self.contents),
739            TextSnapshotKind::File => self.contents.clone(),
740        };
741        // Then this we do for both kinds
742        let out = kind_specific_normalization.trim_end();
743        // Normalize Windows CRLF to LF. We intentionally do NOT normalize
744        // standalone \r (old Mac Classic line endings) to \n here, as standalone
745        // \r in snapshot content is more likely to be data (terminal control
746        // codes, binary output) than a line ending. If this assumption proves
747        // wrong, we could add: .replace('\r', "\n")
748        out.replace("\r\n", "\n")
749    }
750
751    /// Returns the string literal, including `#` delimiters, to insert into a
752    /// Rust source file.
753    pub fn to_inline(&self, indentation: &str) -> String {
754        let contents = self.normalize();
755        let mut out = String::new();
756
757        let needs_escaping = Self::needs_escaped_format(&contents);
758
759        // We prefer raw strings for strings containing a quote or an escape
760        // character, as these would require escaping in regular strings.
761        // We can't use raw strings for control characters that need escaping.
762        // We don't use raw strings just for newlines, as they can appear
763        // literally in regular strings (avoids clippy::needless_raw_strings).
764        if !needs_escaping && contents.contains(['\\', '"']) {
765            out.push('r');
766        }
767
768        let delimiter = "#".repeat(required_hashes(&contents));
769
770        out.push_str(&delimiter);
771
772        // If escaping is needed, use Rust's Debug formatting for escape sequences.
773        // We don't attempt block mode for these (though not impossible to do so).
774        if needs_escaping {
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        // Only consider horizontal whitespace (space and tab) as indentation.
874        // Other whitespace like \r should not be stripped as indentation.
875        .take_while(|x| *x == ' ' || *x == '\t')
876        .collect::<String>()
877}
878
879fn min_indentation(snapshot: &str) -> String {
880    let lines = snapshot.trim_end().lines();
881
882    lines
883        .filter(|l| !l.is_empty())
884        .map(leading_space)
885        .min_by(|a, b| a.len().cmp(&b.len()))
886        .unwrap_or("".into())
887}
888
889/// Normalize snapshot value, which we apply to both generated and literal
890/// snapshots. Remove excess indentation, excess ending whitespace and coerce
891/// newlines to `\n`.
892fn normalize_inline(snapshot: &str) -> String {
893    // If it's a single line string, then we don't do anything.
894    if snapshot.trim_end().lines().count() <= 1 {
895        return snapshot.trim_end().to_string();
896    }
897
898    let indentation = min_indentation(snapshot);
899    snapshot
900        .lines()
901        .map(|l| l.get(indentation.len()..).unwrap_or(""))
902        .collect::<Vec<&str>>()
903        .join("\n")
904}
905
906#[test]
907fn test_normalize_inline_snapshot() {
908    fn normalized_of_literal(snapshot: &str) -> String {
909        normalize_inline(&TextSnapshotContents::from_inline_literal(snapshot).contents)
910    }
911
912    use similar_asserts::assert_eq;
913    // here we do exact matching (rather than `assert_snapshot`) to ensure we're
914    // not incorporating the modifications that insta itself makes
915
916    assert_eq!(
917        normalized_of_literal(
918            "
919   1
920   2
921"
922        ),
923        "1
9242"
925    );
926
927    assert_eq!(
928        normalized_of_literal(
929            "
930            1
931    2
932    "
933        ),
934        "        1
9352
936"
937    );
938
939    assert_eq!(
940        normalized_of_literal(
941            "
942            1
943            2
944    "
945        ),
946        "1
9472
948"
949    );
950
951    assert_eq!(
952        normalized_of_literal(
953            "
954   1
955   2
956"
957        ),
958        "1
9592"
960    );
961
962    assert_eq!(
963        normalized_of_literal(
964            "
965        a
966    "
967        ),
968        "        a"
969    );
970
971    assert_eq!(normalized_of_literal(""), "");
972
973    assert_eq!(
974        normalized_of_literal(
975            "
976    a
977    b
978c
979    "
980        ),
981        "    a
982    b
983c
984    "
985    );
986
987    assert_eq!(
988        normalized_of_literal(
989            "
990a
991    "
992        ),
993        "a"
994    );
995
996    // This is a bit of a weird case, but because it's not a true multiline
997    // (which requires an opening and closing newline), we don't trim the
998    // indentation. Not terrible if this needs to change. The next test shows
999    // how a real multiline string is handled.
1000    assert_eq!(
1001        normalized_of_literal(
1002            "
1003    a"
1004        ),
1005        "    a"
1006    );
1007
1008    // This test will pass but raise a warning, so we comment it out for the moment.
1009    // assert_eq!(
1010    //     normalized_of_literal(
1011    //         "a
1012    //   a"
1013    //     ),
1014    //     "a
1015    //   a"
1016    // );
1017}
1018
1019/// Extracts the module and snapshot name from a snapshot path
1020fn names_of_path(path: &Path) -> (String, String) {
1021    // The final part of the snapshot file name is the test name; the
1022    // initial parts are the module name
1023    let parts: Vec<&str> = path
1024        .file_stem()
1025        .unwrap()
1026        .to_str()
1027        .unwrap_or("")
1028        .rsplitn(2, "__")
1029        .collect();
1030
1031    match parts.as_slice() {
1032        [snapshot_name, module_name] => (snapshot_name.to_string(), module_name.to_string()),
1033        [snapshot_name] => (snapshot_name.to_string(), String::new()),
1034        _ => (String::new(), "<unknown>".to_string()),
1035    }
1036}
1037
1038#[test]
1039fn test_names_of_path() {
1040    assert_debug_snapshot!(
1041        names_of_path(Path::new("/src/snapshots/insta_tests__tests__name_foo.snap")), @r#"
1042    (
1043        "name_foo",
1044        "insta_tests__tests",
1045    )
1046    "#
1047    );
1048    assert_debug_snapshot!(
1049        names_of_path(Path::new("/src/snapshots/name_foo.snap")), @r#"
1050    (
1051        "name_foo",
1052        "",
1053    )
1054    "#
1055    );
1056    assert_debug_snapshot!(
1057        names_of_path(Path::new("foo/src/snapshots/go1.20.5.snap")), @r#"
1058    (
1059        "go1.20.5",
1060        "",
1061    )
1062    "#
1063    );
1064}
1065
1066/// legacy format - retain so old snapshots still work
1067fn legacy_inline_normalize(frozen_value: &str) -> String {
1068    if !frozen_value.trim_start().starts_with('⋮') {
1069        return frozen_value.to_string();
1070    }
1071    let mut buf = String::new();
1072    let mut line_iter = frozen_value.lines();
1073    let mut indentation = 0;
1074
1075    for line in &mut line_iter {
1076        let line_trimmed = line.trim_start();
1077        if line_trimmed.is_empty() {
1078            continue;
1079        }
1080        indentation = line.len() - line_trimmed.len();
1081        // 3 because '⋮' is three utf-8 bytes long
1082        buf.push_str(&line_trimmed[3..]);
1083        buf.push('\n');
1084        break;
1085    }
1086
1087    for line in &mut line_iter {
1088        if let Some(prefix) = line.get(..indentation) {
1089            if !prefix.trim().is_empty() {
1090                return "".to_string();
1091            }
1092        }
1093        if let Some(remainder) = line.get(indentation..) {
1094            if let Some(rest) = remainder.strip_prefix('⋮') {
1095                buf.push_str(rest);
1096                buf.push('\n');
1097            } else if remainder.trim().is_empty() {
1098                continue;
1099            } else {
1100                return "".to_string();
1101            }
1102        }
1103    }
1104
1105    buf.trim_end().to_string()
1106}
1107
1108#[test]
1109fn test_snapshot_contents_to_inline() {
1110    use similar_asserts::assert_eq;
1111    let snapshot_contents =
1112        TextSnapshotContents::new("testing".to_string(), TextSnapshotKind::Inline);
1113    assert_eq!(snapshot_contents.to_inline(""), r#""testing""#);
1114
1115    assert_eq!(
1116        TextSnapshotContents::new("\na\nb".to_string(), TextSnapshotKind::Inline).to_inline(""),
1117        r##""
1118
1119a
1120b
1121""##
1122    );
1123
1124    assert_eq!(
1125        TextSnapshotContents::new("a\nb".to_string(), TextSnapshotKind::Inline).to_inline("    "),
1126        r##""
1127    a
1128    b
1129    ""##
1130    );
1131
1132    assert_eq!(
1133        TextSnapshotContents::new("\n    a\n    b".to_string(), TextSnapshotKind::Inline)
1134            .to_inline(""),
1135        r##""
1136
1137a
1138b
1139""##
1140    );
1141
1142    assert_eq!(
1143        TextSnapshotContents::new("\na\n\nb".to_string(), TextSnapshotKind::Inline)
1144            .to_inline("    "),
1145        r##""
1146
1147    a
1148
1149    b
1150    ""##
1151    );
1152
1153    assert_eq!(
1154        TextSnapshotContents::new(
1155            "ab
1156    "
1157            .to_string(),
1158            TextSnapshotKind::Inline
1159        )
1160        .to_inline(""),
1161        r#""ab""#
1162    );
1163
1164    assert_eq!(
1165        TextSnapshotContents::new(
1166            "    ab
1167    "
1168            .to_string(),
1169            TextSnapshotKind::Inline
1170        )
1171        .to_inline(""),
1172        r##""    ab""##
1173    );
1174
1175    assert_eq!(
1176        TextSnapshotContents::new("\n    ab\n".to_string(), TextSnapshotKind::Inline).to_inline(""),
1177        r##""
1178
1179ab
1180""##
1181    );
1182
1183    assert_eq!(
1184        TextSnapshotContents::new("ab".to_string(), TextSnapshotKind::Inline).to_inline(""),
1185        r#""ab""#
1186    );
1187
1188    // Test control and special characters
1189    assert_eq!(
1190        TextSnapshotContents::new("a\tb".to_string(), TextSnapshotKind::Inline).to_inline(""),
1191        r##""a	b""##
1192    );
1193
1194    assert_eq!(
1195        TextSnapshotContents::new("a\t\nb".to_string(), TextSnapshotKind::Inline).to_inline(""),
1196        "\"
1197a\t
1198b
1199\""
1200    );
1201
1202    assert_eq!(
1203        TextSnapshotContents::new("a\rb".to_string(), TextSnapshotKind::Inline).to_inline(""),
1204        r##""a\rb""##
1205    );
1206
1207    // Issue #865: carriage return at start of line should be preserved.
1208    // Escaped format doesn't add a formatting newline (unlike block format).
1209    assert_eq!(
1210        TextSnapshotContents::new("\n\r foo  bar".to_string(), TextSnapshotKind::Inline)
1211            .to_inline(""),
1212        r##""\n\r foo  bar""##
1213    );
1214
1215    assert_eq!(
1216        TextSnapshotContents::new("a\0b".to_string(), TextSnapshotKind::Inline).to_inline(""),
1217        // Nul byte is printed as `\0` in Rust string literals
1218        r##""a\0b""##
1219    );
1220
1221    assert_eq!(
1222        TextSnapshotContents::new("a\u{FFFD}b".to_string(), TextSnapshotKind::Inline).to_inline(""),
1223        // Replacement character is returned as the character in literals
1224        r##""a�b""##
1225    );
1226
1227    // CRLF edge cases - CRLF normalizes to LF, but standalone CR is preserved
1228    assert_eq!(
1229        TextSnapshotContents::new("hello\r\nworld".to_string(), TextSnapshotKind::Inline)
1230            .to_inline("    "),
1231        // CRLF becomes LF after normalization, so uses block format
1232        r##""
1233    hello
1234    world
1235    ""##
1236    );
1237
1238    assert_eq!(
1239        TextSnapshotContents::new("\r\nhello".to_string(), TextSnapshotKind::Inline).to_inline(""),
1240        // Leading CRLF normalizes to leading LF
1241        r##""
1242
1243hello
1244""##
1245    );
1246
1247    assert_eq!(
1248        TextSnapshotContents::new("hello\r\n".to_string(), TextSnapshotKind::Inline).to_inline(""),
1249        // Trailing CRLF is trimmed (like all trailing whitespace)
1250        r##""hello""##
1251    );
1252
1253    // LFCR (not CRLF) - the CR is standalone and triggers escaped format
1254    // Escaped format doesn't add a formatting newline (unlike block format)
1255    assert_eq!(
1256        TextSnapshotContents::new("hello\n\rworld".to_string(), TextSnapshotKind::Inline)
1257            .to_inline(""),
1258        r##""hello\n\rworld""##
1259    );
1260
1261    // Mixed CR and CRLF
1262    assert_eq!(
1263        TextSnapshotContents::new("a\rb\r\nc".to_string(), TextSnapshotKind::Inline).to_inline(""),
1264        // After CRLF normalization: "a\rb\nc" - has CR, uses escaped format
1265        r##""a\rb\nc""##
1266    );
1267}
1268
1269/// Test that escaped format content roundtrips correctly through `from_inline_literal`.
1270/// Issue #865: content with control chars like \r should not lose its leading newline.
1271#[test]
1272fn test_escaped_format_preserves_content() {
1273    // Escaped format: from_inline_literal receives the value as-is (no formatting newline)
1274    // Block format: from_inline_literal receives the value with a leading formatting newline
1275
1276    // The bug was: "\n\r foo" would lose its leading \n because from_inline_literal
1277    // stripped it, thinking it was a formatting newline from block format.
1278
1279    // Escaped format cases (has control chars like \r)
1280    assert_eq!(
1281        TextSnapshotContents::from_inline_literal("\n\r foo").contents,
1282        "\n\r foo"
1283    );
1284    assert_eq!(
1285        TextSnapshotContents::from_inline_literal("a\rb").contents,
1286        "a\rb"
1287    );
1288
1289    // Block format cases (no control chars) - leading newline IS a formatting newline
1290    // (trailing newline is trimmed by from_inline_literal)
1291    assert_eq!(
1292        TextSnapshotContents::from_inline_literal("\nfoo\nbar\n").contents,
1293        "foo\nbar"
1294    );
1295}
1296
1297#[test]
1298fn test_snapshot_contents_hashes() {
1299    assert_eq!(
1300        TextSnapshotContents::new("a###b".to_string(), TextSnapshotKind::Inline).to_inline(""),
1301        r#""a###b""#
1302    );
1303
1304    assert_eq!(
1305        TextSnapshotContents::new("a\n\\###b".to_string(), TextSnapshotKind::Inline).to_inline(""),
1306        r#####"r"
1307a
1308\###b
1309""#####
1310    );
1311}
1312
1313#[test]
1314fn test_snapshot_contents_as_text() {
1315    let text = SnapshotContents::Text(TextSnapshotContents::new(
1316        "hello".to_string(),
1317        TextSnapshotKind::Inline,
1318    ));
1319    assert!(text.as_text().is_some());
1320    assert_eq!(text.as_text().unwrap().to_string(), "hello");
1321
1322    let binary = SnapshotContents::Binary(Rc::new(vec![1, 2, 3]));
1323    assert!(binary.as_text().is_none());
1324}
1325
1326#[test]
1327fn test_min_indentation() {
1328    use similar_asserts::assert_eq;
1329    assert_eq!(
1330        min_indentation(
1331            "
1332   1
1333   2
1334   ",
1335        ),
1336        "   ".to_string()
1337    );
1338
1339    assert_eq!(
1340        min_indentation(
1341            "
1342            1
1343    2"
1344        ),
1345        "    ".to_string()
1346    );
1347
1348    assert_eq!(
1349        min_indentation(
1350            "
1351            1
1352            2
1353    "
1354        ),
1355        "            ".to_string()
1356    );
1357
1358    assert_eq!(
1359        min_indentation(
1360            "
1361   1
1362   2
1363"
1364        ),
1365        "   ".to_string()
1366    );
1367
1368    assert_eq!(
1369        min_indentation(
1370            "
1371        a
1372    "
1373        ),
1374        "        ".to_string()
1375    );
1376
1377    assert_eq!(min_indentation(""), "".to_string());
1378
1379    assert_eq!(
1380        min_indentation(
1381            "
1382    a
1383    b
1384c
1385    "
1386        ),
1387        "".to_string()
1388    );
1389
1390    assert_eq!(
1391        min_indentation(
1392            "
1393a
1394    "
1395        ),
1396        "".to_string()
1397    );
1398
1399    assert_eq!(
1400        min_indentation(
1401            "
1402    a"
1403        ),
1404        "    ".to_string()
1405    );
1406
1407    assert_eq!(
1408        min_indentation(
1409            "a
1410  a"
1411        ),
1412        "".to_string()
1413    );
1414
1415    assert_eq!(
1416        normalize_inline(
1417            "
1418			1
1419	2"
1420        ),
1421        "
1422		1
14232"
1424    );
1425
1426    assert_eq!(
1427        normalize_inline(
1428            "
1429	  	  1
1430	  	  2
1431    "
1432        ),
1433        "
14341
14352
1436"
1437    );
1438}
1439
1440#[test]
1441fn test_min_indentation_additional() {
1442    use similar_asserts::assert_eq;
1443
1444    let t = "
1445   1
1446   2
1447";
1448    assert_eq!(min_indentation(t), "   ".to_string());
1449
1450    let t = "
1451        a
1452    ";
1453    assert_eq!(min_indentation(t), "        ".to_string());
1454
1455    let t = "";
1456    assert_eq!(min_indentation(t), "".to_string());
1457
1458    let t = "
1459    a
1460    b
1461c
1462    ";
1463    assert_eq!(min_indentation(t), "".to_string());
1464
1465    let t = "
1466a";
1467    assert_eq!(min_indentation(t), "".to_string());
1468
1469    let t = "
1470    a";
1471    assert_eq!(min_indentation(t), "    ".to_string());
1472
1473    let t = "a
1474  a";
1475    assert_eq!(min_indentation(t), "".to_string());
1476
1477    let t = "
1478 	1
1479 	2
1480    ";
1481    assert_eq!(min_indentation(t), " 	".to_string());
1482
1483    let t = "
1484  	  	  	1
1485  	2";
1486    assert_eq!(min_indentation(t), "  	".to_string());
1487
1488    let t = "
1489			1
1490	2";
1491    assert_eq!(min_indentation(t), "	".to_string());
1492}
1493
1494#[test]
1495fn test_inline_snapshot_value_newline() {
1496    // https://github.com/mitsuhiko/insta/issues/39
1497    assert_eq!(normalize_inline("\n"), "");
1498}
1499
1500#[test]
1501fn test_parse_yaml_error() {
1502    use std::env::temp_dir;
1503    let mut temp = temp_dir();
1504    temp.push("bad.yaml");
1505    let mut f = fs::File::create(temp.clone()).unwrap();
1506
1507    let invalid = "---
1508    This is invalid yaml:
1509     {
1510        {
1511    ---
1512    ";
1513
1514    f.write_all(invalid.as_bytes()).unwrap();
1515
1516    let error = format!("{}", Snapshot::from_file(temp.as_path()).unwrap_err());
1517    assert!(error.contains("Failed parsing the YAML from"));
1518    assert!(error.contains("bad.yaml"));
1519}
1520
1521/// Check that snapshots don't take ownership of the value
1522#[test]
1523fn test_ownership() {
1524    // Range is non-copy
1525    use std::ops::Range;
1526    let r = Range { start: 0, end: 10 };
1527    assert_debug_snapshot!(r, @"0..10");
1528    assert_debug_snapshot!(r, @"0..10");
1529}
1530
1531#[test]
1532fn test_empty_lines() {
1533    assert_snapshot!("single line should fit on a single line", @"single line should fit on a single line");
1534    assert_snapshot!("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");
1535
1536    assert_snapshot!("multiline content starting on first line
1537
1538    final line
1539    ", @"
1540    multiline content starting on first line
1541
1542        final line
1543    ");
1544
1545    assert_snapshot!("
1546    multiline content starting on second line
1547
1548    final line
1549    ", @"
1550
1551    multiline content starting on second line
1552
1553    final line
1554    ");
1555}