use std::borrow::Cow;
use std::fs::{self, File};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::Path;
use std::time::SystemTime;
use anyhow::{Context, Result, bail};
use std::collections::BTreeMap;
use super::edit::Overlay;
use super::index::{self, RowIndex};
use super::jsonl;
use super::loader;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Stamp {
len: u64,
mtime: Option<SystemTime>,
}
impl Stamp {
pub fn of(path: &Path) -> Result<Self> {
let meta = fs::metadata(path).with_context(|| format!("cannot stat {}", path.display()))?;
Ok(Self {
len: meta.len(),
mtime: meta.modified().ok(),
})
}
pub fn still_matches(&self, path: &Path) -> bool {
Stamp::of(path).is_ok_and(|now| now == *self)
}
}
pub fn save(
src: &Path,
dst: &Path,
separator: u8,
has_header: bool,
overlay: &Overlay,
expected_rows: usize,
) -> Result<Saved> {
let mut index = None;
let preamble = loader::preamble(src, separator)?.bytes;
let stamp = replace(src, dst, |input, output| {
index = Some(splice(
input,
output,
separator,
preamble,
has_header,
overlay,
expected_rows,
)?);
Ok(())
})?;
Ok(Saved {
stamp,
index: index.expect("a successful splice built one"),
})
}
#[derive(Debug)]
pub struct Saved {
pub stamp: Stamp,
pub index: RowIndex,
}
pub fn save_json(
src: &Path,
dst: &Path,
columns: &[JsonColumn],
overlay: &Overlay,
expected_rows: usize,
) -> Result<Stamp> {
replace(src, dst, |input, output| {
splice_json(input, output, columns, overlay, expected_rows)
})
}
fn replace<F>(src: &Path, dst: &Path, write: F) -> Result<Stamp>
where
F: FnOnce(BufReader<File>, &mut BufWriter<File>) -> Result<()>,
{
let dir = dst.parent().unwrap_or_else(|| Path::new("."));
let name = dst
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("plv-output");
let tmp = dir.join(format!(".{name}.plv-tmp"));
let result = (|| -> Result<()> {
let input = BufReader::new(
File::open(src).with_context(|| format!("cannot read {}", src.display()))?,
);
let file =
File::create(&tmp).with_context(|| format!("cannot write beside {}", dst.display()))?;
let mut output = BufWriter::new(file);
write(input, &mut output)?;
output.flush()?;
output
.into_inner()
.map_err(|e| e.into_error())?
.sync_all()?;
Ok(())
})();
if let Err(e) = result {
let _ = fs::remove_file(&tmp);
return Err(e);
}
if let Ok(meta) = fs::metadata(dst) {
let _ = fs::set_permissions(&tmp, meta.permissions());
}
fs::rename(&tmp, dst).with_context(|| format!("cannot replace {}", dst.display()))?;
Stamp::of(dst)
}
pub fn splice<R: BufRead, W: Write>(
mut src: R,
out: W,
separator: u8,
preamble: u64,
has_header: bool,
overlay: &Overlay,
expected_rows: usize,
) -> Result<RowIndex> {
let mut splicer = Splicer::new(out, separator, has_header, overlay);
let mut passing = preamble;
if !has_header {
splicer.index.begin(preamble);
}
loop {
let chunk = src.fill_buf()?;
if chunk.is_empty() {
break;
}
let read = chunk.len();
let pass = passing.min(read as u64) as usize;
if pass > 0 {
splicer.out.write_all(&chunk[..pass])?;
splicer.written += pass as u64;
passing -= pass as u64;
}
for &byte in &chunk[pass..] {
splicer.byte(byte)?;
}
src.consume(read);
}
let (rows, applied, dropped, added, index) = splicer.finish()?;
if rows != expected_rows {
bail!(
"refusing to write: the file holds {rows} data rows but the view has \
{expected_rows} — it may have changed on disk"
);
}
if applied != overlay.len() {
bail!(
"refusing to write: {} of {} edits had no field to land in \
(a row with fewer fields than the header)",
overlay.len() - applied,
overlay.len()
);
}
if added != overlay.added_count() {
bail!(
"refusing to write: {} of {} added rows had nowhere to go",
overlay.added_count() - added,
overlay.added_count()
);
}
if dropped != overlay.struck_count() {
bail!(
"refusing to write: {} of {} deleted rows were not found",
overlay.struck_count() - dropped,
overlay.struck_count()
);
}
Ok(index)
}
pub struct JsonColumn {
pub path: Vec<String>,
pub kind: JsonType,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum JsonType {
Int,
Float,
Bool,
Text,
}
pub fn splice_json<R: BufRead, W: Write>(
mut src: R,
mut out: W,
columns: &[JsonColumn],
overlay: &Overlay,
expected_rows: usize,
) -> Result<()> {
let mut line = Vec::new();
let mut record = 0usize;
let (mut applied, mut dropped, mut added) = (0usize, 0usize, 0usize);
let mut newline: &[u8] = b"\n";
let mut unterminated = false;
loop {
line.clear();
if src.read_until(b'\n', &mut line)? == 0 {
break;
}
let body_len = line.len() - terminator(&line).len();
let (body, term) = line.split_at(body_len);
if !term.is_empty() {
newline = if term == b"\r\n" { b"\r\n" } else { b"\n" };
}
unterminated = term.is_empty();
added += write_added(&mut out, overlay, columns, record, newline, &mut applied)?;
if overlay.is_struck(record) {
dropped += 1;
record += 1;
continue;
}
match overlay.row(record) {
None => out.write_all(&line)?,
Some(edits) => {
let (rewritten, placed) = rewrite(body, edits, columns);
applied += placed;
out.write_all(&rewritten)?;
out.write_all(term)?;
}
}
record += 1;
}
if !overlay.added_at(record).is_empty() {
if unterminated {
out.write_all(newline)?;
}
added += write_added(&mut out, overlay, columns, record, newline, &mut applied)?;
}
out.flush()?;
if record != expected_rows {
bail!(
"refusing to write: the file holds {record} records but the view has \
{expected_rows} — it may have changed on disk"
);
}
if applied != overlay.len() {
bail!(
"refusing to write: {} of {} edits had no key to land in (a record \
that does not go that deep)",
overlay.len() - applied,
overlay.len()
);
}
if added != overlay.added_count() {
bail!(
"refusing to write: {} of {} added records had nowhere to go",
overlay.added_count() - added,
overlay.added_count()
);
}
if dropped != overlay.struck_count() {
bail!(
"refusing to write: {} of {} deleted records were not found",
overlay.struck_count() - dropped,
overlay.struck_count()
);
}
Ok(())
}
fn terminator(line: &[u8]) -> &[u8] {
if line.ends_with(b"\r\n") {
b"\r\n"
} else if line.ends_with(b"\n") {
b"\n"
} else {
b""
}
}
fn rewrite(
body: &[u8],
edits: &BTreeMap<usize, String>,
columns: &[JsonColumn],
) -> (Vec<u8>, usize) {
let mut splices: Vec<(std::ops::Range<usize>, String)> = Vec::new();
let mut appended: Vec<String> = Vec::new();
for (&col, value) in edits {
let Some(column) = columns.get(col) else {
continue;
};
let encoded = encode_json(value, column.kind);
match jsonl::locate(body, &column.path) {
Some(span) => splices.push((span, encoded)),
None if column.path.len() == 1 => {
appended.push(format!("{}:{}", json_string(&column.path[0]), encoded));
}
None => continue,
}
}
let placed = splices.len() + appended.len();
splices.sort_by_key(|(span, _)| span.start);
let mut out = Vec::with_capacity(body.len() + 64);
let mut at = 0usize;
for (span, encoded) in &splices {
if span.start < at {
continue; }
out.extend_from_slice(&body[at..span.start]);
out.extend_from_slice(encoded.as_bytes());
at = span.end;
}
out.extend_from_slice(&body[at..]);
if !appended.is_empty()
&& let Some((close, has_members)) = jsonl::insert_point(&out)
{
let mut addition = Vec::new();
for (i, member) in appended.iter().enumerate() {
if has_members || i > 0 {
addition.push(b',');
}
addition.extend_from_slice(member.as_bytes());
}
out.splice(close..close, addition);
}
(out, placed)
}
fn write_added<W: Write>(
out: &mut W,
overlay: &Overlay,
columns: &[JsonColumn],
before: usize,
newline: &[u8],
applied: &mut usize,
) -> Result<usize> {
let ids = overlay.added_at(before);
for &id in ids {
let cells = overlay.row(id);
let mut entries: Vec<(&[String], String)> = Vec::new();
if let Some(cells) = cells {
for (&col, value) in cells {
if value.is_empty() {
continue;
}
let Some(column) = columns.get(col) else {
continue;
};
entries.push((&column.path, encode_json(value, column.kind)));
*applied += 1;
}
}
if let Some(cells) = cells {
*applied += cells.values().filter(|v| v.is_empty()).count();
}
entries.sort_by_key(|(path, _)| *path);
out.write_all(build(&entries).as_bytes())?;
out.write_all(newline)?;
}
Ok(ids.len())
}
fn build(entries: &[(&[String], String)]) -> String {
let mut out = String::from("{");
let mut at = 0usize;
while at < entries.len() {
let key = &entries[at].0[0];
let mut end = at;
while end < entries.len() && &entries[end].0[0] == key {
end += 1;
}
if at > 0 {
out.push(',');
}
out.push_str(&json_string(key));
out.push(':');
if entries[at].0.len() == 1 {
out.push_str(&entries[at].1);
} else {
let inner: Vec<(&[String], String)> = entries[at..end]
.iter()
.map(|(path, value)| (&path[1..], value.clone()))
.collect();
out.push_str(&build(&inner));
}
at = end;
}
out.push('}');
out
}
fn encode_json(value: &str, kind: JsonType) -> String {
if value.is_empty() {
return "null".to_string();
}
let number = jsonl::is_number(value);
match kind {
JsonType::Int if number && value.parse::<i64>().is_ok() => value.to_string(),
JsonType::Float if number => value.to_string(),
JsonType::Bool if value == "true" || value == "false" => value.to_string(),
_ => json_string(value),
}
}
fn json_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
struct Splicer<'a, W: Write> {
out: W,
separator: u8,
overlay: &'a Overlay,
header_rows: usize,
record: usize,
record_started: bool,
field: usize,
field_started: bool,
replacing: bool,
in_quotes: bool,
pending_quote: bool,
pending_cr: bool,
applied: usize,
dropping: bool,
dropped: usize,
added: usize,
columns: usize,
newline: &'static [u8],
written: u64,
header: Vec<u8>,
index: index::Building,
}
impl<'a, W: Write> Splicer<'a, W> {
fn new(out: W, separator: u8, has_header: bool, overlay: &'a Overlay) -> Self {
Self {
out,
separator,
overlay,
header_rows: usize::from(has_header),
record: 0,
record_started: false,
field: 0,
field_started: false,
replacing: false,
in_quotes: false,
pending_quote: false,
pending_cr: false,
applied: 0,
dropping: false,
dropped: 0,
added: 0,
columns: 0,
newline: b"\n",
written: 0,
header: Vec::new(),
index: index::Building::new(),
}
}
fn byte(&mut self, b: u8) -> Result<()> {
if self.pending_quote {
self.pending_quote = false;
if b == b'"' {
self.emit(b"\"\"")?;
return Ok(());
}
self.emit(b"\"")?;
self.in_quotes = false;
}
if self.in_quotes {
if b == b'"' {
self.pending_quote = true;
} else {
self.emit(&[b])?;
}
return Ok(());
}
if self.pending_cr {
self.pending_cr = false;
if b == b'\n' {
return self.terminator(b"\r\n");
}
self.emit(b"\r")?;
}
match b {
b'\n' => self.terminator(b"\n"),
b'\r' => {
self.begin_field()?;
self.pending_cr = true;
Ok(())
}
_ if b == self.separator => {
self.begin_field()?;
self.end_field();
self.raw(&[b])
}
b'"' if !self.field_started => {
self.begin_field()?;
self.in_quotes = true;
self.emit(b"\"")
}
_ => {
self.begin_field()?;
self.emit(&[b])
}
}
}
fn begin_field(&mut self) -> Result<()> {
if !self.record_started {
self.record_started = true;
self.field = 0;
self.field_started = false;
if self.record >= self.header_rows {
self.write_added(self.record - self.header_rows)?;
}
self.dropping = self.record >= self.header_rows
&& self.overlay.is_struck(self.record - self.header_rows);
if self.dropping {
self.dropped += 1;
}
}
if self.field_started {
return Ok(());
}
self.field_started = true;
self.replacing = false;
if self.record >= self.header_rows
&& let Some(value) = self
.overlay
.get((self.record - self.header_rows, self.field))
{
self.replacing = true;
self.applied += 1;
if !self.dropping {
let encoded = encode(value, self.separator);
self.write_out(encoded.as_bytes())?;
}
}
Ok(())
}
fn write_added(&mut self, before: usize) -> Result<()> {
for &id in self.overlay.added_at(before) {
let cells = self.overlay.row(id);
for field in 0..self.columns {
if field > 0 {
self.write_out(&[self.separator])?;
}
if let Some(value) = cells.and_then(|cells| cells.get(&field)) {
let encoded = encode(value, self.separator);
self.write_out(encoded.as_bytes())?;
self.applied += 1;
}
}
self.write_out(self.newline)?;
self.index.record(self.written);
self.added += 1;
}
Ok(())
}
fn end_field(&mut self) {
self.field += 1;
self.field_started = false;
self.replacing = false;
}
fn terminator(&mut self, eol: &'static [u8]) -> Result<()> {
self.begin_field()?;
if self.record == 0 {
self.columns = self.field + 1;
self.newline = eol;
}
self.end_field();
self.record += 1;
self.record_started = false;
if self.dropping {
self.dropping = false;
return Ok(());
}
self.raw(eol)?;
if self.record == self.header_rows {
self.index.begin(self.written);
}
if self.record > self.header_rows {
self.index.record(self.written);
}
Ok(())
}
fn finish(mut self) -> Result<(usize, usize, usize, usize, RowIndex)> {
if self.pending_quote {
self.emit(b"\"")?;
self.in_quotes = false;
}
if self.pending_cr {
self.pending_cr = false;
self.emit(b"\r")?;
}
let unterminated = self.record_started;
if unterminated {
self.begin_field()?;
self.end_field();
self.record += 1;
if self.record > self.header_rows {
self.index.record(self.written);
}
}
let rows = self.record.saturating_sub(self.header_rows);
if !self.overlay.added_at(rows).is_empty() {
if unterminated {
self.write_out(self.newline)?;
}
self.write_added(rows)?;
}
self.out.flush()?;
let written = self.written;
let header = std::mem::take(&mut self.header);
Ok((
self.record.saturating_sub(self.header_rows),
self.applied,
self.dropped,
self.added,
self.index.finish(header, written),
))
}
fn emit(&mut self, bytes: &[u8]) -> Result<()> {
if self.replacing {
return Ok(());
}
self.raw(bytes)
}
fn raw(&mut self, bytes: &[u8]) -> Result<()> {
if self.dropping {
return Ok(());
}
self.write_out(bytes)
}
fn write_out(&mut self, bytes: &[u8]) -> Result<()> {
self.out.write_all(bytes)?;
self.written += bytes.len() as u64;
if self.record < self.header_rows {
self.header.extend_from_slice(bytes);
}
Ok(())
}
}
pub fn encode(value: &str, separator: u8) -> Cow<'_, str> {
let needs_quotes = value
.bytes()
.any(|b| b == separator || b == b'"' || b == b'\n' || b == b'\r');
if !needs_quotes {
return Cow::Borrowed(value);
}
Cow::Owned(format!("\"{}\"", value.replace('"', "\"\"")))
}
#[cfg(test)]
mod tests {
use super::*;
fn spliced(input: &str, edits: &[((usize, usize), &str)], rows: usize) -> String {
run(input, edits, rows, b',').expect("splice failed")
}
fn run(
input: &str,
edits: &[((usize, usize), &str)],
rows: usize,
separator: u8,
) -> Result<String> {
let mut overlay = Overlay::new();
for &(cell, value) in edits {
overlay.set([(cell, value.to_string())]);
}
let mut out = Vec::new();
splice(input.as_bytes(), &mut out, separator, 0, true, &overlay, rows)?;
Ok(String::from_utf8(out).unwrap())
}
#[test]
fn an_unedited_file_is_copied_byte_for_byte() {
let input = "\u{feff}name,note\r\n\"a, b\", x \r\n\"say \"\"hi\"\"\",y";
assert_eq!(spliced(input, &[], 2), input);
}
#[test]
fn only_the_edited_field_changes() {
let input = "name,count\na,1\nb,2\nc,3\n";
assert_eq!(
spliced(input, &[((1, 1), "99")], 3),
"name,count\na,1\nb,99\nc,3\n"
);
}
#[test]
fn the_header_is_not_a_data_row() {
let input = "name,count\na,1\n";
assert_eq!(spliced(input, &[((0, 0), "z")], 1), "name,count\nz,1\n");
}
#[test]
fn a_quoted_field_is_replaced_whole() {
let input = "name,note\n\"a, b\",\"keep, me\"\n";
assert_eq!(
spliced(input, &[((0, 0), "plain")], 1),
"name,note\nplain,\"keep, me\"\n"
);
}
#[test]
fn a_new_value_is_quoted_only_when_it_has_to_be() {
let input = "a,b\n1,2\n";
assert_eq!(spliced(input, &[((0, 0), "x,y")], 1), "a,b\n\"x,y\",2\n");
assert_eq!(
spliced(input, &[((0, 0), "say \"hi\"")], 1),
"a,b\n\"say \"\"hi\"\"\",2\n"
);
assert_eq!(
spliced(input, &[((0, 0), "two\nlines")], 1),
"a,b\n\"two\nlines\",2\n"
);
assert_eq!(spliced(input, &[((0, 0), "plain")], 1), "a,b\nplain,2\n");
assert_eq!(spliced(input, &[((0, 0), "")], 1), "a,b\n,2\n");
}
#[test]
fn a_newline_inside_quotes_does_not_end_the_record() {
let input = "name,note\n\"a\",\"line one\nline two\"\nb,plain\n";
assert_eq!(
spliced(input, &[((1, 1), "edited")], 2),
"name,note\n\"a\",\"line one\nline two\"\nb,edited\n"
);
}
#[test]
fn crlf_survives_an_edit() {
let input = "a,b\r\n1,2\r\n3,4\r\n";
assert_eq!(spliced(input, &[((0, 1), "9")], 2), "a,b\r\n1,9\r\n3,4\r\n");
}
#[test]
fn a_bare_cr_is_field_content() {
let input = "a,b\n1\r5,2\n";
assert_eq!(spliced(input, &[((0, 1), "9")], 1), "a,b\n1\r5,9\n");
assert_eq!(spliced(input, &[((0, 0), "x")], 1), "a,b\nx,2\n");
}
#[test]
fn an_empty_field_is_a_place_an_edit_can_land() {
let input = "a,b,c\n1,,3\n";
assert_eq!(spliced(input, &[((0, 1), "2")], 1), "a,b,c\n1,2,3\n");
}
#[test]
fn a_trailing_empty_field_can_be_edited() {
let input = "a,b,c\n1,2,\n";
assert_eq!(spliced(input, &[((0, 2), "3")], 1), "a,b,c\n1,2,3\n");
}
#[test]
fn a_missing_final_newline_stays_missing() {
let input = "a,b\n1,2";
assert_eq!(spliced(input, &[((0, 1), "9")], 1), "a,b\n1,9");
}
#[test]
fn a_blank_line_takes_a_row_number_of_its_own() {
let input = "a,b\nx,1\n\ny,2\n";
assert_eq!(spliced(input, &[((2, 0), "z")], 3), "a,b\nx,1\n\nz,2\n");
}
#[test]
fn a_trailing_blank_line_is_a_row_too() {
let input = "a,b\nx,1\n\n";
assert_eq!(spliced(input, &[((0, 0), "z")], 2), "a,b\nz,1\n\n");
}
#[test]
fn a_tab_separated_file_quotes_on_tabs_not_commas() {
let input = "a\tb\n1\t2\n";
assert_eq!(
run(input, &[((0, 0), "x,y")], 1, b'\t').unwrap(),
"a\tb\nx,y\t2\n"
);
assert_eq!(
run(input, &[((0, 0), "x\ty")], 1, b'\t').unwrap(),
"a\tb\n\"x\ty\"\t2\n"
);
}
#[test]
fn several_edits_in_one_record() {
let input = "a,b,c\n1,2,3\n";
assert_eq!(
spliced(input, &[((0, 0), "x"), ((0, 2), "z")], 1),
"a,b,c\nx,2,z\n"
);
}
fn without(input: &str, struck: &[usize], rows: usize) -> String {
let mut overlay = Overlay::new();
overlay.delete(struck.iter().copied(), usize::MAX);
let mut out = Vec::new();
splice(input.as_bytes(), &mut out, b',', 0, true, &overlay, rows).expect("splice failed");
String::from_utf8(out).unwrap()
}
#[test]
fn a_struck_row_leaves_nothing_behind() {
let input = "name,count\na,1\nb,2\nc,3\n";
assert_eq!(without(input, &[1], 3), "name,count\na,1\nc,3\n");
assert_eq!(without(input, &[0, 2], 3), "name,count\nb,2\n");
assert_eq!(without(input, &[0, 1, 2], 3), "name,count\n");
}
#[test]
fn the_header_is_never_struck() {
let input = "name,count\na,1\nb,2\n";
assert_eq!(without(input, &[0], 2), "name,count\nb,2\n");
}
#[test]
fn deleting_a_row_with_a_quoted_newline_removes_all_of_it() {
let input = "a,b\n\"one\ntwo\",x\ny,z\n";
assert_eq!(without(input, &[0], 2), "a,b\ny,z\n");
}
#[test]
fn crlf_survives_a_deletion() {
let input = "a,b\r\n1,2\r\n3,4\r\n";
assert_eq!(without(input, &[0], 2), "a,b\r\n3,4\r\n");
}
#[test]
fn deleting_the_last_row_of_a_file_with_no_final_newline() {
let input = "a,b\n1,2\n3,4";
assert_eq!(without(input, &[1], 2), "a,b\n1,2\n");
assert_eq!(without(input, &[0], 2), "a,b\n3,4");
}
#[test]
fn a_row_can_be_edited_and_struck_in_the_same_write() {
let mut overlay = Overlay::new();
overlay.set([((0, 1), "99".to_string())]);
overlay.delete([1], usize::MAX);
let mut out = Vec::new();
splice(
"name,count\na,1\nb,2\nc,3\n".as_bytes(),
&mut out,
b',',
0,
true,
&overlay,
3,
)
.unwrap();
assert_eq!(
String::from_utf8(out).unwrap(),
"name,count\na,99\nc,3\n",
"the edit lands and the other row goes"
);
}
#[test]
fn an_edit_on_a_struck_row_is_written_nowhere() {
let mut overlay = Overlay::new();
overlay.set([((1, 0), "zz".to_string())]);
overlay.delete([1], usize::MAX);
let mut out = Vec::new();
splice(
"name,count\na,1\nb,2\n".as_bytes(),
&mut out,
b',',
0,
true,
&overlay,
2,
)
.unwrap();
assert_eq!(String::from_utf8(out).unwrap(), "name,count\na,1\n");
}
#[test]
fn a_deletion_that_finds_no_row_is_refused() {
let mut overlay = Overlay::new();
overlay.delete([9], usize::MAX);
let mut out = Vec::new();
let e = splice("a,b\n1,2\n".as_bytes(), &mut out, b',', 0, true, &overlay, 1)
.unwrap_err()
.to_string();
assert!(e.contains("deleted rows were not found"), "{e}");
}
fn with_added(input: &str, at: &[(usize, &[(usize, &str)])], rows: usize) -> String {
let mut overlay = Overlay::new();
for &(before, cells) in at {
let id = overlay.add_row(before, usize::MAX, rows);
for &(field, value) in cells {
overlay.set([((id, field), value.to_string())]);
}
}
let mut out = Vec::new();
splice(input.as_bytes(), &mut out, b',', 0, true, &overlay, rows).expect("splice failed");
String::from_utf8(out).unwrap()
}
#[test]
fn an_added_row_goes_in_before_the_row_it_was_put_before() {
let input = "name,count\na,1\nb,2\n";
assert_eq!(
with_added(input, &[(1, &[(0, "new"), (1, "9")])], 2),
"name,count\na,1\nnew,9\nb,2\n"
);
assert_eq!(
with_added(input, &[(0, &[(0, "first")])], 2),
"name,count\nfirst,\na,1\nb,2\n",
"before the first row, and still under the header"
);
}
#[test]
fn a_row_added_past_the_last_one_lands_at_the_end() {
let input = "name,count\na,1\nb,2\n";
assert_eq!(
with_added(input, &[(2, &[(0, "last"), (1, "3")])], 2),
"name,count\na,1\nb,2\nlast,3\n"
);
}
#[test]
fn an_added_row_has_a_field_for_every_column() {
let input = "a,b,c\n1,2,3\n";
assert_eq!(
with_added(input, &[(1, &[(1, "only")])], 1),
"a,b,c\n1,2,3\n,only,\n"
);
}
#[test]
fn added_rows_keep_the_files_own_line_ending() {
let input = "a,b\r\n1,2\r\n";
assert_eq!(
with_added(input, &[(1, &[(0, "x"), (1, "y")])], 1),
"a,b\r\n1,2\r\nx,y\r\n"
);
}
#[test]
fn a_row_added_to_a_file_with_no_final_newline_gets_one_first() {
let input = "a,b\n1,2";
assert_eq!(
with_added(input, &[(1, &[(0, "x"), (1, "y")])], 1),
"a,b\n1,2\nx,y\n",
"or the added row would join the last one"
);
}
#[test]
fn several_rows_added_at_one_place_keep_their_order() {
let input = "a,b\n1,2\n";
let mut overlay = Overlay::new();
for name in ["one", "two", "three"] {
let id = overlay.add_row(1, usize::MAX, 1);
overlay.set([((id, 0), name.to_string())]);
}
let mut out = Vec::new();
splice(input.as_bytes(), &mut out, b',', 0, true, &overlay, 1).unwrap();
assert_eq!(
String::from_utf8(out).unwrap(),
"a,b\n1,2\none,\ntwo,\nthree,\n"
);
}
#[test]
fn a_value_needing_quotes_is_quoted_in_an_added_row() {
let input = "a,b\n1,2\n";
assert_eq!(
with_added(input, &[(1, &[(0, "x,y"), (1, "say \"hi\"")])], 1),
"a,b\n1,2\n\"x,y\",\"say \"\"hi\"\"\"\n"
);
}
#[test]
fn a_row_added_where_no_row_exists_is_refused() {
let mut overlay = Overlay::new();
overlay.add_row(9, 0, 2);
let mut out = Vec::new();
let e = splice(
"a,b\n1,2\n3,4\n".as_bytes(),
&mut out,
b',',
0,
true,
&overlay,
2,
)
.unwrap_err()
.to_string();
assert!(e.contains("added rows had nowhere to go"), "{e}");
}
#[test]
fn a_row_count_that_disagrees_is_refused() {
let err = run("a,b\n1,2\n", &[], 5, b',').unwrap_err().to_string();
assert!(err.contains("1 data rows"), "{err}");
assert!(err.contains("changed on disk"), "{err}");
}
#[test]
fn an_edit_with_no_field_to_land_in_is_refused() {
let err = run("a,b\n1,2\n", &[((0, 4), "x")], 1, b',')
.unwrap_err()
.to_string();
assert!(err.contains("no field to land in"), "{err}");
}
#[test]
fn a_comment_preamble_survives_a_write_and_the_new_index_agrees() {
let dir = std::env::temp_dir().join("plv-writer-preamble");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("notes.tsv");
let before = "# kept\r\n# as is, here\r\nname\tn\r\na\t1\r\nb\t2\r\n";
fs::write(&path, before).unwrap();
let mut overlay = Overlay::new();
overlay.set([((1, 1), "9".to_string())]);
let saved = save(&path, &path, b'\t', true, &overlay, 2).unwrap();
let after = fs::read_to_string(&path).unwrap();
assert_eq!(after, before.replace("b\t2", "b\t9"));
let fresh = RowIndex::build(&path, b'\t').unwrap();
assert_eq!(
saved.index.seek(0),
fresh.seek(0),
"row 0 is where a rescan puts it"
);
assert_eq!(saved.index.header(), fresh.header());
assert_eq!(saved.index.rows(), 2);
}
#[test]
fn save_replaces_the_file_atomically_and_leaves_no_temp_file() {
let dir = std::env::temp_dir().join("plv-writer-tests");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("save.csv");
fs::write(&path, "a,b\n1,2\n").unwrap();
let mut overlay = Overlay::new();
overlay.set([((0, 1), "9".to_string())]);
let saved = save(&path, &path, b',', true, &overlay, 1).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "a,b\n1,9\n");
assert!(saved.stamp.still_matches(&path));
assert_eq!(saved.index.rows(), 1, "and it knows the file it wrote");
assert!(!dir.join(".save.csv.plv-tmp").exists());
}
#[test]
fn the_index_a_write_returns_describes_the_file_it_wrote() {
let dir = std::env::temp_dir().join("plv-writer-tests");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("index-after.csv");
fs::write(&path, "a,b\n1,2\n3,4\n5,6\n7,8\n").unwrap();
let mut overlay = Overlay::new();
overlay.set([((0, 1), "a much longer value".to_string())]);
overlay.delete([2], 4);
let id = overlay.add_row(1, 0, 4);
overlay.set([((id, 0), "new".to_string())]);
let saved = save(&path, &path, b',', true, &overlay, 4).unwrap();
let fresh = RowIndex::build(&path, b',').unwrap();
assert_eq!(saved.index.rows(), fresh.rows(), "records");
assert_eq!(
saved.index.end_of(fresh.rows()),
fresh.end_of(fresh.rows()),
"and the same number of bytes: a replacement written past the \
counter would leave every offset after it short"
);
assert_eq!(saved.index.header(), fresh.header());
}
#[test]
fn a_refused_save_leaves_the_original_untouched() {
let dir = std::env::temp_dir().join("plv-writer-tests");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("refused.csv");
fs::write(&path, "a,b\n1,2\n").unwrap();
let err = save(&path, &path, b',', true, &Overlay::new(), 7).unwrap_err();
assert!(err.to_string().contains("changed on disk"));
assert_eq!(fs::read_to_string(&path).unwrap(), "a,b\n1,2\n");
assert!(!dir.join(".refused.csv.plv-tmp").exists());
}
#[test]
fn record_numbering_agrees_with_the_polars_reader() {
use crate::data::loader;
let dir = std::env::temp_dir().join("plv-writer-tests");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("roundtrip.csv");
let source = "name,note\nx,1\n\n\"a, b\",\"two\nlines\"\nz,3\n";
fs::write(&path, source).unwrap();
let df = loader::load(&path).unwrap().collect().unwrap();
let rows = df.height();
let mut overlay = Overlay::new();
overlay.set([((rows - 1, 1), "edited".to_string())]);
let separator = loader::separator(&path).unwrap().unwrap();
save(&path, &path, separator, true, &overlay, rows).unwrap();
let after = loader::load(&path).unwrap().collect().unwrap();
assert_eq!(after.height(), rows, "the write changed the row count");
let note = after.column("note").unwrap().str().unwrap();
assert_eq!(note.get(rows - 1), Some("edited"));
let name = after.column("name").unwrap().str().unwrap();
assert_eq!(name.get(0), Some("x"));
assert_eq!(name.get(rows - 1), Some("z"));
}
#[test]
fn a_stamp_notices_a_file_changing_underneath_it() {
let dir = std::env::temp_dir().join("plv-writer-tests");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("stamp.csv");
fs::write(&path, "a,b\n1,2\n").unwrap();
let stamp = Stamp::of(&path).unwrap();
assert!(stamp.still_matches(&path));
fs::write(&path, "a,b\n1,2\n3,4\n").unwrap();
assert!(!stamp.still_matches(&path));
}
}