diff --git a/cargo-insta/tests/functional/main.rs b/cargo-insta/tests/functional/main.rs index 28a91f05..d07cacc9 100644 --- a/cargo-insta/tests/functional/main.rs +++ b/cargo-insta/tests/functional/main.rs @@ -76,6 +76,7 @@ mod inline; mod inline_snapshot_trimming; mod nextest_doctest; mod raw_strings; +mod read_snapshot; mod test_runner_fallback; mod test_workspace_source_path; mod unreferenced; @@ -172,7 +173,7 @@ fn target_dir() -> PathBuf { struct TestProject { /// Temporary directory where the project is created - workspace_dir: PathBuf, + pub(crate) workspace_dir: PathBuf, /// Original files when the project is created. files: HashMap, /// File tree when the test is created. diff --git a/cargo-insta/tests/functional/read_snapshot.rs b/cargo-insta/tests/functional/read_snapshot.rs new file mode 100644 index 00000000..13c89172 --- /dev/null +++ b/cargo-insta/tests/functional/read_snapshot.rs @@ -0,0 +1,680 @@ +//! Functional tests for the experimental `read_snapshot!` macro. + +use std::fs; + +use crate::TestFiles; + +/// Tests reading a YAML snapshot that was created with `assert_yaml_snapshot!` +#[test] +fn test_read_yaml_snapshot() { + let test_project = TestFiles::new() + .add_file( + "Cargo.toml", + r#" +[package] +name = "test_read_yaml" +version = "0.1.0" +edition = "2021" + +[dependencies] +insta = { path = '$PROJECT_PATH', features = ["yaml"] } +"# + .to_string(), + ) + .add_file( + "src/lib.rs", + r#" +#[test] +fn test_read_yaml() { + // Read the pre-existing YAML snapshot + let content = insta::read_snapshot!("user_data").unwrap(); + + // The content should be YAML-formatted + assert!(content.contains("name:"), "Expected YAML format with 'name:' key"); + assert!(content.contains("Alice"), "Expected to find 'Alice' in snapshot"); + assert!(content.contains("age:"), "Expected YAML format with 'age:' key"); + assert!(content.contains("30"), "Expected to find '30' in snapshot"); +} +"# + .to_string(), + ) + .add_file( + "src/snapshots/test_read_yaml__user_data.snap", + r#"--- +source: src/lib.rs +expression: user +--- +name: Alice +age: 30 +"# + .to_string(), + ) + .create_project(); + + // Run the test - it should pass since we're reading an existing snapshot + let output = test_project + .insta_cmd() + .args(["test", "--", "--nocapture"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "Test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Tests reading a JSON snapshot that was created with `assert_json_snapshot!` +#[test] +fn test_read_json_snapshot() { + let test_project = TestFiles::new() + .add_file( + "Cargo.toml", + r#" +[package] +name = "test_read_json" +version = "0.1.0" +edition = "2021" + +[dependencies] +insta = { path = '$PROJECT_PATH', features = ["json"] } +"# + .to_string(), + ) + .add_file( + "src/lib.rs", + r##" +#[test] +fn test_read_json() { + // Read the pre-existing JSON snapshot + let content = insta::read_snapshot!("api_response").unwrap(); + + // The content should be JSON-formatted + assert!(content.contains(r#""status":"#), "Expected JSON with 'status' key"); + assert!(content.contains(r#""ok""#), "Expected 'ok' value"); + assert!(content.contains(r#""data":"#), "Expected JSON with 'data' key"); +} +"## + .to_string(), + ) + .add_file( + "src/snapshots/test_read_json__api_response.snap", + r#"--- +source: src/lib.rs +expression: response +--- +{ + "status": "ok", + "data": [1, 2, 3] +} +"# + .to_string(), + ) + .create_project(); + + let output = test_project + .insta_cmd() + .args(["test", "--", "--nocapture"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "Test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Tests reading a plain text snapshot +#[test] +fn test_read_text_snapshot() { + let test_project = TestFiles::new() + .add_file( + "Cargo.toml", + r#" +[package] +name = "test_read_text" +version = "0.1.0" +edition = "2021" + +[dependencies] +insta = { path = '$PROJECT_PATH' } +"# + .to_string(), + ) + .add_file( + "src/lib.rs", + r#" +#[test] +fn test_read_text() { + let content = insta::read_snapshot!("greeting").unwrap(); + assert_eq!(content, "Hello, World!"); +} +"# + .to_string(), + ) + .add_file( + "src/snapshots/test_read_text__greeting.snap", + r#"--- +source: src/lib.rs +expression: msg +--- +Hello, World! +"# + .to_string(), + ) + .create_project(); + + let output = test_project + .insta_cmd() + .args(["test", "--", "--nocapture"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "Test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Tests that reading a nonexistent snapshot returns an error +#[test] +fn test_read_nonexistent_snapshot() { + let test_project = TestFiles::new() + .add_file( + "Cargo.toml", + r#" +[package] +name = "test_read_nonexistent" +version = "0.1.0" +edition = "2021" + +[dependencies] +insta = { path = '$PROJECT_PATH' } +"# + .to_string(), + ) + .add_file( + "src/lib.rs", + r#" +#[test] +fn test_nonexistent() { + let result = insta::read_snapshot!("does_not_exist"); + assert!(result.is_err(), "Expected error for nonexistent snapshot"); +} +"# + .to_string(), + ) + .create_project(); + + let output = test_project + .insta_cmd() + .args(["test", "--", "--nocapture"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "Test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Tests reading a snapshot with auto-generated name (based on function name) +#[test] +fn test_read_snapshot_auto_name() { + let test_project = TestFiles::new() + .add_file( + "Cargo.toml", + r#" +[package] +name = "test_read_auto" +version = "0.1.0" +edition = "2021" + +[dependencies] +insta = { path = '$PROJECT_PATH' } +"# + .to_string(), + ) + .add_file( + "src/lib.rs", + r#" +#[test] +fn test_auto_named() { + // Using read_snapshot!() without a name should use the function name + let content = insta::read_snapshot!().unwrap(); + assert_eq!(content, "auto named content"); +} +"# + .to_string(), + ) + .add_file( + // Snapshot name derived from function name "test_auto_named" -> "auto_named" + "src/snapshots/test_read_auto__auto_named.snap", + r#"--- +source: src/lib.rs +expression: value +--- +auto named content +"# + .to_string(), + ) + .create_project(); + + let output = test_project + .insta_cmd() + .args(["test", "--", "--nocapture"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "Test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Tests reading a snapshot with custom snapshot_path setting +#[test] +fn test_read_snapshot_custom_path() { + let test_project = TestFiles::new() + .add_file( + "Cargo.toml", + r#" +[package] +name = "test_read_custom_path" +version = "0.1.0" +edition = "2021" + +[dependencies] +insta = { path = '$PROJECT_PATH' } +"# + .to_string(), + ) + .add_file( + "src/lib.rs", + r#" +#[test] +fn test_custom_path() { + insta::with_settings!({snapshot_path => "custom_snaps"}, { + let content = insta::read_snapshot!("custom").unwrap(); + assert_eq!(content, "from custom path"); + }); +} +"# + .to_string(), + ) + .add_file( + "src/custom_snaps/test_read_custom_path__custom.snap", + r#"--- +source: src/lib.rs +expression: value +--- +from custom path +"# + .to_string(), + ) + .create_project(); + + let output = test_project + .insta_cmd() + .args(["test", "--", "--nocapture"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "Test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Tests reading a binary snapshot +#[test] +fn test_read_binary_snapshot() { + let test_project = TestFiles::new() + .add_file( + "Cargo.toml", + r#" +[package] +name = "test_read_binary" +version = "0.1.0" +edition = "2021" + +[dependencies] +insta = { path = '$PROJECT_PATH' } +"# + .to_string(), + ) + .add_file( + "src/lib.rs", + r#" +#[test] +fn test_binary() { + let bytes = insta::read_binary_snapshot!("data.bin").unwrap(); + assert_eq!(bytes, vec![0x00, 0x01, 0x02, 0x03]); +} +"# + .to_string(), + ) + .add_file( + "src/snapshots/test_read_binary__data.snap", + r#"--- +source: src/lib.rs +expression: bytes +snapshot_kind: binary +extension: bin +--- +"# + .to_string(), + ) + .create_project(); + + // Write the binary file separately (can't include binary in string) + fs::write( + test_project + .workspace_dir + .join("src/snapshots/test_read_binary__data.snap.bin"), + [0x00, 0x01, 0x02, 0x03], + ) + .unwrap(); + + let output = test_project + .insta_cmd() + .args(["test", "--", "--nocapture"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "Test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Tests reading a snapshot with snapshot_suffix setting +#[test] +fn test_read_snapshot_with_suffix() { + let test_project = TestFiles::new() + .add_file( + "Cargo.toml", + r#" +[package] +name = "test_read_suffix" +version = "0.1.0" +edition = "2021" + +[dependencies] +insta = { path = '$PROJECT_PATH' } +"# + .to_string(), + ) + .add_file( + "src/lib.rs", + r#" +#[test] +fn test_suffix() { + insta::with_settings!({snapshot_suffix => "linux"}, { + let content = insta::read_snapshot!("platform_data").unwrap(); + assert_eq!(content, "linux-specific content"); + }); +} +"# + .to_string(), + ) + .add_file( + // Snapshot with @linux suffix + "src/snapshots/test_read_suffix__platform_data@linux.snap", + r#"--- +source: src/lib.rs +expression: value +--- +linux-specific content +"# + .to_string(), + ) + .create_project(); + + let output = test_project + .insta_cmd() + .args(["test", "--", "--nocapture"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "Test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Tests reading a snapshot with prepend_module_to_snapshot => false +#[test] +fn test_read_snapshot_no_module_prefix() { + let test_project = TestFiles::new() + .add_file( + "Cargo.toml", + r#" +[package] +name = "test_read_no_prefix" +version = "0.1.0" +edition = "2021" + +[dependencies] +insta = { path = '$PROJECT_PATH' } +"# + .to_string(), + ) + .add_file( + "src/lib.rs", + r#" +#[test] +fn test_no_prefix() { + insta::with_settings!({prepend_module_to_snapshot => false}, { + let content = insta::read_snapshot!("no_prefix_data").unwrap(); + assert_eq!(content, "content without module prefix"); + }); +} +"# + .to_string(), + ) + .add_file( + // Snapshot WITHOUT module prefix (just name.snap) + "src/snapshots/no_prefix_data.snap", + r#"--- +source: src/lib.rs +expression: value +--- +content without module prefix +"# + .to_string(), + ) + .create_project(); + + let output = test_project + .insta_cmd() + .args(["test", "--", "--nocapture"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "Test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Tests reading a snapshot from a nested module +#[test] +fn test_read_snapshot_nested_module() { + let test_project = TestFiles::new() + .add_file( + "Cargo.toml", + r#" +[package] +name = "test_read_nested" +version = "0.1.0" +edition = "2021" + +[dependencies] +insta = { path = '$PROJECT_PATH' } +"# + .to_string(), + ) + .add_file( + "src/lib.rs", + r#" +mod parent { + pub mod child { + #[test] + fn test_nested() { + let content = insta::read_snapshot!("nested_data").unwrap(); + assert_eq!(content, "from nested module"); + } + } +} +"# + .to_string(), + ) + .add_file( + // Snapshot with nested module path: parent__child__nested_data.snap + "src/snapshots/test_read_nested__parent__child__nested_data.snap", + r#"--- +source: src/lib.rs +expression: value +--- +from nested module +"# + .to_string(), + ) + .create_project(); + + let output = test_project + .insta_cmd() + .args(["test", "--", "--nocapture"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "Test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Tests reading a snapshot with combined settings (suffix + custom path + no module prefix) +#[test] +fn test_read_snapshot_combined_settings() { + let test_project = TestFiles::new() + .add_file( + "Cargo.toml", + r#" +[package] +name = "test_read_combined" +version = "0.1.0" +edition = "2021" + +[dependencies] +insta = { path = '$PROJECT_PATH' } +"# + .to_string(), + ) + .add_file( + "src/lib.rs", + r#" +#[test] +fn test_combined() { + insta::with_settings!({ + snapshot_path => "custom", + snapshot_suffix => "macos", + prepend_module_to_snapshot => false + }, { + let content = insta::read_snapshot!("combined").unwrap(); + assert_eq!(content, "combined settings work"); + }); +} +"# + .to_string(), + ) + .add_file( + // Snapshot with all settings: custom/combined@macos.snap (no module prefix) + "src/custom/combined@macos.snap", + r#"--- +source: src/lib.rs +expression: value +--- +combined settings work +"# + .to_string(), + ) + .create_project(); + + let output = test_project + .insta_cmd() + .args(["test", "--", "--nocapture"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "Test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Tests that reading a text snapshot with read_binary_snapshot returns an error +#[test] +fn test_read_text_as_binary_fails() { + let test_project = TestFiles::new() + .add_file( + "Cargo.toml", + r#" +[package] +name = "test_read_text_binary" +version = "0.1.0" +edition = "2021" + +[dependencies] +insta = { path = '$PROJECT_PATH' } +"# + .to_string(), + ) + .add_file( + "src/lib.rs", + r#" +#[test] +fn test_wrong_type() { + // This snapshot is text, not binary + let result = insta::read_binary_snapshot!("text_data.bin"); + assert!(result.is_err(), "Expected error when reading text as binary"); +} +"# + .to_string(), + ) + .add_file( + "src/snapshots/test_read_text_binary__text_data.snap", + r#"--- +source: src/lib.rs +expression: value +--- +this is text, not binary +"# + .to_string(), + ) + .create_project(); + + let output = test_project + .insta_cmd() + .args(["test", "--", "--nocapture"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "Test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/insta/src/lib.rs b/insta/src/lib.rs index 36dbb730..fc2fb7df 100644 --- a/insta/src/lib.rs +++ b/insta/src/lib.rs @@ -374,8 +374,8 @@ pub mod _macro_support { pub use crate::content::Content; pub use crate::env::{get_cargo_workspace, Workspace}; pub use crate::runtime::{ - assert_snapshot, with_allow_duplicates, AutoName, BinarySnapshotValue, InlineValue, - SnapshotValue, + assert_snapshot, read_binary_snapshot_content, read_snapshot_content, + with_allow_duplicates, AutoName, BinarySnapshotValue, InlineValue, SnapshotValue, }; pub use core::{file, line, module_path}; pub use std::{any, env, format, option_env, path, vec}; diff --git a/insta/src/macros.rs b/insta/src/macros.rs index 5a695c95..dbd49a5c 100644 --- a/insta/src/macros.rs +++ b/insta/src/macros.rs @@ -430,6 +430,95 @@ macro_rules! assert_binary_snapshot { }; } +/// (Experimental) Reads a text snapshot's contents. +/// +/// This macro reads the contents of a previously saved snapshot file and returns it as a +/// `Result>`. The path is resolved using the same logic +/// as [`assert_snapshot!`](crate::assert_snapshot!), respecting settings like `snapshot_path`, +/// `snapshot_suffix`, and `prepend_module_to_snapshot`. +/// +/// This is useful when you need to use a snapshot's value programmatically rather than +/// just asserting against it. +/// +/// This feature is considered experimental: we may make incompatible changes for the next +/// couple of versions. +/// +/// # Examples +/// +/// ```ignore +/// // Read a named snapshot +/// let content = insta::read_snapshot!("my_snapshot").unwrap(); +/// +/// // Read with auto-generated name (based on function name) +/// let content = insta::read_snapshot!().unwrap(); +/// ``` +/// +/// # Errors +/// +/// Returns an error if: +/// - The snapshot file does not exist +/// - The snapshot file cannot be parsed +/// - The snapshot contains binary content +#[macro_export] +macro_rules! read_snapshot { + ($name:expr $(,)?) => {{ + $crate::_macro_support::read_snapshot_content( + Some($name), + $crate::_get_workspace_root!().as_path(), + $crate::_function_name!(), + $crate::_macro_support::module_path!(), + $crate::_macro_support::file!(), + ) + }}; + () => {{ + $crate::_macro_support::read_snapshot_content( + None, + $crate::_get_workspace_root!().as_path(), + $crate::_function_name!(), + $crate::_macro_support::module_path!(), + $crate::_macro_support::file!(), + ) + }}; +} + +/// (Experimental) Reads a binary snapshot's contents. +/// +/// This macro reads the contents of a previously saved binary snapshot file and returns it as a +/// `Result, Box>`. The path is resolved using the same logic +/// as [`assert_binary_snapshot!`](crate::assert_binary_snapshot!). +/// +/// This feature is considered experimental: we may make incompatible changes for the next +/// couple of versions. +/// +/// # Examples +/// +/// ```ignore +/// // Read a binary snapshot (name must include extension) +/// let bytes = insta::read_binary_snapshot!("my_image.png").unwrap(); +/// +/// // Read with auto-generated name (extension only) +/// let bytes = insta::read_binary_snapshot!(".bin").unwrap(); +/// ``` +/// +/// # Errors +/// +/// Returns an error if: +/// - The snapshot file does not exist +/// - The snapshot file cannot be parsed +/// - The snapshot contains text content +#[macro_export] +macro_rules! read_binary_snapshot { + ($name_and_extension:expr $(,)?) => {{ + $crate::_macro_support::read_binary_snapshot_content( + $name_and_extension, + $crate::_get_workspace_root!().as_path(), + $crate::_function_name!(), + $crate::_macro_support::module_path!(), + $crate::_macro_support::file!(), + ) + }}; +} + /// Asserts a [`Display`](std::fmt::Display) snapshot. /// /// This is now deprecated, replaced by the more generic [`assert_snapshot!`](crate::assert_snapshot!) diff --git a/insta/src/runtime.rs b/insta/src/runtime.rs index 1c31c1fc..d6db81d9 100644 --- a/insta/src/runtime.rs +++ b/insta/src/runtime.rs @@ -822,6 +822,114 @@ where } } +/// (Experimental) Reads a snapshot's text content from disk. +/// +/// This function resolves the snapshot path using the same logic as `assert_snapshot!` +/// and returns the snapshot contents as a string. This is useful when you need to +/// use a snapshot's value programmatically rather than just asserting against it. +/// +/// # Errors +/// +/// Returns an error if: +/// - The snapshot file does not exist +/// - The snapshot file cannot be parsed +/// - The snapshot contains binary content (use `read_binary_snapshot_content` instead) +/// +/// # Example +/// +/// ```ignore +/// let expected = insta::read_snapshot!("my_snapshot"); +/// // Use `expected` in your test logic +/// ``` +pub fn read_snapshot_content( + name: Option<&str>, + workspace: &Path, + function_name: &str, + module_path: &str, + assertion_file: &str, +) -> Result> { + let is_doctest = is_doctest(function_name); + + let snapshot_name: Cow<'_, str> = match name { + Some(name) => add_suffix_to_snapshot_name(Cow::Borrowed(name)), + None => { + if is_doctest { + return Err("Cannot determine reliable names for snapshot in doctests. Please use explicit names instead.".into()); + } + detect_snapshot_name(function_name, module_path)?.into() + } + }; + + let snapshot_file = get_snapshot_filename( + module_path, + assertion_file, + &snapshot_name, + workspace, + is_doctest, + ); + + let snapshot = Snapshot::from_file(&snapshot_file)?; + + match snapshot.contents() { + SnapshotContents::Text(text) => Ok(text.to_string()), + SnapshotContents::Binary(_) => { + Err("Cannot read binary snapshot as text; use read_binary_snapshot! instead".into()) + } + } +} + +/// (Experimental) Reads a snapshot's binary content from disk. +/// +/// This function resolves the snapshot path using the same logic as `assert_binary_snapshot!` +/// and returns the snapshot contents as a byte vector. +/// +/// # Errors +/// +/// Returns an error if: +/// - The snapshot file does not exist +/// - The snapshot file cannot be parsed +/// - The snapshot contains text content (use `read_snapshot_content` instead) +pub fn read_binary_snapshot_content( + name: &str, + workspace: &Path, + function_name: &str, + module_path: &str, + assertion_file: &str, +) -> Result, Box> { + let is_doctest = is_doctest(function_name); + + // Binary snapshots require explicit names with extensions + let (snapshot_name, _extension) = name + .split_once('.') + .ok_or_else(|| format!("\"{name}\" does not match the format \"name.extension\""))?; + + let snapshot_name = if snapshot_name.is_empty() { + if is_doctest { + return Err("Cannot determine reliable names for snapshot in doctests. Please use explicit names instead.".into()); + } + detect_snapshot_name(function_name, module_path)? + } else { + add_suffix_to_snapshot_name(Cow::Borrowed(snapshot_name)).into_owned() + }; + + let snapshot_file = get_snapshot_filename( + module_path, + assertion_file, + &snapshot_name, + workspace, + is_doctest, + ); + + let snapshot = Snapshot::from_file(&snapshot_file)?; + + match snapshot.contents() { + SnapshotContents::Binary(bytes) => Ok(bytes.as_ref().clone()), + SnapshotContents::Text(_) => { + Err("Cannot read text snapshot as binary; use read_snapshot! instead".into()) + } + } +} + /// This function is invoked from the macros to run the main assertion logic. /// /// This will create the assertion context, run the main logic to assert diff --git a/insta/tests/test_basic.rs b/insta/tests/test_basic.rs index 4f47326d..5b3fe98a 100644 --- a/insta/tests/test_basic.rs +++ b/insta/tests/test_basic.rs @@ -135,3 +135,22 @@ fn test_trailing_crlf_inline() { baz "); } + +// Tests for experimental read_snapshot! macro + +#[test] +fn test_read_snapshot_named() { + // Read the "debug_vector" snapshot that was created by test_debug_vector + let content = insta::read_snapshot!("debug_vector").unwrap(); + assert!(content.contains("[")); + assert!(content.contains("1")); + assert!(content.contains("2")); + assert!(content.contains("3")); +} + +#[test] +fn test_read_snapshot_nonexistent() { + // Reading a nonexistent snapshot should return an error + let result = insta::read_snapshot!("this_snapshot_does_not_exist"); + assert!(result.is_err()); +}