Skip to main content

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}