reddb-io-tq 0.29.8

jq-style CLI for TOON v4.1, TOONL v0.2, JSON, YAML, and XML
use std::fs::{self, OpenOptions};
use std::io::{self, Cursor, Write};
use std::path::{Path, PathBuf};
use std::process;

use reddb_io_toon::ToonlReader;

#[derive(Debug)]
pub(super) struct TrimPlan {
    pub(super) output: String,
    pub(super) changed: bool,
}

#[derive(Debug)]
struct TrimSegment {
    header_start: usize,
    trailer: Option<(usize, usize)>,
}

#[derive(Debug)]
struct TrimRow {
    start: usize,
    live_headers: Vec<String>,
    anonymous_segment: Option<usize>,
}

pub(super) fn trim_toonl_keep_last(input: &str, keep_last: usize) -> Result<TrimPlan, String> {
    validate_toonl(input)?;
    let scan = scan_toonl_trim_units(input)?;

    if scan.rows.len() <= keep_last {
        return Ok(TrimPlan {
            output: input.to_owned(),
            changed: false,
        });
    }

    let (headers, suffix_start) = if keep_last == 0 {
        (scan.live_headers_at_end.clone(), input.len())
    } else {
        let cut_index = scan.rows.len() - keep_last;
        let cut = &scan.rows[cut_index];
        (cut.live_headers.clone(), cut.start)
    };

    let mut output = String::new();
    for header in &headers {
        output.push_str(&line_with_lf(header));
    }
    if keep_last == 0 {
        if scan
            .last_anonymous_segment
            .and_then(|segment| scan.segments.get(segment))
            .and_then(|segment| segment.trailer)
            .is_some()
        {
            output.push_str("[=0]\n");
        }
    } else {
        append_trimmed_suffix(input, suffix_start, &scan, &mut output);
    }
    validate_toonl(&output)?;

    Ok(TrimPlan {
        changed: output != input,
        output,
    })
}

#[derive(Debug)]
struct TrimScan {
    segments: Vec<TrimSegment>,
    rows: Vec<TrimRow>,
    live_headers_at_end: Vec<String>,
    last_anonymous_segment: Option<usize>,
}

fn validate_toonl(input: &str) -> Result<(), String> {
    for row in ToonlReader::new(Cursor::new(input.as_bytes())) {
        row.map_err(|error| error.to_string())?;
    }
    Ok(())
}

fn scan_toonl_trim_units(input: &str) -> Result<TrimScan, String> {
    let mut segments: Vec<TrimSegment> = Vec::new();
    let mut rows: Vec<TrimRow> = Vec::new();
    let mut current: Option<usize> = None;
    let mut last_segment: Option<usize> = None;
    let mut live_headers = LiveHeaders::default();
    let mut offset = 0;
    let mut line_number = 0;

    while offset < input.len() {
        let line_start = offset;
        let line_end = input[offset..]
            .find('\n')
            .map(|index| offset + index + 1)
            .unwrap_or(input.len());
        offset = line_end;
        line_number += 1;

        let raw_line = &input[line_start..line_end];
        let line = raw_line.trim_end_matches('\n').trim_end_matches('\r');
        if line.is_empty() {
            continue;
        }
        if is_toonl_trailer(line) {
            let segment = current
                .take()
                .ok_or_else(|| format!("line {line_number}: trailer without header"))?;
            segments[segment].trailer = Some((line_start, line_end));
            continue;
        }
        if let Some(header) = parse_toonl_trim_header(line) {
            match header {
                TrimHeader::Continuation => {}
                TrimHeader::Anonymous => {
                    let segment = segments.len();
                    segments.push(TrimSegment {
                        header_start: line_start,
                        trailer: None,
                    });
                    current = Some(segment);
                    last_segment = Some(segment);
                    live_headers.set_anonymous(raw_line.to_owned());
                }
                TrimHeader::Tagged(tag) => {
                    live_headers.set_tagged(tag, raw_line.to_owned());
                }
            }
            continue;
        }

        let anonymous_segment = if is_toonl_tagged_row(line, &live_headers) {
            None
        } else {
            Some(current.ok_or_else(|| format!("line {line_number}: row before header"))?)
        };
        rows.push(TrimRow {
            start: line_start,
            live_headers: live_headers.lines(),
            anonymous_segment,
        });
    }

    Ok(TrimScan {
        segments,
        rows,
        live_headers_at_end: live_headers.lines(),
        last_anonymous_segment: last_segment,
    })
}

fn is_toonl_trailer(line: &str) -> bool {
    line.starts_with("[=") && line.ends_with(']')
}

