tftio-org-gdocs 0.1.1

Sync org-mode documents to Google Docs and pull reviewer comments back into org-mode
Documentation
//! Q2 — the `pull` orchestration: fetch reviewer comments from the linked Google
//! Doc and merge them into the org file as structural-anchored `TODO` headings.
//!
//! This is the imperative shell (EI-4). It sequences the effects (list comments
//! over the network, then rewrite the file content) and delegates every decision
//! to the pure cores — anchor→section mapping ([`crate::comments`]), comment
//! rendering/merge ([`crate::comments_meta`]), sync state ([`crate::syncstate`]),
//! region writeback ([`crate::orgfile`]).
//!
//! Guarantees:
//!
//! - **Merge-by-id (A3).** Existing comment headings are preserved byte-for-byte
//!   and never reordered; only ids not already present are appended. A pull that
//!   finds no new comments is a no-op on the file (proven by a test).
//! - **DI-2 — the body is sacrosanct.** Pull reuses the verbatim-body writeback;
//!   the only body change is the idempotent, already-present `:CUSTOM_ID:`
//!   insertion (so every section a comment references has a real drawer for elisp
//!   to jump to).
//! - **DI-3 — stateless.** Everything is read from and written to the file content
//!   (plus the live comment list); no sidecar.
//! - **DI-4 — pull never creates or anchors a comment in Google;** it only lists.
//!
//! Pull does **not** re-project the body: the position map describes the *pushed*
//! doc (what the Google anchors point into), so it is preserved from `** Sync
//! State` rather than regenerated. Only the collaborator cache and
//! `#+GDOC_LAST_PULL:` change.

use std::collections::HashSet;

use kb::parser::parse_document;

use crate::comments::{Location, Resolver};
use crate::comments_meta;
use crate::custom_id::ensure_section_ids;
use crate::envelope;
use crate::error::{Error, Result};
use crate::google::client::GoogleClient;
use crate::google::drive::Comment;
use crate::orgfile;
use crate::sexp::Sexp;
use crate::syncstate::{Collaborator, SyncState};

/// The tool-owned metadata heading that opens the machine region.
const METADATA_HEADING: &str = "* GDOC_METADATA :noexport:";

/// The result of a pull: what was merged and the new file content to persist.
#[derive(Debug, Clone)]
pub struct PullOutcome {
    /// The linked document the comments were pulled from.
    pub document_id: String,
    /// Ids of comments newly inserted as `TODO` headings this pull.
    pub new_comment_ids: Vec<String>,
    /// Total comments Google returned for the document.
    pub total_comments: usize,
    /// The new file content to write back (body preserved, machine region rebuilt).
    pub new_content: String,
}

/// Fetch the linked doc's comments and merge new ones into `content`, returning
/// the rewritten file.
///
/// `now` is the caller-supplied pull timestamp (clock at the edge, EI-4).
///
/// # Errors
///
/// Returns [`Error::NotLinked`] when the file has no `#+GDOC_ID:`, [`Error::Google`]
/// on any comment-list failure, [`Error::OrgParse`] on org-parse failure, or
/// [`Error::Sexp`] when the existing `** Sync State` block is malformed.
pub async fn pull(client: &GoogleClient, content: &str, now: &str) -> Result<PullOutcome> {
    let document_id = orgfile::read_keyword(content, "GDOC_ID").ok_or(Error::NotLinked)?;

    let (body, machine) = orgfile::split(content);
    let machine = machine.unwrap_or("");

    // Idempotent body mutation: guarantee every heading a comment may reference has
    // a `:CUSTOM_ID:` drawer (DI-2). After a push this is already true — a no-op.
    let body_with_ids = ensure_section_ids(body);
    let document =
        parse_document(&body_with_ids).map_err(|err| Error::OrgParse(err.to_string()))?;

    // The position map describes the pushed doc; preserve it (pull does not push).
    let existing = SyncState::parse_block(machine)?;

    // List the live comments (DI-4: read only).
    let comments = client.list_comments(&document_id).await?;
    let total_comments = comments.len();

    // Map each comment to its section once, scoping the resolver's borrow of the
    // preserved positions so they can be moved into the rebuilt sync state below.
    let sections: Vec<Option<String>> = {
        let resolver = Resolver::new(&existing.positions, &document);
        comments.iter().map(|c| section_id(&resolver, c)).collect()
    };

    // New = unresolved in Google and not already filed under a known id (A3).
    let known: HashSet<String> = comments_meta::parse_entries(machine)
        .into_iter()
        .map(|entry| entry.id)
        .collect();
    let fresh: Vec<(&Comment, Option<&str>)> = comments
        .iter()
        .zip(&sections)
        .filter(|(comment, _)| !comment.resolved && !known.contains(&comment.id))
        .map(|(comment, section)| (comment, section.as_deref()))
        .collect();
    let new_comment_ids: Vec<String> = fresh.iter().map(|(c, _)| c.id.clone()).collect();

    let new_content = write_back(&body_with_ids, machine, existing, &comments, &fresh, now);

    Ok(PullOutcome {
        document_id,
        new_comment_ids,
        total_comments,
        new_content,
    })
}

