use serde::{Deserialize, Serialize};
use wist_contracts::API_VERSION_V1;
pub const API_VERSION: &str = API_VERSION_V1;
pub const REPORT_AGENT_FACT_SUMMARY_KIND: &str = "report_agent_fact_summary";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FactSummaryAckStatus {
Accepted,
Duplicate,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReportAgentFactSummary {
pub api_version: String,
pub kind: String,
pub report_id: String,
pub agent_id: String,
pub instance_id: String,
pub content_digest: String,
pub revision: i64,
pub observed_at: String,
pub os: String,
pub arch: String,
pub process_count: i64,
pub process_executables: Vec<String>,
pub packages: Vec<String>,
pub listen_ports: Vec<String>,
#[serde(default)]
pub host_id: String,
#[serde(default)]
pub host_name: String,
#[serde(default)]
pub network_addresses: Vec<String>,
pub reported_at: String,
}
impl ReportAgentFactSummary {
#[allow(clippy::too_many_arguments)]
pub fn new_agent_facts(
report_id: String,
agent_id: String,
instance_id: String,
content_digest: String,
revision: i64,
observed_at: String,
os: String,
arch: String,
process_count: i64,
process_executables: Vec<String>,
packages: Vec<String>,
listen_ports: Vec<String>,
reported_at: String,
) -> Self {
Self {
api_version: API_VERSION_V1.to_string(),
kind: REPORT_AGENT_FACT_SUMMARY_KIND.to_string(),
report_id,
agent_id,
instance_id,
content_digest,
revision,
observed_at,
os,
arch,
process_count,
process_executables,
packages,
listen_ports,
host_id: String::new(),
host_name: String::new(),
network_addresses: Vec::new(),
reported_at,
}
}
pub fn with_display(
mut self,
host_id: String,
host_name: String,
network_addresses: Vec<String>,
) -> Self {
self.host_id = host_id;
self.host_name = host_name;
self.network_addresses = network_addresses;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FactSummaryAccepted {
pub report_id: String,
pub agent_id: String,
pub content_digest: String,
pub ack_status: FactSummaryAckStatus,
pub suggestion_id: Option<String>,
pub received_at: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fact_summary_ack_status_uses_snake_case_and_rejects_unknown() {
assert_eq!(
serde_json::to_string(&FactSummaryAckStatus::Duplicate).unwrap(),
"\"duplicate\""
);
assert!(serde_json::from_str::<FactSummaryAckStatus>("\"nope\"").is_err());
}
#[test]
fn new_agent_facts_defaults_display_fields_then_with_display_fills_them() {
let summary = ReportAgentFactSummary::new_agent_facts(
"fact_1".to_string(),
"agent-1".to_string(),
"inst-1".to_string(),
"fact-v1:sha256:abc".to_string(),
7,
"2026-09-27T00:00:00Z".to_string(),
"macos".to_string(),
"arm64".to_string(),
3,
vec!["/usr/bin/a".to_string()],
Vec::new(),
vec!["443".to_string()],
"2026-09-27T00:00:01Z".to_string(),
);
assert_eq!(summary.kind, REPORT_AGENT_FACT_SUMMARY_KIND);
assert!(summary.host_id.is_empty());
assert!(summary.network_addresses.is_empty());
let with_display = summary.with_display(
"host-id".to_string(),
"host-name".to_string(),
vec!["en0 10.0.0.1/24".to_string()],
);
assert_eq!(with_display.host_id, "host-id");
assert_eq!(with_display.host_name, "host-name");
let json = serde_json::to_string(&with_display).expect("encode");
let back: ReportAgentFactSummary = serde_json::from_str(&json).expect("decode");
assert_eq!(back, with_display);
}
}