magi-code 0.63.4

Repository-aware CLI coding agent for terminal work
Documentation
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, &current, &mut current_lines);
            current = Some(header);
            continue;
        }
        current_lines.push(line.to_string());
    }
    flush(&mut sections, &current, &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 == &section.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()
            })
        );
    }
}