fn parse_toonl_trim_header(line: &str) -> Option<TrimHeader> {
    let rest = line.strip_prefix('[')?;
    let close_bracket = rest.find(']')?;
    let bracket = &rest[..close_bracket];
    let continuation = bracket.starts_with('~');
    let delimiter = if continuation { &bracket[1..] } else { bracket };
    if !matches!(delimiter, "" | "|" | "\t") {
        return None;
    }
    let mut suffix = &rest[close_bracket + 1..];
    if continuation {
        return if suffix.starts_with('{') && suffix.ends_with("}:") {
            Some(TrimHeader::Continuation)
        } else {
            None
        };
    }
    if let Some(after_open) = suffix.strip_prefix('<') {
        let tag_end = after_open.find('>')?;
        let tag = &after_open[..tag_end];
        suffix = &after_open[tag_end + 1..];
        return if suffix.starts_with('{') && suffix.ends_with("}:") {
            Some(TrimHeader::Tagged(tag.to_owned()))
        } else {
            None
        };
    }
    if suffix.starts_with('{') && suffix.ends_with("}:") {
        Some(TrimHeader::Anonymous)
    } else {
        None
    }
}

fn is_toonl_tagged_row(line: &str, live_headers: &LiveHeaders) -> bool {
    let Some(colon) = line.find(':') else {
        return false;
    };
    if colon == 0 {
        return false;
    }
    let tag = &line[..colon];
    live_headers.has_tag(tag)
        && tag
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
}

fn append_trimmed_suffix(input: &str, suffix_start: usize, scan: &TrimScan, output: &mut String) {
    let mut cursor = suffix_start;
    for (segment_index, segment) in scan.segments.iter().enumerate() {
        let Some((trailer_start, trailer_end)) = segment.trailer else {
            continue;
        };
        if trailer_start < suffix_start || segment.header_start >= suffix_start {
            continue;
        }
        output.push_str(&input[cursor..trailer_start]);
        let retained = scan
            .rows
            .iter()
            .filter(|row| row.start >= suffix_start && row.anonymous_segment == Some(segment_index))
            .count();
        output.push_str(&format!("[={retained}]\n"));
        cursor = trailer_end;
    }
    output.push_str(&input[cursor..]);
}

#[derive(Debug)]
enum TrimHeader {
    Anonymous,
    Continuation,
    Tagged(String),
}

#[derive(Debug, Default)]
struct LiveHeaders {
    order: Vec<LiveHeaderKey>,
    anonymous: Option<String>,
    tagged: Vec<(String, String)>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum LiveHeaderKey {
    Anonymous,
    Tagged(String),
}

impl LiveHeaders {
    fn set_anonymous(&mut self, header: String) {
        if self.anonymous.is_none() {
            self.order.push(LiveHeaderKey::Anonymous);
        }
        self.anonymous = Some(header);
    }

    fn set_tagged(&mut self, tag: String, header: String) {
        if let Some((_, existing)) = self
            .tagged
            .iter_mut()
            .find(|(existing_tag, _)| existing_tag == &tag)
        {
            *existing = header;
            return;
        }
        self.order.push(LiveHeaderKey::Tagged(tag.clone()));
        self.tagged.push((tag, header));
    }

    fn has_tag(&self, tag: &str) -> bool {
        self.tagged
            .iter()
            .any(|(existing_tag, _)| existing_tag == tag)
    }

    fn lines(&self) -> Vec<String> {
        self.order
            .iter()
            .filter_map(|key| match key {
                LiveHeaderKey::Anonymous => self.anonymous.clone(),
                LiveHeaderKey::Tagged(tag) => self
                    .tagged
                    .iter()
                    .find(|(existing_tag, _)| existing_tag == tag)
                    .map(|(_, header)| header.clone()),
            })
            .collect()
    }
}

fn line_with_lf(line: &str) -> String {
    if line.ends_with('\n') {
        line.to_owned()
    } else {
        format!("{line}\n")
    }
}

pub(super) fn write_in_place_atomically(path: &str, bytes: &[u8]) -> Result<(), String> {
    let path = Path::new(path);
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .ok_or_else(|| "input path must name a file".to_owned())?;

    let mut last_error = None;
    for attempt in 0..100 {
        let tmp_path = parent.join(format!(
            ".{file_name}.tq-trim.{}.{}.tmp",
            process::id(),
            attempt
        ));
        match write_temp_then_rename(path, &tmp_path, bytes) {
            Ok(()) => return Ok(()),
            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
                last_error = Some(error);
            }
            Err(error) => {
                let _ = fs::remove_file(&tmp_path);
                return Err(format!("{}: {error}", path.display()));
            }
        }
    }

    Err(format!(
        "{}: could not create temporary trim file: {}",
        path.display(),
        last_error
            .map(|error| error.to_string())
            .unwrap_or_else(|| "too many collisions".to_owned())
    ))
}

fn write_temp_then_rename(path: &Path, tmp_path: &PathBuf, bytes: &[u8]) -> io::Result<()> {
    {
        let mut file = OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(tmp_path)?;
        file.write_all(bytes)?;
        file.sync_all()?;
    }
    fs::rename(tmp_path, path)
}