ic-query 0.75.1

Internet Computer query library for CloudEngine, NNS, SNS, ICRC, system canisters, and public network metadata
Documentation
//! Module: nns::proposals::report::cache::reports
//!
//! Responsibility: build NNS proposal cache list and status reports.
//! Does not own: refresh execution, live governance calls, or text rendering.
//! Boundary: loads local complete snapshots and projects cache metadata.

use super::{
    NNS_PROPOSAL_CACHE_COMPONENT, NNS_PROPOSAL_CACHE_LIST_REPORT_SCHEMA_VERSION,
    NNS_PROPOSAL_CACHE_SCHEMA_VERSION,
    model::{NnsProposalCache, NnsProposalCacheListReport},
    paths::nns_proposal_cache_paths,
};
use crate::{
    cache::{CacheCollectionCompleteness, validate_cache_collection_completeness},
    cache_file::{LoadJsonCacheRequest, open_managed_file},
    nns::{
        NnsGovernanceCacheRequest,
        governance::{
            NnsGovernanceCacheStatusReport, NnsGovernanceCacheSummary, NnsGovernanceReportContext,
            read_governance_refresh_attempt_status, validate_governance_cache_metadata,
        },
        proposals::report::{
            NnsProposalHostError,
            activity::{
                NnsProposalActivityReport, NnsProposalActivityRequest,
                build_nns_proposal_activity_report, validate_time_window,
            },
            assemble::{
                NnsProposalReportParts, NnsProposalReportProvenance,
                nns_proposal_list_report_from_rows, nns_proposal_report_from_parts,
            },
            collection::validate_collection_state,
            enforce_mainnet_network,
            model::{
                NnsProposalListReport, NnsProposalListRequest, NnsProposalReport,
                NnsProposalRequest,
            },
            view::{
                project_nns_proposal_rows, proposal_matches_before, proposal_matches_reward_status,
                proposal_matches_status,
            },
        },
    },
    snapshot_cache::{SnapshotIdentityMismatch, SnapshotKey, load_complete_snapshot_for_key},
};
use std::{
    collections::HashSet,
    fs::File,
    path::{Path, PathBuf},
};

/// Build proposal activity from the complete local snapshot without making a network call.
pub fn build_nns_proposal_activity_report_from_cache(
    cache_request: &NnsGovernanceCacheRequest,
    request: &NnsProposalActivityRequest,
) -> Result<NnsProposalActivityReport, NnsProposalHostError> {
    enforce_mainnet_network(&cache_request.network)?;
    validate_time_window(request)?;
    let path =
        nns_proposal_cache_paths(&cache_request.cache_root, &cache_request.network).snapshot_path;
    let cache = load_nns_proposal_cache(
        &cache_request.cache_root,
        path,
        &cache_request.network,
        None,
    )?;
    Ok(build_nns_proposal_activity_report(
        request,
        &cache.data.collection_state,
        &cache.data.proposals,
    )?)
}

/// Build a local NNS proposal cache list report.
pub fn build_nns_proposal_cache_list_report(
    request: &NnsGovernanceCacheRequest,
) -> Result<NnsProposalCacheListReport, NnsProposalHostError> {
    let status = build_nns_proposal_cache_status_report(request)?;
    let caches = status.cache.into_iter().collect::<Vec<_>>();
    Ok(NnsProposalCacheListReport {
        schema_version: NNS_PROPOSAL_CACHE_LIST_REPORT_SCHEMA_VERSION,
        network: status.network,
        collection: status.collection,
        cache_root: status.cache_root,
        cache_count: caches.len(),
        caches,
        refresh_attempt_path: status.refresh_attempt_path,
        latest_attempt: status.latest_attempt,
    })
}

