Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cargo-insta/tests/functional/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ use tempfile::TempDir;
mod binary;
mod delete_pending;
mod inline;
mod test_workspace_source_path;
mod unreferenced;
mod workspace;

Expand Down
212 changes: 212 additions & 0 deletions cargo-insta/tests/functional/test_workspace_source_path.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
use crate::TestFiles;
use std::fs;

/// Test for issue #777: Insta "source" in snapshot is full absolute path when workspace is not parent
#[test]
fn test_workspace_source_path_issue_777() {
// Create a workspace structure where project is not a child of workspace
// This reproduces the exact issue from #777
let test_project = TestFiles::new()
.add_file(
"workspace/Cargo.toml",
r#"
[workspace]
resolver = "2"
members = ["../project1"]
"#
.to_string(),
)
.add_file(
"project1/Cargo.toml",
r#"
[package]
name = "project1"
version = "0.1.0"
edition = "2021"

workspace = "../workspace"

[dependencies]
insta = { path = '$PROJECT_PATH', features = ["yaml"] }
"#
.to_string(),
)
.add_file(
"project1/src/lib.rs",
r#"
#[test]
fn test_something() {
insta::assert_yaml_snapshot!(vec![1, 2, 3]);
}
"#
.to_string(),
)
.create_project();

// Run test to create snapshot from within project1 directory
// This should trigger the issue where source path becomes absolute
let output = test_project
.insta_cmd()
.current_dir(test_project.workspace_dir.join("project1"))
// Set workspace root to the actual workspace directory
.env(
"INSTA_WORKSPACE_ROOT",
test_project.workspace_dir.join("workspace"),
)
.args(["test", "--accept"])
.output()
.unwrap();

assert!(output.status.success());

// Read the generated snapshot
let snapshot_path = test_project
.workspace_dir
.join("project1/src/snapshots/project1__something.snap");

let snapshot_content = fs::read_to_string(&snapshot_path).unwrap();

// Parse the snapshot to check the source field
let source_line = snapshot_content
.lines()
.find(|line| line.starts_with("source:"))
.expect("source line not found");

let source_path = source_line
.strip_prefix("source: ")
.expect("invalid source line")
.trim()
.trim_matches('"');

// The source path should be relative and start with ../ (since workspace and project are siblings)
assert!(
source_path.starts_with("../"),
"Source path should be relative starting with '../', but got: {}",
source_path
);

// The path should be exactly ../project1/src/lib.rs
assert_eq!(
source_path, "../project1/src/lib.rs",
"Expected simplified relative path"
);
}

