use serde::{Deserialize, Serialize};
use crate::ValidationError;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PushRequest {
pub project_key: String,
pub file_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub source_env: String,
#[serde(default, skip_serializing_if = "is_false")]
pub deleted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_sha256: Option<String>,
}
impl PushRequest {
pub fn validate(&self) -> Result<(), ValidationError> {
crate::validate_project_key(&self.project_key)?;
crate::validate_file_path(&self.file_path)?;
match &self.base_sha256 {
Some(base) => crate::validate_base_sha256(base),
None => Ok(()),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PushResponse {
pub ok: bool,
pub project_key: String,
pub file_path: String,
pub deleted: bool,
pub merged: bool,
pub updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merge_job: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct File {
pub file_path: String,
pub content: Option<String>,
#[serde(default)]
pub source_env: String,
#[serde(default)]
pub updated_at: String,
#[serde(default)]
pub deleted: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SyncResponse {
pub project_key: String,
pub files: Vec<File>,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
!*b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_push_requests() {
let valid = PushRequest {
project_key: "acme/app".into(),
file_path: "MEMORY.md".into(),
content: Some("hi".into()),
..Default::default()
};
assert!(valid.validate().is_ok());
let no_key = PushRequest {
file_path: "MEMORY.md".into(),
..Default::default()
};
assert_eq!(no_key.validate(), Err(ValidationError::MissingProjectKey));
let delete = PushRequest {
project_key: "acme/app".into(),
file_path: "MEMORY.md".into(),
deleted: true,
..Default::default()
};
assert!(delete.validate().is_ok());
}
#[test]
fn push_body_matches_the_node_server() {
let push = PushRequest {
project_key: "acme/app".into(),
file_path: "MEMORY.md".into(),
content: Some("hello".into()),
source_env: "laptop".into(),
deleted: false,
base_sha256: None,
};
assert_eq!(
serde_json::to_string(&push).unwrap(),
r#"{"project_key":"acme/app","file_path":"MEMORY.md","content":"hello","source_env":"laptop"}"#
);
let delete = PushRequest {
project_key: "acme/app".into(),
file_path: "gone.md".into(),
source_env: "laptop".into(),
deleted: true,
..Default::default()
};
assert_eq!(
serde_json::to_string(&delete).unwrap(),
r#"{"project_key":"acme/app","file_path":"gone.md","source_env":"laptop","deleted":true}"#
);
}
#[test]
fn an_empty_file_still_sends_its_content_field() {
let push = PushRequest {
project_key: "acme/app".into(),
file_path: "empty.md".into(),
content: Some(String::new()),
source_env: "laptop".into(),
deleted: false,
base_sha256: None,
};
let json = serde_json::to_string(&push).unwrap();
assert!(
json.contains(r#""content":"""#),
"empty file must serialize its content field, got {json}"
);
let delete = PushRequest {
project_key: "acme/app".into(),
file_path: "gone.md".into(),
deleted: true,
..Default::default()
};
assert!(!serde_json::to_string(&delete).unwrap().contains("content"));
}
#[test]
fn tombstoned_file_serializes_content_as_null() {
let resp = SyncResponse {
project_key: "acme/app".into(),
files: vec![File {
file_path: "gone.md".into(),
content: None,
source_env: "laptop".into(),
updated_at: "2026-01-01T00:00:00.000Z".into(),
deleted: true,
}],
};
assert_eq!(
serde_json::to_string(&resp).unwrap(),
r#"{"project_key":"acme/app","files":[{"file_path":"gone.md","content":null,"source_env":"laptop","updated_at":"2026-01-01T00:00:00.000Z","deleted":true}]}"#
);
}
#[test]
fn empty_file_is_distinguishable_from_a_tombstone() {
let empty = File {
file_path: "empty.md".into(),
content: Some(String::new()),
..Default::default()
};
let json = serde_json::to_string(&empty).unwrap();
assert!(json.contains(r#""content":"""#), "got {json}");
let tombstone = File {
file_path: "empty.md".into(),
content: None,
..Default::default()
};
assert!(serde_json::to_string(&tombstone)
.unwrap()
.contains(r#""content":null"#));
}
#[test]
fn parses_a_real_node_server_response() {
let body = r#"{"project_key":"acme/app","files":[
{"file_path":"MEMORY.md","content":"written by the NODE server\n","source_env":"node-era","updated_at":"2026-09-03T21:49:55.191Z","deleted":false},
{"file_path":"gone.md","content":null,"source_env":"node-era","updated_at":"2026-09-03T21:49:55.212Z","deleted":true}
]}"#;
let parsed: SyncResponse = serde_json::from_str(body).unwrap();
assert_eq!(parsed.files.len(), 2);
assert_eq!(
parsed.files[0].content.as_deref(),
Some("written by the NODE server\n")
);
assert!(parsed.files[1].deleted);
assert_eq!(parsed.files[1].content, None);
}
}