diff --git a/.vscode/settings.json b/.vscode/settings.json index 3a71ab9a..cbb7229a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "editor.formatOnSave": true, + "rust.clippy_preference": "on", "rust.cfg_test": true, "rust.all_features": true } \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 583e6757..11a488c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "insta" -version = "0.5.4" +version = "0.6.0" license = "Apache-2.0" authors = ["Armin Ronacher "] description = "A snapshot testing library for Rust" @@ -29,13 +29,11 @@ serde = { version = "1.0.85", features = ["derive"] } failure = "0.1.5" serde_yaml = "0.8.8" console = "0.7.5" -chrono = "0.4.6" +chrono = { version = "0.4.6", features = ["serde"] } serde_json = "1.0.36" lazy_static = "1.2.0" ci_info = "0.3.1" pest = "2.1.0" pest_derive = "2.1.0" ron = "0.4.1" - -[dev-dependencies] uuid = { version = "0.7.1", features = ["serde", "v4"] } diff --git a/README.md b/README.md index 3f61c2fe..0ad4f84f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This crate exports multiple macros for snapshot testing: - `assert_snapshot_matches!` for comparing basic string snapshots. - `assert_debug_snapshot_matches!` for comparing `Debug` outputs of values. -- `assert_serialized_snapshot_matches!` for comparing YAML serialized +- `assert_yaml_snapshot_matches!` for comparing YAML serialized output of types implementing `serde::Serialize`. - `assert_ron_snapshot_matches!` for comparing RON serialized output of types implementing `serde::Serialize`. @@ -22,6 +22,10 @@ Snapshots are stored in the `snapshots` folder right next to the test file where this is used. The name of the file is `__.snap` where the `name` of the snapshot has to be provided to the assertion macro. +Additionally snapshots can also be stored inline. In that case the +`cargo-insta` tool is necessary. See [inline snapshots](#inline-snapshots) +for more information. + For macros that work with `serde::Serialize` this crate also permits redacting of partial values. See [redactions](#redactions) for more information. @@ -120,6 +124,13 @@ This can be enabled by setting `INSTA_FORCE_PASS` to `1`: $ INSTA_FORCE_PASS=1 cargo test --no-fail-fast ``` +A better way to do this is to run `cargo insta test --review` which will +run all tests with force pass and then bring up the review tool: + +```rust +$ cargo insta test --review +``` + ## Redactions For all snapshots created based on `serde::Serialize` output `insta` @@ -151,7 +162,7 @@ pub struct User { username: String, } -assert_serialized_snapshot_matches!("user", &User { +assert_yaml_snapshot_matches!("user", &User { id: Uuid::new_v4(), username: "john_doe".to_string(), }, { @@ -159,4 +170,28 @@ assert_serialized_snapshot_matches!("user", &User { }); ``` +## Inline Snapshots + +Additionally snapshots can also be stored inline. In that case the format +for the snapshot macros is `assert_snapshot_matches!(reference_value, @"snapshot")`. +The leading at sign (`@`) indicates that the following string is the +reference value. `cargo-insta` will then update that string with the new +value on review. + +Example: + +```rust +#[derive(Serialize)] +pub struct User { + username: String, +} + +assert_yaml_snapshot_matches!(User { + username: "john_doe".to_string(), +}, @""); +``` + +After the initial test failure you can run `cargo insta review` to +accept the change. The file will then be updated automatically. + License: Apache-2.0 diff --git a/cargo-insta/Cargo.toml b/cargo-insta/Cargo.toml index 0fc63381..c5836252 100644 --- a/cargo-insta/Cargo.toml +++ b/cargo-insta/Cargo.toml @@ -11,7 +11,7 @@ edition = "2018" readme = "README.md" [dependencies] -insta = { version = "0.5.0", path = ".." } +insta = { version = "0.6.0", path = ".." } console = "0.7.5" clap = "2.32.0" difference = "2.0.0" @@ -21,3 +21,5 @@ serde_json = "1.0.36" failure = "0.1.5" glob = "0.2.11" walkdir = "2.2.7" +proc-macro2 = { version = "0.4.27", features = ["span-locations"] } +syn = { version = "0.15.26", features = ["full"] } diff --git a/cargo-insta/README.md b/cargo-insta/README.md index d6c3ada8..188b5187 100644 --- a/cargo-insta/README.md +++ b/cargo-insta/README.md @@ -12,4 +12,6 @@ $ cargo install cargo-insta $ cargo insta --help ``` +For more information see [the insta crate documentation](https://docs.rs/insta). + License: Apache-2.0 diff --git a/cargo-insta/src/cargo.rs b/cargo-insta/src/cargo.rs index 19c65ad1..0ddcbfac 100644 --- a/cargo-insta/src/cargo.rs +++ b/cargo-insta/src/cargo.rs @@ -5,10 +5,12 @@ use std::path::{Component, Path, PathBuf}; use std::process; use failure::{err_msg, Error}; -pub use insta::Snapshot; +use insta::{PendingInlineSnapshot, Snapshot}; use serde::Deserialize; use walkdir::{DirEntry, WalkDir}; +use crate::inline::FilePatcher; + #[derive(Deserialize, Clone, Debug)] pub struct Target { src_path: PathBuf, @@ -30,6 +32,13 @@ pub struct Metadata { workspace_root: String, } +#[derive(Clone, Copy, Debug)] +pub enum Operation { + Accept, + Reject, + Skip, +} + impl Metadata { pub fn workspace_root(&self) -> &Path { Path::new(&self.workspace_root) @@ -42,41 +51,157 @@ struct ProjectLocation { } #[derive(Debug)] -pub struct SnapshotRef { - old_path: PathBuf, - new_path: PathBuf, +pub enum SnapshotContainerKind { + Inline, + External, } -impl SnapshotRef { - fn new(new_path: PathBuf) -> SnapshotRef { - let mut old_path = new_path.clone(); - old_path.set_extension(""); - SnapshotRef { old_path, new_path } +#[derive(Debug)] +pub struct PendingSnapshot { + pub id: usize, + pub old: Option, + pub new: Snapshot, + pub op: Operation, + pub line: Option, +} + +impl PendingSnapshot { + pub fn summary(&self) -> String { + use std::fmt::Write; + let mut rv = String::new(); + if let Some(ref source) = self.new.metadata().source { + write!(&mut rv, "{}", source).unwrap(); + } + if let Some(line) = self.line { + write!(&mut rv, ":{}", line).unwrap(); + } + if let Some(name) = self.new.snapshot_name() { + write!(&mut rv, " ({})", name).unwrap(); + } + rv } +} - pub fn path(&self) -> &Path { - &self.old_path +#[derive(Debug)] +pub struct SnapshotContainer { + snapshot_path: PathBuf, + target_path: PathBuf, + kind: SnapshotContainerKind, + snapshots: Vec, + patcher: Option, +} + +impl SnapshotContainer { + fn load( + snapshot_path: PathBuf, + target_path: PathBuf, + kind: SnapshotContainerKind, + ) -> Result { + let mut snapshots = Vec::new(); + let patcher = match kind { + SnapshotContainerKind::External => { + let old = if fs::metadata(&target_path).is_err() { + None + } else { + Some(Snapshot::from_file(&target_path)?) + }; + let new = Snapshot::from_file(&snapshot_path)?; + snapshots.push(PendingSnapshot { + id: 0, + old, + new, + op: Operation::Skip, + line: None, + }); + None + } + SnapshotContainerKind::Inline => { + let pending_vec = PendingInlineSnapshot::load_batch(&snapshot_path)?; + let mut patcher = FilePatcher::open(&target_path)?; + for (id, pending) in pending_vec.into_iter().enumerate() { + snapshots.push(PendingSnapshot { + id, + old: pending.old, + new: pending.new, + op: Operation::Skip, + line: Some(pending.line), + }); + patcher.add_snapshot_macro(pending.line as usize); + } + Some(patcher) + } + }; + + Ok(SnapshotContainer { + snapshot_path, + target_path, + kind, + snapshots, + patcher, + }) } - pub fn load_old(&self) -> Result, Error> { - if fs::metadata(&self.old_path).is_err() { - Ok(None) - } else { - Snapshot::from_file(&self.old_path).map(Some) + pub fn snapshot_file(&self) -> Option<&Path> { + match self.kind { + SnapshotContainerKind::External => Some(&self.target_path), + SnapshotContainerKind::Inline => None, } } - pub fn load_new(&self) -> Result { - Snapshot::from_file(&self.new_path) + pub fn len(&self) -> usize { + self.snapshots.len() } - pub fn accept(&self) -> Result<(), Error> { - fs::rename(&self.new_path, &self.old_path)?; - Ok(()) + pub fn iter_snapshots(&mut self) -> impl Iterator { + self.snapshots.iter_mut() } - pub fn reject(&self) -> Result<(), Error> { - fs::remove_file(&self.new_path)?; + pub fn commit(&mut self) -> Result<(), Error> { + if let Some(ref mut patcher) = self.patcher { + let mut new_pending = vec![]; + let mut did_accept = false; + let mut did_skip = false; + + for (idx, snapshot) in self.snapshots.iter().enumerate() { + match snapshot.op { + Operation::Accept => { + patcher.set_new_content(idx, snapshot.new.contents()); + did_accept = true; + } + Operation::Reject => {} + Operation::Skip => { + new_pending.push(PendingInlineSnapshot::new( + snapshot.new.clone(), + snapshot.old.clone(), + patcher.get_new_line(idx) as u32, + )); + did_skip = true; + } + } + } + + if did_accept { + patcher.save()?; + } + if did_skip { + PendingInlineSnapshot::save_batch(&self.snapshot_path, &new_pending)?; + } else { + fs::remove_file(&self.snapshot_path)?; + } + } else { + // should only be one or this is weird + for snapshot in self.snapshots.iter() { + match snapshot.op { + Operation::Accept => { + fs::rename(&self.snapshot_path, &self.target_path)?; + } + Operation::Reject => { + fs::remove_file(&self.snapshot_path)?; + } + Operation::Skip => {} + } + } + } Ok(()) } } @@ -98,7 +223,9 @@ impl Package { &self.version } - pub fn iter_snapshots(&self) -> impl Iterator { + pub fn iter_snapshot_containers( + &self, + ) -> impl Iterator> { let mut roots = HashSet::new(); for target in &self.targets { let root = target.src_path.parent().unwrap(); @@ -109,10 +236,11 @@ impl Package { roots.into_iter().flat_map(|root| { WalkDir::new(root.clone()) .into_iter() - .filter_entry(|e| !is_hidden(e)) + .filter_entry(|e| e.file_type().is_file() || !is_hidden(e)) .filter_map(|e| e.ok()) - .filter(move |e| { - e.file_name().to_string_lossy().ends_with(".snap.new") + .filter_map(move |e| { + let fname = e.file_name().to_string_lossy(); + if fname.ends_with(".snap.new") && e.path() .strip_prefix(&root) .unwrap() @@ -121,13 +249,32 @@ impl Package { Component::Normal(dir) => dir.to_str() == Some("snapshots"), _ => false, }) + { + let new_path = e.into_path(); + let mut old_path = new_path.clone(); + old_path.set_extension(""); + Some(SnapshotContainer::load( + new_path, + old_path, + SnapshotContainerKind::External, + )) + } else if fname.starts_with('.') && fname.ends_with(".pending-snap") { + let mut target_path = e.path().to_path_buf(); + target_path.set_file_name(&fname[1..fname.len() - 13]); + Some(SnapshotContainer::load( + e.path().to_path_buf(), + target_path, + SnapshotContainerKind::Inline, + )) + } else { + None + } }) - .map(|e| SnapshotRef::new(e.into_path())) }) } } -fn get_cargo() -> String { +pub fn get_cargo() -> String { env::var("CARGO") .ok() .unwrap_or_else(|| "cargo".to_string()) diff --git a/cargo-insta/src/cli.rs b/cargo-insta/src/cli.rs index 535396cf..7886b471 100644 --- a/cargo-insta/src/cli.rs +++ b/cargo-insta/src/cli.rs @@ -1,12 +1,19 @@ use std::env; use std::path::{Path, PathBuf}; +use std::process; use console::{set_colors_enabled, style, Key, Term}; -use failure::{err_msg, Error}; +use failure::{err_msg, Error, Fail}; +use insta::{print_snapshot_diff, Snapshot}; use structopt::clap::AppSettings; use structopt::StructOpt; -use crate::cargo::{find_packages, get_package_metadata, Metadata, Package, SnapshotRef}; +use crate::cargo::{find_packages, get_cargo, get_package_metadata, Metadata, Operation, Package}; + +/// Close without message but exit code. +#[derive(Fail, Debug)] +#[fail(display = "exit with {}", _0)] +pub struct QuietExit(pub i32); /// A helper utility to work with insta snapshots. #[derive(StructOpt, Debug)] @@ -40,18 +47,21 @@ pub enum Command { /// Accept all snapshots #[structopt(name = "accept")] Accept(ProcessCommand), + /// Run tests and then reviews + #[structopt(name = "test")] + Test(TestCommand), } -#[derive(StructOpt, Debug)] +#[derive(StructOpt, Debug, Clone)] #[structopt(rename_all = "kebab-case")] pub struct PackageArgs { - /// Review all packages - #[structopt(long)] - pub all: bool, - /// Path to Cargo.toml #[structopt(long, value_name = "PATH", parse(from_os_str))] pub manifest_path: Option, + + /// Work on all packages in the workspace + #[structopt(long)] + pub all: bool, } #[derive(StructOpt, Debug)] @@ -61,45 +71,59 @@ pub struct ProcessCommand { pub pkg_args: PackageArgs, } -#[derive(Clone, Copy, Debug)] -enum Operation { - Accept, - Reject, - Skip, +#[derive(StructOpt, Debug)] +#[structopt(rename_all = "kebab-case")] +pub struct TestCommand { + #[structopt(flatten)] + pub pkg_args: PackageArgs, + /// Package to run tests for + #[structopt(short = "p", long)] + pub package: Option, + /// Disable force-passing of snapshot tests + #[structopt(long)] + pub no_force_pass: bool, + /// Prevent running all tests regardless of failure + #[structopt(long)] + pub fail_fast: bool, + /// Space-separated list of features to activate + #[structopt(long, value_name = "FEATURES")] + pub features: Option, + /// Activate all available features + #[structopt(long)] + pub all_features: bool, + /// Do not activate the `default` feature + #[structopt(long)] + pub no_default_features: bool, + /// Follow up with review. + #[structopt(long)] + pub review: bool, } -fn process_snapshot( +#[allow(clippy::too_many_arguments)] +fn query_snapshot( + workspace_root: &Path, term: &Term, - cargo_workspace: &Path, - snapshot_ref: &SnapshotRef, + new: &Snapshot, + old: Option<&Snapshot>, pkg: &Package, + line: Option, i: usize, n: usize, + snapshot_file: Option<&Path>, ) -> Result { - let old = snapshot_ref.load_old()?; - let new = snapshot_ref.load_new()?; - - let path = snapshot_ref - .path() - .strip_prefix(cargo_workspace) - .ok() - .unwrap_or_else(|| snapshot_ref.path()); - term.clear_screen()?; println!( - "{}{}{} {} in {} ({})", + "{}{}{} {} ({})", style("Reviewing [").bold(), style(format!("{}/{}", i, n)).yellow().bold(), style("]:").bold(), - style(path.display()).cyan().underlined().bold(), style(pkg.name()).dim(), pkg.version() ); - println!("Snapshot: {}", style(new.snapshot_name()).yellow()); - new.print_changes(old.as_ref()); + print_snapshot_diff(workspace_root, new, old, snapshot_file, line); - println!(""); + println!(); println!( " {} accept {}", style("A").green().bold(), @@ -145,12 +169,15 @@ fn handle_pkg_args(pkg_args: &PackageArgs) -> Result<(Metadata, Vec), E fn process_snapshots(cmd: &ProcessCommand, op: Option) -> Result<(), Error> { let term = Term::stdout(); let (metadata, packages) = handle_pkg_args(&cmd.pkg_args)?; - let snapshots: Vec<_> = packages - .iter() - .flat_map(|p| p.iter_snapshots().map(move |s| (s, p))) - .collect(); + let mut snapshot_containers = vec![]; + for package in &packages { + for snapshot_container in package.iter_snapshot_containers() { + snapshot_containers.push((snapshot_container?, package)); + } + } + let snapshot_count = snapshot_containers.iter().map(|x| x.0.len()).sum(); - if snapshots.is_empty() { + if snapshot_count == 0 { println!("{}: no snapshots to review", style("done").bold()); return Ok(()); } @@ -158,33 +185,41 @@ fn process_snapshots(cmd: &ProcessCommand, op: Option) -> Result<(), let mut accepted = vec![]; let mut rejected = vec![]; let mut skipped = vec![]; + let mut num = 0; - for (idx, (snapshot, package)) in snapshots.iter().enumerate() { - let op = match op { - Some(op) => op, - None => process_snapshot( - &term, - metadata.workspace_root(), - snapshot, - package, - idx + 1, - snapshots.len(), - )?, - }; - let path = snapshot.path().to_path_buf(); - match op { - Operation::Accept => { - snapshot.accept()?; - accepted.push(path); - } - Operation::Reject => { - snapshot.reject()?; - rejected.push(path); - } - Operation::Skip => { - skipped.push(path); + for (snapshot_container, package) in snapshot_containers.iter_mut() { + let snapshot_file = snapshot_container.snapshot_file().map(|x| x.to_path_buf()); + for snapshot_ref in snapshot_container.iter_snapshots() { + num += 1; + let op = match op { + Some(op) => op, + None => query_snapshot( + metadata.workspace_root(), + &term, + &snapshot_ref.new, + snapshot_ref.old.as_ref(), + package, + snapshot_ref.line, + num, + snapshot_count, + snapshot_file.as_ref().map(|x| x.as_path()), + )?, + }; + match op { + Operation::Accept => { + snapshot_ref.op = Operation::Accept; + accepted.push(snapshot_ref.summary()); + } + Operation::Reject => { + snapshot_ref.op = Operation::Reject; + rejected.push(snapshot_ref.summary()); + } + Operation::Skip => { + skipped.push(snapshot_ref.summary()); + } } } + snapshot_container.commit()?; } if op.is_none() { @@ -195,20 +230,79 @@ fn process_snapshots(cmd: &ProcessCommand, op: Option) -> Result<(), if !accepted.is_empty() { println!("{}:", style("accepted").green()); for item in accepted { - println!(" {}", item.display()); + println!(" {}", item); } } if !rejected.is_empty() { println!("{}:", style("rejected").red()); for item in rejected { - println!(" {}", item.display()); + println!(" {}", item); } } if !skipped.is_empty() { println!("{}:", style("skipped").yellow()); for item in skipped { - println!(" {}", item.display()); + println!(" {}", item); + } + } + + Ok(()) +} + +fn test_run(cmd: &TestCommand) -> Result<(), Error> { + let mut proc = process::Command::new(get_cargo()); + proc.arg("test"); + if cmd.pkg_args.all { + proc.arg("--all"); + } + if let Some(ref pkg) = cmd.package { + proc.arg("--package"); + proc.arg(pkg); + } + if let Some(ref manifest_path) = cmd.pkg_args.manifest_path { + proc.arg("--manifest-path"); + proc.arg(manifest_path); + } + if !cmd.fail_fast { + proc.arg("--no-fail-fast"); + } + if !cmd.no_force_pass { + proc.env("INSTA_FORCE_PASS", "1"); + } + if cmd.review { + proc.env("INSTA_UPDATE", "new"); + } + if let Some(ref features) = cmd.features { + proc.arg("--features"); + proc.arg(features); + } + if cmd.all_features { + proc.arg("--all-features"); + } + if cmd.no_default_features { + proc.arg("--no-default-features"); + } + proc.arg("--"); + proc.arg("-q"); + let status = proc.status()?; + + if !status.success() { + if cmd.review { + eprintln!( + "{} non snapshot tests failed, skipping review", + style("warning:").bold().yellow() + ); } + return Err(QuietExit(1).into()); + } + + if cmd.review { + process_snapshots( + &ProcessCommand { + pkg_args: cmd.pkg_args.clone(), + }, + None, + )? } Ok(()) @@ -225,5 +319,6 @@ pub fn run() -> Result<(), Error> { Command::Review(cmd) => process_snapshots(&cmd, None), Command::Accept(cmd) => process_snapshots(&cmd, Some(Operation::Accept)), Command::Reject(cmd) => process_snapshots(&cmd, Some(Operation::Reject)), + Command::Test(cmd) => test_run(&cmd), } } diff --git a/cargo-insta/src/inline.rs b/cargo-insta/src/inline.rs new file mode 100644 index 00000000..c752c07f --- /dev/null +++ b/cargo-insta/src/inline.rs @@ -0,0 +1,161 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use failure::Error; +use proc_macro2::TokenTree; +use syn; +use syn::spanned::Spanned; + +#[derive(Debug)] +pub struct InlineSnapshot { + start: (usize, usize), + end: (usize, usize), +} + +#[derive(Debug)] +pub struct FilePatcher { + filename: PathBuf, + lines: Vec, + newline: &'static str, + source: syn::File, + inline_snapshots: Vec, +} + +impl FilePatcher { + pub fn open>(p: P) -> Result { + let filename = p.as_ref().to_path_buf(); + let contents = fs::read_to_string(p)?; + let source = syn::parse_file(&contents)?; + let mut line_iter = contents.lines().peekable(); + let newline = if let Some(line) = line_iter.peek() { + match contents.as_bytes().get(line.len() + 1) { + Some(b'\r') => &"\r\n", + _ => &"\n", + } + } else { + &"\n" + }; + let lines: Vec = line_iter.map(|x| x.into()).collect(); + Ok(FilePatcher { + filename, + source, + newline, + lines, + inline_snapshots: vec![], + }) + } + + pub fn save(&self) -> Result<(), Error> { + let mut f = fs::File::create(&self.filename)?; + for line in &self.lines { + writeln!(&mut f, "{}", line)?; + } + Ok(()) + } + + pub fn add_snapshot_macro(&mut self, line: usize) { + match self.find_snapshot_macro(line) { + Some(snapshot) => self.inline_snapshots.push(snapshot), + None => panic!("Could not find snapshot in line {}", line), + } + } + + pub fn get_new_line(&self, id: usize) -> usize { + self.inline_snapshots[id].start.0 + 1 + } + + pub fn set_new_content(&mut self, id: usize, snapshot: &str) { + let inline = &mut self.inline_snapshots[id]; + let old_lines = inline.end.0 - inline.start.0 + 1; + + // find prefix and suffix + let prefix: String = self.lines[inline.start.0] + .chars() + .take(inline.start.1) + .collect(); + let suffix: String = self.lines[inline.end.0] + .chars() + .skip(inline.end.1) + .collect(); + + // replace lines + let mut new_lines: Vec<_> = snapshot.lines().collect(); + if new_lines.is_empty() { + new_lines.push(""); + } + let (quote_start, quote_end) = + if new_lines.len() > 1 || new_lines[0].contains(&['\\', '"'][..]) { + ("r###\"", "\"###") + } else { + ("\"", "\"") + }; + let line_count_diff = new_lines.len() as i64 - old_lines as i64; + + self.lines.splice( + inline.start.0..=inline.end.0, + new_lines.iter().enumerate().map(|(idx, line)| { + let mut rv = String::new(); + if idx == 0 { + rv.push_str(&prefix); + rv.push_str(quote_start); + } + rv.push_str(&line); + if idx + 1 == new_lines.len() { + rv.push_str(quote_end); + rv.push_str(&suffix); + } + rv + }), + ); + + for inl in &mut self.inline_snapshots[id..] { + inl.start.0 = (inl.start.0 as i64 + line_count_diff) as usize; + inl.end.0 = (inl.end.0 as i64 + line_count_diff) as usize; + } + } + + fn find_snapshot_macro(&self, line: usize) -> Option { + struct Visitor(usize, Option); + + impl<'ast> syn::visit::Visit<'ast> for Visitor { + fn visit_macro(&mut self, i: &'ast syn::Macro) { + if i.span().start().line != self.0 || i.path.segments.is_empty() { + return; + } + + let last = i.path.segments[i.path.segments.len() - 1].ident.to_string(); + if !last.starts_with("assert_") || !last.ends_with("_snapshot_matches") { + return; + } + + let tokens: Vec<_> = i.tts.clone().into_iter().collect(); + if tokens.len() < 2 { + return; + } + + match &tokens[tokens.len() - 2] { + TokenTree::Punct(ref punct) if punct.as_char() == '@' => {} + _ => return, + } + + let (start, end) = match &tokens[tokens.len() - 1] { + TokenTree::Literal(lit) => { + let span = lit.span(); + ( + (span.start().line - 1, span.start().column), + (span.end().line - 1, span.end().column), + ) + } + _ => return, + }; + + self.1 = Some(InlineSnapshot { start, end }); + } + } + + let mut visitor = Visitor(line, None); + syn::visit::visit_file(&mut visitor, &self.source); + visitor.1 + } +} \ No newline at end of file diff --git a/cargo-insta/src/main.rs b/cargo-insta/src/main.rs index f54272b4..4d4d02ab 100644 --- a/cargo-insta/src/main.rs +++ b/cargo-insta/src/main.rs @@ -9,12 +9,22 @@ //! $ cargo install cargo-insta //! $ cargo insta --help //! ``` +//! +//! For more information see [the insta crate documentation](https://docs.rs/insta). mod cargo; mod cli; +mod inline; + +use console::style; fn main() { if let Err(err) = cli::run() { - println!("error: {}", err); - std::process::exit(1); + let exit_code = if let Some(ref exit) = err.downcast_ref::() { + exit.0 + } else { + println!("{} {}", style("error:").red().bold(), err); + 1 + }; + std::process::exit(exit_code); } } diff --git a/src/content.rs b/src/content.rs index 829d58b8..a3af6f3c 100644 --- a/src/content.rs +++ b/src/content.rs @@ -355,7 +355,7 @@ where len: usize, ) -> Result { Ok(SerializeTupleStruct { - name: name, + name, fields: Vec::with_capacity(len), error: PhantomData, }) @@ -369,9 +369,9 @@ where len: usize, ) -> Result { Ok(SerializeTupleVariant { - name: name, - variant_index: variant_index, - variant: variant, + name, + variant_index, + variant, fields: Vec::with_capacity(len), error: PhantomData, }) @@ -387,7 +387,7 @@ where fn serialize_struct(self, name: &'static str, len: usize) -> Result { Ok(SerializeStruct { - name: name, + name, fields: Vec::with_capacity(len), error: PhantomData, }) @@ -401,9 +401,9 @@ where len: usize, ) -> Result { Ok(SerializeStructVariant { - name: name, - variant_index: variant_index, - variant: variant, + name, + variant_index, + variant, fields: Vec::with_capacity(len), error: PhantomData, }) diff --git a/src/lib.rs b/src/lib.rs index d3400acb..f44fb10f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,7 @@ //! //! - `assert_snapshot_matches!` for comparing basic string snapshots. //! - `assert_debug_snapshot_matches!` for comparing `Debug` outputs of values. -//! - `assert_serialized_snapshot_matches!` for comparing YAML serialized +//! - `assert_yaml_snapshot_matches!` for comparing YAML serialized //! output of types implementing `serde::Serialize`. //! - `assert_ron_snapshot_matches!` for comparing RON serialized output of //! types implementing `serde::Serialize`. @@ -20,6 +20,10 @@ //! where this is used. The name of the file is `__.snap` where //! the `name` of the snapshot has to be provided to the assertion macro. //! +//! Additionally snapshots can also be stored inline. In that case the +//! `cargo-insta` tool is necessary. See [inline snapshots](#inline-snapshots) +//! for more information. +//! //! For macros that work with `serde::Serialize` this crate also permits //! redacting of partial values. See [redactions](#redactions) for more //! information. @@ -118,6 +122,13 @@ //! $ INSTA_FORCE_PASS=1 cargo test --no-fail-fast //! ``` //! +//! A better way to do this is to run `cargo insta test --review` which will +//! run all tests with force pass and then bring up the review tool: +//! +//! ```ignore +//! $ cargo insta test --review +//! ``` +//! //! # Redactions //! //! For all snapshots created based on `serde::Serialize` output `insta` @@ -149,31 +160,60 @@ //! username: String, //! } //! -//! assert_serialized_snapshot_matches!("user", &User { +//! assert_yaml_snapshot_matches!("user", &User { //! id: Uuid::new_v4(), //! username: "john_doe".to_string(), //! }, { //! ".id" => "[uuid]" //! }); //! ``` +//! +//! # Inline Snapshots +//! +//! Additionally snapshots can also be stored inline. In that case the format +//! for the snapshot macros is `assert_snapshot_matches!(reference_value, @"snapshot")`. +//! The leading at sign (`@`) indicates that the following string is the +//! reference value. `cargo-insta` will then update that string with the new +//! value on review. +//! +//! Example: +//! +//! ```rust,ignore +//! #[derive(Serialize)] +//! pub struct User { +//! username: String, +//! } +//! +//! assert_yaml_snapshot_matches!(User { +//! username: "john_doe".to_string(), +//! }, @""); +//! ``` +//! +//! After the initial test failure you can run `cargo insta review` to +//! accept the change. The file will then be updated automatically. #[macro_use] mod macros; mod content; mod redaction; mod runtime; mod serialization; +mod snapshot; #[cfg(test)] mod test; -pub use crate::runtime::Snapshot; +pub use crate::snapshot::{MetaData, Snapshot}; + +// exported for cargo-insta only +#[doc(hidden)] +pub use crate::{runtime::print_snapshot_diff, snapshot::PendingInlineSnapshot}; // these are here to make the macros work #[doc(hidden)] pub mod _macro_support { pub use crate::content::Content; pub use crate::redaction::Selector; - pub use crate::runtime::assert_snapshot; + pub use crate::runtime::{assert_snapshot, ReferenceValue}; pub use crate::serialization::{ serialize_value, serialize_value_redacted, SerializationFormat, }; diff --git a/src/macros.rs b/src/macros.rs index 7514d466..fab18189 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -1,3 +1,20 @@ +/// Alias for `assert_yaml_snapshot_matches`. +#[macro_export] +macro_rules! assert_serialized_snapshot_matches { + ($value:expr, @$snapshot:literal) => {{ + $crate::_assert_serialized_snapshot_matches!($value, Yaml, @$snapshot); + }}; + ($value:expr, {$($k:expr => $v:expr),*}, @$snapshot:literal) => {{ + $crate::_assert_serialized_snapshot_matches!($value, {$($k => $v),*}, Yaml, @$snapshot); + }}; + ($name:expr, $value:expr) => {{ + $crate::_assert_serialized_snapshot_matches!($name, $value, Yaml); + }}; + ($name:expr, $value:expr, {$($k:expr => $v:expr),*}) => {{ + $crate::_assert_serialized_snapshot_matches!($name, $value, {$($k => $v),*}, Yaml); + }} +} + /// Assets a `Serialize` snapshot in YAML format. /// /// The value needs to implement the `serde::Serialize` trait and the snapshot @@ -10,7 +27,7 @@ /// Example: /// /// ```no_run,ignore -/// assert_serialized_snapshot_matches!("snapshot_name", vec[1, 2, 3]); +/// assert_yaml_snapshot_matches!("snapshot_name", vec[1, 2, 3]); /// ``` /// /// Unlike the `assert_debug_snapshot_matches` macro, this one has a secondary @@ -23,15 +40,26 @@ /// Example: /// /// ```no_run,ignore -/// assert_serialized_snapshot_matches!("name", value, { +/// assert_yaml_snapshot_matches!("name", value, { /// ".key.to.redact" => "[replacement value]", /// ".another.key.*.to.redact" => 42 /// }); /// ``` /// /// The replacement value can be a string, integer or any other primitive value. +/// +/// For inline usage the format is `(expression, @reference_value)` where the +/// reference value must be a string literal. If you make the initial snapshot +/// just use an empty string (`@""`). For more information see +/// [inline snapshots](index.html#inline-snapshots). #[macro_export] -macro_rules! assert_serialized_snapshot_matches { +macro_rules! assert_yaml_snapshot_matches { + ($value:expr, @$snapshot:literal) => {{ + $crate::_assert_serialized_snapshot_matches!($value, Yaml, @$snapshot); + }}; + ($value:expr, {$($k:expr => $v:expr),*}, @$snapshot:literal) => {{ + $crate::_assert_serialized_snapshot_matches!($value, {$($k => $v),*}, Yaml, @$snapshot); + }}; ($name:expr, $value:expr) => {{ $crate::_assert_serialized_snapshot_matches!($name, $value, Yaml); }}; @@ -57,6 +85,12 @@ macro_rules! assert_serialized_snapshot_matches { /// about redactions see [redactions](index.html#redactions). #[macro_export] macro_rules! assert_ron_snapshot_matches { + ($value:expr, @$snapshot:literal) => {{ + $crate::_assert_serialized_snapshot_matches!($value, Ron, @$snapshot); + }}; + ($value:expr, {$($k:expr => $v:expr),*}, @$snapshot:literal) => {{ + $crate::_assert_serialized_snapshot_matches!($value, {$($k => $v),*}, Ron, @$snapshot); + }}; ($name:expr, $value:expr) => {{ $crate::_assert_serialized_snapshot_matches!($name, $value, Ron); }}; @@ -82,6 +116,12 @@ macro_rules! assert_ron_snapshot_matches { /// about redactions see [redactions](index.html#redactions). #[macro_export] macro_rules! assert_json_snapshot_matches { + ($value:expr, @$snapshot:literal) => {{ + $crate::_assert_serialized_snapshot_matches!($value, Json, @$snapshot); + }}; + ($value:expr, {$($k:expr => $v:expr),*}, @$snapshot:literal) => {{ + $crate::_assert_serialized_snapshot_matches!($value, {$($k => $v),*}, Json, @$snapshot); + }}; ($name:expr, $value:expr) => {{ $crate::_assert_serialized_snapshot_matches!($name, $value, Json); }}; @@ -93,6 +133,31 @@ macro_rules! assert_json_snapshot_matches { #[doc(hidden)] #[macro_export] macro_rules! _assert_serialized_snapshot_matches { + ($value:expr, $format:ident, @$snapshot:literal) => {{ + let value = $crate::_macro_support::serialize_value( + &$value, + $crate::_macro_support::SerializationFormat::$format + ); + $crate::assert_snapshot_matches!( + value, + stringify!($value), + @$snapshot + ); + }}; + ($value:expr, {$($k:expr => $v:expr),*}, $format:ident, @$snapshot:literal) => {{ + let vec = vec![ + $(( + $crate::_macro_support::Selector::parse($k).unwrap(), + $crate::_macro_support::Content::from($v) + ),)* + ]; + let value = $crate::_macro_support::serialize_value_redacted( + &$value, + &vec, + $crate::_macro_support::SerializationFormat::$format + ); + $crate::assert_snapshot_matches!(value, stringify!($value), @$snapshot); + }}; ($name:expr, $value:expr, $format:ident) => {{ let value = $crate::_macro_support::serialize_value( &$value, @@ -127,6 +192,10 @@ macro_rules! _assert_serialized_snapshot_matches { /// permit redactions. #[macro_export] macro_rules! assert_debug_snapshot_matches { + ($value:expr, @$snapshot:literal) => {{ + let value = format!("{:#?}", $value); + $crate::assert_snapshot_matches!(value, stringify!($value), @$snapshot); + }}; ($name:expr, $value:expr) => {{ let value = format!("{:#?}", $value); $crate::assert_snapshot_matches!($name, value, stringify!($value)); @@ -148,14 +217,44 @@ macro_rules! assert_debug_snapshot_matches { /// source of this macro and other assertion macros. #[macro_export] macro_rules! assert_snapshot_matches { + ($value:expr, @$snapshot:literal) => { + $crate::_assert_snapshot_matches!( + $crate::_macro_support::ReferenceValue::Inline($snapshot), + $value, + stringify!($value) + ) + }; + ($value:expr, $debug_expr:expr, @$snapshot:literal) => { + $crate::_assert_snapshot_matches!( + $crate::_macro_support::ReferenceValue::Inline($snapshot), + $value, + $debug_expr + ) + }; ($name:expr, $value:expr) => { - $crate::assert_snapshot_matches!($name, $value, stringify!($value)) + $crate::_assert_snapshot_matches!( + $crate::_macro_support::ReferenceValue::Named($name), + $value, + stringify!($value) + ) }; ($name:expr, $value:expr, $debug_expr:expr) => { + $crate::_assert_snapshot_matches!( + $crate::_macro_support::ReferenceValue::Named($name), + $value, + $debug_expr + ) + }; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! _assert_snapshot_matches { + ($refval:expr, $value:expr, $debug_expr:expr) => { match &$value { value => { $crate::_macro_support::assert_snapshot( - &$name, + $refval, value, env!("CARGO_MANIFEST_DIR"), module_path!(), diff --git a/src/runtime.rs b/src/runtime.rs index 9525f8e0..88836568 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -7,8 +7,8 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::Mutex; -use chrono::Utc; -use console::{style, Color}; +use chrono::{Local, Utc}; +use console::style; use difference::{Changeset, Difference}; use failure::Error; use lazy_static::lazy_static; @@ -17,6 +17,8 @@ use ci_info::is_ci; use serde::Deserialize; use serde_json; +use crate::snapshot::{MetaData, PendingInlineSnapshot, Snapshot}; + lazy_static! { static ref WORKSPACES: Mutex> = Mutex::new(BTreeMap::new()); } @@ -112,7 +114,8 @@ fn get_cargo() -> String { } fn get_cargo_workspace(manifest_dir: &str) -> &Path { - let mut workspaces = WORKSPACES.lock().unwrap(); + // we really do not care about locking here. + let mut workspaces = WORKSPACES.lock().unwrap_or_else(|x| x.into_inner()); if let Some(rv) = workspaces.get(manifest_dir) { rv } else { @@ -134,7 +137,7 @@ fn get_cargo_workspace(manifest_dir: &str) -> &Path { } } -fn print_changeset_diff(changeset: &Changeset, expr: Option<&str>) { +fn print_changeset(changeset: &Changeset, expr: Option<&str>) { let Changeset { ref diffs, .. } = *changeset; #[derive(PartialEq)] enum Mode { @@ -215,218 +218,123 @@ fn print_changeset_diff(changeset: &Changeset, expr: Option<&str>) { } pub fn get_snapshot_filename( - name: &str, + module_name: &str, + snapshot_name: &str, cargo_workspace: &Path, - module_path: &str, base: &str, ) -> PathBuf { let root = Path::new(cargo_workspace); let base = Path::new(base); root.join(base.parent().unwrap()) .join("snapshots") - .join(format!( - "{}__{}.snap", - module_path.rsplit("::").next().unwrap(), - name - )) + .join(format!("{}__{}.snap", module_name, snapshot_name)) } -/// A helper to work with stored snapshots. -#[derive(Debug)] -pub struct Snapshot { - path: PathBuf, - metadata: BTreeMap, - snapshot: String, -} - -impl Snapshot { - /// Loads a snapshot from a file. - pub fn from_file>(p: P) -> Result { - let mut f = BufReader::new(fs::File::open(p.as_ref())?); - let mut buf = String::new(); - - f.read_line(&mut buf)?; - - // yaml format - let metadata = if buf.trim_end() == "---" { - loop { - let read = f.read_line(&mut buf)?; - if read == 0 { - break; - } - if buf[buf.len() - read..].trim_end() == "---" { - buf.truncate(buf.len() - read); - break; - } - } - serde_yaml::from_str(&buf)? - // legacy format - } else { - let mut rv = BTreeMap::new(); - loop { - buf.clear(); - let read = f.read_line(&mut buf)?; - if read == 0 || buf.trim_end().is_empty() { - buf.truncate(buf.len() - read); - break; - } - let mut iter = buf.splitn(2, ':'); - if let Some(key) = iter.next() { - if let Some(value) = iter.next() { - rv.insert(key.to_lowercase(), value.to_string()); - } - } - } - rv - }; - - buf.clear(); - for (idx, line) in f.lines().enumerate() { - let line = line?; - if idx > 0 { - buf.push('\n'); - } - buf.push_str(&line); - } - - Ok(Snapshot { - path: p.as_ref().to_path_buf(), - metadata, - snapshot: buf, - }) - } - - /// The path of the snapshot - pub fn path(&self) -> &Path { - &self.path - } - - /// Relative path to the workspace root. - pub fn relative_path(&self, root: &Path) -> &Path { - self.path.strip_prefix(root).ok().unwrap_or(&self.path) - } - - /// Returns the module name. - pub fn module_name(&self) -> &str { - self.path - .file_name() - .unwrap() - .to_str() - .unwrap_or("") - .split("__") - .next() - .unwrap() - } - - /// Returns the snapshot name. - pub fn snapshot_name(&self) -> &str { - self.path - .file_name() - .unwrap() - .to_str() - .unwrap_or("") - .split('.') - .next() - .unwrap_or("") - .splitn(2, "__") - .nth(1) - .unwrap_or("unknown") - } - - /// The metadata in the snapshot. - pub fn metadata(&self) -> &BTreeMap { - &self.metadata +/// Prints a diff against an old snapshot. +pub fn print_snapshot_diff( + workspace_root: &Path, + new: &Snapshot, + old_snapshot: Option<&Snapshot>, + snapshot_file: Option<&Path>, + line: Option, +) { + if let Some(snapshot_file) = snapshot_file { + let snapshot_file = workspace_root + .join(snapshot_file) + .strip_prefix(workspace_root) + .ok() + .map(|x| x.to_path_buf()) + .unwrap_or_else(|| snapshot_file.to_path_buf()); + println!( + "Snapshot file: {}", + style(snapshot_file.display()).cyan().underlined() + ); } - - /// The snapshot contents - pub fn contents(&self) -> &str { - &self.snapshot + if let Some(name) = new.snapshot_name() { + println!("Snapshot: {}", style(name).yellow()); + } else { + println!("Snapshot: {}", style("").dim()); } - /// Prints a diff against an old snapshot. - pub fn print_changes(&self, old_snapshot: Option<&Snapshot>) { - if let Some(value) = self.metadata.get("source") { - println!("Source: {}", style(value).cyan()); - } - if let Some(value) = self.metadata.get("created") { - println!("New: {}", style(value).cyan()); - } - let changeset = Changeset::new( - old_snapshot.as_ref().map_or("", |x| x.contents()), - &self.snapshot, - "\n", - ); - if let Some(old_snapshot) = old_snapshot { - if let Some(value) = old_snapshot.metadata.get("created") { - println!("Old: {}", style(value).cyan()); + if let Some(ref value) = new.metadata().get_relative_source(workspace_root) { + println!( + "Source: {}{}", + style(value.display()).cyan(), + if let Some(line) = line { + format!(":{}", style(line).bold()) + } else { + "".to_string() } - println!(); - println!("{}", style("-old snapshot").red()); - println!("{}", style("+new results").green()); - } else { - println!("Old: {}", style("n.a.").red()); - println!(); - println!("{}", style("+new results").green()); - } - print_changeset_diff( - &changeset, - self.metadata.get("expression").map(|x| x.as_str()), ); } - - fn save(&self) -> Result<(), Error> { - self.save_impl(&self.path) - } - - fn save_new(&self) -> Result { - let mut path = self.path.to_path_buf(); - path.set_extension("snap.new"); - self.save_impl(&path)?; - Ok(path) - } - - fn save_impl(&self, path: &Path) -> Result<(), Error> { - if let Some(folder) = path.parent() { - fs::create_dir_all(&folder)?; + let changeset = Changeset::new( + old_snapshot.as_ref().map_or("", |x| x.contents()), + &new.contents(), + "\n", + ); + if let Some(old_snapshot) = old_snapshot { + if let Some(ref value) = old_snapshot.metadata().created { + println!( + "Old: {}", + style(value.with_timezone(&Local).to_rfc2822()).cyan() + ); + } + if let Some(ref value) = new.metadata().created { + println!( + "New: {}", + style(value.with_timezone(&Local).to_rfc2822()).cyan() + ); } - let mut f = fs::File::create(&path)?; - serde_yaml::to_writer(&mut f, &self.metadata)?; - f.write_all(b"\n---\n")?; - f.write_all(self.snapshot.as_bytes())?; - f.write_all(b"\n")?; - Ok(()) + println!(); + println!("{}", style("-old snapshot").red()); + println!("{}", style("+new results").green()); + } else { + println!("Old: {}", style("n.a.").red()); + if let Some(ref value) = new.metadata().created { + println!( + "New: {}", + style(value.with_timezone(&Local).to_rfc2822()).cyan() + ); + } + println!(); + println!("{}", style("+new results").green()); } + print_changeset( + &changeset, + new.metadata().expression.as_ref().map(|x| x.as_str()), + ); } -fn print_snapshot_diff( - cargo_workspace: &Path, - name: &str, - old_snapshot: Option<&Snapshot>, +fn print_snapshot_diff_with_title( + workspace_root: &Path, new_snapshot: &Snapshot, + old_snapshot: Option<&Snapshot>, + line: u32, + snapshot_file: Option<&Path>, ) { let width = console::Term::stdout().size().1 as usize; - - let file = style(new_snapshot.relative_path(&cargo_workspace).display()) - .underlined() - .fg(if fs::metadata(&new_snapshot.path).is_ok() { - Color::Cyan - } else { - Color::Red - }); - println!( - "{title:━^width$}\nFile: {file}\nSnapshot: {name}", - name = style(name).yellow(), - file = file, + "{title:━^width$}", title = style(" Snapshot Differences ").bold(), width = width ); + print_snapshot_diff( + workspace_root, + new_snapshot, + old_snapshot, + snapshot_file, + Some(line), + ); +} - new_snapshot.print_changes(old_snapshot); +pub enum ReferenceValue<'a> { + Named(&'a str), + Inline(&'a str), } +#[allow(clippy::too_many_arguments)] pub fn assert_snapshot( - name: &str, + refval: ReferenceValue<'_>, new_snapshot: &str, manifest_dir: &str, module_path: &str, @@ -434,52 +342,113 @@ pub fn assert_snapshot( line: u32, expr: &str, ) -> Result<(), Error> { + let module_name = module_path.rsplit("::").next().unwrap(); let cargo_workspace = get_cargo_workspace(manifest_dir); - let snapshot_file = get_snapshot_filename(name, &cargo_workspace, module_path, file); - let old = Snapshot::from_file(&snapshot_file).ok(); + + let (snapshot_name, snapshot_file, old, pending_snapshots) = match refval { + ReferenceValue::Named(snapshot_name) => { + let snapshot_file = + get_snapshot_filename(module_name, snapshot_name, &cargo_workspace, file); + let old = if fs::metadata(&snapshot_file).is_ok() { + Some(Snapshot::from_file(&snapshot_file)?) + } else { + None + }; + (Some(snapshot_name), Some(snapshot_file), old, None) + } + ReferenceValue::Inline(contents) => { + let mut filename = cargo_workspace.join(file); + let created = fs::metadata(&filename)?.created().ok().map(|x| x.into()); + filename.set_file_name(format!( + ".{}.pending-snap", + filename + .file_name() + .expect("no filename") + .to_str() + .expect("non unicode filename") + )); + ( + None, + None, + Some(Snapshot::from_components( + module_name.to_string(), + None, + MetaData { + created, + ..MetaData::default() + }, + contents.to_string(), + )), + Some(filename), + ) + } + }; // if the snapshot matches we're done. - if old.as_ref().map_or(false, |x| x.snapshot == new_snapshot) { + if old.as_ref().map_or(false, |x| x.contents() == new_snapshot) { return Ok(()); } - let mut metadata = BTreeMap::new(); - metadata.insert("created".to_string(), Utc::now().to_rfc3339()); - metadata.insert( - "creator".to_string(), - format!("{}@{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")), + let new = Snapshot::from_components( + module_name.to_string(), + snapshot_name.map(|x| x.to_string()), + MetaData { + created: Some(Utc::now()), + creator: Some(format!( + "{}@{}", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION") + )), + source: Some(path_to_storage(file)), + expression: Some(expr.to_string()), + }, + new_snapshot.to_string(), ); - metadata.insert("source".to_string(), path_to_storage(file)); - metadata.insert("expression".to_string(), expr.to_string()); - let new = Snapshot { - path: snapshot_file.to_path_buf(), - metadata, - snapshot: new_snapshot.to_string(), - }; - print_snapshot_diff(cargo_workspace, name, old.as_ref(), &new); + print_snapshot_diff_with_title( + cargo_workspace, + &new, + old.as_ref(), + line, + snapshot_file.as_ref().map(|x| x.as_path()), + ); println!( "{hint}", - hint = style("To update snapshots re-run the tests with INSTA_UPDATE=yes or use `cargo insta review`").dim(), + hint = style("To update snapshots run `cargo insta review`").dim(), ); match update_snapshot_behavior() { UpdateBehavior::InPlace => { - new.save()?; - eprintln!( - " {} {}\n", - style("updated snapshot").green(), - style(snapshot_file.display()).cyan().underlined(), - ); - return Ok(()); + if let Some(ref snapshot_file) = snapshot_file { + new.save(snapshot_file)?; + eprintln!( + " {} {}\n", + style("updated snapshot").green(), + style(snapshot_file.display()).cyan().underlined(), + ); + return Ok(()); + } else { + eprintln!( + " {}", + style("error: cannot update inline snapshots in-place") + .red() + .bold(), + ); + } } UpdateBehavior::NewFile => { - let new_path = new.save_new()?; - eprintln!( - " {} {}\n", - style("stored new snapshot").green(), - style(new_path.display()).cyan().underlined(), - ); + if let Some(ref snapshot_file) = snapshot_file { + let mut new_path = snapshot_file.to_path_buf(); + new_path.set_extension("snap.new"); + new.save(&new_path)?; + eprintln!( + " {} {}\n", + style("stored new snapshot").green(), + style(new_path.display()).cyan().underlined(), + ); + } else { + PendingInlineSnapshot::new(new, old, line).save(pending_snapshots.unwrap())?; + } } UpdateBehavior::NoUpdate => {} } @@ -488,7 +457,8 @@ pub fn assert_snapshot( assert!( false, "snapshot assertion for '{}' failed in line {}", - name, line + snapshot_name.unwrap_or("inline snapshot"), + line ); } diff --git a/src/snapshot.rs b/src/snapshot.rs new file mode 100644 index 00000000..6ec9a915 --- /dev/null +++ b/src/snapshot.rs @@ -0,0 +1,242 @@ +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +use chrono::{DateTime, Utc}; +use failure::Error; +use lazy_static::lazy_static; +use serde::{Deserialize, Serialize}; +use serde_json; + +lazy_static! { + static ref RUN_ID: Uuid = Uuid::new_v4(); +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PendingInlineSnapshot { + pub run_id: Uuid, + pub line: u32, + pub new: Snapshot, + pub old: Option, +} + +impl PendingInlineSnapshot { + pub fn new(new: Snapshot, old: Option, line: u32) -> PendingInlineSnapshot { + PendingInlineSnapshot { + new, + old, + line, + run_id: *RUN_ID, + } + } + + pub fn load_batch>(p: P) -> Result, Error> { + let f = BufReader::new(fs::File::open(p)?); + let iter = serde_json::Deserializer::from_reader(f).into_iter::(); + let mut rv = iter.collect::, _>>()?; + + // remove all but the last run + if let Some(last_run_id) = rv.last().map(|x| x.run_id) { + rv.retain(|x| x.run_id == last_run_id); + } + + Ok(rv) + } + + pub fn save_batch>(p: P, batch: &[PendingInlineSnapshot]) -> Result<(), Error> { + fs::remove_file(&p).ok(); + for snap in batch { + snap.save(&p)?; + } + Ok(()) + } + + pub fn save>(&self, p: P) -> Result<(), Error> { + let mut f = fs::OpenOptions::new().create(true).append(true).open(p)?; + let mut s = serde_json::to_string(self)?; + s.push('\n'); + f.write_all(s.as_bytes())?; + Ok(()) + } +} + +/// Snapshot metadata information. +#[derive(Debug, Default, Serialize, Deserialize, Clone)] +pub struct MetaData { + /// The timestamp of when the snapshot was created. + #[serde(skip_serializing_if = "Option::is_none")] + pub created: Option>, + /// The creator of the snapshot. + #[serde(skip_serializing_if = "Option::is_none")] + pub creator: Option, + /// The source file (relative to workspace root). + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + /// Optionally the expression that created the snapshot. + #[serde(skip_serializing_if = "Option::is_none")] + pub expression: Option, +} + +impl MetaData { + pub fn get_relative_source(&self, base: &Path) -> Option { + self.source.as_ref().map(|source| { + base.join(source) + .canonicalize() + .ok() + .and_then(|s| s.strip_prefix(base).ok().map(|x| x.to_path_buf())) + .unwrap_or_else(|| base.to_path_buf()) + }) + } +} + +/// A helper to work with stored snapshots. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Snapshot { + module_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + snapshot_name: Option, + metadata: MetaData, + snapshot: String, +} + +impl Snapshot { + /// Loads a snapshot from a file. + pub fn from_file>(p: P) -> Result { + let mut f = BufReader::new(fs::File::open(p.as_ref())?); + let mut buf = String::new(); + + f.read_line(&mut buf)?; + + // yaml format + let metadata = if buf.trim_end() == "---" { + loop { + let read = f.read_line(&mut buf)?; + if read == 0 { + break; + } + if buf[buf.len() - read..].trim_end() == "---" { + buf.truncate(buf.len() - read); + break; + } + } + serde_yaml::from_str(&buf)? + // legacy format + } else { + let mut rv = MetaData::default(); + loop { + buf.clear(); + let read = f.read_line(&mut buf)?; + if read == 0 || buf.trim_end().is_empty() { + buf.truncate(buf.len() - read); + break; + } + let mut iter = buf.splitn(2, ':'); + if let Some(key) = iter.next() { + if let Some(value) = iter.next() { + let value = value.trim(); + match key.to_lowercase().as_str() { + "created" => { + rv.created = + Some(DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc)) + } + "creator" => rv.creator = Some(value.to_string()), + "expression" => rv.expression = Some(value.to_string()), + "source" => rv.source = Some(value.into()), + _ => {} + } + } + } + } + rv + }; + + buf.clear(); + for (idx, line) in f.lines().enumerate() { + let line = line?; + if idx > 0 { + buf.push('\n'); + } + buf.push_str(&line); + } + + let module_name = p + .as_ref() + .file_name() + .unwrap() + .to_str() + .unwrap_or("") + .split("__") + .next() + .unwrap_or("") + .to_string(); + + let snapshot_name = p + .as_ref() + .file_name() + .unwrap() + .to_str() + .unwrap_or("") + .split('.') + .next() + .unwrap_or("") + .splitn(2, "__") + .nth(1) + .map(|x| x.to_string()); + + Ok(Snapshot::from_components( + module_name, + snapshot_name, + metadata, + buf, + )) + } + + /// Creates an empty snapshot. + pub(crate) fn from_components( + module_name: String, + snapshot_name: Option, + metadata: MetaData, + snapshot: String, + ) -> Snapshot { + Snapshot { + module_name, + snapshot_name, + metadata, + snapshot, + } + } + + /// Returns the module name. + pub fn module_name(&self) -> &str { + &self.module_name + } + + /// Returns the snapshot name. + pub fn snapshot_name(&self) -> Option<&str> { + self.snapshot_name.as_ref().map(|x| x.as_str()) + } + + /// The metadata in the snapshot. + pub fn metadata(&self) -> &MetaData { + &self.metadata + } + + /// The snapshot contents + pub fn contents(&self) -> &str { + &self.snapshot + } + + pub(crate) fn save>(&self, path: P) -> Result<(), Error> { + let path = path.as_ref(); + if let Some(folder) = path.parent() { + fs::create_dir_all(&folder)?; + } + let mut f = fs::File::create(&path)?; + serde_yaml::to_writer(&mut f, &self.metadata)?; + f.write_all(b"\n---\n")?; + f.write_all(self.snapshot.as_bytes())?; + f.write_all(b"\n")?; + Ok(()) + } +} diff --git a/tests/snapshots/test_basic__json_json.snap b/tests/snapshots/test_basic__json_json.snap new file mode 100644 index 00000000..58593dd7 --- /dev/null +++ b/tests/snapshots/test_basic__json_json.snap @@ -0,0 +1,11 @@ +--- +created: "2019-01-29T23:47:38.350858Z" +creator: insta@0.6.0 +source: tests/test_basic.rs +expression: "vec![1 , 2 , 3]" +--- +[ + 1, + 2, + 3 +] diff --git a/tests/snapshots/test_basic__json_vector.snap b/tests/snapshots/test_basic__json_vector.snap new file mode 100644 index 00000000..107d3346 --- /dev/null +++ b/tests/snapshots/test_basic__json_vector.snap @@ -0,0 +1,11 @@ +--- +created: "2019-01-29T23:35:18.809083Z" +creator: insta@0.6.0 +source: tests/test_basic.rs +expression: "vec![1 , 2 , 3]" +--- +[ + 1, + 2, + 3 +] diff --git a/tests/snapshots/test_basic__yaml_vector.snap b/tests/snapshots/test_basic__yaml_vector.snap new file mode 100644 index 00000000..bd018900 --- /dev/null +++ b/tests/snapshots/test_basic__yaml_vector.snap @@ -0,0 +1,9 @@ +--- +created: "2019-01-29T23:40:11.452071Z" +creator: insta@0.6.0 +source: tests/test_basic.rs +expression: "vec![1 , 2 , 3]" +--- +- 1 +- 2 +- 3 diff --git a/tests/test_basic.rs b/tests/test_basic.rs index 52ef39c1..013c9248 100644 --- a/tests/test_basic.rs +++ b/tests/test_basic.rs @@ -1,6 +1,9 @@ extern crate insta; -use insta::{assert_debug_snapshot_matches, assert_serialized_snapshot_matches}; +use insta::{ + assert_debug_snapshot_matches, assert_json_snapshot_matches, + assert_serialized_snapshot_matches, assert_yaml_snapshot_matches, +}; #[test] fn test_vector() { @@ -11,3 +14,13 @@ fn test_vector() { fn test_serialized_vector() { assert_serialized_snapshot_matches!("serialized_vector", vec![1, 2, 3]); } + +#[test] +fn test_yaml_vector() { + assert_yaml_snapshot_matches!("yaml_vector", vec![1, 2, 3]); +} + +#[test] +fn test_json_vector() { + assert_json_snapshot_matches!("json_json", vec![1, 2, 3]); +} diff --git a/tests/test_inline.rs b/tests/test_inline.rs new file mode 100644 index 00000000..442471bc --- /dev/null +++ b/tests/test_inline.rs @@ -0,0 +1,91 @@ +use insta::{ + assert_debug_snapshot_matches, assert_json_snapshot_matches, assert_ron_snapshot_matches, + assert_serialized_snapshot_matches, assert_snapshot_matches, assert_yaml_snapshot_matches, +}; +use serde::Serialize; + +#[test] +fn test_simple() { + assert_debug_snapshot_matches!(vec![1, 2, 3, 4], @r###"[ + 1, + 2, + 3, + 4 +]"###); +} + +#[test] +fn test_single_line() { + assert_snapshot_matches!("Testing", @"Testing"); +} + +#[test] +fn test_ron_inline() { + #[derive(Serialize)] + pub struct Email(String); + + #[derive(Serialize)] + pub struct User { + id: u32, + username: String, + email: Email, + } + + assert_ron_snapshot_matches!(User { + id: 42, + username: "peter-doe".into(), + email: Email("peter@doe.invalid".into()), + }, @r###"User( + id: 42, + username: "peter-doe", + email: Email("peter@doe.invalid"), +)"###); +} + +#[test] +fn test_json_inline() { + assert_json_snapshot_matches!(vec!["foo", "bar"], @r###"[ + "foo", + "bar" +]"###); +} + +#[test] +fn test_serialize_inline_redacted() { + #[derive(Serialize)] + pub struct User { + id: u32, + username: String, + email: String, + } + + assert_serialized_snapshot_matches!(User { + id: 42, + username: "peter-pan".into(), + email: "peterpan@wonderland.invalid".into() + }, { + ".id" => "[user-id]" + }, @r###"id: "[user-id]" +username: peter-pan +email: peterpan@wonderland.invalid"###); +} + +#[test] +fn test_yaml_inline_redacted() { + #[derive(Serialize)] + pub struct User { + id: u32, + username: String, + email: String, + } + + assert_yaml_snapshot_matches!(User { + id: 42, + username: "peter-pan".into(), + email: "peterpan@wonderland.invalid".into() + }, { + ".id" => "[user-id]" + }, @r###"id: "[user-id]" +username: peter-pan +email: peterpan@wonderland.invalid"###); +}