Skip to main content

recall_wire/
sync.rs

1//! `POST /sync` and `GET /sync` — the endpoints that carry memory files.
2
3use serde::{Deserialize, Serialize};
4
5use crate::ValidationError;
6
7/// Body of `POST /sync`: one memory file, or one delete.
8///
9/// `content` is an [`Option`], not a [`String`], and that is load-bearing:
10/// it has to distinguish "this file is empty" ([`Some`]`("")`, serialized as
11/// `"content":""`) from "this is a delete, there is no content" ([`None`],
12/// omitted entirely).
13///
14/// Skipping on emptiness instead — the obvious-looking
15/// `skip_serializing_if = "String::is_empty"` — omits the field for an
16/// empty *file* too, and the deployed Node server rejects that push with a
17/// 400, since `typeof undefined !== "string"`. Both the Go and the first
18/// Rust implementation had exactly that bug, and neither test suite caught
19/// it: every test that exercised an empty file used a stand-in server that
20/// accepted anything. It surfaced only when the compatibility script ran a
21/// real empty-file push against the real Node server.
22#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
23pub struct PushRequest {
24    /// Which project this file belongs to. See `recall_hooks::project`.
25    pub project_key: String,
26    /// The file's path relative to the project's memory directory.
27    pub file_path: String,
28    /// The file's exact bytes, or [`None`] to mean "this is a delete".
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub content: Option<String>,
31    /// A label for the machine that sent this, for display only.
32    #[serde(default, skip_serializing_if = "String::is_empty")]
33    pub source_env: String,
34    /// Whether this push is a delete rather than a write.
35    #[serde(default, skip_serializing_if = "is_false")]
36    pub deleted: bool,
37}
38
39impl PushRequest {
40    /// Checks a push is well-formed, applying the same rules on both sides
41    /// of the wire: the client refuses to send what the server would refuse
42    /// to accept, so the user sees the real reason rather than a 400.
43    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/// Body returned by `POST /sync`.
52#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
53pub struct PushResponse {
54    /// Always `true`; a failure is a non-2xx carrying an
55    /// [`ErrorResponse`](crate::ErrorResponse) instead.
56    pub ok: bool,
57    /// Echoed back from the request.
58    pub project_key: String,
59    /// Echoed back from the request.
60    pub file_path: String,
61    /// Whether the stored row is now a tombstone.
62    pub deleted: bool,
63    /// Whether the stored content is the result of a semantic merge rather
64    /// than a plain write.
65    pub merged: bool,
66    /// When the row was written, in the crate's frozen timestamp format.
67    pub updated_at: String,
68}
69
70/// One memory file as returned by `GET /sync`.
71///
72/// `content` is an [`Option`] so a tombstoned row reports JSON `null` rather
73/// than `""`: the server withholds deleted content so a pull cannot
74/// resurrect it, and an empty string would be indistinguishable from a
75/// genuinely empty file.
76#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
77pub struct File {
78    /// The file's path relative to the project's memory directory.
79    pub file_path: String,
80    /// The file's exact bytes, or [`None`] for a tombstone.
81    pub content: Option<String>,
82    /// The machine that last wrote this file.
83    #[serde(default)]
84    pub source_env: String,
85    /// When it was last written, in the crate's frozen timestamp format.
86    #[serde(default)]
87    pub updated_at: String,
88    /// Whether this row is a tombstone, meaning a puller should delete its
89    /// local copy.
90    #[serde(default)]
91    pub deleted: bool,
92}
93
94/// Body returned by `GET /sync`: every file held for one project,
95/// tombstones included.
96#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
97pub struct SyncResponse {
98    /// Echoed back from the query string.
99    pub project_key: String,
100    /// Every file the server holds for the project.
101    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        // A delete carries no content; that must not read as invalid.
130        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    /// Byte-for-byte compatibility with the deployed Node server. These
140    /// exact strings were captured from it.
141    #[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    /// The bug the compatibility script caught: an empty memory file must
169    /// still send `"content":""`. Omitting it makes the Node server answer
170    /// 400, so a project containing one empty note could never sync.
171    #[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        // A delete still omits it, which is what keeps the two apart.
187        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    /// An empty file is a legitimate memory file. If it serialized like a
215    /// tombstone, a pull would skip writing it.
216    #[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    /// Responses from the deployed Node server must deserialize as-is.
237    #[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}