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}