treeherder-cli 0.2.5

Fetch errors from a Firefox CI push on Treeherder, formatted as markdown
use crate::models::{CachedPushMetadata, Job, JobWithLogs, LogMatch};
use anyhow::Result;
use regex::Regex;
use std::fs;
use std::path::{Path, PathBuf};

pub fn save_cache_metadata(cache_dir: &Path, metadata: &CachedPushMetadata) -> Result<()> {
    let metadata_path = cache_dir.join("metadata.json");
    let json = serde_json::to_string_pretty(metadata)?;
    fs::write(metadata_path, json)?;
    Ok(())
}

pub fn load_cache_metadata(cache_dir: &Path) -> Result<CachedPushMetadata> {
    let metadata_path = cache_dir.join("metadata.json");
    let json = fs::read_to_string(metadata_path)?;
    let metadata: CachedPushMetadata = serde_json::from_str(&json)?;
    Ok(metadata)
}

pub fn search_cached_logs(
    cache_dir: &Path,
    jobs: &[Job],
    pattern: Option<&Regex>,
) -> Result<Vec<JobWithLogs>> {
    let mut results = Vec::new();

    for job in jobs {
        let job_dir = cache_dir.join(format!("job_{}", job.id));

        if !job_dir.exists() {
            eprintln!("Warning: Job directory not found: {}", job_dir.display());
            continue;
        }

        let mut log_matches = Vec::new();

        if let Some(regex) = pattern {
            let log_files = fs::read_dir(&job_dir)?;
            for entry in log_files.flatten() {
                let path = entry.path();
                if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("log") {
                    let log_name = path
                        .file_stem()
                        .and_then(|s| s.to_str())
                        .unwrap_or("unknown")
                        .to_string();

                    if let Ok(matches) = search_log_file(&path, regex, &log_name) {
                        log_matches.extend(matches);
                    }
                }
            }
        }

        results.push(JobWithLogs {
            job: job.clone(),
            errors: vec![],
            log_matches,
            log_dir: Some(job_dir.to_string_lossy().to_string()),
        });
    }

    Ok(results)
}

fn search_log_file(log_path: &PathBuf, pattern: &Regex, log_name: &str) -> Result<Vec<LogMatch>> {
    let content = fs::read_to_string(log_path)?;
    let mut matches = Vec::new();

    for (line_num, line) in content.lines().enumerate() {
        if pattern.is_match(line) {
            matches.push(LogMatch {
                log_name: log_name.to_string(),
                line_number: line_num + 1,
                line_content: line.to_string(),
            });
        }
    }

    Ok(matches)
}