errsight 0.1.1

Rust client for ErrSight error tracking — captures panics, errors, and log/tracing events and ships them to the ErrSight API from a background thread.
Documentation
//! Source-context fetching: the snippet of code around a failing line.
//!
//! Turns "frame at user.rs:42" into "here are the five lines on either side of
//! user.rs:42." Reads are cached in a small bounded LRU because a single
//! backtrace touches the same files repeatedly. Everything here is best-effort
//! and never panics — a missing or unreadable file just yields no context.

use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex};

/// Lines of context on each side of the failing line. Five is a comfortable
/// debugging window and matches peer SDKs.
const CONTEXT_LINES: usize = 5;

/// Per-line byte cap. A stray minified/generated line shouldn't bloat the
/// event or push it past the ingestion limit.
const MAX_LINE_BYTES: usize = 256;

/// Number of distinct files to keep cached. ~100 files × ~100 lines is a small
/// footprint and absorbs cross-capture reuse.
const CACHE_CAPACITY: usize = 100;

/// Skip files larger than this. We only ever use ±5 lines, so reading and
/// retaining a multi-megabyte source file (a generated or misclassified file)
/// would be pure waste; skipping just means that frame lacks source context.
const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;

/// The resolved context for one frame.
pub struct SourceContext {
    pub pre: Vec<String>,
    pub line: String,
    pub post: Vec<String>,
}

struct LineCache {
    /// `Arc` so a cache hit clones a pointer, not the whole file — a single
    /// backtrace can hit the same file across many frames.
    files: HashMap<String, Arc<Vec<String>>>,
    /// LRU order, most-recently-used at the back.
    order: Vec<String>,
}

static CACHE: LazyLock<Mutex<LineCache>> = LazyLock::new(|| {
    Mutex::new(LineCache {
        files: HashMap::new(),
        order: Vec::new(),
    })
});

/// Fetch context for `lineno` (1-based) in `path`, or `None` if the file can't
/// be read or the line is out of range.
pub fn fetch(path: &str, lineno: u32) -> Option<SourceContext> {
    if lineno == 0 || path.is_empty() {
        return None;
    }
    let lines = read_lines_cached(path)?;
    let idx = (lineno - 1) as usize;
    if idx >= lines.len() {
        return None;
    }

    let pre_start = idx.saturating_sub(CONTEXT_LINES);
    let post_end = (idx + CONTEXT_LINES).min(lines.len() - 1);

    Some(SourceContext {
        pre: lines[pre_start..idx].iter().map(|l| truncate(l)).collect(),
        line: truncate(&lines[idx]),
        post: lines[idx + 1..=post_end]
            .iter()
            .map(|l| truncate(l))
            .collect(),
    })
}

/// Test-only: clear the cache so a test that rewrites a fixture sees it fresh.
#[cfg(test)]
pub fn reset_cache() {
    if let Ok(mut c) = CACHE.lock() {
        c.files.clear();
        c.order.clear();
    }
}

fn read_lines_cached(path: &str) -> Option<Arc<Vec<String>>> {
    let mut cache = match CACHE.lock() {
        Ok(c) => c,
        Err(poisoned) => poisoned.into_inner(),
    };

    if cache.files.contains_key(path) {
        touch(&mut cache, path);
        return cache.files.get(path).cloned(); // clones the Arc, not the Vec
    }

    // Skip pathologically large files before reading them into memory.
    let meta = std::fs::metadata(path).ok()?;
    if meta.len() > MAX_FILE_BYTES {
        return None;
    }

    // Read off-lock would be nicer, but source files are small and reads are
    // rare (only on capture, only for in_app frames). Keep it simple and
    // correct: a brief lock hold is acceptable on the cold path.
    let contents = std::fs::read(path).ok()?;
    // Lossy UTF-8 so a file with odd bytes still yields context rather than
    // failing the whole frame.
    let text = String::from_utf8_lossy(&contents);
    let lines = Arc::new(text.lines().map(|l| l.to_string()).collect::<Vec<String>>());

    cache.files.insert(path.to_string(), lines.clone());
    cache.order.push(path.to_string());
    evict_if_needed(&mut cache);

    Some(lines)
}

fn touch(cache: &mut LineCache, path: &str) {
    if let Some(pos) = cache.order.iter().position(|p| p == path) {
        let key = cache.order.remove(pos);
        cache.order.push(key);
    }
}

fn evict_if_needed(cache: &mut LineCache) {
    while cache.order.len() > CACHE_CAPACITY {
        let evicted = cache.order.remove(0);
        cache.files.remove(&evicted);
    }
}

/// Truncate on a char boundary so multi-byte UTF-8 isn't split mid-codepoint.
fn truncate(line: &str) -> String {
    if line.len() <= MAX_LINE_BYTES {
        return line.to_string();
    }
    let mut end = MAX_LINE_BYTES;
    while end > 0 && !line.is_char_boundary(end) {
        end -= 1;
    }
    format!("{}…[truncated]", &line[..end])
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    #[test]
    fn fetches_window_around_line() {
        reset_cache();
        let mut tmp = std::env::temp_dir();
        tmp.push(format!("errsight_src_test_{}.rs", crate::util::new_uuid()));
        {
            let mut f = std::fs::File::create(&tmp).unwrap();
            for i in 1..=20 {
                writeln!(f, "line {i}").unwrap();
            }
        }
        let path = tmp.display().to_string();
        let ctx = fetch(&path, 10).expect("context");
        assert_eq!(ctx.line, "line 10");
        assert_eq!(
            ctx.pre,
            vec!["line 5", "line 6", "line 7", "line 8", "line 9"]
        );
        assert_eq!(
            ctx.post,
            vec!["line 11", "line 12", "line 13", "line 14", "line 15"]
        );
        let _ = std::fs::remove_file(&tmp);
    }

    #[test]
    fn out_of_range_and_missing_return_none() {
        reset_cache();
        assert!(fetch("/no/such/file/at/all.rs", 1).is_none());
        assert!(fetch("", 1).is_none());
    }

    #[test]
    fn truncates_long_lines_on_char_boundary() {
        let long = "é".repeat(500); // 2 bytes each → 1000 bytes
        let t = truncate(&long);
        assert!(t.ends_with("…[truncated]"));
        assert!(t.len() <= MAX_LINE_BYTES + "…[truncated]".len());
    }
}