/// Build a local NNS proposal cache status report.
pub fn build_nns_proposal_cache_status_report(
    request: &NnsGovernanceCacheRequest,
) -> Result<NnsGovernanceCacheStatusReport, NnsProposalHostError> {
    enforce_mainnet_network(&request.network)?;
    let paths = nns_proposal_cache_paths(&request.cache_root, &request.network);
    let cache = if let Some(file) = open_proposal_cache(&request.cache_root, &paths.snapshot_path)?
    {
        Some(load_nns_proposal_cache_summary(
            &request.cache_root,
            paths.snapshot_path.clone(),
            &request.network,
            file,
        ))
    } else {
        None
    };
    let latest_attempt = read_governance_refresh_attempt_status(
        &request.cache_root,
        &paths.refresh_attempt_path,
        &request.network,
        NNS_PROPOSAL_CACHE_COMPONENT,
    )?;
    Ok(NnsGovernanceCacheStatusReport::from_cache(
        request,
        "proposals",
        &paths,
        cache,
        latest_attempt,
    ))
}

/// Build an NNS proposal list report from a complete local proposal snapshot.
pub fn build_nns_proposal_list_report_from_cache(
    request: &NnsProposalListRequest,
    cache_root: &Path,
) -> Result<Option<NnsProposalListReport>, NnsProposalHostError> {
    enforce_mainnet_network(&request.governance.network)?;
    let Some((path, cache)) = load_optional_cache(cache_root, &request.governance.network)? else {
        return Ok(None);
    };
    Ok(Some(nns_proposal_list_report_from_cache(
        request, path, cache,
    )))
}

/// Build an NNS proposal detail report from a complete local proposal snapshot.
pub fn build_nns_proposal_report_from_cache(
    request: &NnsProposalRequest,
    cache_root: &Path,
) -> Result<Option<NnsProposalReport>, NnsProposalHostError> {
    enforce_mainnet_network(&request.governance.network)?;
    let Some((path, cache)) = load_optional_cache(cache_root, &request.governance.network)? else {
        return Ok(None);
    };
    Ok(nns_proposal_report_from_cache(request, path, cache))
}

fn open_proposal_cache(
    cache_root: &Path,
    path: &Path,
) -> Result<Option<File>, NnsProposalHostError> {
    open_managed_file(cache_root, path).map_err(|source| {
        NnsProposalHostError::Cache(crate::HostCacheError::operation(
            NNS_PROPOSAL_CACHE_COMPONENT,
            source,
        ))
    })
}

fn load_nns_proposal_cache_summary(
    cache_root: &Path,
    cache_path: PathBuf,
    network: &str,
    file: File,
) -> NnsGovernanceCacheSummary {
    match load_nns_proposal_cache(cache_root, cache_path.clone(), network, Some(file)) {
        Ok(cache) => NnsGovernanceCacheSummary::from_snapshot(&cache_path, &cache),
        Err(error) => NnsGovernanceCacheSummary::invalid(&cache_path, error.to_string()),
    }
}

fn load_nns_proposal_cache(
    cache_root: &Path,
    cache_path: PathBuf,
    network: &str,
    opened_file: Option<File>,
) -> Result<NnsProposalCache, NnsProposalHostError> {
    let key = SnapshotKey::full("nns", network, "governance", "proposals");
    let cache = load_complete_snapshot_for_key(
        LoadJsonCacheRequest {
            component: NNS_PROPOSAL_CACHE_COMPONENT,
            cache_root,
            path: cache_path.clone(),
            network,
            expected_schema_version: NNS_PROPOSAL_CACHE_SCHEMA_VERSION,
            maximum_bytes: crate::cache_file::MAX_COLLECTION_SNAPSHOT_BYTES,
        },
        None,
        &key,
        missing_proposal_cache_error,
        incomplete_snapshot_error,
        |mismatch| nns_identity_mismatch_error(cache_path.clone(), mismatch),
        opened_file,
    )?;
    validate_nns_proposal_cache(&cache_path, &cache)?;
    Ok(cache)
}