/// Test that the fix works with a more complex workspace structure
#[test]
fn test_workspace_source_path_complex() {
// Create a complex workspace structure
let test_project = TestFiles::new()
.add_file(
"code/workspace/Cargo.toml",
r#"
[workspace]
resolver = "2"
members = ["../../projects/app1", "../../projects/app2"]
"#
.to_string(),
)
.add_file(
"projects/app1/Cargo.toml",
r#"
[package]
name = "app1"
version = "0.1.0"
edition = "2021"

workspace = "../../code/workspace"

[dependencies]
insta = { path = '$PROJECT_PATH', features = ["yaml"] }
"#
.to_string(),
)
.add_file(
"projects/app1/src/lib.rs",
r#"
#[test]
fn test_app1() {
insta::assert_yaml_snapshot!(vec!["app1"]);
}
"#
.to_string(),
)
.add_file(
"projects/app2/Cargo.toml",
r#"
[package]
name = "app2"
version = "0.1.0"
edition = "2021"

workspace = "../../code/workspace"

[dependencies]
insta = { path = '$PROJECT_PATH', features = ["yaml"] }
"#
.to_string(),
)
.add_file(
"projects/app2/src/lib.rs",
r#"
#[test]
fn test_app2() {
insta::assert_yaml_snapshot!(vec!["app2"]);
}
"#
.to_string(),
)
.create_project();

// Run tests for both projects
let output1 = test_project
.insta_cmd()
.current_dir(test_project.workspace_dir.join("projects/app1"))
.args(["test", "--accept"])
.output()
.unwrap();

assert!(output1.status.success());

let output2 = test_project
.insta_cmd()
.current_dir(test_project.workspace_dir.join("projects/app2"))
.args(["test", "--accept"])
.output()
.unwrap();

assert!(output2.status.success());

// Check both snapshots
let snapshot1_path = test_project
.workspace_dir
.join("projects/app1/src/snapshots/app1__app1.snap");
let snapshot1_content = fs::read_to_string(&snapshot1_path).unwrap();

let snapshot2_path = test_project
.workspace_dir
.join("projects/app2/src/snapshots/app2__app2.snap");
let snapshot2_content = fs::read_to_string(&snapshot2_path).unwrap();

// Neither snapshot should contain absolute paths
assert!(
!snapshot1_content.contains(&test_project.workspace_dir.to_string_lossy().to_string()),
"App1 snapshot contains absolute path"
);
assert!(
!snapshot2_content.contains(&test_project.workspace_dir.to_string_lossy().to_string()),
"App2 snapshot contains absolute path"
);

// Both should have relative paths
assert!(
snapshot1_content.contains("source: \"../../projects/app1/src/lib.rs\""),
"App1 snapshot source is not the expected relative path. Got:\n{}",
snapshot1_content
);
assert!(
snapshot2_content.contains("source: \"../../projects/app2/src/lib.rs\""),
"App2 snapshot source is not the expected relative path. Got:\n{}",
snapshot2_content
);
}
70 changes: 69 additions & 1 deletion insta/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,27 @@ impl<'a> SnapshotAssertionContext<'a> {
self.module_path.replace("::", "__"),
self.snapshot_name.as_ref().map(|x| x.to_string()),
Settings::with(|settings| MetaData {
source: Some(path_to_storage(Path::new(self.assertion_file))),
source: {
let source_path = Path::new(self.assertion_file);
// We need to compute a relative path from the workspace to the source file.
// This is necessary for workspace setups where the project is not a direct
// child of the workspace root (e.g., when workspace and project are siblings).
// We canonicalize paths first to properly handle symlinks.
let canonicalized_base = self.workspace.canonicalize().ok();
let canonicalized_path = source_path.canonicalize().ok();

let relative = if let (Some(base), Some(path)) =
(canonicalized_base, canonicalized_path)
{
path_relative_from(&path, &base)
.unwrap_or_else(|| source_path.to_path_buf())
} else {
// If canonicalization fails, try with original paths
path_relative_from(source_path, self.workspace)
.unwrap_or_else(|| source_path.to_path_buf())
};
Some(path_to_storage(&relative))
},
assertion_line: Some(self.assertion_line),
description: settings.description().map(Into::into),
expression: if settings.omit_expression() {
Expand Down Expand Up @@ -685,6 +705,54 @@ impl<'a> SnapshotAssertionContext<'a> {
}
}

/// Computes a relative path from `base` to `path`, returning a path with `../` components
/// if necessary.
///
/// This function is vendored from the old Rust standard library implementation
/// (pre-1.0, removed in RFC 474) and is distributed under the same terms as the
/// Rust project (MIT/Apache-2.0 dual license).
///
/// Unlike `Path::strip_prefix`, this function can handle cases where `path` is not
/// a descendant of `base`, making it suitable for finding relative paths between
/// arbitrary directories (e.g., between sibling directories in a workspace).
fn path_relative_from(path: &Path, base: &Path) -> Option<PathBuf> {
use std::path::Component;

if path.is_absolute() != base.is_absolute() {
if path.is_absolute() {
Some(PathBuf::from(path))
} else {
None
}
} else {
let mut ita = path.components();
let mut itb = base.components();
let mut comps: Vec<Component> = vec![];
loop {
match (ita.next(), itb.next()) {
(None, None) => break,
(Some(a), None) => {
comps.push(a);
comps.extend(ita.by_ref());
break;
}
(None, _) => comps.push(Component::ParentDir),
(Some(a), Some(b)) if comps.is_empty() && a == b => {}
(Some(a), Some(_b)) => {
comps.push(Component::ParentDir);
for _ in itb {
comps.push(Component::ParentDir);
}
comps.push(a);
comps.extend(ita.by_ref());
break;
}
}
}
Some(comps.iter().map(|c| c.as_os_str()).collect())
}
}

fn prevent_inline_duplicate(function_name: &str, assertion_file: &str, assertion_line: u32) {
let key = format!("{}|{}|{}", function_name, assertion_file, assertion_line);
let mut set = INLINE_DUPLICATES.lock().unwrap();
Expand Down