paircomp_core/lib.rs
1//! Raw-byte file inspection, prefix fingerprinting, and difference localization.
2//!
3//! Each instance reads only its local file. The caller exchanges counts and
4//! compares fingerprints with the other instance; this crate handles file
5//! access and search state without terminal or network I/O.
6//!
7//! # Comparison workflow
8//!
9//! 1. Call [`inspect_file`] and compare the whole-file fingerprints. If they
10//! match, the comparison is complete.
11//! 2. On a mismatch, exchange line counts and construct a [`LineSearch`]. Use
12//! [`fingerprint_through_line`] for each requested comparison and pass the
13//! answer to [`LineSearch::record_result`] until the differing line is known.
14//! 3. To continue within that line, call [`inspect_line`], exchange byte lengths
15//! (zero for an absent line), and construct a [`ByteSearch`]. Compare
16//! [`fingerprint_line_prefix`] results and call [`ByteSearch::record_result`].
17//! 4. Optionally annotate the differing byte with [`utf8_character_position`].
18//!
19//! Both instances must use accurate counts and the same comparison answers.
20//! Localization also assumes that distinct prefixes have distinct fingerprints.
21//!
22//! # Bytes and positions
23//!
24//! Hashing uses raw bytes without normalization or UTF-8 decoding. LF terminates
25//! a line and belongs to it; CR is ordinary content. A nonempty suffix after the
26//! last LF counts as a line, and a trailing LF creates no extra line.
27//!
28//! Line, byte, and Unicode code-point positions start at 1. Only
29//! [`fingerprint_through_line`] accepts line zero, denoting an empty file prefix.
30//! Byte positions are relative to the selected line. Character annotations
31//! require that entire line to be valid UTF-8 and count code points, not grapheme
32//! clusters or visual columns.
33//!
34//! # File stability
35//!
36//! Each file operation opens the supplied path afresh. The library does
37//! not snapshot files, lock them, or detect changes.
38//!
39//! Both files must remain unchanged from the start of the initial inspection
40//! until the comparison ends, including during reads and between calls. If
41//! either file is edited or replaced, discard the collected metadata,
42//! fingerprints, and [`LineSearch`]/[`ByteSearch`] state, and restart both
43//! instances from the initial inspection.
44
45use std::error::Error as StdError;
46use std::fmt;
47use std::io;
48
49mod file;
50mod search;
51mod within_line;
52pub use file::{fingerprint_file, fingerprint_through_line, inspect_file};
53pub use search::{ByteSearch, ByteSearchStep, LineSearch, LineSearchStep};
54pub use within_line::{fingerprint_line_prefix, inspect_line, utf8_character_position};
55
56/// Metadata calculated from the bytes read from a regular file.
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58pub struct FileInfo {
59 /// Number of raw bytes in the file.
60 pub byte_len: u64,
61 /// Number of LF bytes, plus one for a nonempty unterminated suffix.
62 ///
63 /// A trailing LF creates no extra line; an empty file has zero lines.
64 pub line_count: u64,
65 /// BLAKE3 digest of all raw file bytes, without normalization.
66 pub fingerprint: Fingerprint,
67}
68
69/// Metadata for an existing line, including its terminating LF if present.
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71pub struct LineInfo {
72 /// Number of raw bytes in the line, including its LF if present.
73 ///
74 /// An existing line always contains at least one byte.
75 pub byte_len: u64,
76}
77
78/// A full 256-bit BLAKE3 digest of raw bytes.
79///
80/// Equality compares all 32 digest bytes. Matching fingerprints provide strong
81/// evidence of equal input, subject to the possibility of a hash collision.
82/// Use [`Self::as_bytes`] to format the digest for display in a frontend.
83#[derive(Clone, Copy, Debug, Eq, PartialEq)]
84pub struct Fingerprint([u8; 32]);
85
86impl Fingerprint {
87 /// Borrows the complete 32-byte BLAKE3 digest in its original byte order.
88 ///
89 /// No bytes are truncated or converted to a display string.
90 pub fn as_bytes(&self) -> &[u8; 32] {
91 &self.0
92 }
93}
94
95/// An error returned by a core operation.
96#[derive(Debug)]
97pub enum Error {
98 /// Metadata lookup, opening, or reading the file failed.
99 Io(io::Error),
100 /// The path does not identify a regular file.
101 NotRegularFile,
102 /// A byte count, line count, or character position cannot fit in `u64`.
103 FileTooLarge,
104 /// Both supplied line counts are zero despite a reported mismatch.
105 EmptyFilesCannotDiffer,
106 /// Both supplied line byte lengths are zero despite a reported mismatch.
107 EmptyLinesCannotDiffer,
108 /// A line-local operation was given line zero; its lines start at 1.
109 InvalidLineNumber,
110 /// The byte position is zero or more than one past the selected line.
111 ///
112 /// An absent line permits only byte position 1.
113 InvalidBytePosition,
114 /// An answer was submitted after the search had reached its result.
115 SearchAlreadyComplete,
116}
117
118impl fmt::Display for Error {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 match self {
121 Self::Io(error) => write!(f, "file I/O failed: {error}"),
122 Self::NotRegularFile => f.write_str("input is not a regular file"),
123 Self::FileTooLarge => f.write_str("file size or line count exceeds u64"),
124 Self::EmptyFilesCannotDiffer => f.write_str("two empty files cannot differ"),
125 Self::EmptyLinesCannotDiffer => f.write_str("two zero-length lines cannot differ"),
126 Self::InvalidLineNumber => f.write_str("line numbers must start at 1"),
127 Self::InvalidBytePosition => {
128 f.write_str("byte position must be within the line or immediately after it")
129 }
130 Self::SearchAlreadyComplete => f.write_str("search is already complete"),
131 }
132 }
133}
134
135impl StdError for Error {
136 fn source(&self) -> Option<&(dyn StdError + 'static)> {
137 match self {
138 Self::Io(error) => Some(error),
139 Self::NotRegularFile
140 | Self::FileTooLarge
141 | Self::EmptyFilesCannotDiffer
142 | Self::EmptyLinesCannotDiffer
143 | Self::InvalidLineNumber
144 | Self::InvalidBytePosition
145 | Self::SearchAlreadyComplete => None,
146 }
147 }
148}
149
150impl From<io::Error> for Error {
151 fn from(error: io::Error) -> Self {
152 Self::Io(error)
153 }
154}