fn validate_nns_proposal_cache(
    path: &Path,
    cache: &NnsProposalCache,
) -> Result<(), NnsProposalHostError> {
    let invalid = |reason| NnsProposalHostError::InvalidCache {
        path: path.to_path_buf(),
        reason,
    };
    validate_cache_collection_completeness(&cache.completeness, cache.data.proposals.len())
        .map_err(invalid)?;
    if cache.completeness.point_in_time_guaranteed {
        return Err(invalid(
            "Governance proposal pagination cannot claim a point-in-time guarantee".to_string(),
        ));
    }
    validate_governance_cache_metadata(&cache.metadata).map_err(invalid)?;
    let state = &cache.data.collection_state;
    validate_collection_state(state).map_err(|error| invalid(error.to_string()))?;
    if !crate::nns::governance::collection::governance_snapshot_matches!(
        cache,
        state,
        proposals_fetched
    ) {
        return Err(invalid(
            "collection_state disagrees with snapshot identity, provenance, or completeness"
                .to_string(),
        ));
    }
    let mut proposal_ids = HashSet::new();
    for proposal in &cache.data.proposals {
        let proposal_id = proposal
            .proposal_id
            .ok_or_else(|| invalid("cache contains a proposal without an id".to_string()))?;
        if proposal_id == 0 {
            return Err(invalid("cache contains proposal id zero".to_string()));
        }
        if !proposal_ids.insert(proposal_id) {
            return Err(invalid(format!("duplicate proposal id {proposal_id}")));
        }
    }
    Ok(())
}

fn nns_proposal_list_report_from_cache(
    request: &NnsProposalListRequest,
    cache_path: PathBuf,
    cache: NnsProposalCache,
) -> NnsProposalListReport {
    let cache_complete = cache.completeness.is_api_exhausted();
    let context = NnsGovernanceReportContext::from_snapshot(&cache);
    let proposals = cache
        .data
        .proposals
        .into_iter()
        .filter(|proposal| proposal_matches_before(proposal, request.before_proposal_id))
        .filter(|proposal| proposal_matches_status(proposal, request.status))
        .filter(|proposal| proposal_matches_reward_status(proposal, request.reward_status));
    let mut proposals = project_nns_proposal_rows(proposals, request);
    proposals.truncate(usize::try_from(request.limit).unwrap_or(usize::MAX));
    nns_proposal_list_report_from_rows(
        request,
        context,
        NnsProposalReportProvenance::cache(&cache_path, cache_complete),
        proposals,
    )
}

fn nns_proposal_report_from_cache(
    request: &NnsProposalRequest,
    cache_path: PathBuf,
    cache: NnsProposalCache,
) -> Option<NnsProposalReport> {
    let cache_complete = cache.completeness.is_api_exhausted();
    let context = NnsGovernanceReportContext::from_snapshot(&cache);
    let proposal = cache
        .data
        .proposals
        .into_iter()
        .find(|proposal| proposal.proposal_id == Some(request.proposal_id))?;
    Some(nns_proposal_report_from_parts(NnsProposalReportParts {
        context,
        provenance: NnsProposalReportProvenance::cache(&cache_path, cache_complete),
        proposal_id: request.proposal_id,
        show_ballots: request.show_ballots,
        verbose: request.verbose,
        proposal,
    }))
}

fn incomplete_snapshot_error(completeness: &CacheCollectionCompleteness) -> NnsProposalHostError {
    NnsProposalHostError::IncompleteRefresh {
        pages_fetched: completeness.page_count,
        rows_fetched: completeness.row_count,
        reason: "cached NNS proposals snapshot is not complete".to_string(),
    }
}

const fn missing_proposal_cache_error(path: PathBuf) -> NnsProposalHostError {
    NnsProposalHostError::MissingProposalCache { path }
}

fn nns_identity_mismatch_error(
    path: PathBuf,
    mismatch: SnapshotIdentityMismatch,
) -> NnsProposalHostError {
    NnsProposalHostError::CacheIdentityMismatch {
        path,
        field: mismatch.field,
        expected: mismatch.expected,
        actual: mismatch.actual,
    }
}

fn load_optional_cache(
    cache_root: &Path,
    network: &str,
) -> Result<Option<(PathBuf, NnsProposalCache)>, NnsProposalHostError> {
    let path = nns_proposal_cache_paths(cache_root, network).snapshot_path;
    match load_nns_proposal_cache(cache_root, path.clone(), network, None) {
        Ok(cache) => Ok(Some((path, cache))),
        Err(NnsProposalHostError::MissingProposalCache { .. }) => Ok(None),
        Err(error) => Err(error),
    }
}