repopilot 0.20.0

Local-first CLI for reviewing Git changes, security boundaries, and blast radius before merge.
Documentation
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, VecDeque};

const MAX_STORED_ANALYSES: usize = 8;
const DEFAULT_PAGE_LIMIT: usize = 100;
const MAX_PAGE_LIMIT: usize = 1_000;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnalysisKind {
    Scan,
    Review,
}

impl AnalysisKind {
    pub fn label(self) -> &'static str {
        match self {
            Self::Scan => "scan",
            Self::Review => "review",
        }
    }
}

#[derive(Debug, Clone)]
pub struct AnalysisRecord {
    pub kind: AnalysisKind,
    pub report: String,
    pub workspace_revision: String,
}

#[derive(Default)]
pub struct AnalysisStore {
    records: HashMap<String, AnalysisRecord>,
    order: VecDeque<String>,
}

impl AnalysisStore {
    pub fn insert(
        &mut self,
        kind: AnalysisKind,
        report: String,
        workspace_revision: &str,
    ) -> String {
        let handle = analysis_handle(kind, &report, workspace_revision);
        if !self.records.contains_key(&handle) {
            self.order.push_back(handle.clone());
        }
        self.records.insert(
            handle.clone(),
            AnalysisRecord {
                kind,
                report,
                workspace_revision: workspace_revision.to_string(),
            },
        );
        while self.order.len() > MAX_STORED_ANALYSES {
            if let Some(expired) = self.order.pop_front() {
                self.records.remove(&expired);
            }
        }
        handle
    }

    pub fn get(&self, handle: &str) -> Option<&AnalysisRecord> {
        self.records.get(handle)
    }

    pub fn summaries(&self) -> Vec<Value> {
        self.order
            .iter()
            .rev()
            .filter_map(|handle| {
                let record = self.records.get(handle)?;
                let report = serde_json::from_str::<Value>(&record.report).ok();
                let findings = report
                    .as_ref()
                    .and_then(|value| value.get("findings"))
                    .and_then(Value::as_array)
                    .map_or(0, Vec::len);
                let review_signals = report
                    .as_ref()
                    .and_then(|value| value.get("tiered_signals"))
                    .map(count_review_signals)
                    .unwrap_or(0);
                Some(json!({
                    "analysis_handle": handle,
                    "kind": record.kind.label(),
                    "workspace_revision": record.workspace_revision,
                    "findings": findings,
                    "review_signals": review_signals
                }))
            })
            .collect()
    }
}

fn count_review_signals(tiered: &Value) -> usize {
    ["definitely", "maybe", "noise"]
        .into_iter()
        .filter_map(|tier| tiered.get(tier).and_then(Value::as_array))
        .map(Vec::len)
        .sum()
}

#[cfg(test)]
mod summary_tests {
    use super::*;

    #[test]
    fn summaries_are_newest_first_and_count_analysis_contents() {
        let mut store = AnalysisStore::default();
        let scan = store.insert(
            AnalysisKind::Scan,
            json!({ "findings": [{ "id": "one" }] }).to_string(),
            "revision-1",
        );
        let review = store.insert(
            AnalysisKind::Review,
            json!({
                "findings": [],
                "tiered_signals": { "definitely": [{}], "maybe": [{}], "noise": [] }
            })
            .to_string(),
            "revision-1",
        );

        let summaries = store.summaries();
        assert_eq!(summaries[0]["analysis_handle"], review);
        assert_eq!(summaries[0]["review_signals"], 2);
        assert_eq!(summaries[1]["analysis_handle"], scan);
        assert_eq!(summaries[1]["findings"], 1);
    }
}

fn analysis_handle(kind: AnalysisKind, report: &str, workspace_revision: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(b"mcp-analysis-handle-v1\n");
    hasher.update(kind.label().as_bytes());
    hasher.update(b"\n");
    hasher.update(workspace_revision.as_bytes());
    hasher.update(b"\n");
    hasher.update(report.as_bytes());
    let digest = hasher
        .finalize()
        .iter()
        .map(|byte| format!("{byte:02x}"))
        .collect::<String>();
    format!("{}-{}", kind.label(), &digest[..24])
}

pub struct PaginatedReport {
    pub text: String,
    pub metadata: Option<Value>,
}

pub fn paginate_findings(report: &str, arguments: &Value) -> Result<PaginatedReport, String> {
    let offset = parse_usize(arguments, "offset")?;
    let limit = parse_usize(arguments, "limit")?;
    if offset.is_none() && limit.is_none() {
        return Ok(PaginatedReport {
            text: report.to_string(),
            metadata: None,
        });
    }

    let offset = offset.unwrap_or(0);
    let limit = limit.unwrap_or(DEFAULT_PAGE_LIMIT);
    if limit == 0 || limit > MAX_PAGE_LIMIT {
        return Err(format!("`limit` must be between 1 and {MAX_PAGE_LIMIT}"));
    }

    let mut value: Value =
        serde_json::from_str(report).map_err(|error| format!("paginate failed: {error}"))?;
    let findings = value
        .get_mut("findings")
        .and_then(Value::as_array_mut)
        .ok_or_else(|| "analysis report has no findings array".to_string())?;
    let total = findings.len();
    let start = offset.min(total);
    let end = start.saturating_add(limit).min(total);
    let page = findings[start..end].to_vec();
    *findings = page;
    let returned = end - start;
    let next_offset = (end < total).then_some(end);
    let text = serde_json::to_string_pretty(&value)
        .map_err(|error| format!("paginate failed: {error}"))?;

    Ok(PaginatedReport {
        text,
        metadata: Some(json!({
            "offset": offset,
            "limit": limit,
            "total": total,
            "returned": returned,
            "next_offset": next_offset
        })),
    })
}

fn parse_usize(arguments: &Value, key: &str) -> Result<Option<usize>, String> {
    let Some(value) = arguments.get(key) else {
        return Ok(None);
    };
    let raw = value
        .as_u64()
        .ok_or_else(|| format!("`{key}` must be a non-negative integer"))?;
    usize::try_from(raw)
        .map(Some)
        .map_err(|_| format!("`{key}` is too large"))
}

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

    #[test]
    fn pagination_preserves_total_and_returns_next_offset() {
        let report = json!({ "findings": [1, 2, 3], "other": true }).to_string();
        let page =
            paginate_findings(&report, &json!({ "offset": 1, "limit": 1 })).expect("paginate");
        let value: Value = serde_json::from_str(&page.text).expect("report JSON");
        assert_eq!(value["findings"], json!([2]));
        assert_eq!(page.metadata.as_ref().expect("metadata")["total"], 3);
        assert_eq!(page.metadata.as_ref().expect("metadata")["next_offset"], 2);
    }

    #[test]
    fn store_evicts_old_handles() {
        let mut store = AnalysisStore::default();
        let first = store.insert(AnalysisKind::Scan, "first".into(), "r1");
        for index in 0..MAX_STORED_ANALYSES {
            store.insert(AnalysisKind::Scan, format!("report-{index}"), "r1");
        }
        assert!(store.get(&first).is_none());
    }
}