Skip to main content

org_gdocs/
syncstate.rs

1//! P5 — the tool-owned `** Sync State` block.
2//!
3//! Under `* GDOC_METADATA`, `** Sync State` holds a single s-expression (A5 — not
4//! JSON) in a `#+begin_src emacs-lisp` block. It is **pure machine state**, fully
5//! regenerated on every sync: the projection position map ([`crate::project`]) plus
6//! a cache of the reviewers (collaborators) seen on pulled comments. The operator
7//! never hand-edits it.
8//!
9//! ## Block shape
10//!
11//! ```text
12//! ** Sync State
13//! #+begin_src emacs-lisp
14//! (gdoc-sync-state 1
15//!  (positions (pos "sec-intro" 1 heading) (pos "sec-intro/paragraph-1" 5 paragraph))
16//!  (collaborators (collab "alice@example.com" "Alice Smith")))
17//! #+end_src
18//! ```
19//!
20//! Parsing is total (EI-4): a *missing* block decodes to an empty-but-valid state,
21//! while a *present-but-corrupt* block surfaces a typed [`Error::Sexp`] — never a
22//! panic.
23
24use crate::error::{Error, Result};
25use crate::project::{ElementKind, Position, PositionMap};
26use crate::sexp::{Sexp, SexpError, parse};
27
28/// Sync-state s-expression schema version. Bump when the shape changes.
29const VERSION: i64 = 1;
30
31/// The `** Sync State` heading line.
32const HEADING: &str = "** Sync State";
33/// The source-block fence opening the machine s-expression.
34const SRC_OPEN: &str = "#+begin_src emacs-lisp";
35/// The source-block fence closing the machine s-expression.
36const SRC_CLOSE: &str = "#+end_src";
37
38/// A reviewer cached from pulled comments, so pull can show a name for an email
39/// without re-deriving it (the position map and this cache are the only state the
40/// tool keeps between runs — DI-3).
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Collaborator {
43    /// The reviewer's email address (the stable key Google reports).
44    pub email: String,
45    /// The reviewer's display name.
46    pub display_name: String,
47}
48
49/// A reply already posted to Google, recorded so `push` does not post it again.
50///
51/// The operator authors a reply as a `**** REPLY` subheading under a comment; once
52/// `push` posts it via `replies.create`, this record (keyed by comment id + the
53/// reply text) keeps the post idempotent without rewriting the operator's text
54/// (A3). Editing a posted reply's text changes the key and re-posts it.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct PostedReply {
57    /// The `:COMMENT_ID:` of the comment the reply answers.
58    pub comment_id: String,
59    /// The reply text as authored (the match key).
60    pub content: String,
61}
62
63/// The decoded `** Sync State` contents.
64#[derive(Debug, Clone, PartialEq, Eq, Default)]
65pub struct SyncState {
66    /// Where each anchorable element landed in the projected doc (P3 output).
67    pub positions: PositionMap,
68    /// Reviewers seen on pulled comments.
69    pub collaborators: Vec<Collaborator>,
70    /// Operator replies already posted to Google (push idempotence).
71    pub posted_replies: Vec<PostedReply>,
72    /// Fingerprint of the projection last pushed to Google
73    /// ([`crate::project::Projection::fingerprint`]). A re-push with a matching
74    /// fingerprint skips the full-replace so existing comment anchors survive.
75    pub projection_hash: Option<String>,
76}
77
78impl SyncState {
79    /// Whether a reply with this `comment_id` + `content` has already been posted.
80    #[must_use]
81    pub fn reply_posted(&self, comment_id: &str, content: &str) -> bool {
82        self.posted_replies
83            .iter()
84            .any(|reply| reply.comment_id == comment_id && reply.content == content)
85    }
86}
87
88impl SyncState {
89    /// Render the full `** Sync State` subtree (heading + source block).
90    #[must_use]
91    pub fn render_block(&self) -> String {
92        format!(
93            "{HEADING}\n{SRC_OPEN}\n{}\n{SRC_CLOSE}\n",
94            self.to_sexp().render()
95        )
96    }
97
98    /// Render just the inner s-expression (without the org wrapping).
99    #[must_use]
100    pub fn to_sexp(&self) -> Sexp {
101        let mut items = vec![
102            Sexp::symbol("gdoc-sync-state"),
103            Sexp::int(VERSION),
104            self.positions_sexp(),
105            self.collaborators_sexp(),
106            self.posted_replies_sexp(),
107        ];
108        if let Some(hash) = &self.projection_hash {
109            items.push(Sexp::list(vec![
110                Sexp::symbol("projection-hash"),
111                Sexp::string(hash.clone()),
112            ]));
113        }
114        Sexp::list(items)
115    }
116
117    /// Parse a [`SyncState`] from a region that may contain the `** Sync State`
118    /// block (e.g. the whole `* GDOC_METADATA` machine region).
119    ///
120    /// A region without the block decodes to [`SyncState::default`].
121    ///
122    /// # Errors
123    ///
124    /// Returns [`Error::Sexp`] when the block is present but its s-expression is
125    /// malformed or does not match the schema.
126    pub fn parse_block(region: &str) -> Result<Self> {
127        match extract_inner_sexp(region) {
128            None => Ok(Self::default()),
129            Some(inner) => {
130                let sexp = parse(&inner)?;
131                Self::from_sexp(&sexp).map_err(Error::from)
132            }
133        }
134    }
135
136    /// Decode a [`SyncState`] from its inner s-expression.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`SexpError::Malformed`] when the shape or schema version is wrong.
141    pub fn from_sexp(sexp: &Sexp) -> std::result::Result<Self, SexpError> {
142        let items = sexp
143            .as_list()
144            .ok_or_else(|| malformed("expected (gdoc-sync-state …)"))?;
145        if items.first().and_then(Sexp::as_symbol) != Some("gdoc-sync-state") {
146            return Err(malformed("expected (gdoc-sync-state …)"));
147        }
148        match items.get(1).and_then(Sexp::as_int) {
149            Some(VERSION) => {}
150            Some(other) => {
151                return Err(malformed(&format!(
152                    "unsupported sync-state version {other}"
153                )));
154            }
155            None => return Err(malformed("sync-state needs a version")),
156        }
157
158        let mut state = Self::default();
159        for section in items.get(2..).unwrap_or_default() {
160            let list = section.as_list().ok_or_else(|| {
161                malformed("expected a (positions …) or (collaborators …) section")
162            })?;
163            let body = list.get(1..).unwrap_or_default();
164            match list.first().and_then(Sexp::as_symbol) {
165                Some("positions") => decode_positions(body, &mut state.positions)?,
166                Some("collaborators") => decode_collaborators(body, &mut state.collaborators)?,
167                Some("posted-replies") => decode_posted_replies(body, &mut state.posted_replies)?,
168                Some("projection-hash") => {
169                    state.projection_hash = body.first().and_then(Sexp::as_str).map(str::to_owned);
170                }
171                // Unknown sections are ignored for forward compatibility.
172                _ => {}
173            }
174        }
175        Ok(state)
176    }
177
178    fn positions_sexp(&self) -> Sexp {
179        let mut items = Vec::with_capacity(self.positions.len() + 1);
180        items.push(Sexp::symbol("positions"));
181        for (id, position) in &self.positions {
182            items.push(Sexp::list(vec![
183                Sexp::symbol("pos"),
184                Sexp::string(id.clone()),
185                Sexp::int(i64::from(position.index)),
186                Sexp::symbol(position.kind.slug()),
187            ]));
188        }
189        Sexp::list(items)
190    }
191
192    fn collaborators_sexp(&self) -> Sexp {
193        let mut items = Vec::with_capacity(self.collaborators.len() + 1);
194        items.push(Sexp::symbol("collaborators"));
195        for collaborator in &self.collaborators {
196            items.push(Sexp::list(vec![
197                Sexp::symbol("collab"),
198                Sexp::string(collaborator.email.clone()),
199                Sexp::string(collaborator.display_name.clone()),
200            ]));
201        }
202        Sexp::list(items)
203    }
204
205    fn posted_replies_sexp(&self) -> Sexp {
206        let mut items = Vec::with_capacity(self.posted_replies.len() + 1);
207        items.push(Sexp::symbol("posted-replies"));
208        for reply in &self.posted_replies {
209            items.push(Sexp::list(vec![
210                Sexp::symbol("reply"),
211                Sexp::string(reply.comment_id.clone()),
212                Sexp::string(reply.content.clone()),
213            ]));
214        }
215        Sexp::list(items)
216    }
217}
218
219/// Decode the `(pos id index kind)` entries of a `(positions …)` section.
220fn decode_positions(items: &[Sexp], out: &mut PositionMap) -> std::result::Result<(), SexpError> {
221    for item in items {
222        let parts = item
223            .as_list()
224            .ok_or_else(|| malformed("expected (pos id index kind)"))?;
225        if parts.first().and_then(Sexp::as_symbol) != Some("pos") {
226            return Err(malformed("expected (pos id index kind)"));
227        }
228        let id = parts
229            .get(1)
230            .and_then(Sexp::as_str)
231            .ok_or_else(|| malformed("pos needs an id"))?;
232        let raw_index = parts
233            .get(2)
234            .and_then(Sexp::as_int)
235            .ok_or_else(|| malformed("pos needs an index"))?;
236        let index = u32::try_from(raw_index)
237            .map_err(|_| malformed("pos index must be a non-negative u32"))?;
238        let kind_slug = parts
239            .get(3)
240            .and_then(Sexp::as_symbol)
241            .ok_or_else(|| malformed("pos needs an element kind"))?;
242        let kind =
243            ElementKind::from_slug(kind_slug).ok_or_else(|| malformed("unknown element kind"))?;
244        out.insert(id.to_owned(), Position { index, kind });
245    }
246    Ok(())
247}
248
249/// Decode the `(collab email name)` entries of a `(collaborators …)` section.
250fn decode_collaborators(
251    items: &[Sexp],
252    out: &mut Vec<Collaborator>,
253) -> std::result::Result<(), SexpError> {
254    for item in items {
255        let parts = item
256            .as_list()
257            .ok_or_else(|| malformed("expected (collab email name)"))?;
258        if parts.first().and_then(Sexp::as_symbol) != Some("collab") {
259            return Err(malformed("expected (collab email name)"));
260        }
261        let email = parts
262            .get(1)
263            .and_then(Sexp::as_str)
264            .ok_or_else(|| malformed("collab needs an email"))?;
265        let display_name = parts
266            .get(2)
267            .and_then(Sexp::as_str)
268            .ok_or_else(|| malformed("collab needs a display name"))?;
269        out.push(Collaborator {
270            email: email.to_owned(),
271            display_name: display_name.to_owned(),
272        });
273    }
274    Ok(())
275}
276
277/// Decode the `(reply comment-id content)` entries of a `(posted-replies …)`
278/// section.
279fn decode_posted_replies(
280    items: &[Sexp],
281    out: &mut Vec<PostedReply>,
282) -> std::result::Result<(), SexpError> {
283    for item in items {
284        let parts = item
285            .as_list()
286            .ok_or_else(|| malformed("expected (reply comment-id content)"))?;
287        if parts.first().and_then(Sexp::as_symbol) != Some("reply") {
288            return Err(malformed("expected (reply comment-id content)"));
289        }
290        let comment_id = parts
291            .get(1)
292            .and_then(Sexp::as_str)
293            .ok_or_else(|| malformed("reply needs a comment id"))?;
294        let content = parts
295            .get(2)
296            .and_then(Sexp::as_str)
297            .ok_or_else(|| malformed("reply needs content"))?;
298        out.push(PostedReply {
299            comment_id: comment_id.to_owned(),
300            content: content.to_owned(),
301        });
302    }
303    Ok(())
304}
305
306/// Extract the s-expression text from the `** Sync State` source block within
307/// `region`, or `None` when the block is absent.
308fn extract_inner_sexp(region: &str) -> Option<String> {
309    let mut lines = region.lines();
310    lines.by_ref().find(|line| line.trim_end() == HEADING)?;
311    lines.by_ref().find(|line| line.trim() == SRC_OPEN)?;
312    let inner: Vec<&str> = lines.take_while(|line| line.trim() != SRC_CLOSE).collect();
313    if inner.is_empty() {
314        return None;
315    }
316    Some(inner.join("\n"))
317}
318
319fn malformed(message: &str) -> SexpError {
320    SexpError::Malformed(message.to_owned())
321}
322
323#[cfg(test)]
324mod tests {
325    use super::{Collaborator, PostedReply, SyncState};
326    use crate::project::{ElementKind, Position};
327
328    fn sample() -> SyncState {
329        let mut positions = std::collections::BTreeMap::new();
330        positions.insert(
331            "sec-intro".to_owned(),
332            Position {
333                index: 1,
334                kind: ElementKind::Heading,
335            },
336        );
337        positions.insert(
338            "sec-intro/paragraph-1".to_owned(),
339            Position {
340                index: 5,
341                kind: ElementKind::Paragraph,
342            },
343        );
344        SyncState {
345            positions,
346            collaborators: vec![Collaborator {
347                email: "alice@example.com".to_owned(),
348                display_name: "Alice Smith".to_owned(),
349            }],
350            posted_replies: vec![PostedReply {
351                comment_id: "C1".to_owned(),
352                content: "Fixed, thanks.".to_owned(),
353            }],
354            projection_hash: Some("deadbeef".to_owned()),
355        }
356    }
357
358    #[test]
359    fn sexp_round_trips_through_text() {
360        let state = sample();
361        let text = state.to_sexp().render();
362        let parsed = crate::sexp::parse(&text).expect("parses");
363        let decoded = SyncState::from_sexp(&parsed).expect("decodes");
364        assert_eq!(decoded, state);
365    }
366
367    #[test]
368    fn block_round_trips() {
369        let state = sample();
370        let block = state.render_block();
371        assert!(block.starts_with("** Sync State\n#+begin_src emacs-lisp\n"));
372        assert!(block.trim_end().ends_with("#+end_src"));
373        let decoded = SyncState::parse_block(&block).expect("decodes");
374        assert_eq!(decoded, state);
375    }
376
377    #[test]
378    fn block_round_trips_within_a_larger_region() {
379        let region = format!(
380            "* GDOC_METADATA :noexport:\n{}** Active Comments\n",
381            sample().render_block()
382        );
383        let decoded = SyncState::parse_block(&region).expect("decodes");
384        assert_eq!(decoded, sample());
385    }
386
387    #[test]
388    fn missing_block_decodes_to_empty_state() {
389        let region = "* GDOC_METADATA :noexport:\n** Active Comments\n";
390        let decoded = SyncState::parse_block(region).expect("decodes");
391        assert_eq!(decoded, SyncState::default());
392    }
393
394    #[test]
395    fn empty_state_round_trips() {
396        let decoded =
397            SyncState::parse_block(&SyncState::default().render_block()).expect("decodes");
398        assert_eq!(decoded, SyncState::default());
399    }
400
401    #[test]
402    fn corrupt_block_is_a_typed_error_not_a_panic() {
403        let region = "** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions (pos \"x\"\n#+end_src\n";
404        assert!(matches!(
405            SyncState::parse_block(region),
406            Err(crate::error::Error::Sexp(_))
407        ));
408    }
409
410    #[test]
411    fn unsupported_version_is_rejected() {
412        let region = "** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 2 (positions) (collaborators))\n#+end_src\n";
413        assert!(matches!(
414            SyncState::parse_block(region),
415            Err(crate::error::Error::Sexp(_))
416        ));
417    }
418
419    #[test]
420    fn unknown_element_kind_is_rejected() {
421        let region = "** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions (pos \"x\" 1 bogus)) (collaborators))\n#+end_src\n";
422        assert!(matches!(
423            SyncState::parse_block(region),
424            Err(crate::error::Error::Sexp(_))
425        ));
426    }
427
428    #[test]
429    fn posted_replies_round_trip_and_dedupe_lookup() {
430        let state = sample();
431        let decoded = SyncState::parse_block(&state.render_block()).expect("decodes");
432        assert_eq!(decoded, state);
433        assert!(decoded.reply_posted("C1", "Fixed, thanks."));
434        assert!(!decoded.reply_posted("C1", "a different reply"));
435        assert!(!decoded.reply_posted("C2", "Fixed, thanks."));
436    }
437
438    #[test]
439    fn unknown_sections_are_ignored_for_forward_compat() {
440        let region = "** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions (pos \"x\" 3 table)) (collaborators) (future-section 9))\n#+end_src\n";
441        let decoded = SyncState::parse_block(region).expect("decodes");
442        assert_eq!(decoded.positions.len(), 1);
443        assert_eq!(
444            decoded.positions.get("x"),
445            Some(&Position {
446                index: 3,
447                kind: ElementKind::Table
448            })
449        );
450    }
451}