unicode-plot 0.1.0

unicode-plot-rs: Unicode terminal plotting library for Rust
Documentation
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};

use crate::Plot;
use crate::graphics::GraphicsArea;

fn fixture_path(path: impl AsRef<Path>) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join(path)
}

fn first_diff_char_index(left: &str, right: &str) -> usize {
    left.chars()
        .zip(right.chars())
        .position(|(a, b)| a != b)
        .unwrap_or_else(|| left.chars().count().min(right.chars().count()))
}

fn split_preserving_newlines(text: &str) -> Vec<&str> {
    if text.is_empty() {
        return Vec::new();
    }

    text.split_inclusive('\n').collect()
}

/// Asserts that `actual` matches the fixture at `relative_path` exactly.
///
/// On mismatch, this includes per-line diagnostics with line numbers and the
/// first differing character index for each changed line.
#[track_caller]
pub(crate) fn assert_fixture_eq(actual: &str, relative_path: impl AsRef<Path>) {
    let resolved_path = fixture_path(relative_path);
    let expected = fs::read_to_string(&resolved_path).unwrap_or_else(|error| {
        panic!(
            "failed to read fixture {}: {error}",
            resolved_path.display()
        )
    });

    if expected == actual {
        return;
    }

    let expected_lines = split_preserving_newlines(&expected);
    let actual_lines = split_preserving_newlines(actual);
    let max_lines = expected_lines.len().max(actual_lines.len());

    let mut details = String::new();
    for index in 0..max_lines {
        let expected_line = expected_lines.get(index).copied();
        let actual_line = actual_lines.get(index).copied();

        if expected_line == actual_line {
            continue;
        }

        let char_index = match (expected_line, actual_line) {
            (Some(left), Some(right)) => first_diff_char_index(left, right),
            _ => 0,
        };

        let expected_display = expected_line.unwrap_or("<missing>");
        let actual_display = actual_line.unwrap_or("<missing>");

        let _ = writeln!(
            details,
            "line {:>4}, char {:>4}: expected {:?}, actual {:?}",
            index + 1,
            char_index,
            expected_display,
            actual_display
        );
    }

    panic!(
        "fixture mismatch for {}\n{}",
        resolved_path.display(),
        details
    );
}

/// Renders `plot` via the production rendering pipeline and returns UTF-8 text.
#[track_caller]
pub(crate) fn render_plot_text<G: GraphicsArea>(plot: &Plot<G>, color: bool) -> String {
    let mut bytes = Vec::new();
    plot.render(&mut bytes, color)
        .expect("rendering fixture text should succeed");
    String::from_utf8(bytes).expect("plot output should be valid UTF-8")
}

#[cfg(test)]
mod tests {
    use std::any::Any;
    use std::fs;
    use std::path::Path;

    use super::assert_fixture_eq;
    use super::first_diff_char_index;

    fn panic_message(payload: &(dyn Any + Send)) -> String {
        if let Some(message) = payload.downcast_ref::<String>() {
            return message.clone();
        }

        if let Some(message) = payload.downcast_ref::<&'static str>() {
            return (*message).to_owned();
        }

        "<non-string panic payload>".to_owned()
    }

    fn write_temp_fixture(relative_path: &str, content: &str) {
        let absolute_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative_path);
        if let Some(parent) = absolute_path.parent() {
            fs::create_dir_all(parent).expect("parent dir should be creatable");
        }

        fs::write(&absolute_path, content).expect("fixture file should be writable");
    }

    #[test]
    fn first_diff_char_index_reports_prefix_extension() {
        assert_eq!(3, first_diff_char_index("abc", "abcd"));
    }

    #[test]
    fn first_diff_char_index_reports_same_length_difference() {
        assert_eq!(2, first_diff_char_index("abz", "abx"));
    }

    #[test]
    fn assert_fixture_eq_accepts_exact_match() {
        let path = "tests/fixtures/data/randn.txt";
        let contents = fs::read_to_string(path).expect("fixture should load");
        assert_fixture_eq(&contents, path);
    }

    #[test]
    fn assert_fixture_eq_supports_ansi_escaped_text() {
        let path = "target/test-fixtures/ansi.txt";
        let content = "\x1b[31mred\x1b[0m\n";
        write_temp_fixture(path, content);
        assert_fixture_eq(content, path);
    }

    #[test]
    fn assert_fixture_eq_reports_line_and_char_for_mismatch() {
        let path = "target/test-fixtures/mismatch.txt";
        write_temp_fixture(path, "abc\ndef\n");

        let panic = std::panic::catch_unwind(|| assert_fixture_eq("abc\ndxf\n", path))
            .expect_err("mismatch should panic");
        let message = panic_message(panic.as_ref());

        assert!(message.contains(path));
        assert!(message.contains("line    2, char    1"));
        assert!(message.contains("expected \"def\\n\""));
        assert!(message.contains("actual \"dxf\\n\""));
    }

    #[test]
    fn assert_fixture_eq_reports_trailing_newline_mismatch() {
        let path = "target/test-fixtures/newline-shape.txt";
        write_temp_fixture(path, "abc\n");

        let panic = std::panic::catch_unwind(|| assert_fixture_eq("abc", path))
            .expect_err("newline mismatch should panic");
        let message = panic_message(panic.as_ref());

        assert!(message.contains(path));
        assert!(message.contains("line    1, char    3"));
        assert!(message.contains("expected \"abc\\n\""));
        assert!(message.contains("actual \"abc\""));
    }
}