The test suite for weaveback-tangle lives in src/tests/ and is compiled
only under #[cfg(test)]. It is structured as five modules:
-
common— sharedTestSetuphelper and string constants for test fixtures. -
utils— low-level helper functions (create_test_writer,write_file). -
basic— core chunk parsing, expansion, indentation, and delimiter tests. -
advanced—@replace,@reversed, error handling, and recursion tests. -
safe_writer—SafeFileWriterbehaviour: write flow, baselines, formatter hooks, modification detection, and path security.
|
Note
|
The test fixture strings in |
Test infrastructure
TestSetup::new(comment_markers) creates a temporary directory, puts a gen/
subdirectory in it, and wires up a Clip with << / >> delimiters and
@ chunk-end — the classic noweb defaults that the tests exercise.
create_test_writer() and write_file() provide lower-level access to
SafeFileWriter for the safe-writer tests.
// <[@file weaveback-tangle/src/tests/mod.rs]>=
mod advanced;
mod basic;
mod common;
mod safe_writer;
mod utils;
pub(crate) use common::*;
pub(crate) use utils::*;
// @@
// <[@file weaveback-tangle/src/tests/common.rs]>=
// src/tests/common.rs
use crate::*;
use std::fs;
use tempfile::TempDir;
pub(crate) struct TestSetup {
pub _temp_dir: TempDir,
pub clip: Clip,
}
impl TestSetup {
pub fn new(comment_markers: &[&str]) -> Self {
let temp_dir = TempDir::new().unwrap();
let gen_path = temp_dir.path().join("gen");
fs::create_dir_all(&gen_path).unwrap();
let safe_writer = SafeFileWriter::new(gen_path).unwrap();
let comment_markers = comment_markers
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>();
let clip = Clip::new(safe_writer, "<<", ">>", "@", &comment_markers);
TestSetup {
_temp_dir: temp_dir,
clip,
}
}
}
pub(crate) const BASIC_CHUNK: &str = r#"
# <<test>>=
Hello
# @
"#;
pub(crate) const TWO_CHUNKS: &str = r#"
# <<chunk1>>=
First chunk
# @
# <<chunk2>>=
Second chunk
# @
"#;
pub(crate) const NESTED_CHUNKS: &str = r#"
# <<outer>>=
Before
# <<inner>>
After
# @
# <<inner>>=
Nested content
# @
"#;
pub(crate) const INDENTED_CHUNK: &str = r#"
# <<main>>=
# <<indented>>
# @
# <<indented>>=
some code
# @
"#;
pub(crate) const PYTHON_CODE: &str = r#"
# <<code>>=
def example():
# <<body>>
# @
# <<body>>=
print('hello')
# @
"#;
pub(crate) const MULTI_COMMENT_CHUNKS: &str = r#"
# <<python_chunk>>=
def hello():
print("Hello")
# @
// <<rust_chunk>>=
fn main() {
println!("Hello");
}
// @
"#;
pub(crate) const FILE_CHUNKS: &str = r#"
# <<@file output.txt>>=
content
# @
# <<other>>=
other content
# @
"#;
pub(crate) const SEQUENTIAL_CHUNKS: &str = r#"
# <<main>>=
# <<part1>>
# <<part2>>
# @
# <<part1>>=
First part
# @
# <<part2>>=
Second part
# @
"#;
pub(crate) const EMPTY_CHUNK: &str = r#"
# <<empty>>=
# @
"#;
// @@
// <[@file weaveback-tangle/src/tests/utils.rs]>=
// src/tests/utils.rs
use crate::safe_writer::SafeWriterConfig;
use crate::{WeavebackError, SafeFileWriter};
use std::{fs, io::Write, path::PathBuf};
use tempfile::TempDir;
pub(crate) fn create_test_writer() -> (TempDir, SafeFileWriter) {
let temp = TempDir::new().unwrap();
let writer = SafeFileWriter::with_config(
temp.path().join("gen"),
SafeWriterConfig::default(),
).unwrap();
(temp, writer)
}
pub(crate) fn write_file(
writer: &mut SafeFileWriter,
path: &PathBuf,
content: &str,
) -> Result<(), WeavebackError> {
let private_path = writer.before_write(path)?;
{
let mut file = fs::File::create(&private_path)?;
write!(file, "{}", content)?;
}
writer.after_write(path)?;
Ok(())
}
// @@
// <[@file weaveback-tangle/src/tests/basic.rs]>=
// src/tests/basic.rs
use super::*;
use crate::ChunkError;
#[test]
fn test_basic_chunk() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(BASIC_CHUNK, "test_basic.nw");
assert!(setup.clip.has_chunk("test"));
assert_eq!(
setup.clip.get_chunk_content("test").unwrap(),
vec!["Hello\n"]
);
}
#[test]
fn test_multiple_chunks() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(TWO_CHUNKS, "test_multiple.nw");
assert!(setup.clip.has_chunk("chunk1"));
assert!(setup.clip.has_chunk("chunk2"));
assert_eq!(
setup.clip.get_chunk_content("chunk1").unwrap(),
vec!["First chunk\n"]
);
assert_eq!(
setup.clip.get_chunk_content("chunk2").unwrap(),
vec!["Second chunk\n"]
);
}
#[test]
fn test_nested_chunk_expansion() -> Result<(), ChunkError> {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(NESTED_CHUNKS, "test_nested.nw");
let expanded = setup.clip.expand("outer", "")?;
let expected = vec!["Before\n", "Nested content\n", "After\n"];
assert_eq!(expanded, expected, "Nested chunks should expand correctly");
Ok(())
}
#[test]
fn test_indentation_preservation() -> Result<(), ChunkError> {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(INDENTED_CHUNK, "test_indent.nw");
let expanded = setup.clip.expand("main", "")?;
assert_eq!(
expanded,
vec![" some code\n"],
"Indentation should be preserved"
);
Ok(())
}
#[test]
fn test_complex_indentation() -> Result<(), ChunkError> {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(PYTHON_CODE, "test_python.nw");
let expanded = setup.clip.expand("code", "")?;
let expected = vec!["def example():\n", " print('hello')\n"];
assert_eq!(expanded, expected);
let expanded_indented = setup.clip.expand("code", " ")?;
let expected_indented = vec![" def example():\n", " print('hello')\n"];
assert_eq!(expanded_indented, expected_indented);
Ok(())
}
#[test]
fn test_multi_comment_styles() {
let mut setup = TestSetup::new(&["#", "//"]);
setup.clip.read(MULTI_COMMENT_CHUNKS, "test_comments.nw");
assert!(setup.clip.has_chunk("python_chunk"));
assert!(setup.clip.has_chunk("rust_chunk"));
let python_content = setup.clip.get_chunk_content("python_chunk").unwrap();
assert!(python_content.join("").contains("print(\"Hello\")"));
let rust_content = setup.clip.get_chunk_content("rust_chunk").unwrap();
assert!(rust_content.join("").contains("println!(\"Hello\")"));
}
#[test]
fn test_sequential_chunks() -> Result<(), ChunkError> {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(SEQUENTIAL_CHUNKS, "test_sequential.nw");
let expanded = setup.clip.expand("main", "")?;
assert_eq!(expanded, vec!["First part\n", "Second part\n"]);
Ok(())
}
#[test]
fn test_empty_chunk() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(EMPTY_CHUNK, "test_empty.nw");
assert!(setup.clip.has_chunk("empty"));
assert!(
setup.clip.get_chunk_content("empty").unwrap().is_empty(),
"empty chunk should have no content"
);
}
// @@
// <[@file weaveback-tangle/src/tests/advanced.rs]>=
// src/tests/advanced.rs
use super::*;
use crate::{Clip, SafeFileWriter, WeavebackError};
use crate::ChunkError;
use std::fs;
/// Bug fix: duplicate @file chunk without @replace used to silently discard
/// both definitions. Now it reports an error and keeps the first definition.
#[test]
fn test_duplicate_file_chunk_keeps_first_definition() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(
r#"
# <<@file out.txt>>=
first definition
# @
# <<@file out.txt>>=
second definition
# @
"#,
"duplicate.nw",
);
// The first definition must survive.
assert!(
setup.clip.has_chunk("@file out.txt"),
"first definition should be kept"
);
let content = setup.clip.get_chunk_content("@file out.txt").unwrap();
assert!(
content.iter().any(|l| l.contains("first definition")),
"first definition content should be preserved, got: {:?}",
content
);
assert!(
!content.iter().any(|l| l.contains("second definition")),
"second definition should be rejected"
);
}
#[test]
fn test_file_chunk_detection() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(FILE_CHUNKS, "test_files.nw");
let file_chunks = setup.clip.get_file_chunks();
assert_eq!(file_chunks.len(), 1);
assert!(file_chunks.contains(&"@file output.txt".to_string()));
}
#[test]
fn test_undefined_chunk_is_error() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(
r#"
# <<main>>=
# <<nonexistent>>
# @
"#,
"undefined.nw",
);
setup.clip.set_strict_undefined(true);
let result = setup.clip.expand("main", "");
assert!(result.is_err(), "referencing an undefined chunk must be an error");
let err = result.unwrap_err();
assert!(
matches!(err, WeavebackError::Chunk(ChunkError::UndefinedChunk { ref chunk, .. }) if chunk == "nonexistent"),
"expected UndefinedChunk error, got: {err}",
);
}
#[test]
fn test_undefined_chunk_is_empty_when_not_strict() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(
r#"
# <<main>>=
line before
# <<optional>>
line after
# @
"#,
"undefined.nw",
);
// Default is permissive; no set_strict_undefined call needed.
let result = setup.clip.expand("main", "");
assert!(result.is_ok(), "undefined chunk should expand to empty when not strict");
let lines = result.unwrap();
assert_eq!(lines, vec!["line before\n", "line after\n"]);
}
#[test]
fn test_recursive_chunk_error() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(
r#"
# <<recursive>>=
Start
# <<recursive>>
End
# @
"#,
"recursive.nw",
);
let result = setup.clip.expand("recursive", "");
match result {
Err(WeavebackError::Chunk(ChunkError::RecursiveReference {
chunk,
cycle,
file_name,
location,
})) => {
assert_eq!(chunk, "recursive");
assert_eq!(file_name, "recursive.nw");
assert_eq!(location.line, 2);
assert_eq!(cycle, vec!["recursive", "recursive"]);
}
_ => panic!("Expected RecursiveReference error"),
}
}
#[test]
fn test_mutual_recursion_error() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(
r#"
# <<chunk-a>>=
Start A
# <<chunk-b>>
End A
# @
# <<chunk-b>>=
Middle B
# <<chunk-a>>
End B
# @
"#,
"mutual_recursion.nw",
);
let result = setup.clip.expand("chunk-a", "");
match result {
Err(WeavebackError::Chunk(ChunkError::RecursiveReference {
chunk,
cycle,
file_name,
location,
})) => {
assert_eq!(chunk, "chunk-a");
assert_eq!(file_name, "mutual_recursion.nw");
assert_eq!(location.line, 8);
assert_eq!(cycle, vec!["chunk-a", "chunk-b", "chunk-a"]);
}
_ => panic!("Expected RecursiveReference error"),
}
}
#[test]
fn test_max_recursion_depth() {
let mut setup = TestSetup::new(&["#"]);
let mut content = String::from(
r#"
# <<a-000>>=
# <<a-001>>
# @"#,
);
let chain_length = 150; // More than MAX_DEPTH = 100
for i in 1..chain_length {
content.push_str(&format!(
r#"
# <<a-{:03}>>=
# <<a-{:03}>>
# @"#,
i, // a-001, a-002, etc.
i + 1 // a-002, a-003, etc.
));
}
setup.clip.read(&content, "max_recursion.nw");
let result = setup.clip.expand("a-000", "");
// We just match the variant here (less strict). Alternatively, pattern match with { chunk, file_name, location }
assert!(
matches!(
result,
Err(WeavebackError::Chunk(ChunkError::RecursionLimit { .. }))
),
"Expected RecursionLimit error"
);
}
#[test]
fn test_error_messages_format() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(
r#"
# <<a>>=
# <<a>>
# @
"#,
"errors.nw",
);
let err = setup.clip.expand("a", "").unwrap_err();
let error_msg = err.to_string();
assert!(error_msg.contains("Chunk error: errors.nw line 2:"));
assert!(error_msg.contains("recursive reference detected in chunk 'a'"));
assert!(error_msg.contains("cycle: a -> a"));
}
#[test]
fn test_dangerous_comment_markers() {
let markers = &[
"#", // normal case
r".*", // regex wildcard
r"[a-z]+", // regex character class
r"\d+", // regex digit
"<<", // same as delimiter
">>", // same as delimiter
"(comment)", // regex group
];
let content = r#"
#<<test1>>=
Content1
@
.*<<test2>>=
Content2
@
[a-z]+<<test3>>=
Content3
@
(comment)<<test4>>=
Content4
@
"#;
let mut setup = TestSetup::new(markers);
setup.clip.read(content, "regex_test.nw");
assert!(setup.clip.has_chunk("test1"), "Basic marker # failed");
assert!(setup.clip.has_chunk("test2"), "Wildcard marker .* failed");
assert!(
setup.clip.has_chunk("test3"),
"Character class marker [a-z]+ failed"
);
assert!(
setup.clip.has_chunk("test4"),
"Group marker (comment) failed"
);
assert_eq!(
setup.clip.get_chunk_content("test1").unwrap(),
vec!["Content1\n"]
);
}
// ── @replace ─────────────────────────────────────────────────────────────────
/// `@replace` on a regular chunk discards all prior definitions and installs
/// only the new one.
#[test]
fn test_replace_normal_chunk() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(
r#"
# <<greet>>=
Hello
# @
# <<@replace greet>>=
Hi
# @
"#,
"replace_normal.nw",
);
let content = setup.clip.get_chunk_content("greet").unwrap();
assert_eq!(
content,
vec!["Hi\n"],
"only the @replace definition should survive"
);
}
/// `@replace` on a file chunk replaces the earlier definition.
#[test]
fn test_replace_file_chunk() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(
r#"
# <<@file out.txt>>=
old content
# @
# <<@replace @file out.txt>>=
new content
# @
"#,
"replace_file.nw",
);
let content = setup.clip.get_chunk_content("@file out.txt").unwrap();
assert_eq!(
content,
vec!["new content\n"],
"@replace should replace file chunk"
);
}
/// Without `@replace`, a second file-chunk definition is an error and the first
/// definition is kept (regression guard).
#[test]
fn test_no_replace_file_chunk_keeps_first() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(
r#"
# <<@file out.txt>>=
first
# @
# <<@file out.txt>>=
second
# @
"#,
"no_replace_file.nw",
);
let content = setup.clip.get_chunk_content("@file out.txt").unwrap();
assert!(
content.iter().any(|l| l.contains("first")),
"first definition should be kept without @replace"
);
assert!(
!content.iter().any(|l| l.contains("second")),
"second definition should be rejected without @replace"
);
}
// ── @reversed ────────────────────────────────────────────────────────────────
/// A regular chunk may accumulate multiple definitions (without @replace).
/// A plain reference expands them in definition order (first → last).
#[test]
fn test_accumulated_chunks_normal_order() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(
r#"
# <<items>>=
alpha
# @
# <<items>>=
beta
# @
# <<items>>=
gamma
# @
# <<list>>=
# <<items>>
# @
"#,
"normal_order.nw",
);
let expanded = setup.clip.expand("list", "").unwrap();
assert_eq!(expanded, vec!["alpha\n", "beta\n", "gamma\n"]);
}
/// `@reversed` on a reference expands the chunk's accumulated definitions in
/// reverse order (last-defined first).
#[test]
fn test_reversed_reference() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(
r#"
# <<items>>=
alpha
# @
# <<items>>=
beta
# @
# <<items>>=
gamma
# @
# <<list>>=
# <<@reversed items>>
# @
"#,
"reversed.nw",
);
let expanded = setup.clip.expand("list", "").unwrap();
assert_eq!(expanded, vec!["gamma\n", "beta\n", "alpha\n"]);
}
/// `~` in an `@file` path expands to `$HOME` when `--allow-home` is set.
#[test]
fn test_tilde_expansion_in_file_chunk() {
let fake_home = tempfile::TempDir::new().unwrap();
// Override HOME for this test
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::set_var("HOME", fake_home.path()) };
// Tilde expansion writes outside gen/ and requires allow_home: true.
let temp_dir = tempfile::TempDir::new().unwrap();
let gen_path = temp_dir.path().join("gen");
fs::create_dir_all(&gen_path).unwrap();
let safe_writer = SafeFileWriter::with_config(
gen_path,
crate::safe_writer::SafeWriterConfig {
allow_home: true,
..crate::safe_writer::SafeWriterConfig::default()
},
).unwrap();
let mut clip = Clip::new(safe_writer, "<<", ">>", "@", &["#".to_string()]);
clip.read(
"# <<@file ~/tilde_test.txt>>=\nhello tilde\n# @\n",
"tilde.nw",
);
clip.write_files().unwrap();
let expected = fake_home.path().join("tilde_test.txt");
assert!(
expected.exists(),
"file should be written to expanded ~ path"
);
let content = fs::read_to_string(&expected).unwrap();
assert_eq!(content, "hello tilde\n");
}
/// Without `--allow-home`, `@file ~/…` is refused rather than silently
/// escaping the gen/ sandbox.
#[test]
fn test_tilde_expansion_blocked_without_allow_home() {
let mut setup = TestSetup::new(&["#"]);
setup.clip.read(
"# <<@file ~/should_not_exist.txt>>=\ndata\n# @\n",
"tilde_blocked.nw",
);
let result = setup.clip.write_files();
assert!(
matches!(
result,
Err(WeavebackError::SafeWriter(
crate::safe_writer::SafeWriterError::SecurityViolation(_)
))
),
"expected SecurityViolation without --allow-home, got: {:?}",
result
);
}
// @@
// <[@file weaveback-tangle/src/tests/safe_writer.rs]>=
// src/tests/safe_writer.rs
use super::*;
use crate::WeavebackError;
use crate::SafeWriterError;
use crate::safe_writer::{SafeFileWriter, SafeWriterConfig};
use std::{collections::HashMap, fs, io::Write, path::PathBuf, thread, time::Duration};
#[test]
fn test_basic_file_writing() -> Result<(), WeavebackError> {
let (_temp, mut writer) = create_test_writer();
let test_file = PathBuf::from("test.txt");
let test_content = "Hello, World!";
write_file(&mut writer, &test_file, test_content)?;
let final_path = writer.get_gen_base().join(&test_file);
let content = fs::read_to_string(&final_path)?;
assert_eq!(content, test_content);
Ok(())
}
#[test]
fn test_multiple_file_generation() -> Result<(), WeavebackError> {
let (_temp, mut writer) = create_test_writer();
let test_file1 = PathBuf::from("file1.txt");
let test_file2 = PathBuf::from("file2.txt");
write_file(&mut writer, &test_file1, "Content 1")?;
write_file(&mut writer, &test_file2, "Content 2")?;
let content1 = fs::read_to_string(writer.get_gen_base().join(&test_file1))?;
let content2 = fs::read_to_string(writer.get_gen_base().join(&test_file2))?;
assert_eq!(content1.trim(), "Content 1");
assert_eq!(content2.trim(), "Content 2");
Ok(())
}
#[test]
fn test_unmodified_file_update() -> Result<(), WeavebackError> {
let (_temp, mut writer) = create_test_writer();
let test_file = PathBuf::from("test.txt");
write_file(&mut writer, &test_file, "Initial content")?;
write_file(&mut writer, &test_file, "New content")?;
let content = fs::read_to_string(writer.get_gen_base().join(&test_file))?;
assert_eq!(content, "New content", "New content should be written");
Ok(())
}
#[test]
fn test_backup_creation() -> Result<(), WeavebackError> {
let (_temp, mut writer) = create_test_writer();
let test_file = PathBuf::from("test.txt");
let content = "Test content";
write_file(&mut writer, &test_file, content)?;
let baseline = writer.get_baseline_for_test("test.txt");
assert!(baseline.is_some(), "Baseline should be stored in db");
assert_eq!(
baseline.as_deref().unwrap(),
content.as_bytes(),
"Baseline content should match written content"
);
Ok(())
}
#[test]
fn test_nested_directory_creation() -> Result<(), WeavebackError> {
let (_temp, mut writer) = create_test_writer();
let nested_path = PathBuf::from("dir1/dir2/test.txt");
write_file(&mut writer, &nested_path, "Nested content")?;
let gen_dir = writer.get_gen_base().join("dir1").join("dir2");
assert!(gen_dir.exists(), "Generated directory structure should exist");
let baseline = writer.get_baseline_for_test("dir1/dir2/test.txt");
assert!(
baseline.is_some(),
"Baseline for nested file should be stored in db"
);
Ok(())
}
#[test]
fn test_modification_detection() -> Result<(), WeavebackError> {
let (_temp, mut writer) = create_test_writer();
let test_file = PathBuf::from("test.txt");
let modified_content = "Modified content";
write_file(&mut writer, &test_file, "Initial content")?;
thread::sleep(Duration::from_millis(10));
let final_path = writer.get_gen_base().join(&test_file);
{
let mut file = fs::File::create(&final_path)?;
write!(file, "{}", modified_content)?;
}
let result = write_file(&mut writer, &test_file, "New content");
match result {
Err(WeavebackError::SafeWriter(SafeWriterError::ModifiedExternally(_))) => {
let content = fs::read_to_string(&final_path)?;
assert_eq!(
content, modified_content,
"Modified content should be preserved"
);
Ok(())
}
Ok(_) => panic!("Expected ModifiedExternally error"),
Err(e) => panic!("Unexpected error: {}", e),
}
}
#[test]
fn test_concurrent_modifications() -> Result<(), WeavebackError> {
let (_temp, mut writer) = create_test_writer();
let test_file = PathBuf::from("test.txt");
let modified_content_2 = "Modified 2";
write_file(&mut writer, &test_file, "Initial")?;
let final_path = writer.get_gen_base().join(&test_file);
thread::sleep(Duration::from_millis(10));
{
let mut file = fs::File::create(&final_path)?;
write!(file, "Modified 1")?;
}
thread::sleep(Duration::from_millis(10));
{
let mut file = fs::File::create(&final_path)?;
write!(file, "{}", modified_content_2)?;
}
let result = write_file(&mut writer, &test_file, "New content");
match result {
Err(WeavebackError::SafeWriter(SafeWriterError::ModifiedExternally(_))) => {
let content = fs::read_to_string(&final_path)?;
assert_eq!(
content, modified_content_2,
"Latest modification should be preserved"
);
Ok(())
}
Ok(_) => panic!("Expected ModifiedExternally error"),
Err(e) => panic!("Unexpected error: {}", e),
}
}
#[test]
fn test_baseline_always_written() -> Result<(), WeavebackError> {
let (_temp, mut writer) = create_test_writer();
let test_file = PathBuf::from("test.txt");
write_file(&mut writer, &test_file, "Test content")?;
assert!(
writer.get_baseline_for_test("test.txt").is_some(),
"Baseline must always be stored in db for modification detection"
);
Ok(())
}
#[test]
fn test_validate_filename_relative_path() -> Result<(), WeavebackError> {
let (_temp, mut writer) = create_test_writer();
let test_file = PathBuf::from("simple.txt");
write_file(&mut writer, &test_file, "Allowed")?;
let final_path = writer.get_gen_base().join(&test_file);
let content = fs::read_to_string(&final_path)?;
assert_eq!(content, "Allowed");
Ok(())
}
#[test]
fn test_path_safety() {
let (_temp, mut writer) = create_test_writer();
let test_cases = [
(
PathBuf::from("../outside.txt"),
"Path traversal detected (..)",
),
(
PathBuf::from("/absolute/path.txt"),
"Absolute paths are not allowed",
),
(
PathBuf::from("C:/windows/path.txt"),
"Windows-style absolute paths are not allowed",
),
(
PathBuf::from("C:test.txt"),
"Windows-style absolute paths are not allowed",
),
];
for (path, expected_msg) in test_cases {
let result = write_file(&mut writer, &path, "Should fail");
match result {
Err(WeavebackError::SafeWriter(SafeWriterError::SecurityViolation(msg))) => {
assert!(
msg.contains(expected_msg),
"Expected message '{}' for path {}",
expected_msg,
path.display()
);
}
_ => panic!("Expected SecurityViolation for path: {}", path.display()),
}
}
}
#[test]
fn test_formatter_is_applied() -> Result<(), WeavebackError> {
let temp = tempfile::TempDir::new().unwrap();
let mut formatters = HashMap::new();
// Shell script that replaces the file content with "FORMATTED\n"
formatters.insert(
"txt".to_string(),
"sh -c echo FORMATTED > \"$1\" && echo FORMATTED > \"$1\"".to_string(),
);
// Use a simpler approach: a script file
let script_path = temp.path().join("fmt.sh");
fs::write(&script_path, "#!/bin/sh\necho FORMATTED > \"$1\"\n").unwrap();
std::process::Command::new("chmod")
.arg("+x")
.arg(&script_path)
.status()
.unwrap();
let mut formatters2 = HashMap::new();
formatters2.insert("txt".to_string(), script_path.to_string_lossy().to_string());
let config = SafeWriterConfig {
formatters: formatters2,
..SafeWriterConfig::default()
};
let mut writer =
SafeFileWriter::with_config(temp.path().join("gen"), config)?;
let test_file = PathBuf::from("test.txt");
write_file(&mut writer, &test_file, "original content")?;
let output = fs::read_to_string(writer.get_gen_base().join(&test_file))?;
assert!(
output.contains("FORMATTED"),
"Formatter should have replaced content, got: {:?}",
output
);
Ok(())
}
#[test]
fn test_formatter_error_propagates() -> Result<(), WeavebackError> {
let temp = tempfile::TempDir::new().unwrap();
let mut formatters = HashMap::new();
formatters.insert("txt".to_string(), "nonexistent-formatter-xyz".to_string());
let config = SafeWriterConfig {
formatters,
..SafeWriterConfig::default()
};
let mut writer =
SafeFileWriter::with_config(temp.path().join("gen"), config)?;
let test_file = PathBuf::from("test.txt");
let result = write_file(&mut writer, &test_file, "some content");
match result {
Err(WeavebackError::SafeWriter(SafeWriterError::FormatterError(_))) => Ok(()),
Ok(_) => panic!("Expected FormatterError but write succeeded"),
Err(e) => panic!("Unexpected error: {}", e),
}
}
#[test]
fn test_formatter_prevents_false_positive() -> Result<(), WeavebackError> {
let temp = tempfile::TempDir::new().unwrap();
let script_path = temp.path().join("noop.sh");
// A no-op formatter: copies file to itself (content unchanged)
fs::write(
&script_path,
"#!/bin/sh\ncp \"$1\" \"$1.bak\" && mv \"$1.bak\" \"$1\"\n",
)
.unwrap();
std::process::Command::new("chmod")
.arg("+x")
.arg(&script_path)
.status()
.unwrap();
let mut formatters = HashMap::new();
formatters.insert("txt".to_string(), script_path.to_string_lossy().to_string());
let config = SafeWriterConfig {
formatters,
..SafeWriterConfig::default()
};
let mut writer =
SafeFileWriter::with_config(temp.path().join("gen"), config)?;
let test_file = PathBuf::from("test.txt");
write_file(&mut writer, &test_file, "initial content")?;
// Simulate formatter running externally on the output (content unchanged)
let output_path = writer.get_gen_base().join(&test_file);
let content = fs::read_to_string(&output_path)?;
fs::write(&output_path, &content)?;
// Second write should NOT trigger ModifiedExternally (content is the same as baseline)
let result = write_file(&mut writer, &test_file, "initial content");
assert!(
result.is_ok(),
"Expected success but got: {:?}",
result.err()
);
Ok(())
}
// @@