focl 0.1.0

focl/focld - lightweight Rust BGP speaker
Documentation
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::archive::types::ArchiveStream;
use crate::config::CompressionKind;
use crate::config::LayoutProfile;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SegmentManifest {
    pub collector_id: String,
    pub stream: String,
    pub start_ts: i64,
    pub end_ts: i64,
    pub record_count: u64,
    pub bytes: u64,
    pub sha256: String,
    pub compression: CompressionKind,
    pub layout_profile: LayoutProfile,
    pub relative_path: String,
}

impl SegmentManifest {
    #[allow(clippy::too_many_arguments)]
    pub fn build(
        collector_id: impl Into<String>,
        stream: ArchiveStream,
        start_ts: i64,
        end_ts: i64,
        record_count: u64,
        compression: CompressionKind,
        layout_profile: LayoutProfile,
        segment_path: &Path,
        relative_path: &Path,
    ) -> Result<Self> {
        let metadata = fs::metadata(segment_path)
            .with_context(|| format!("failed to stat segment {}", segment_path.display()))?;
        let bytes = metadata.len();

        let sha256 = compute_sha256(segment_path)?;

        Ok(Self {
            collector_id: collector_id.into(),
            stream: stream.as_str().to_string(),
            start_ts,
            end_ts,
            record_count,
            bytes,
            sha256,
            compression,
            layout_profile,
            relative_path: relative_path.to_string_lossy().to_string(),
        })
    }

    pub fn write_sidecar(&self, segment_path: &Path) -> Result<PathBuf> {
        let manifest_path = PathBuf::from(format!("{}.json", segment_path.display()));
        let json = serde_json::to_vec_pretty(self)?;
        fs::write(&manifest_path, json)
            .with_context(|| format!("failed to write manifest {}", manifest_path.display()))?;
        Ok(manifest_path)
    }
}

fn compute_sha256(path: &Path) -> Result<String> {
    let mut file = fs::File::open(path)
        .with_context(|| format!("failed to open segment for hashing {}", path.display()))?;
    let mut hasher = Sha256::new();
    let mut buf = [0u8; 8192];

    loop {
        let read = file
            .read(&mut buf)
            .with_context(|| format!("failed reading {} for hashing", path.display()))?;
        if read == 0 {
            break;
        }
        hasher.update(&buf[..read]);
    }

    Ok(hex::encode(hasher.finalize()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{CompressionKind, LayoutProfile};

    #[test]
    fn writes_manifest_sidecar() {
        let dir = tempfile::tempdir().unwrap();
        let segment = dir.path().join("updates.20260221.1200.gz");
        fs::write(&segment, b"test-bytes").unwrap();

        let manifest = SegmentManifest::build(
            "focl01",
            ArchiveStream::Updates,
            100,
            200,
            3,
            CompressionKind::Gzip,
            LayoutProfile::RouteViews,
            &segment,
            Path::new("focl01/2026.02/UPDATES/updates.20260221.1200.gz"),
        )
        .unwrap();

        let path = manifest.write_sidecar(&segment).unwrap();
        assert!(path.exists());
    }
}