use std::io::Write;
use std::path::Path;
use crate::{Diagram, DistanceMatrix, Error, Result, SparseDistanceMatrix};
fn read_to_string(path: &Path) -> Result<String> {
std::fs::read_to_string(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))
}
fn tokens(line: &str) -> impl Iterator<Item = &str> {
line.split(|c: char| c == ',' || c.is_whitespace())
.filter(|t| !t.is_empty())
}
fn is_skipped(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.is_empty() || trimmed.starts_with('#')
}
pub fn read_point_cloud(path: &Path) -> Result<Vec<Vec<f64>>> {
let text = read_to_string(path)?;
let mut points: Vec<Vec<f64>> = Vec::new();
for (idx, line) in text.lines().enumerate() {
let lineno = idx + 1;
if is_skipped(line) {
continue;
}
let point = tokens(line)
.map(|t| {
t.parse::<f64>().map_err(|_| {
Error::InvalidInput(format!("{}:{lineno}: not a number: {t:?}", path.display()))
})
})
.collect::<Result<Vec<f64>>>()?;
if point.is_empty() {
continue;
}
if let Some(first) = points.first() {
if point.len() != first.len() {
return Err(Error::InvalidInput(format!(
"{}:{lineno}: point has {} coordinates, expected {}",
path.display(),
point.len(),
first.len()
)));
}
}
points.push(point);
}
Ok(points)
}
pub fn read_lower_distance_matrix(path: &Path) -> Result<DistanceMatrix> {
let text = read_to_string(path)?;
let mut data = Vec::new();
for (idx, line) in text.lines().enumerate() {
let lineno = idx + 1;
if is_skipped(line) {
continue;
}
for t in tokens(line) {
let value = t.parse::<f64>().map_err(|_| {
Error::InvalidInput(format!("{}:{lineno}: not a number: {t:?}", path.display()))
})?;
data.push(value);
}
}
DistanceMatrix::from_condensed(data)
}
pub fn read_sparse_matrix(path: &Path) -> Result<SparseDistanceMatrix> {
let text = read_to_string(path)?;
let mut triplets: Vec<(usize, usize, f64)> = Vec::new();
let mut n = 0usize;
for (idx, line) in text.lines().enumerate() {
let lineno = idx + 1;
if is_skipped(line) {
continue;
}
let fields: Vec<&str> = tokens(line).collect();
if fields.len() != 3 {
return Err(Error::InvalidInput(format!(
"{}:{lineno}: expected 'i j d', got {} fields",
path.display(),
fields.len()
)));
}
let parse_vertex = |t: &str| {
t.parse::<usize>().map_err(|_| {
Error::InvalidInput(format!(
"{}:{lineno}: not a vertex index: {t:?}",
path.display()
))
})
};
let i = parse_vertex(fields[0])?;
let j = parse_vertex(fields[1])?;
if i == usize::MAX || j == usize::MAX {
return Err(Error::InvalidInput(format!(
"{}:{lineno}: vertex index out of range",
path.display()
)));
}
let d = fields[2].parse::<f64>().map_err(|_| {
Error::InvalidInput(format!(
"{}:{lineno}: not a number: {:?}",
path.display(),
fields[2]
))
})?;
n = n.max(i + 1).max(j + 1);
triplets.push((i, j, d));
}
SparseDistanceMatrix::from_triplets(n, &triplets)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Ripser,
Csv,
}
pub fn write_diagram<W: Write>(
w: &mut W,
diagram: &Diagram,
format: OutputFormat,
max_dim: usize,
) -> Result<()> {
let io_err = |e: std::io::Error| Error::Io(e.to_string());
match format {
OutputFormat::Ripser => {
for dim in 0..=max_dim {
writeln!(w, "persistence intervals in dim {dim}:").map_err(io_err)?;
for bar in diagram.in_dim(dim) {
if bar.is_essential() {
writeln!(w, " [{}, )", bar.birth).map_err(io_err)?;
} else {
writeln!(w, " [{},{})", bar.birth, bar.death).map_err(io_err)?;
}
}
}
}
OutputFormat::Csv => {
writeln!(w, "dim,birth,death").map_err(io_err)?;
for bar in &diagram.bars {
writeln!(w, "{},{},{}", bar.dim, bar.birth, bar.death).map_err(io_err)?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Bar;
use std::path::PathBuf;
struct TempFile(PathBuf);
impl TempFile {
fn new(name: &str, contents: &str) -> Self {
let path =
std::env::temp_dir().join(format!("holos_io_test_{}_{name}", std::process::id()));
std::fs::write(&path, contents).unwrap();
TempFile(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[test]
fn point_cloud_mixed_separators() {
let f = TempFile::new("pc_mixed.csv", "0.0, 1.0\n2.0\t3.0\n4.0 5.0\n");
let points = read_point_cloud(f.path()).unwrap();
assert_eq!(points, vec![vec![0.0, 1.0], vec![2.0, 3.0], vec![4.0, 5.0]]);
}
#[test]
fn point_cloud_skips_comments_and_blanks() {
let f = TempFile::new(
"pc_comments.csv",
"# header comment\n\n1.0 2.0\n \n# mid comment\n3.0 4.0\n",
);
let points = read_point_cloud(f.path()).unwrap();
assert_eq!(points, vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
}
#[test]
fn point_cloud_inconsistent_dimension_reports_line() {
let f = TempFile::new("pc_baddim.csv", "1.0 2.0\n\n1.0 2.0 3.0\n");
let err = read_point_cloud(f.path()).unwrap_err();
let msg = err.to_string();
assert!(matches!(err, Error::InvalidInput(_)), "{msg}");
assert!(msg.contains(":3:"), "missing line number: {msg}");
assert!(msg.contains("3 coordinates"), "{msg}");
assert!(msg.contains("expected 2"), "{msg}");
}
#[test]
fn point_cloud_bad_token_reports_line() {
let f = TempFile::new("pc_badtok.csv", "1.0 2.0\n1.0 oops\n");
let err = read_point_cloud(f.path()).unwrap_err();
let msg = err.to_string();
assert!(matches!(err, Error::InvalidInput(_)), "{msg}");
assert!(msg.contains(":2:"), "missing line number: {msg}");
assert!(msg.contains("oops"), "{msg}");
}
#[test]
fn missing_file_is_io_error_with_path() {
let path = std::env::temp_dir().join(format!(
"holos_io_test_{}_does_not_exist",
std::process::id()
));
let err = read_point_cloud(&path).unwrap_err();
assert!(matches!(err, Error::Io(_)));
assert!(err.to_string().contains("does_not_exist"));
}
#[test]
fn lower_distance_round_trip() {
let f = TempFile::new("ld_roundtrip.lower", "1.5 2.5, 3.5\n");
let m = read_lower_distance_matrix(f.path()).unwrap();
let direct = DistanceMatrix::from_condensed(vec![1.5, 2.5, 3.5]).unwrap();
assert_eq!(m.len(), 3);
for i in 0..3 {
for j in 0..3 {
assert_eq!(m.get(i, j), direct.get(i, j));
}
}
assert_eq!(m.get(1, 0), 1.5);
assert_eq!(m.get(2, 0), 2.5);
assert_eq!(m.get(2, 1), 3.5);
}
#[test]
fn lower_distance_skips_comments_and_spans_lines() {
let f = TempFile::new("ld_comments.lower", "# 4 points\n1 2\n3 4\n\n5, 6\n");
let m = read_lower_distance_matrix(f.path()).unwrap();
assert_eq!(m.len(), 4);
assert_eq!(m.get(3, 2), 6.0);
}
#[test]
fn lower_distance_bad_length_errors() {
let f = TempFile::new("ld_badlen.lower", "1 2\n");
let err = read_lower_distance_matrix(f.path()).unwrap_err();
let msg = err.to_string();
assert!(matches!(err, Error::InvalidInput(_)), "{msg}");
assert!(msg.contains("condensed length"), "{msg}");
}
#[test]
fn lower_distance_bad_token_reports_line() {
let f = TempFile::new("ld_badtok.lower", "1.0\n2.0 x\n");
let err = read_lower_distance_matrix(f.path()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains(":2:"), "missing line number: {msg}");
assert!(msg.contains('x'), "{msg}");
}
fn sample_diagram() -> Diagram {
let mut diagram = Diagram {
bars: vec![
Bar {
dim: 0,
birth: 0.0,
death: f64::INFINITY,
},
Bar {
dim: 0,
birth: 0.0,
death: 0.25,
},
Bar {
dim: 1,
birth: 0.5,
death: 1.0,
},
],
};
diagram.canonicalize();
diagram
}
#[test]
fn ripser_output_format() {
let mut out = Vec::new();
write_diagram(&mut out, &sample_diagram(), OutputFormat::Ripser, 1).unwrap();
assert_eq!(
String::from_utf8(out).unwrap(),
"persistence intervals in dim 0:\n [0,0.25)\n [0, )\npersistence intervals in dim 1:\n [0.5,1)\n"
);
}
#[test]
fn csv_output_format() {
let mut out = Vec::new();
write_diagram(&mut out, &sample_diagram(), OutputFormat::Csv, 1).unwrap();
assert_eq!(
String::from_utf8(out).unwrap(),
"dim,birth,death\n0,0,0.25\n0,0,inf\n1,0.5,1\n"
);
}
#[test]
fn empty_diagram_ripser_output_prints_headers_only() {
let mut out = Vec::new();
write_diagram(&mut out, &Diagram::default(), OutputFormat::Ripser, 1).unwrap();
let text = String::from_utf8(out).unwrap();
assert_eq!(
text,
"persistence intervals in dim 0:\npersistence intervals in dim 1:\n"
);
}
}