use crate::format::{HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX};
use crate::messages::format_anchored_context;
const EXAMPLE_HASH: &str = "1A2B";
#[derive(Debug, Clone)]
pub struct MismatchDetails {
pub path: Option<String>,
pub expected_file_hash: String,
pub actual_file_hash: String,
pub file_lines: Vec<String>,
pub anchor_lines: Vec<u32>,
pub hash_recognized: bool,
}
impl Default for MismatchDetails {
fn default() -> Self {
Self {
path: None,
expected_file_hash: String::new(),
actual_file_hash: String::new(),
file_lines: Vec::new(),
anchor_lines: Vec::new(),
hash_recognized: true,
}
}
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("{message}")]
pub struct MismatchError {
pub message: String,
pub details: MismatchDetails,
}
impl MismatchError {
pub fn new(details: MismatchDetails) -> Self {
let message = format_message(&details);
Self { message, details }
}
pub fn display_message(&self) -> &str {
&self.message
}
}
#[derive(Debug, thiserror::Error)]
pub enum HashlineError {
#[error("Parse error at line {line}: {msg}")]
Parse {
line: u32,
msg: String,
},
#[error("File not found: {path}. Use the write tool to create new files.")]
NotFound {
path: String,
},
#[error("{0}")]
MissingSnapshotTag(String),
#[error("{0}")]
UnseenLines(String),
#[error("{detail}")]
Mismatch {
detail: String,
expected: String,
actual: String,
},
#[error("Multiple sections resolve to {path}")]
DuplicateCanonicalPath {
path: String,
},
#[error("Edits to {path} resulted in no changes")]
NoOp {
path: String,
},
#[error("Line {line} does not exist (file has {total} lines)")]
LineOutOfBounds {
line: u32,
total: usize,
},
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[cfg(feature = "block-ops")]
#[error("Block resolver unavailable for {path}")]
BlockResolverUnavailable { path: String },
}
impl HashlineError {
pub fn parse(line: u32, msg: impl Into<String>) -> Self {
HashlineError::Parse {
line,
msg: msg.into(),
}
}
}
pub fn rejection_header(details: &MismatchDetails) -> Vec<String> {
let path_text = details
.path
.as_deref()
.map(|p| format!(" for {p}"))
.unwrap_or_default();
if !details.hash_recognized {
vec![
format!(
"Edit rejected{path_text}: hash {sep}{expected} is not from this session.",
sep = HL_FILE_HASH_SEP,
expected = details.expected_file_hash,
),
format!(
"The current file hashes to {sep}{actual}. Re-read the file with `read` to copy a \
current {pfx}path{sep}tag{sfx} header — never invent the tag and never reuse one \
from a prior session.",
sep = HL_FILE_HASH_SEP,
actual = details.actual_file_hash,
pfx = HL_FILE_PREFIX,
sfx = HL_FILE_SUFFIX,
),
]
} else {
vec![
format!("Edit rejected{path_text}: file changed between read and edit."),
format!(
"Section is bound to {sep}{expected}, but the current file hashes to \
{sep}{actual}. If a prior edit in this session modified this file, copy the \
{pfx}path{sep}newhash{sfx} header from that edit's response; otherwise re-read \
the file with `read` to refresh the tag before retrying.",
sep = HL_FILE_HASH_SEP,
expected = details.expected_file_hash,
actual = details.actual_file_hash,
pfx = HL_FILE_PREFIX,
sfx = HL_FILE_SUFFIX,
),
]
}
}
pub fn format_message(details: &MismatchDetails) -> String {
let mut lines = rejection_header(details);
let context = format_anchored_context(&details.anchor_lines, &details.file_lines);
if context.is_empty() {
lines.join("\n")
} else {
lines.push(String::new());
lines.extend(context);
lines.join("\n")
}
}
pub fn format_display_message(details: &MismatchDetails) -> String {
format_message(details)
}
pub fn validate_line_ref(line: u32, file_lines: &[String]) -> Result<(), String> {
if line < 1 || (line as usize) > file_lines.len() {
return Err(format!(
"Line {line} does not exist (file has {} lines)",
file_lines.len()
));
}
Ok(())
}
pub fn format_full_anchor_requirement(raw: Option<&str>) -> String {
let received = match raw {
Some(r) => format!(" Received {r:?}."),
None => String::new(),
};
format!(
"a bare line number from read/search output plus the section header content-hash tag \
(for example {pfx}src/foo.ts{sep}{ex}{sfx} and line \"160\"){received}",
pfx = HL_FILE_PREFIX,
sep = HL_FILE_HASH_SEP,
ex = EXAMPLE_HASH,
sfx = HL_FILE_SUFFIX,
)
}
pub fn parse_tag(reference: &str) -> Result<u32, String> {
match try_parse_line_ref(reference) {
Some(line) if line >= 1 => Ok(line),
Some(line) => Err(format!(
"Line number must be >= 1, got {line} in {reference:?}."
)),
None => Err(format!(
"Invalid line reference. Expected {}. Expected {}.",
reference,
format_full_anchor_requirement(Some(reference))
)),
}
}
fn try_parse_line_ref(reference: &str) -> Option<u32> {
let s = reference.trim_start();
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() && matches!(bytes[i], b'>' | b'+' | b'-' | b'*') {
i += 1;
}
let rest = &s[i..];
let rest = rest.trim_start();
let digit_len = rest.bytes().take_while(|b| b.is_ascii_digit()).count();
if digit_len == 0 {
return None;
}
let line: u32 = rest[..digit_len].parse().ok()?;
let tail = rest[digit_len..].trim_end();
if tail.is_empty() || tail.starts_with(':') {
Some(line)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn details(expected: &str, actual: &str, recognized: bool) -> MismatchDetails {
MismatchDetails {
path: Some("src/foo.rs".to_string()),
expected_file_hash: expected.to_string(),
actual_file_hash: actual.to_string(),
file_lines: vec!["a".into(), "b".into(), "c".into(), "d".into(), "e".into()],
anchor_lines: vec![2],
hash_recognized: recognized,
}
}
#[test]
fn recognized_mismatch_renders_header_and_context() {
let d = details("AAAA", "BBBB", true);
let err = MismatchError::new(d);
assert!(err.message.contains("file changed between read and edit"));
assert!(err.message.contains("#AAAA"));
assert!(err.message.contains("#BBBB"));
assert!(err.message.contains("\n\n"));
assert!(
err.message.contains("*2:b"),
"anchored line 2 appears in context"
);
assert_eq!(err.details.expected_file_hash, "AAAA");
assert_eq!(err.details.actual_file_hash, "BBBB");
assert!(err.details.hash_recognized);
}
#[test]
fn unrecognized_mismatch_uses_fabrication_message() {
let d = details("AAAA", "BBBB", false);
let msg = format_message(&d);
assert!(msg.contains("is not from this session"));
assert!(msg.contains("never invent the tag"));
}
#[test]
fn no_context_when_file_lines_absent() {
let d = MismatchDetails {
path: None,
expected_file_hash: "AAAA".into(),
actual_file_hash: "BBBB".into(),
file_lines: Vec::new(),
anchor_lines: Vec::new(),
hash_recognized: true,
};
let msg = format_message(&d);
assert!(!msg.contains("\n\n"));
assert!(msg.contains("Edit rejected"));
}
#[test]
fn validate_line_ref_bounds() {
let file: Vec<String> = vec!["x".into(), "y".into()];
assert!(validate_line_ref(1, &file).is_ok());
assert!(validate_line_ref(2, &file).is_ok());
assert!(validate_line_ref(0, &file).is_err());
assert!(validate_line_ref(3, &file).is_err());
}
#[test]
fn parse_tag_accepts_decorated_refs() {
assert_eq!(parse_tag("42").unwrap(), 42);
assert_eq!(parse_tag(" *42:foo").unwrap(), 42);
assert_eq!(parse_tag(" > 7").unwrap(), 7);
assert_eq!(parse_tag("160:some content").unwrap(), 160);
}
#[test]
fn parse_tag_rejects_garbage() {
assert!(parse_tag("not a line").is_err());
assert!(parse_tag(":42").is_err());
assert!(parse_tag("").is_err());
assert!(parse_tag("42 extra").is_err());
}
#[test]
fn mismatch_error_implements_std_error() {
let err = MismatchError::new(details("AAAA", "BBBB", true));
let _: &dyn std::error::Error = &err;
assert_eq!(err.display_message(), format!("{}", err));
}
#[test]
fn format_full_anchor_requirement_includes_example() {
let req = format_full_anchor_requirement(None);
assert!(req.contains("[src/foo.ts#1A2B]"));
assert!(req.contains("\"160\""));
let req_with = format_full_anchor_requirement(Some("xyz"));
assert!(req_with.contains("Received \"xyz\"."));
}
}