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}
38
39impl PushRequest {
40 pub fn validate(&self) -> Result<(), ValidationError> {
44 if self.project_key.is_empty() {
45 return Err(ValidationError::MissingProjectKey);
46 }
47 crate::validate_file_path(&self.file_path)
48 }
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
53pub struct PushResponse {
54 pub ok: bool,
57 pub project_key: String,
59 pub file_path: String,
61 pub deleted: bool,
63 pub merged: bool,
66 pub updated_at: String,
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
77pub struct File {
78 pub file_path: String,
80 pub content: Option<String>,
82 #[serde(default)]
84 pub source_env: String,
85 #[serde(default)]
87 pub updated_at: String,
88 #[serde(default)]
91 pub deleted: bool,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
97pub struct SyncResponse {
98 pub project_key: String,
100 pub files: Vec<File>,
102}
103
104#[allow(clippy::trivially_copy_pass_by_ref)]
105fn is_false(b: &bool) -> bool {
106 !*b
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn validates_push_requests() {
115 let valid = PushRequest {
116 project_key: "acme/app".into(),
117 file_path: "MEMORY.md".into(),
118 content: Some("hi".into()),
119 ..Default::default()
120 };
121 assert!(valid.validate().is_ok());
122
123 let no_key = PushRequest {
124 file_path: "MEMORY.md".into(),
125 ..Default::default()
126 };
127 assert_eq!(no_key.validate(), Err(ValidationError::MissingProjectKey));
128
129 let delete = PushRequest {
131 project_key: "acme/app".into(),
132 file_path: "MEMORY.md".into(),
133 deleted: true,
134 ..Default::default()
135 };
136 assert!(delete.validate().is_ok());
137 }
138
139 #[test]
142 fn push_body_matches_the_node_server() {
143 let push = PushRequest {
144 project_key: "acme/app".into(),
145 file_path: "MEMORY.md".into(),
146 content: Some("hello".into()),
147 source_env: "laptop".into(),
148 deleted: false,
149 };
150 assert_eq!(
151 serde_json::to_string(&push).unwrap(),
152 r#"{"project_key":"acme/app","file_path":"MEMORY.md","content":"hello","source_env":"laptop"}"#
153 );
154
155 let delete = PushRequest {
156 project_key: "acme/app".into(),
157 file_path: "gone.md".into(),
158 source_env: "laptop".into(),
159 deleted: true,
160 ..Default::default()
161 };
162 assert_eq!(
163 serde_json::to_string(&delete).unwrap(),
164 r#"{"project_key":"acme/app","file_path":"gone.md","source_env":"laptop","deleted":true}"#
165 );
166 }
167
168 #[test]
172 fn an_empty_file_still_sends_its_content_field() {
173 let push = PushRequest {
174 project_key: "acme/app".into(),
175 file_path: "empty.md".into(),
176 content: Some(String::new()),
177 source_env: "laptop".into(),
178 deleted: false,
179 };
180 let json = serde_json::to_string(&push).unwrap();
181 assert!(
182 json.contains(r#""content":"""#),
183 "empty file must serialize its content field, got {json}"
184 );
185
186 let delete = PushRequest {
188 project_key: "acme/app".into(),
189 file_path: "gone.md".into(),
190 deleted: true,
191 ..Default::default()
192 };
193 assert!(!serde_json::to_string(&delete).unwrap().contains("content"));
194 }
195
196 #[test]
197 fn tombstoned_file_serializes_content_as_null() {
198 let resp = SyncResponse {
199 project_key: "acme/app".into(),
200 files: vec![File {
201 file_path: "gone.md".into(),
202 content: None,
203 source_env: "laptop".into(),
204 updated_at: "2026-01-01T00:00:00.000Z".into(),
205 deleted: true,
206 }],
207 };
208 assert_eq!(
209 serde_json::to_string(&resp).unwrap(),
210 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}]}"#
211 );
212 }
213
214 #[test]
217 fn empty_file_is_distinguishable_from_a_tombstone() {
218 let empty = File {
219 file_path: "empty.md".into(),
220 content: Some(String::new()),
221 ..Default::default()
222 };
223 let json = serde_json::to_string(&empty).unwrap();
224 assert!(json.contains(r#""content":"""#), "got {json}");
225
226 let tombstone = File {
227 file_path: "empty.md".into(),
228 content: None,
229 ..Default::default()
230 };
231 assert!(serde_json::to_string(&tombstone)
232 .unwrap()
233 .contains(r#""content":null"#));
234 }
235
236 #[test]
238 fn parses_a_real_node_server_response() {
239 let body = r#"{"project_key":"acme/app","files":[
240 {"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},
241 {"file_path":"gone.md","content":null,"source_env":"node-era","updated_at":"2026-09-03T21:49:55.212Z","deleted":true}
242 ]}"#;
243 let parsed: SyncResponse = serde_json::from_str(body).unwrap();
244 assert_eq!(parsed.files.len(), 2);
245 assert_eq!(
246 parsed.files[0].content.as_deref(),
247 Some("written by the NODE server\n")
248 );
249 assert!(parsed.files[1].deleted);
250 assert_eq!(parsed.files[1].content, None);
251 }
252}