1use crate::error::{Error, Result};
25use crate::project::{ElementKind, Position, PositionMap};
26use crate::sexp::{Sexp, SexpError, parse};
27
28const VERSION: i64 = 1;
30
31const HEADING: &str = "** Sync State";
33const SRC_OPEN: &str = "#+begin_src emacs-lisp";
35const SRC_CLOSE: &str = "#+end_src";
37
38#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Collaborator {
43 pub email: String,
45 pub display_name: String,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct PostedReply {
57 pub comment_id: String,
59 pub content: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Default)]
65pub struct SyncState {
66 pub positions: PositionMap,
68 pub collaborators: Vec<Collaborator>,
70 pub posted_replies: Vec<PostedReply>,
72 pub projection_hash: Option<String>,
76}
77
78impl SyncState {
79 #[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 #[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 #[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 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 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 _ => {}
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
219fn 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
249fn 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
277fn 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
306fn 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(®ion).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}