1use serde::{Deserialize, Serialize};
4
5use crate::ValidationError;
6
7#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
23pub struct PushRequest {
24 pub project_key: String,
26 pub file_path: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub content: Option<String>,
31 #[serde(default, skip_serializing_if = "String::is_empty")]
33 pub source_env: String,
34 #[serde(default, skip_serializing_if = "is_false")]
36 pub deleted: bool,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub base_sha256: Option<String>,
51}
52
53impl PushRequest {
54 pub fn validate(&self) -> Result<(), ValidationError> {
58 if self.project_key.is_empty() {
59 return Err(ValidationError::MissingProjectKey);
60 }
61 crate::validate_file_path(&self.file_path)
62 }
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
67pub struct PushResponse {
68 pub ok: bool,
71 pub project_key: String,
73 pub file_path: String,
75 pub deleted: bool,
77 pub merged: bool,
80 pub updated_at: String,
82}
83
84#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
91pub struct File {
92 pub file_path: String,
94 pub content: Option<String>,
96 #[serde(default)]
98 pub source_env: String,
99 #[serde(default)]
101 pub updated_at: String,
102 #[serde(default)]
105 pub deleted: bool,
106}
107
108#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
111pub struct SyncResponse {
112 pub project_key: String,
114 pub files: Vec<File>,
116}
117
118#[allow(clippy::trivially_copy_pass_by_ref)]
119fn is_false(b: &bool) -> bool {
120 !*b
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn validates_push_requests() {
129 let valid = PushRequest {
130 project_key: "acme/app".into(),
131 file_path: "MEMORY.md".into(),
132 content: Some("hi".into()),
133 ..Default::default()
134 };
135 assert!(valid.validate().is_ok());
136
137 let no_key = PushRequest {
138 file_path: "MEMORY.md".into(),
139 ..Default::default()
140 };
141 assert_eq!(no_key.validate(), Err(ValidationError::MissingProjectKey));
142
143 let delete = PushRequest {
145 project_key: "acme/app".into(),
146 file_path: "MEMORY.md".into(),
147 deleted: true,
148 ..Default::default()
149 };
150 assert!(delete.validate().is_ok());
151 }
152
153 #[test]
156 fn push_body_matches_the_node_server() {
157 let push = PushRequest {
158 project_key: "acme/app".into(),
159 file_path: "MEMORY.md".into(),
160 content: Some("hello".into()),
161 source_env: "laptop".into(),
162 deleted: false,
163 base_sha256: None,
164 };
165 assert_eq!(
166 serde_json::to_string(&push).unwrap(),
167 r#"{"project_key":"acme/app","file_path":"MEMORY.md","content":"hello","source_env":"laptop"}"#
168 );
169
170 let delete = PushRequest {
171 project_key: "acme/app".into(),
172 file_path: "gone.md".into(),
173 source_env: "laptop".into(),
174 deleted: true,
175 ..Default::default()
176 };
177 assert_eq!(
178 serde_json::to_string(&delete).unwrap(),
179 r#"{"project_key":"acme/app","file_path":"gone.md","source_env":"laptop","deleted":true}"#
180 );
181 }
182
183 #[test]
187 fn an_empty_file_still_sends_its_content_field() {
188 let push = PushRequest {
189 project_key: "acme/app".into(),
190 file_path: "empty.md".into(),
191 content: Some(String::new()),
192 source_env: "laptop".into(),
193 deleted: false,
194 base_sha256: None,
195 };
196 let json = serde_json::to_string(&push).unwrap();
197 assert!(
198 json.contains(r#""content":"""#),
199 "empty file must serialize its content field, got {json}"
200 );
201
202 let delete = PushRequest {
204 project_key: "acme/app".into(),
205 file_path: "gone.md".into(),
206 deleted: true,
207 ..Default::default()
208 };
209 assert!(!serde_json::to_string(&delete).unwrap().contains("content"));
210 }
211
212 #[test]
213 fn tombstoned_file_serializes_content_as_null() {
214 let resp = SyncResponse {
215 project_key: "acme/app".into(),
216 files: vec![File {
217 file_path: "gone.md".into(),
218 content: None,
219 source_env: "laptop".into(),
220 updated_at: "2026-01-01T00:00:00.000Z".into(),
221 deleted: true,
222 }],
223 };
224 assert_eq!(
225 serde_json::to_string(&resp).unwrap(),
226 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}]}"#
227 );
228 }
229
230 #[test]
233 fn empty_file_is_distinguishable_from_a_tombstone() {
234 let empty = File {
235 file_path: "empty.md".into(),
236 content: Some(String::new()),
237 ..Default::default()
238 };
239 let json = serde_json::to_string(&empty).unwrap();
240 assert!(json.contains(r#""content":"""#), "got {json}");
241
242 let tombstone = File {
243 file_path: "empty.md".into(),
244 content: None,
245 ..Default::default()
246 };
247 assert!(serde_json::to_string(&tombstone)
248 .unwrap()
249 .contains(r#""content":null"#));
250 }
251
252 #[test]
254 fn parses_a_real_node_server_response() {
255 let body = r#"{"project_key":"acme/app","files":[
256 {"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},
257 {"file_path":"gone.md","content":null,"source_env":"node-era","updated_at":"2026-09-03T21:49:55.212Z","deleted":true}
258 ]}"#;
259 let parsed: SyncResponse = serde_json::from_str(body).unwrap();
260 assert_eq!(parsed.files.len(), 2);
261 assert_eq!(
262 parsed.files[0].content.as_deref(),
263 Some("written by the NODE server\n")
264 );
265 assert!(parsed.files[1].deleted);
266 assert_eq!(parsed.files[1].content, None);
267 }
268}