use crate::error::{Error, Result};
use crate::project::{ElementKind, Position, PositionMap};
use crate::sexp::{Sexp, SexpError, parse};
const VERSION: i64 = 1;
const HEADING: &str = "** Sync State";
const SRC_OPEN: &str = "#+begin_src emacs-lisp";
const SRC_CLOSE: &str = "#+end_src";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Collaborator {
pub email: String,
pub display_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PostedReply {
pub comment_id: String,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SyncState {
pub positions: PositionMap,
pub collaborators: Vec<Collaborator>,
pub posted_replies: Vec<PostedReply>,
pub projection_hash: Option<String>,
}
impl SyncState {
#[must_use]
pub fn reply_posted(&self, comment_id: &str, content: &str) -> bool {
self.posted_replies
.iter()
.any(|reply| reply.comment_id == comment_id && reply.content == content)
}
}
impl SyncState {
#[must_use]
pub fn render_block(&self) -> String {
format!(
"{HEADING}\n{SRC_OPEN}\n{}\n{SRC_CLOSE}\n",
self.to_sexp().render()
)
}
#[must_use]
pub fn to_sexp(&self) -> Sexp {
let mut items = vec![
Sexp::symbol("gdoc-sync-state"),
Sexp::int(VERSION),
self.positions_sexp(),
self.collaborators_sexp(),
self.posted_replies_sexp(),
];
if let Some(hash) = &self.projection_hash {
items.push(Sexp::list(vec![
Sexp::symbol("projection-hash"),
Sexp::string(hash.clone()),
]));
}
Sexp::list(items)
}
pub fn parse_block(region: &str) -> Result<Self> {
match extract_inner_sexp(region) {
None => Ok(Self::default()),
Some(inner) => {
let sexp = parse(&inner)?;
Self::from_sexp(&sexp).map_err(Error::from)
}
}
}
pub fn from_sexp(sexp: &Sexp) -> std::result::Result<Self, SexpError> {
let items = sexp
.as_list()
.ok_or_else(|| malformed("expected (gdoc-sync-state …)"))?;
if items.first().and_then(Sexp::as_symbol) != Some("gdoc-sync-state") {
return Err(malformed("expected (gdoc-sync-state …)"));
}
match items.get(1).and_then(Sexp::as_int) {
Some(VERSION) => {}
Some(other) => {
return Err(malformed(&format!(
"unsupported sync-state version {other}"
)));
}
None => return Err(malformed("sync-state needs a version")),
}
let mut state = Self::default();
for section in items.get(2..).unwrap_or_default() {
let list = section.as_list().ok_or_else(|| {
malformed("expected a (positions …) or (collaborators …) section")
})?;
let body = list.get(1..).unwrap_or_default();
match list.first().and_then(Sexp::as_symbol) {
Some("positions") => decode_positions(body, &mut state.positions)?,
Some("collaborators") => decode_collaborators(body, &mut state.collaborators)?,
Some("posted-replies") => decode_posted_replies(body, &mut state.posted_replies)?,
Some("projection-hash") => {
state.projection_hash = body.first().and_then(Sexp::as_str).map(str::to_owned);
}
_ => {}
}
}
Ok(state)
}
fn positions_sexp(&self) -> Sexp {
let mut items = Vec::with_capacity(self.positions.len() + 1);
items.push(Sexp::symbol("positions"));
for (id, position) in &self.positions {
items.push(Sexp::list(vec![
Sexp::symbol("pos"),
Sexp::string(id.clone()),
Sexp::int(i64::from(position.index)),
Sexp::symbol(position.kind.slug()),
]));
}
Sexp::list(items)
}
fn collaborators_sexp(&self) -> Sexp {
let mut items = Vec::with_capacity(self.collaborators.len() + 1);
items.push(Sexp::symbol("collaborators"));
for collaborator in &self.collaborators {
items.push(Sexp::list(vec![
Sexp::symbol("collab"),
Sexp::string(collaborator.email.clone()),
Sexp::string(collaborator.display_name.clone()),
]));
}
Sexp::list(items)
}
fn posted_replies_sexp(&self) -> Sexp {
let mut items = Vec::with_capacity(self.posted_replies.len() + 1);
items.push(Sexp::symbol("posted-replies"));
for reply in &self.posted_replies {
items.push(Sexp::list(vec![
Sexp::symbol("reply"),
Sexp::string(reply.comment_id.clone()),
Sexp::string(reply.content.clone()),
]));
}
Sexp::list(items)
}
}
fn decode_positions(items: &[Sexp], out: &mut PositionMap) -> std::result::Result<(), SexpError> {
for item in items {
let parts = item
.as_list()
.ok_or_else(|| malformed("expected (pos id index kind)"))?;
if parts.first().and_then(Sexp::as_symbol) != Some("pos") {
return Err(malformed("expected (pos id index kind)"));
}
let id = parts
.get(1)
.and_then(Sexp::as_str)
.ok_or_else(|| malformed("pos needs an id"))?;
let raw_index = parts
.get(2)
.and_then(Sexp::as_int)
.ok_or_else(|| malformed("pos needs an index"))?;
let index = u32::try_from(raw_index)
.map_err(|_| malformed("pos index must be a non-negative u32"))?;
let kind_slug = parts
.get(3)
.and_then(Sexp::as_symbol)
.ok_or_else(|| malformed("pos needs an element kind"))?;
let kind =
ElementKind::from_slug(kind_slug).ok_or_else(|| malformed("unknown element kind"))?;
out.insert(id.to_owned(), Position { index, kind });
}
Ok(())
}
fn decode_collaborators(
items: &[Sexp],
out: &mut Vec<Collaborator>,
) -> std::result::Result<(), SexpError> {
for item in items {
let parts = item
.as_list()
.ok_or_else(|| malformed("expected (collab email name)"))?;
if parts.first().and_then(Sexp::as_symbol) != Some("collab") {
return Err(malformed("expected (collab email name)"));
}
let email = parts
.get(1)
.and_then(Sexp::as_str)
.ok_or_else(|| malformed("collab needs an email"))?;
let display_name = parts
.get(2)
.and_then(Sexp::as_str)
.ok_or_else(|| malformed("collab needs a display name"))?;
out.push(Collaborator {
email: email.to_owned(),
display_name: display_name.to_owned(),
});
}
Ok(())
}
fn decode_posted_replies(
items: &[Sexp],
out: &mut Vec<PostedReply>,
) -> std::result::Result<(), SexpError> {
for item in items {
let parts = item
.as_list()
.ok_or_else(|| malformed("expected (reply comment-id content)"))?;
if parts.first().and_then(Sexp::as_symbol) != Some("reply") {
return Err(malformed("expected (reply comment-id content)"));
}
let comment_id = parts
.get(1)
.and_then(Sexp::as_str)
.ok_or_else(|| malformed("reply needs a comment id"))?;
let content = parts
.get(2)
.and_then(Sexp::as_str)
.ok_or_else(|| malformed("reply needs content"))?;
out.push(PostedReply {
comment_id: comment_id.to_owned(),
content: content.to_owned(),
});
}
Ok(())
}
fn extract_inner_sexp(region: &str) -> Option<String> {
let mut lines = region.lines();
lines.by_ref().find(|line| line.trim_end() == HEADING)?;
lines.by_ref().find(|line| line.trim() == SRC_OPEN)?;
let inner: Vec<&str> = lines.take_while(|line| line.trim() != SRC_CLOSE).collect();
if inner.is_empty() {
return None;
}
Some(inner.join("\n"))
}
fn malformed(message: &str) -> SexpError {
SexpError::Malformed(message.to_owned())
}
#[cfg(test)]
mod tests {
use super::{Collaborator, PostedReply, SyncState};
use crate::project::{ElementKind, Position};
fn sample() -> SyncState {
let mut positions = std::collections::BTreeMap::new();
positions.insert(
"sec-intro".to_owned(),
Position {
index: 1,
kind: ElementKind::Heading,
},
);
positions.insert(
"sec-intro/paragraph-1".to_owned(),
Position {
index: 5,
kind: ElementKind::Paragraph,
},
);
SyncState {
positions,
collaborators: vec![Collaborator {
email: "alice@example.com".to_owned(),
display_name: "Alice Smith".to_owned(),
}],
posted_replies: vec![PostedReply {
comment_id: "C1".to_owned(),
content: "Fixed, thanks.".to_owned(),
}],
projection_hash: Some("deadbeef".to_owned()),
}
}
#[test]
fn sexp_round_trips_through_text() {
let state = sample();
let text = state.to_sexp().render();
let parsed = crate::sexp::parse(&text).expect("parses");
let decoded = SyncState::from_sexp(&parsed).expect("decodes");
assert_eq!(decoded, state);
}
#[test]
fn block_round_trips() {
let state = sample();
let block = state.render_block();
assert!(block.starts_with("** Sync State\n#+begin_src emacs-lisp\n"));
assert!(block.trim_end().ends_with("#+end_src"));
let decoded = SyncState::parse_block(&block).expect("decodes");
assert_eq!(decoded, state);
}
#[test]
fn block_round_trips_within_a_larger_region() {
let region = format!(
"* GDOC_METADATA :noexport:\n{}** Active Comments\n",
sample().render_block()
);
let decoded = SyncState::parse_block(®ion).expect("decodes");
assert_eq!(decoded, sample());
}
#[test]
fn missing_block_decodes_to_empty_state() {
let region = "* GDOC_METADATA :noexport:\n** Active Comments\n";
let decoded = SyncState::parse_block(region).expect("decodes");
assert_eq!(decoded, SyncState::default());
}
#[test]
fn empty_state_round_trips() {
let decoded =
SyncState::parse_block(&SyncState::default().render_block()).expect("decodes");
assert_eq!(decoded, SyncState::default());
}
#[test]
fn corrupt_block_is_a_typed_error_not_a_panic() {
let region = "** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions (pos \"x\"\n#+end_src\n";
assert!(matches!(
SyncState::parse_block(region),
Err(crate::error::Error::Sexp(_))
));
}
#[test]
fn unsupported_version_is_rejected() {
let region = "** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 2 (positions) (collaborators))\n#+end_src\n";
assert!(matches!(
SyncState::parse_block(region),
Err(crate::error::Error::Sexp(_))
));
}
#[test]
fn unknown_element_kind_is_rejected() {
let region = "** Sync State\n#+begin_src emacs-lisp\n(gdoc-sync-state 1 (positions (pos \"x\" 1 bogus)) (collaborators))\n#+end_src\n";
assert!(matches!(
SyncState::parse_block(region),
Err(crate::error::Error::Sexp(_))
));
}
#[test]
fn posted_replies_round_trip_and_dedupe_lookup() {
let state = sample();
let decoded = SyncState::parse_block(&state.render_block()).expect("decodes");
assert_eq!(decoded, state);
assert!(decoded.reply_posted("C1", "Fixed, thanks."));
assert!(!decoded.reply_posted("C1", "a different reply"));
assert!(!decoded.reply_posted("C2", "Fixed, thanks."));
}
#[test]
fn unknown_sections_are_ignored_for_forward_compat() {
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";
let decoded = SyncState::parse_block(region).expect("decodes");
assert_eq!(decoded.positions.len(), 1);
assert_eq!(
decoded.positions.get("x"),
Some(&Position {
index: 3,
kind: ElementKind::Table
})
);
}
}