/// Reassemble the file: body (with `CUSTOM_ID`s) + refreshed `#+GDOC_LAST_PULL:`
/// keyword + a regenerated machine region (positions preserved, collaborator
/// cache extended, new comments merged into Active Comments verbatim-safe).
fn write_back(
    body_with_ids: &str,
    machine: &str,
    existing: SyncState,
    all_comments: &[Comment],
    fresh: &[(&Comment, Option<&str>)],
    now: &str,
) -> String {
    let sync = SyncState {
        positions: existing.positions,
        collaborators: merge_collaborators(existing.collaborators, all_comments),
        // Posted-reply bookkeeping and the projection fingerprint are push's
        // concern; carry them through untouched.
        posted_replies: existing.posted_replies,
        projection_hash: existing.projection_hash,
    };
    let active = comments_meta::render_section(machine, fresh);
    let region = format!("{METADATA_HEADING}\n{}{active}", sync.render_block());

    let with_keyword = orgfile::upsert_keyword(body_with_ids, "GDOC_LAST_PULL", now);
    orgfile::replace_metadata(&with_keyword, &region)
}

/// The `:CUSTOM_ID:` a comment resolves to, or `None` for document level.
fn section_id(resolver: &Resolver, comment: &Comment) -> Option<String> {
    match resolver.resolve(comment.anchor.as_ref(), comment.quoted_text.as_deref()) {
        Location::Section(id) => Some(id),
        Location::Document => None,
    }
}

/// Build the success envelope for a completed pull (A5).
#[must_use]
pub fn envelope(outcome: &PullOutcome) -> Sexp {
    let new_ids = outcome
        .new_comment_ids
        .iter()
        .map(|id| Sexp::string(id.clone()))
        .collect();
    envelope::ok(
        "pull",
        vec![
            ("document-id", Sexp::string(outcome.document_id.clone())),
            (
                "new",
                Sexp::int(i64::try_from(outcome.new_comment_ids.len()).unwrap_or(i64::MAX)),
            ),
            (
                "total",
                Sexp::int(i64::try_from(outcome.total_comments).unwrap_or(i64::MAX)),
            ),
            ("new-ids", Sexp::list(new_ids)),
        ],
    )
}

/// Merge reviewer authors seen on `comments` into the existing collaborator cache,
/// appending only emails not already present (so the merge is idempotent).
fn merge_collaborators(existing: Vec<Collaborator>, comments: &[Comment]) -> Vec<Collaborator> {
    let mut seen: HashSet<String> = existing.iter().map(|c| c.email.clone()).collect();
    let mut merged = existing;
    for author in comments
        .iter()
        .flat_map(|c| std::iter::once(&c.author).chain(c.replies.iter().map(|r| &r.author)))
    {
        if let (Some(email), Some(name)) = (&author.email, &author.display_name)
            && seen.insert(email.clone())
        {
            merged.push(Collaborator {
                email: email.clone(),
                display_name: name.clone(),
            });
        }
    }
    merged
}

#[cfg(test)]
mod tests {
    use super::pull;
    use crate::error::Error;
    use crate::google::client::GoogleClient;
    use crate::orgfile;
    use mockito::{Matcher, Server};

    const NOW: &str = "2026-06-11T00:00:00+00:00";

    /// A linked file with one section and a populated `** Sync State`, ready to
    /// receive pulled comments.
    const LINKED: &str = "#+GDOC_ID: DOC1\n#+TITLE: Spec\n\
        * Introduction\n:PROPERTIES:\n:CUSTOM_ID: sec-intro\n:END:\nThe quick brown fox.\n\n\
        * GDOC_METADATA :noexport:\n** Sync State\n#+begin_src emacs-lisp\n\
        (gdoc-sync-state 1 (positions (pos \"sec-intro\" 1 heading)) (collaborators))\n\
        #+end_src\n** Active Comments\n";

