use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct RecordGitOpRequest {
pub repo: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
pub op_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GitOpRecord {
pub op_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
pub device_id: String,
pub session_id: String,
pub repo: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
pub op_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
pub recorded_at: String,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GitOpListResponse {
pub items: Vec<GitOpRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DeviceBranchSummary {
pub device_id: String,
pub repo: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha: Option<String>,
pub recorded_at: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_request_minimal_deserializes() {
let raw = serde_json::json!({
"repo": "qontinui-runner",
"op_kind": "commit",
});
let req: RecordGitOpRequest = serde_json::from_value(raw).unwrap();
assert_eq!(req.repo, "qontinui-runner");
assert_eq!(req.op_kind, "commit");
assert!(req.branch.is_none());
assert!(req.sha.is_none());
assert!(req.message.is_none());
assert!(req.metadata.is_none());
}
#[test]
fn record_request_full_deserializes() {
let raw = serde_json::json!({
"repo": "qontinui-runner",
"branch": "main",
"op_kind": "commit",
"sha": "deadbeef",
"message": "feat: x",
"metadata": {"files_changed": 3},
});
let req: RecordGitOpRequest = serde_json::from_value(raw).unwrap();
assert_eq!(req.branch.as_deref(), Some("main"));
assert_eq!(req.sha.as_deref(), Some("deadbeef"));
assert_eq!(req.message.as_deref(), Some("feat: x"));
assert_eq!(req.metadata.unwrap()["files_changed"], 3);
}
#[test]
fn record_request_skips_none_on_serialize() {
let req = RecordGitOpRequest {
repo: "r".into(),
branch: None,
op_kind: "commit".into(),
sha: None,
message: None,
metadata: None,
};
let s = serde_json::to_string(&req).unwrap();
assert!(!s.contains("branch"));
assert!(!s.contains("sha"));
assert!(!s.contains("message"));
assert!(!s.contains("metadata"));
}
#[test]
fn record_serializes_with_named_fields() {
let rec = GitOpRecord {
op_id: "11111111-1111-1111-1111-111111111111".into(),
tenant_id: None,
device_id: "22222222-2222-2222-2222-222222222222".into(),
session_id: "33333333-3333-3333-3333-333333333333".into(),
repo: "qontinui-runner".into(),
branch: Some("main".into()),
op_kind: "commit".into(),
sha: Some("deadbeef".into()),
message: Some("feat: x".into()),
recorded_at: "2026-05-24T00:00:00+00:00".into(),
metadata: serde_json::json!({}),
};
let s = serde_json::to_value(&rec).unwrap();
assert_eq!(s["repo"], "qontinui-runner");
assert_eq!(s["op_kind"], "commit");
assert!(s.get("tenant_id").is_none());
}
#[test]
fn list_response_serialized_as_named_envelope() {
let resp = GitOpListResponse { items: vec![] };
let s = serde_json::to_string(&resp).unwrap();
assert!(s.contains("\"items\""));
}
#[test]
fn device_branch_summary_round_trips() {
let summary = DeviceBranchSummary {
device_id: "22222222-2222-2222-2222-222222222222".into(),
repo: "qontinui-runner".into(),
branch: Some("main".into()),
sha: Some("deadbeef".into()),
recorded_at: "2026-05-24T00:00:00+00:00".into(),
};
let s = serde_json::to_value(&summary).unwrap();
let back: DeviceBranchSummary = serde_json::from_value(s).unwrap();
assert_eq!(back.branch.as_deref(), Some("main"));
}
}