Skip to main content

contrail_types/
lib.rs

1use anyhow::{anyhow, ensure, Context, Result};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6#[derive(Debug, Serialize, Deserialize, Clone)]
7pub struct MasterLog {
8    pub event_id: Uuid,
9    pub timestamp: DateTime<Utc>,
10    pub source_tool: String,
11    pub project_context: String,
12    pub session_id: String,
13    pub interaction: Interaction,
14    pub security_flags: SecurityFlags,
15    pub metadata: serde_json::Value,
16}
17
18#[derive(Debug, Serialize, Deserialize, Clone)]
19pub struct Interaction {
20    pub role: String,
21    pub content: String,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub artifacts: Option<Vec<Artifact>>,
24}
25
26#[derive(Debug, Serialize, Deserialize, Clone)]
27pub struct Artifact {
28    pub r#type: String,
29    pub content: String,
30}
31
32#[derive(Debug, Serialize, Deserialize, Clone)]
33pub struct SecurityFlags {
34    pub has_pii: bool,
35    pub redacted_secrets: Vec<String>,
36}
37
38#[derive(Debug, Serialize, Deserialize, Clone)]
39pub struct SeqEntry {
40    pub seq: u64,
41    pub entry: MasterLog,
42}
43
44#[derive(Debug, Serialize, Deserialize, Clone)]
45pub struct SyncAck {
46    pub ack_seq: u64,
47}
48
49impl MasterLog {
50    pub fn validate_schema(&self) -> Result<()> {
51        validate_log_value(&serde_json::to_value(self)?)
52    }
53}
54
55pub fn validate_log_value(value: &serde_json::Value) -> Result<()> {
56    let obj = value
57        .as_object()
58        .context("log entry must be a JSON object")?;
59
60    let event_id = obj
61        .get("event_id")
62        .and_then(|v| v.as_str())
63        .context("event_id missing or not string")?;
64    Uuid::parse_str(event_id).context("event_id must be a UUID")?;
65
66    let timestamp = obj
67        .get("timestamp")
68        .and_then(|v| v.as_str())
69        .context("timestamp missing or not string")?;
70    DateTime::parse_from_rfc3339(timestamp).context("timestamp must be RFC3339")?;
71
72    ensure_string(obj, "source_tool")?;
73    ensure_string(obj, "project_context")?;
74    ensure_string(obj, "session_id")?;
75
76    let interaction = obj
77        .get("interaction")
78        .and_then(|v| v.as_object())
79        .context("interaction must be an object")?;
80    ensure_string(interaction, "role")?;
81    ensure_string(interaction, "content")?;
82
83    if let Some(artifacts) = interaction.get("artifacts") {
84        let artifacts_array = artifacts
85            .as_array()
86            .context("artifacts must be an array when present")?;
87        for artifact in artifacts_array {
88            let artifact_obj = artifact.as_object().context("artifact must be an object")?;
89            ensure_string(artifact_obj, "type")?;
90            ensure_string(artifact_obj, "content")?;
91        }
92    }
93
94    let security_flags = obj
95        .get("security_flags")
96        .and_then(|v| v.as_object())
97        .context("security_flags must be an object")?;
98    ensure!(
99        security_flags
100            .get("has_pii")
101            .and_then(|v| v.as_bool())
102            .is_some(),
103        "security_flags.has_pii must be a bool"
104    );
105    let redacted_secrets = security_flags
106        .get("redacted_secrets")
107        .and_then(|v| v.as_array())
108        .context("security_flags.redacted_secrets must be an array")?;
109    for entry in redacted_secrets {
110        ensure!(
111            entry.as_str().is_some(),
112            "security_flags.redacted_secrets entries must be strings"
113        );
114    }
115
116    let metadata = obj.get("metadata").context("metadata missing")?;
117    ensure!(
118        metadata.is_object(),
119        "metadata must be a JSON object (can be empty)"
120    );
121
122    Ok(())
123}
124
125fn ensure_string<'a>(
126    map: &'a serde_json::Map<String, serde_json::Value>,
127    key: &str,
128) -> Result<&'a str> {
129    map.get(key)
130        .and_then(|v| v.as_str())
131        .filter(|v| !v.is_empty())
132        .ok_or_else(|| anyhow!("{key} missing or not a non-empty string"))
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn accepts_valid_schema() -> Result<()> {
141        let log = MasterLog {
142            event_id: Uuid::new_v4(),
143            timestamp: Utc::now(),
144            source_tool: "cursor".to_string(),
145            project_context: "/tmp/project".to_string(),
146            session_id: "session-123".to_string(),
147            interaction: Interaction {
148                role: "assistant".to_string(),
149                content: "hello".to_string(),
150                artifacts: None,
151            },
152            security_flags: SecurityFlags {
153                has_pii: false,
154                redacted_secrets: vec![],
155            },
156            metadata: serde_json::json!({"example": true}),
157        };
158
159        log.validate_schema()?;
160        Ok(())
161    }
162
163    #[test]
164    fn rejects_invalid_uuid() {
165        let invalid = serde_json::json!({
166            "event_id": "not-a-uuid",
167            "timestamp": Utc::now().to_rfc3339(),
168            "source_tool": "cursor",
169            "project_context": "/tmp/project",
170            "session_id": "session-123",
171            "interaction": { "role": "assistant", "content": "hello" },
172            "security_flags": { "has_pii": false, "redacted_secrets": [] },
173            "metadata": {}
174        });
175
176        assert!(validate_log_value(&invalid).is_err());
177    }
178
179    #[test]
180    fn rejects_missing_content() {
181        let invalid = serde_json::json!({
182            "event_id": Uuid::new_v4(),
183            "timestamp": Utc::now().to_rfc3339(),
184            "source_tool": "cursor",
185            "project_context": "/tmp/project",
186            "session_id": "session-123",
187            "interaction": { "role": "assistant" },
188            "security_flags": { "has_pii": false, "redacted_secrets": [] },
189            "metadata": {}
190        });
191
192        assert!(validate_log_value(&invalid).is_err());
193    }
194}