    fn test_client(server: &Server) -> GoogleClient {
        let mut client = GoogleClient::new("test-token".to_owned()).expect("client builds");
        client.set_base_url(&server.url());
        client
    }

    async fn mock_comments(server: &mut Server, body: &str) -> mockito::Mock {
        server
            .mock("GET", "/files/DOC1/comments")
            .match_query(Matcher::Any)
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(body)
            .create_async()
            .await
    }

    /// One unresolved comment quoting text from the intro section.
    const ONE_COMMENT: &str = r#"{"comments":[
        {"id":"C1",
         "author":{"displayName":"Alice","emailAddress":"alice@example.com"},
         "content":"Please clarify.","createdTime":"2026-06-01T12:00:00Z","resolved":false,
         "quotedFileContent":{"value":"quick brown fox"}}
    ]}"#;

    #[tokio::test]
    async fn pull_merges_new_comment_under_resolved_section() {
        let mut server = Server::new_async().await;
        let mock = mock_comments(&mut server, ONE_COMMENT).await;

        let outcome = pull(&test_client(&server), LINKED, NOW)
            .await
            .expect("pull succeeds");

        assert_eq!(outcome.document_id, "DOC1");
        assert_eq!(outcome.new_comment_ids, vec!["C1".to_owned()]);
        assert_eq!(outcome.total_comments, 1);

        let written = &outcome.new_content;
        // The quote resolved to sec-intro via the fallback.
        assert!(written.contains(":COMMENT_ID: C1\n"));
        assert!(written.contains(":COMMENT_SECTION: sec-intro\n"));
        assert!(written.contains("*** TODO Alice: Please clarify.\n"));
        // The reviewer was cached, and the pull timestamp recorded.
        assert!(written.contains("(collab \"alice@example.com\" \"Alice\")"));
        assert!(written.contains(&format!("#+GDOC_LAST_PULL: {NOW}\n")));
        // Positions are preserved from the pushed doc, not regenerated.
        assert!(written.contains("(pos \"sec-intro\" 1 heading)"));
        mock.assert_async().await;
    }

    #[tokio::test]
    async fn pull_preserves_body_prose_byte_for_byte() {
        let mut server = Server::new_async().await;
        let _mock = mock_comments(&mut server, ONE_COMMENT).await;

        let outcome = pull(&test_client(&server), LINKED, NOW)
            .await
            .expect("pull succeeds");

        // The body equals the original body + the one refreshed keyword; nothing in
        // the prose region is reflowed or canonicalized (DI-2).
        let expected = {
            let body = orgfile::body(LINKED);
            orgfile::upsert_keyword(body, "GDOC_LAST_PULL", NOW)
        };
        let pulled_body = orgfile::body(&outcome.new_content);
        assert_eq!(
            pulled_body.trim_end_matches('\n'),
            expected.trim_end_matches('\n')
        );
    }

    #[tokio::test]
    async fn re_pull_with_no_new_comments_is_a_noop() {
        let mut server = Server::new_async().await;
        let _mock = mock_comments(&mut server, ONE_COMMENT).await;
        let client = test_client(&server);

        let once = pull(&client, LINKED, NOW).await.expect("first pull");
        let twice = pull(&client, &once.new_content, NOW)
            .await
            .expect("second pull");

        // Merge-by-id (A3): the second pull finds C1 already filed and changes
        // nothing — byte-identical content.
        assert!(twice.new_comment_ids.is_empty());
        assert_eq!(twice.new_content, once.new_content);
    }

    #[tokio::test]
    async fn pull_skips_comments_already_resolved_in_google() {
        let mut server = Server::new_async().await;
        let resolved = r#"{"comments":[
            {"id":"CR","author":{"displayName":"Alice","emailAddress":"alice@example.com"},
             "content":"done already","resolved":true,
             "quotedFileContent":{"value":"quick brown fox"}}
        ]}"#;
        let _mock = mock_comments(&mut server, resolved).await;

        let outcome = pull(&test_client(&server), LINKED, NOW)
            .await
            .expect("pull succeeds");

        assert!(outcome.new_comment_ids.is_empty());
        assert!(!outcome.new_content.contains(":COMMENT_ID: CR\n"));
    }

    #[tokio::test]
    async fn pull_without_a_linked_doc_is_not_linked_error() {
        let server = Server::new_async().await;
        let content = "* Intro\nNo GDOC_ID here.\n";
        let result = pull(&test_client(&server), content, NOW).await;
        assert!(matches!(result, Err(Error::NotLinked)));
    }
}