paircomp_core/file.rs
1use crate::{Error, FileInfo, Fingerprint};
2use std::fs::File;
3use std::io::{self, Read};
4use std::path::Path;
5
6/// Inspects a regular file without interpreting its bytes as text.
7///
8/// Returns its raw byte length, line count, and full-file BLAKE3 fingerprint.
9/// Lines end at LF, which belongs to the line; a nonempty unterminated suffix
10/// also counts as a line. A trailing LF adds no extra line. Empty files have
11/// zero bytes and lines. CR is ordinary content, and invalid UTF-8 is accepted.
12///
13/// Each call reopens the path. Keep the file unchanged throughout inspection
14/// and any subsequent comparison; restart after editing or replacing it.
15/// See the [file stability requirements](crate#file-stability).
16///
17/// # Errors
18///
19/// Returns [`Error::Io`] if metadata lookup, opening, or reading fails,
20/// [`Error::NotRegularFile`] for a non-regular input, or
21/// [`Error::FileTooLarge`] if a byte or line count cannot fit in `u64`.
22///
23/// # Examples
24///
25/// ```no_run
26/// use paircomp_core::inspect_file;
27/// use std::path::Path;
28///
29/// let info = inspect_file(Path::new("local-copy.txt"))?;
30/// println!("{} lines, {} bytes", info.line_count, info.byte_len);
31/// // Display the full digest for manual comparison with the other system.
32/// print!("Fingerprint: ");
33/// for byte in info.fingerprint.as_bytes() {
34/// print!("{byte:02x}");
35/// }
36/// println!();
37/// # Ok::<(), paircomp_core::Error>(())
38/// ```
39pub fn inspect_file(path: &Path) -> Result<FileInfo, Error> {
40 scan_file(path, None)
41}
42
43/// Fingerprints every raw byte of a regular file using BLAKE3.
44///
45/// No whitespace, newline, or encoding normalization is performed. Empty
46/// files and arbitrary non-UTF-8 bytes are accepted. Returns the same digest
47/// as [`inspect_file`], whose example shows how to access the digest bytes.
48///
49/// Each call reopens the path. Keep the file unchanged throughout inspection
50/// and any subsequent comparison; restart after editing or replacing it.
51/// See the [file stability requirements](crate#file-stability).
52///
53/// # Errors
54///
55/// Returns [`Error::Io`] if metadata lookup, opening, or reading fails,
56/// [`Error::NotRegularFile`] for a non-regular input, or
57/// [`Error::FileTooLarge`] if a byte or line count cannot fit in `u64`.
58pub fn fingerprint_file(path: &Path) -> Result<Fingerprint, Error> {
59 Ok(inspect_file(path)?.fingerprint)
60}
61
62/// Fingerprints the file prefix through the selected line, including any LF.
63///
64/// Hashes from the beginning of the file, rather than just the selected line.
65/// Line numbers start at 1; line zero hashes the empty sequence. Requests beyond
66/// EOF hash all available bytes. CR is ordinary content; no normalization or
67/// UTF-8 decoding occurs.
68/// The path must identify a readable regular file even when `line` is zero.
69///
70/// Each call reopens the path. Keep the file unchanged throughout inspection
71/// and any subsequent comparison; restart after editing or replacing it.
72/// See the [file stability requirements](crate#file-stability).
73///
74/// # Errors
75///
76/// Returns [`Error::Io`] if metadata lookup, opening, or reading fails,
77/// [`Error::NotRegularFile`] for a non-regular input, or
78/// [`Error::FileTooLarge`] if a byte or line count cannot fit in `u64`.
79///
80/// # Examples
81///
82/// See [`crate::fingerprint_line_prefix`] for an example comparing file prefixes
83/// with prefixes within a single line.
84pub fn fingerprint_through_line(path: &Path, line: u64) -> Result<Fingerprint, Error> {
85 Ok(scan_file(path, Some(line))?.fingerprint)
86}
87
88fn scan_file(path: &Path, through_line: Option<u64>) -> Result<FileInfo, Error> {
89 scan_reader(open_regular_file(path)?, through_line)
90}
91
92pub(super) fn open_regular_file(path: &Path) -> Result<File, Error> {
93 if !std::fs::metadata(path)?.is_file() {
94 return Err(Error::NotRegularFile);
95 }
96 Ok(File::open(path)?)
97}
98
99fn scan_reader(mut reader: impl Read, through_line: Option<u64>) -> Result<FileInfo, Error> {
100 let mut hasher = blake3::Hasher::new();
101 let mut byte_len = 0_u64;
102 let mut lf_count = 0_u64;
103 let mut ends_with_lf = false;
104 let mut buffer = [0_u8; 8192];
105
106 if through_line != Some(0) {
107 loop {
108 let read = match reader.read(&mut buffer) {
109 Ok(read) => read,
110 Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
111 Err(error) => return Err(error.into()),
112 };
113 if read == 0 {
114 break;
115 }
116
117 // Use the whole chunk unless the requested line ends inside it.
118 let mut included = read;
119 let mut reached_line = false;
120 for (index, &byte) in buffer[..read].iter().enumerate() {
121 if byte == b'\n' {
122 lf_count = lf_count.checked_add(1).ok_or(Error::FileTooLarge)?;
123 if through_line == Some(lf_count) {
124 // The terminating LF belongs to the requested prefix.
125 included = index + 1;
126 reached_line = true;
127 break;
128 }
129 }
130 }
131
132 let bytes = &buffer[..included];
133 hasher.update(bytes);
134 byte_len = byte_len
135 .checked_add(u64::try_from(included).map_err(|_| Error::FileTooLarge)?)
136 .ok_or(Error::FileTooLarge)?;
137 ends_with_lf = bytes.last() == Some(&b'\n');
138
139 if reached_line {
140 break;
141 }
142 }
143 }
144
145 let line_count = lf_count
146 .checked_add(u64::from(byte_len > 0 && !ends_with_lf))
147 .ok_or(Error::FileTooLarge)?;
148 Ok(FileInfo {
149 byte_len,
150 line_count,
151 fingerprint: Fingerprint(*hasher.finalize().as_bytes()),
152 })
153}
154
155#[cfg(test)]
156mod tests;