raymon 0.7.0

Stateful MCP server and TUI for Ray-style logs
Documentation
//! JSONL archive files for the TUI: live buffering, export snapshots, and directory scanning.
//!
//! Archives are ordinary `.jsonl` files under the configured archive directory.

use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, BufWriter};
use std::path::{Path, PathBuf};
use std::time::Instant;

use chrono::{DateTime, Utc};

/// One `.jsonl` archive visible in the TUI archives pane.
#[derive(Debug, Clone)]
pub struct ArchiveFile {
    pub name: String,
    pub count: usize,
    pub path: PathBuf,
    pub live: bool,
}

pub(super) const LIVE_ARCHIVE_FLUSH_EVERY_ENTRIES: usize = 64;
pub(super) const LIVE_ARCHIVE_FLUSH_EVERY_MS: u64 = 1_000;

pub(super) struct LiveArchive {
    pub path: PathBuf,
    pub writer: BufWriter<File>,
    pub writes_since_flush: usize,
    pub last_flush_at: Instant,
}

pub(super) fn archive_stamp(now: DateTime<Utc>) -> String {
    let millis = now.timestamp_subsec_millis();
    format!("{}-{:03}Z", now.format("%Y%m%dT%H%M%S"), millis)
}

pub(super) fn archive_display_name(path: &Path) -> String {
    path.file_stem().and_then(|value| value.to_str()).unwrap_or("archive").to_string()
}

pub(super) fn create_unique_jsonl_file(
    dir: &Path,
    base_name: &str,
) -> Result<(PathBuf, File), std::io::Error> {
    for attempt in 0..=999u16 {
        let filename = if attempt == 0 {
            format!("{base_name}.jsonl")
        } else {
            format!("{base_name}-{attempt}.jsonl")
        };
        let path = dir.join(filename);
        match OpenOptions::new().create_new(true).append(true).open(&path) {
            Ok(file) => return Ok((path, file)),
            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
            Err(err) => return Err(err),
        }
    }
    Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "archive file already exists"))
}

pub(super) fn scan_archives(
    dir: &Path,
    live_path: Option<&Path>,
) -> Result<Vec<ArchiveFile>, std::io::Error> {
    let entries = match fs::read_dir(dir) {
        Ok(entries) => entries,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(err) => return Err(err),
    };

    let mut archives = Vec::new();

    for entry in entries {
        let entry = entry?;
        let path = entry.path();
        if !path.is_file() {
            continue;
        }
        if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
            continue;
        }

        let name = archive_display_name(&path);
        let count = count_jsonl_lines(&path)?;
        let live = live_path.is_some_and(|live| live == path.as_path());
        archives.push(ArchiveFile { name, count, path, live });
    }

    Ok(archives)
}

fn count_jsonl_lines(path: &Path) -> Result<usize, std::io::Error> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);
    let mut count = 0usize;
    for line in reader.lines() {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }
        count += 1;
    }
    Ok(count)
}