use super::{
model::ParsedSection,
parser::parse_patch,
tokenizer::{TokenKind, Tokenizer, try_parse_header},
};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SplitOptions {
pub(crate) cwd: Option<PathBuf>,
pub(crate) path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RawSection {
path: String,
file_hash: Option<String>,
diff: String,
}
pub(crate) fn parse_input(
input: &str,
options: SplitOptions,
) -> Result<Vec<ParsedSection>, String> {
merge_same_path_sections(split_raw_sections(input, &options)?)?
.into_iter()
.map(|raw| {
let parsed = parse_patch(&raw.diff)?;
Ok(ParsedSection {
path: raw.path,
file_hash: raw.file_hash,
diff: raw.diff,
edits: parsed.edits,
file_op: parsed.file_op,
warnings: parsed.warnings,
})
})
.collect()
}
pub(crate) fn contains_recognizable_hashline_operations(input: &str) -> bool {
let tokenizer = Tokenizer::new();
input.lines().any(|line| tokenizer.is_op(line))
}
fn split_raw_sections(input: &str, options: &SplitOptions) -> Result<Vec<RawSection>, String> {
let normalized = normalize_fallback_input(input, options)?;
let stripped = strip_leading_blank_lines(&normalized);
let lines: Vec<&str> = stripped.split('\n').collect();
let first = lines.first().copied().unwrap_or("").trim_end_matches('\r');
if parse_hashline_header_line(first, options)?.is_none() {
if first.trim_end().starts_with("@@") {
return Err("unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. File sections start with `[path#HASH]`.".to_string());
}
return Err(format!(
"input must begin with \"[PATH#HASH]\" on the first non-blank line for anchored edits; got: {:?}.",
first.chars().take(120).collect::<String>()
));
}
let tokenizer = Tokenizer::new();
let mut sections = Vec::new();
let mut current: Option<RawSection> = None;
let mut current_lines: Vec<String> = Vec::new();
let flush = |sections: &mut Vec<RawSection>,
current: &Option<RawSection>,
current_lines: &mut Vec<String>| {
if let Some(current) = current {
let has_ops = current_lines.iter().any(|line| !line.trim().is_empty());
if has_ops {
sections.push(RawSection {
path: current.path.clone(),
file_hash: current.file_hash.clone(),
diff: current_lines.join("\n"),
});
}
}
current_lines.clear();
};
for raw_line in lines {
let line = raw_line.trim_end_matches('\r');
let token = tokenizer.tokenize(line, 0);
if matches!(token.kind, TokenKind::EnvelopeEnd | TokenKind::Abort) {
break;
}
if matches!(token.kind, TokenKind::EnvelopeBegin) {
continue;
}
if line.trim_end().starts_with('[')
&& let Some(header) = parse_hashline_header_line(line, options)?
{
flush(&mut sections, ¤t, &mut current_lines);
current = Some(header);
continue;
}
current_lines.push(line.to_string());
}
flush(&mut sections, ¤t, &mut current_lines);
Ok(sections)
}
fn parse_hashline_header_line(
line: &str,
options: &SplitOptions,
) -> Result<Option<RawSection>, String> {
let trimmed = line.trim_end();
if !trimmed.starts_with('[') {
return Ok(None);
}
if let Some((path, file_hash)) = try_parse_header(trimmed) {
let path = normalize_hashline_path(&path, options.cwd.as_deref());
if path.is_empty() {
return Err("Input header `[]` is empty; provide a file path.".to_string());
}
return Ok(Some(RawSection {
path,
file_hash,
diff: String::new(),
}));
}
if let Some(recovered) = try_parse_recovery_header(trimmed, options.cwd.as_deref()) {
return Ok(Some(recovered));
}
Err(format!(
"Input header must be [PATH] or [PATH#TAG] with a 4-hex content-hash tag; got {trimmed:?}."
))
}
fn try_parse_recovery_header(line: &str, cwd: Option<&Path>) -> Option<RawSection> {
let body = line.strip_prefix('[')?.strip_suffix(']')?.trim();
let body = strip_apply_patch_path_noise(body).trim().to_string();
if body.is_empty() {
return None;
}
let (path_text, file_hash) = split_recovery_hash(&body);
if path_text.contains('#') {
return None;
}
let path = normalize_hashline_path(path_text, cwd);
(!path.is_empty()).then_some(RawSection {
path,
file_hash,
diff: String::new(),
})
}
fn split_recovery_hash(body: &str) -> (&str, Option<String>) {
let trimmed = body.trim_end();
if trimmed.len() >= 5 {
let split = trimmed.len() - 5;
if trimmed.as_bytes()[split] == b'#' {
let candidate = &trimmed[split + 1..];
if candidate.len() == 4 && candidate.chars().all(|ch| ch.is_ascii_hexdigit()) {
return (
trimmed[..split].trim_end(),
Some(candidate.to_ascii_uppercase()),
);
}
}
}
(trimmed, None)
}
fn strip_apply_patch_path_noise(path_text: &str) -> String {
let mut text = path_text.trim_start();
while let Some(rest) = text.strip_prefix('*') {
text = rest.trim_start();
}
let lower = text.to_ascii_lowercase();
for prefix in [
"update file:",
"update:",
"updatefile:",
"add file:",
"add:",
"delete file:",
"delete:",
"move to:",
"move:",
] {
if lower.starts_with(prefix) {
return text[prefix.len()..]
.trim_start_matches('*')
.trim()
.to_string();
}
}
text.trim().to_string()
}
fn normalize_hashline_path(raw_path: &str, cwd: Option<&Path>) -> String {
let unquoted = unquote_hashline_path(strip_apply_patch_path_noise(raw_path).trim());
let Some(cwd) = cwd else {
return unquoted;
};
let path = Path::new(&unquoted);
if !path.is_absolute() {
return unquoted;
}
let cwd = normalize_path_lexical(cwd);
let path = normalize_path_lexical(path);
let Ok(relative) = path.strip_prefix(&cwd) else {
return unquoted;
};
let relative = relative
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
if relative.is_empty() {
".".to_string()
} else {
relative
}
}
fn normalize_path_lexical(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
component => out.push(component.as_os_str()),
}
}
out
}
fn unquote_hashline_path(path_text: &str) -> String {
if path_text.len() < 2 {
return path_text.to_string();
}
let bytes = path_text.as_bytes();
if (bytes[0] == b'\'' && bytes[path_text.len() - 1] == b'\'')
|| (bytes[0] == b'\"' && bytes[path_text.len() - 1] == b'\"')
{
return path_text[1..path_text.len() - 1].to_string();
}
path_text.to_string()
}
fn strip_leading_blank_lines(input: &str) -> String {
let stripped = input.strip_prefix('\u{FEFF}').unwrap_or(input);
let tokenizer = Tokenizer::new();
let mut lines: Vec<&str> = stripped.split('\n').collect();
while let Some(line) = lines.first() {
let head = line.trim_end_matches('\r');
if head.trim().is_empty() || tokenizer.is_envelope_marker(head) {
lines.remove(0);
} else {
break;
}
}
lines.join("\n")
}
fn normalize_fallback_input(input: &str, options: &SplitOptions) -> Result<String, String> {
let stripped = input.strip_prefix('\u{FEFF}').unwrap_or(input);
let mut has_explicit_header = false;
for raw_line in stripped.split('\n') {
if parse_hashline_header_line(raw_line.trim_end_matches('\r'), options)?.is_some() {
has_explicit_header = true;
break;
}
}
if has_explicit_header || !contains_recognizable_hashline_operations(input) {
return Ok(input.to_string());
}
let Some(path) = &options.path else {
return Ok(input.to_string());
};
let fallback_path = normalize_hashline_path(path, options.cwd.as_deref());
if fallback_path.is_empty() {
return Ok(input.to_string());
}
Ok(format!("[{fallback_path}]\n{input}"))
}
fn merge_same_path_sections(sections: Vec<RawSection>) -> Result<Vec<RawSection>, String> {
let mut ordered: Vec<(String, Option<String>, Vec<String>)> = Vec::new();
for section in sections {
if let Some((_, hash, diffs)) = ordered
.iter_mut()
.find(|(path, _, _)| path == §ion.path)
{
if let (Some(existing), Some(next)) = (hash.as_ref(), section.file_hash.as_ref())
&& existing != next
{
return Err(format!(
"Conflicting hashline snapshot tags for {}: #{} and #{}. Re-read the file and retry with one current header.",
section.path, existing, next
));
}
if hash.is_none() && section.file_hash.is_some() {
*hash = section.file_hash;
}
diffs.push(section.diff);
} else {
ordered.push((section.path, section.file_hash, vec![section.diff]));
}
}
Ok(ordered
.into_iter()
.map(|(path, file_hash, diffs)| RawSection {
path,
file_hash,
diff: diffs.join("\n"),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::hash_edit::{model::FileOp, parser::parse_patch};
#[test]
fn parses_section_and_merges_same_path() {
let sections = parse_input(
"[a.txt#ABCD]\nINS.HEAD:\n+one\n[a.txt#ABCD]\nINS.TAIL:\n+two",
SplitOptions::default(),
)
.unwrap();
assert_eq!(sections.len(), 1);
assert_eq!(sections[0].path, "a.txt");
assert_eq!(sections[0].file_hash.as_deref(), Some("ABCD"));
assert_eq!(sections[0].edits.len(), 2);
}
#[test]
fn rejects_conflicting_tags_for_same_path() {
let err = parse_input(
"[a.txt#ABCD]\nINS.HEAD:\n+one\n[a.txt#BCDE]\nINS.TAIL:\n+two",
SplitOptions::default(),
)
.unwrap_err();
assert!(err.contains("Conflicting hashline snapshot tags"));
}
#[test]
fn recovers_apply_patch_header_noise() {
let sections =
parse_input("[*** Update File:a.txt#abcd]\nREM", SplitOptions::default()).unwrap();
assert_eq!(sections[0].path, "a.txt");
assert_eq!(sections[0].file_hash.as_deref(), Some("ABCD"));
assert_eq!(sections[0].file_op, Some(FileOp::Remove));
}
#[test]
fn fallback_path_is_used_when_ops_exist_without_header() {
let sections = parse_input(
"INS.HEAD:\n+x",
SplitOptions {
cwd: None,
path: Some("a.txt".to_string()),
},
)
.unwrap();
assert_eq!(sections[0].path, "a.txt");
assert_eq!(sections[0].edits.len(), 1);
}
#[test]
fn rejects_malformed_header() {
let err = parse_input("[a.txt#BAD]\nREM", SplitOptions::default()).unwrap_err();
assert!(err.contains("Input header must"));
}
#[test]
fn parser_still_accepts_move_dest_from_section_body() {
let parsed = parse_patch("MV 'b.txt'").unwrap();
assert_eq!(
parsed.file_op,
Some(FileOp::Move {
dest: "b.txt".into()
})
);
}
}