framesmith-cli 0.1.0

CLI tool for controlling Samsung Frame TVs over the local network
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};

const MAX_SIZE: u64 = 5 * 1024 * 1024; // 5 MB
const TARGET_SIZE: u64 = 2_500_000; // 2.5 MB
const MAX_AGE_SECS: u64 = 24 * 60 * 60; // 24 hours

/// A `Write` implementation that rotates a log file when it exceeds `MAX_SIZE`.
///
/// Before each write, it checks the file size and prunes old entries if needed.
/// Log lines are expected to start with an ISO-8601 timestamp.
pub struct RotatingLogWriter {
    path: PathBuf,
    file: File,
}

impl RotatingLogWriter {
    pub fn new(path: &Path) -> std::io::Result<Self> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let file = OpenOptions::new().create(true).append(true).open(path)?;
        Ok(Self {
            path: path.to_owned(),
            file,
        })
    }

    fn maybe_rotate(&mut self) -> std::io::Result<()> {
        let metadata = self.file.metadata()?;
        if metadata.len() < MAX_SIZE {
            return Ok(());
        }

        let content = std::fs::read_to_string(&self.path)?;
        let lines: Vec<&str> = content.lines().collect();
        if lines.is_empty() {
            return Ok(());
        }

        let now_secs = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        // Phase 1: Drop lines older than 24 hours
        let mut surviving: Vec<&str> = lines
            .iter()
            .filter(|line| {
                if let Some(ts) = parse_timestamp_secs(line) {
                    now_secs.saturating_sub(ts) < MAX_AGE_SECS
                } else {
                    true // keep lines without parseable timestamps
                }
            })
            .copied()
            .collect();

        // Phase 2: If still too large, drop oldest lines until under target
        let mut total_bytes: u64 = surviving.iter().map(|l| l.len() as u64 + 1).sum();
        if total_bytes > TARGET_SIZE {
            while total_bytes > TARGET_SIZE && !surviving.is_empty() {
                let removed = surviving.remove(0);
                total_bytes -= removed.len() as u64 + 1;
            }
        }

        // Rewrite file
        let mut new_content = String::with_capacity(total_bytes as usize);
        for line in &surviving {
            new_content.push_str(line);
            new_content.push('\n');
        }
        std::fs::write(&self.path, &new_content)?;

        // Reopen in append mode
        self.file = OpenOptions::new().append(true).open(&self.path)?;
        Ok(())
    }
}

impl Write for RotatingLogWriter {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        // Check rotation before writing (errors are non-fatal)
        let _ = self.maybe_rotate();
        self.file.write(buf)
    }

    fn flush(&mut self) -> std::io::Result<()> {
        self.file.flush()
    }
}

/// Parse the leading ISO-8601 timestamp from a log line into epoch seconds.
/// Expected format: `2026-03-10T12:34:56.789` or `2026-03-10T12:34:56`
fn parse_timestamp_secs(line: &str) -> Option<u64> {
    // Expect at least "YYYY-MM-DDThh:mm:ss" = 19 chars
    if line.len() < 19 {
        return None;
    }
    let ts_part = &line[..19];
    if ts_part.as_bytes()[4] != b'-' || ts_part.as_bytes()[10] != b'T' {
        return None;
    }

    let year: u64 = ts_part[0..4].parse().ok()?;
    let month: u64 = ts_part[5..7].parse().ok()?;
    let day: u64 = ts_part[8..10].parse().ok()?;
    let hour: u64 = ts_part[11..13].parse().ok()?;
    let min: u64 = ts_part[14..16].parse().ok()?;
    let sec: u64 = ts_part[17..19].parse().ok()?;

    // Simple epoch calculation (not perfectly accurate for all edge cases, but
    // sufficient for age-based log culling)
    let mut days: u64 = 0;
    for y in 1970..year {
        days += if is_leap(y) { 366 } else { 365 };
    }
    let month_days = if is_leap(year) {
        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    } else {
        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    };
    for m in 0..(month.saturating_sub(1) as usize) {
        days += month_days.get(m).copied().unwrap_or(30) as u64;
    }
    days += day.saturating_sub(1);

    Some(days * 86400 + hour * 3600 + min * 60 + sec)
}

fn is_leap(y: u64) -> bool {
    y.is_multiple_of(4) && (!y.is_multiple_of(100) || y.is_multiple_of(400))
}