//! Utilities for editing null-separated path lists. //! //! This module operates on the same null-separated path-list format consumed by `repo2markdown`. //! The functions are intended for small Unix-style filter binaries such as `r2md-append`, //! `r2md-prepend`, and `r2md-remove`. //! //! Paths that are kept from stdin are written back using their original byte representation. //! Removal compares paths after normalization using the supplied [`Normalizer`]. use std::{ collections::HashSet, ffi::OsString, io::{Read, Write}, os::unix::ffi::OsStrExt, path::PathBuf, }; use crate::{normalizer::Normalizer, util::path_list::paths_from_null_separated_bytes}; const NUL: &[u8] = b"\0"; /// Copies a null-separated path list from `input`, then appends `new_paths`. /// /// Each path in `new_paths` is written as a NUL-terminated entry. /// /// This function is defensive about unterminated input: if `input` is non-empty and does not end /// with a NUL byte, a NUL byte is inserted before appended paths are written. If `new_paths` is /// empty, this still has the effect of repairing a non-empty unterminated input stream into a /// NUL-terminated one. /// /// This function does not normalize, deduplicate, or validate paths. pub fn append_paths( input: R, mut output: W, new_paths: &[OsString], ) -> std::io::Result<()> { let last_byte_in_input = copy_while_tracking_last_byte(input, &mut output)?; // Defensively ensure non-empty input is NUL-terminated. if matches!(last_byte_in_input, Some(byte) if byte != 0) { output.write_all(NUL)?; } for path in new_paths { output.write_all(path.as_bytes())?; output.write_all(NUL)?; } Ok(()) } /// Copies all bytes from `input` to `output`, returning the last byte copied. /// /// Returns `Ok(None)` when `input` is empty. /// /// This helper is used by [`append_paths`] to preserve streaming behavior while still detecting /// whether the input path list ended with a NUL byte. fn copy_while_tracking_last_byte( mut input: R, mut output: W, ) -> std::io::Result> { let mut buf = [0; 8192]; let mut last = None; loop { let read_bytes = input.read(&mut buf)?; if read_bytes == 0 { break; } last = Some(buf[read_bytes - 1]); output.write_all(&buf[..read_bytes])?; } Ok(last) } /// Prepends `new_paths` before the null-separated path list from `input`. /// /// Each path in `new_paths` is written as a NUL-terminated entry before the original input stream /// is copied unchanged. /// /// This function does not normalize, deduplicate, validate, or repair the input stream. In /// particular, unlike [`append_paths`], it does not need to inspect whether the input stream is /// NUL-terminated because no new path is written after stdin. pub fn prepend_paths( mut input: R, mut output: W, new_paths: &[OsString], ) -> std::io::Result<()> { for path in new_paths { output.write_all(path.as_bytes())?; output.write_all(NUL)?; } std::io::copy(&mut input, &mut output)?; Ok(()) } /// Removes paths from a null-separated path list. /// /// The `input` stream is interpreted as a NUL-separated sequence of path entries. Each entry is /// normalized with `normalizer` and compared against the normalized form of each path in /// `unwanted_paths`. /// /// Entries whose normalized path matches one of the normalized `unwanted_paths` are skipped. /// Entries that are kept are written to `output` using their original byte form, followed by a NUL /// byte. /// /// Matching is normalization-aware, so paths such as `src/../a.rs`, `./a.rs`, and an absolute path /// to `a.rs` may match depending on the supplied [`Normalizer`]. This function does not deduplicate /// kept entries. pub fn remove_paths( mut input: R, mut output: W, unwanted_paths: &[PathBuf], normalizer: &Normalizer, ) -> Result<(), Box> { let mut input_buf = Vec::new(); input.read_to_end(&mut input_buf)?; let unwanted_paths = unwanted_paths .iter() .map(|path| normalizer.normalize(path)) .collect::, _>>()?; for path in paths_from_null_separated_bytes(&input_buf) { let normalized_path = normalizer.normalize(path)?; if !unwanted_paths.contains(&normalized_path) { output.write_all(path.as_os_str().as_bytes())?; output.write_all(NUL)?; } } Ok(()) } #[cfg(test)] mod tests { use std::{ ffi::OsString, path::{Path, PathBuf}, }; use crate::normalizer::Normalizer; use super::{append_paths, prepend_paths, remove_paths}; fn get_normalizer() -> Normalizer { let default_path = Path::new("."); Normalizer::new(default_path, default_path).unwrap() } #[test] fn append_paths_works_with_empty_stdin_and_args() { let input = b""; let mut output = Vec::new(); append_paths(&input[..], &mut output, &[]).unwrap(); assert_eq!(output, b""); } #[test] fn append_paths_works_with_empty_args() { let input = b"README.md\0"; let mut output = Vec::new(); append_paths(&input[..], &mut output, &[]).unwrap(); assert_eq!(output, b"README.md\0"); } #[test] fn append_paths_works_with_empty_stdin() { let input = b""; let mut output = Vec::new(); append_paths(&input[..], &mut output, &[OsString::from("README.md")]).unwrap(); assert_eq!(output, b"README.md\0"); } #[test] fn append_paths_works_with_unique_stdin_and_args() { let input = b"a.rs\0b.rs\0"; let mut output = Vec::new(); append_paths(&input[..], &mut output, &[OsString::from("c.rs")]).unwrap(); assert_eq!(output, b"a.rs\0b.rs\0c.rs\0"); } #[test] fn append_paths_keeps_duplicate_entries() { let input = b"a.rs\0b.rs\0"; let mut output = Vec::new(); append_paths(&input[..], &mut output, &[OsString::from("a.rs")]).unwrap(); assert_eq!(output, b"a.rs\0b.rs\0a.rs\0"); } #[test] fn append_paths_inserts_separator_when_stdin_is_not_null_terminated() { let input = b"a.rs"; let mut output = Vec::new(); append_paths(&input[..], &mut output, &[OsString::from("b.rs")]).unwrap(); assert_eq!(output, b"a.rs\0b.rs\0"); } #[test] fn append_paths_does_not_insert_extra_separator_when_stdin_is_null_terminated() { let input = b"a.rs\0"; let mut output = Vec::new(); append_paths(&input[..], &mut output, &[OsString::from("b.rs")]).unwrap(); assert_eq!(output, b"a.rs\0b.rs\0"); } #[test] fn append_paths_handles_partially_terminated_input_sequence() { let input = b"a.rs\0b.rs"; let mut output = Vec::new(); append_paths(&input[..], &mut output, &[OsString::from("c.rs")]).unwrap(); assert_eq!(output, b"a.rs\0b.rs\0c.rs\0"); } #[test] fn prepend_paths_works_with_empty_stdin_and_args() { let input = b""; let mut output = Vec::new(); prepend_paths(&input[..], &mut output, &[]).unwrap(); assert_eq!(output, b""); } #[test] fn prepend_paths_works_with_empty_args() { let input = b"README.md\0"; let mut output = Vec::new(); prepend_paths(&input[..], &mut output, &[]).unwrap(); assert_eq!(output, b"README.md\0"); } #[test] fn prepend_paths_works_with_empty_stdin() { let input = b""; let mut output = Vec::new(); prepend_paths(&input[..], &mut output, &[OsString::from("README.md")]).unwrap(); assert_eq!(output, b"README.md\0"); } #[test] fn prepend_paths_works_with_unique_stdin_and_args() { let input = b"a.rs\0b.rs\0"; let mut output = Vec::new(); prepend_paths(&input[..], &mut output, &[OsString::from("c.rs")]).unwrap(); assert_eq!(output, b"c.rs\0a.rs\0b.rs\0"); } #[test] fn prepend_paths_keeps_duplicate_entries() { let input = b"a.rs\0b.rs\0"; let mut output = Vec::new(); prepend_paths(&input[..], &mut output, &[OsString::from("a.rs")]).unwrap(); assert_eq!(output, b"a.rs\0a.rs\0b.rs\0"); } #[test] fn remove_paths_works_with_empty_stdin_and_args() { let input = b""; let mut output = Vec::new(); remove_paths(&input[..], &mut output, &[], &get_normalizer()).unwrap(); assert_eq!(output, b""); } #[test] fn remove_paths_works_with_empty_args() { let input = b"README.md\0"; let mut output = Vec::new(); remove_paths(&input[..], &mut output, &[], &get_normalizer()).unwrap(); assert_eq!(output, b"README.md\0"); } #[test] fn remove_paths_works_with_empty_stdin() { let input = b""; let mut output = Vec::new(); remove_paths( &input[..], &mut output, &[PathBuf::from("README.md")], &get_normalizer(), ) .unwrap(); assert_eq!(output, b""); } #[test] fn remove_paths_filters_matching_entries() { let input = b"a.rs\0b.rs\0c.rs\0"; let mut output = Vec::new(); remove_paths( &input[..], &mut output, &[PathBuf::from("b.rs")], &get_normalizer(), ) .unwrap(); assert_eq!(output, b"a.rs\0c.rs\0"); } #[test] fn remove_paths_removes_all_matching_entries() { let input = b"a.rs\0b.rs\0b.rs\0c.rs\0"; let mut output = Vec::new(); remove_paths( &input[..], &mut output, &[PathBuf::from("b.rs")], &get_normalizer(), ) .unwrap(); assert_eq!(output, b"a.rs\0c.rs\0"); } #[test] fn remove_paths_matches_dot_normalized_entries() { let input = b"src/./main.rs\0src/lib.rs\0"; let mut output = Vec::new(); remove_paths( &input[..], &mut output, &[PathBuf::from("src/main.rs")], &get_normalizer(), ) .unwrap(); assert_eq!(output, b"src/lib.rs\0"); } #[test] fn remove_paths_matches_parent_dir_normalized_entries() { let input = b"src/../a.rs\0b.rs\0"; let mut output = Vec::new(); remove_paths( &input[..], &mut output, &[PathBuf::from("a.rs")], &get_normalizer(), ) .unwrap(); assert_eq!(output, b"b.rs\0"); } #[test] fn remove_paths_matches_when_unwanted_path_needs_normalization() { let input = b"a.rs\0b.rs\0"; let mut output = Vec::new(); remove_paths( &input[..], &mut output, &[PathBuf::from("src/../a.rs")], &get_normalizer(), ) .unwrap(); assert_eq!(output, b"b.rs\0"); } #[test] fn remove_paths_matches_when_both_sides_need_normalization() { let input = b"src/../a.rs\0b.rs\0"; let mut output = Vec::new(); remove_paths( &input[..], &mut output, &[PathBuf::from("./a.rs")], &get_normalizer(), ) .unwrap(); assert_eq!(output, b"b.rs\0"); } #[test] fn remove_paths_preserves_original_bytes_for_kept_paths() { let input = b"src/./main.rs\0src/lib.rs\0"; let mut output = Vec::new(); remove_paths( &input[..], &mut output, &[PathBuf::from("other.rs")], &get_normalizer(), ) .unwrap(); assert_eq!(output, b"src/./main.rs\0src/lib.rs\0"); } }