feat(contacts): glue sync + Google pagination + Apple discovery & folding

Three atomics finish phase 3 of kei-buddy contacts integration:

## kei-buddy: contact-sync glue + slash commands (+5 tests)

New src/contacts_sync.rs (146 LOC):
  * SyncReport { fetched, added, skipped, errors }
  * sync_from_google(access_token, contacts) — builds GooglePeopleClient,
    list_connections, dedups by (name+email) via search_contacts,
    add_contact in loop
  * sync_from_apple(apple_id, app_pw, addressbook_url, contacts) — same
    pattern over ICloudCardDavClient.list_contacts
  * All errors collected into report.errors; never panics, never propagates

New slash commands in commands.rs / command_exec.rs:
  * /sync-google — reads GOOGLE_OAUTH_ACCESS_TOKEN env, calls sync_from_google,
    Russian-formatted summary "Google: загружено N, добавлено M, пропущено K"
  * /sync-apple — reads APPLE_ID + APPLE_APP_PASSWORD + APPLE_CARDDAV_URL,
    calls sync_from_apple
  * Missing env → human-readable "не настроено: …" response
  * /help text updated

Deps added: kei-contacts-google + kei-contacts-apple as path deps.

## kei-contacts-google: pagination via nextPageToken (+1 test)

Refactor: client.rs 182→56 LOC; pagination logic + deserialization moved
to new src/pagination.rs (188 LOC). list_connections unchanged
(back-compat, returns first page only). New list_all_connections loops
via fetch_page(Some(token)) until token=None; hard cap 50 pages with
tracing::warn on cap.

Test list_all_connections_two_pages: wiremock returns page 1 with
nextPageToken="abc" + page 2 without; assert len = sum AND second
request carries pageToken=abc query.

## kei-contacts-apple: vCard line-folding + CardDAV auto-discovery (+2 tests)

vcard.rs +unfold() helper applied in parse_vcard per RFC 6350 §3.2:
continuation lines starting with space/tab strip the prefix and append
to previous line. Test parse_folded_vcard.

New src/discovery.rs (199 LOC): discover_addressbook() walks
.well-known/carddav → current-user-principal → addressbook-home-set →
first addressbook with C:addressbook resourcetype. Three PROPFIND
requests with canned XML bodies. Regex-based extract_first_href_under +
extract_addressbook_href helpers. Test discover_walks_three_propfinds
against 3-step wiremock fixture.

client.rs adds discover_addressbook_url() method calling discovery.

## Verify-before-commit

  * cargo check --workspace: PASS
  * cargo test -p kei-buddy --lib: 46/0 (was 41)
  * cargo test -p kei-contacts-google: 5/0 (was 4, +1 pagination)
  * cargo test -p kei-contacts-apple: 9/0 (was 7, +1 folding +1 discovery)

NOT deployed — user still in live conversation with bot.

Follow-up (deferred, non-blocking):
  * Real iCloud smoke test for discover_addressbook_url — regex parser
    may need adjustment for deeply-nested namespace prefixes
  * Wiremock-backed integration test for sync_from_google glue (HTTP
    layer already covered in kei-contacts-google tests)
This commit is contained in:
Parfii-bot 2026-05-12 17:04:15 +08:00
parent d2c966d88b
commit 1e9ce21c2a
14 changed files with 734 additions and 150 deletions

View file

@ -3195,6 +3195,8 @@ dependencies = [
"chrono",
"clap",
"kei-chat-store",
"kei-contacts-apple",
"kei-contacts-google",
"kei-memory-sqlite",
"kei-sage",
"kei-social-store",

View file

@ -34,6 +34,8 @@ kei-memory-sqlite = { path = "../kei-memory-sqlite" }
kei-chat-store = { path = "../kei-chat-store" }
kei-social-store = { path = "../kei-social-store" }
kei-sage = { path = "../kei-sage" }
kei-contacts-google = { path = "../kei-contacts-google" }
kei-contacts-apple = { path = "../kei-contacts-apple" }
chrono = { workspace = true }
# serve feature deps

View file

@ -2,7 +2,14 @@
//! Command execution helpers — one function per slash-command.
//! Called by `commands::execute_command`; not public API.
use crate::{chat_log::ChatLog, contacts::Contacts, topics::Topics};
use std::sync::Arc;
use crate::{
chat_log::ChatLog,
contacts::Contacts,
contacts_sync::{sync_from_apple, sync_from_google},
topics::Topics,
};
pub(crate) async fn exec_topics(chat_id: i64, topics: &Topics) -> String {
match topics.list_topics(chat_id).await {
@ -109,3 +116,30 @@ fn truncate(s: &str, max_chars: usize) -> &str {
Some((idx, _)) => &s[..idx],
}
}
pub(crate) async fn exec_sync_google(contacts: &Arc<Contacts>) -> String {
let token = match std::env::var("GOOGLE_OAUTH_ACCESS_TOKEN") {
Ok(t) if !t.is_empty() => t,
_ => return "не настроено: GOOGLE_OAUTH_ACCESS_TOKEN не задан".to_string(),
};
let r = sync_from_google(&token, contacts).await;
format!(
"Google: загружено {}, добавлено {}, пропущено {}\nошибок: {}",
r.fetched, r.added, r.skipped, r.errors.len()
)
}
pub(crate) async fn exec_sync_apple(contacts: &Arc<Contacts>) -> String {
let apple_id = std::env::var("APPLE_ID").unwrap_or_default();
let app_pw = std::env::var("APPLE_APP_PASSWORD").unwrap_or_default();
let url = std::env::var("APPLE_CARDDAV_URL").unwrap_or_default();
if apple_id.is_empty() || app_pw.is_empty() || url.is_empty() {
return "не настроено: APPLE_ID / APPLE_APP_PASSWORD / APPLE_CARDDAV_URL не заданы"
.to_string();
}
let r = sync_from_apple(&apple_id, &app_pw, &url, contacts).await;
format!(
"Apple: загружено {}, добавлено {}, пропущено {}\nошибок: {}",
r.fetched, r.added, r.skipped, r.errors.len()
)
}

View file

@ -5,7 +5,12 @@
use std::sync::Arc;
use crate::{chat_log::ChatLog, command_exec as exec, contacts::Contacts, topics::Topics};
use crate::{
chat_log::ChatLog,
command_exec as exec,
contacts::Contacts,
topics::Topics,
};
/// Recognised slash-commands. `None` = not a command → fall through to FSM.
pub enum Command<'a> {
@ -14,6 +19,8 @@ pub enum Command<'a> {
Topics,
Contacts,
Help,
SyncGoogle,
SyncApple,
}
/// Shared store references passed to `execute_command`.
@ -28,6 +35,8 @@ const HELP_TEXT: &str = "Доступные команды:\n\
/find <текст> поиск по переписке\n\
/topics список тем\n\
/contacts список контактов\n\
/sync-google синхронизировать контакты Google (нужен GOOGLE_OAUTH_ACCESS_TOKEN)\n\
/sync-apple синхронизировать контакты Apple (нужны APPLE_ID / APPLE_APP_PASSWORD / APPLE_CARDDAV_URL)\n\
/help это сообщение";
/// Parse a raw user text into a Command, or None if it is not a slash-command.
@ -53,6 +62,12 @@ pub fn parse_command(text: &str) -> Option<Command<'_>> {
if lower.starts_with("find") {
return Some(Command::Find(rest[4..].trim()));
}
if lower.eq("sync-google") {
return Some(Command::SyncGoogle);
}
if lower.eq("sync-apple") {
return Some(Command::SyncApple);
}
None
}
@ -69,6 +84,8 @@ pub async fn execute_command(
Command::Contacts => exec::exec_contacts(stores.contacts).await,
Command::Whois(name) => exec::exec_whois(name, stores.contacts).await,
Command::Find(query) => exec::exec_find(query, chat_id, stores.chat_log).await,
Command::SyncGoogle => exec::exec_sync_google(stores.contacts).await,
Command::SyncApple => exec::exec_sync_apple(stores.contacts).await,
}
}
@ -143,4 +160,25 @@ mod tests {
let resp = execute_command(Command::Contacts, 1, &stores).await;
assert!(resp.contains("пусты") || resp.contains("контакт"));
}
#[test]
fn parse_sync_google() {
assert!(matches!(parse_command("/sync-google"), Some(Command::SyncGoogle)));
}
#[test]
fn parse_sync_apple() {
assert!(matches!(parse_command("/sync-apple"), Some(Command::SyncApple)));
}
#[tokio::test]
async fn help_includes_sync_commands() {
let cl = Arc::new(ChatLog::from_memory().unwrap());
let co = Arc::new(Contacts::from_memory().unwrap());
let to = Arc::new(Topics::from_memory().unwrap());
let stores = make_stores(&cl, &co, &to);
let resp = execute_command(Command::Help, 1, &stores).await;
assert!(resp.contains("/sync-google"));
assert!(resp.contains("/sync-apple"));
}
}

View file

@ -0,0 +1,146 @@
// SPDX-License-Identifier: Apache-2.0
//! Contact-sync helpers — pull Google / Apple contacts into local store.
//! Each function is fire-and-forget: errors are collected in `SyncReport`.
use std::sync::Arc;
use kei_contacts_apple::ICloudCardDavClient;
use kei_contacts_google::GooglePeopleClient;
use crate::contacts::Contacts;
/// Summary returned by a sync operation regardless of partial failures.
#[derive(Debug, Default)]
pub struct SyncReport {
/// Total contacts returned by the remote source.
pub fetched: usize,
/// Contacts successfully written to the local store.
pub added: usize,
/// Contacts skipped (empty name+email, or duplicate by name+email).
pub skipped: usize,
/// Error strings accumulated during sync; non-fatal individually.
pub errors: Vec<String>,
}
/// Pull contacts from Google People API into `contacts`.
///
/// Requires a valid OAuth2 access token (not obtained here).
/// Never panics; all errors collected in [`SyncReport::errors`].
pub async fn sync_from_google(
access_token: &str,
contacts: &Arc<Contacts>,
) -> SyncReport {
let client = GooglePeopleClient::new(access_token.to_string());
let all = match client.list_connections().await {
Ok(v) => v,
Err(e) => {
return SyncReport {
errors: vec![format!("google list_connections: {e}")],
..Default::default()
};
}
};
let fetched = all.len();
let mut report = SyncReport { fetched, ..Default::default() };
for contact in all {
process_person(contact.to_person(), contacts, &mut report).await;
}
report
}
/// Pull contacts from iCloud CardDAV into `contacts`.
///
/// `addressbook_url` must be the full CardDAV addressbook URL.
/// Never panics; all errors collected in [`SyncReport::errors`].
pub async fn sync_from_apple(
apple_id: &str,
app_password: &str,
addressbook_url: &str,
contacts: &Arc<Contacts>,
) -> SyncReport {
let client = ICloudCardDavClient::new(apple_id.to_string(), app_password.to_string())
.with_addressbook_url(addressbook_url.to_string());
let all = match client.list_contacts().await {
Ok(v) => v,
Err(e) => {
return SyncReport {
errors: vec![format!("apple list_contacts: {e}")],
..Default::default()
};
}
};
let fetched = all.len();
let mut report = SyncReport { fetched, ..Default::default() };
for contact in all {
process_person(contact.to_person(), contacts, &mut report).await;
}
report
}
/// Shared dedup + insert logic for a single Person.
async fn process_person(
person: kei_social_store::people::Person,
contacts: &Arc<Contacts>,
report: &mut SyncReport,
) {
if person.name.is_empty() && person.email.is_empty() {
report.skipped += 1;
return;
}
if is_duplicate(&person, contacts).await {
report.skipped += 1;
return;
}
match contacts.add_contact(person).await {
Ok(_) => report.added += 1,
Err(e) => report.errors.push(format!("add_contact: {e}")),
}
}
/// Returns `true` when `contacts` already has an entry with the same
/// case-insensitive name AND case-insensitive email (both non-empty).
async fn is_duplicate(
person: &kei_social_store::people::Person,
contacts: &Arc<Contacts>,
) -> bool {
if person.name.is_empty() || person.email.is_empty() {
return false;
}
let hits = match contacts.search_contacts(&person.name, 3).await {
Ok(v) => v,
Err(_) => return false,
};
let name_lc = person.name.to_lowercase();
let email_lc = person.email.to_lowercase();
hits.iter().any(|h| {
h.name.to_lowercase() == name_lc && h.email.to_lowercase() == email_lc
})
}
// ── tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_initial_zero() {
let r = SyncReport::default();
assert_eq!(r.fetched, 0);
assert_eq!(r.added, 0);
assert_eq!(r.skipped, 0);
assert!(r.errors.is_empty());
}
#[tokio::test]
async fn sync_google_bad_token_populates_errors() {
// Using an obviously-invalid token; no real network required because
// reqwest will return a connection error in the sandbox environment,
// but we verify the SyncReport shape on any error path.
let contacts = Arc::new(Contacts::from_memory().unwrap());
let report = sync_from_google("invalid-token", &contacts).await;
// fetched == 0 and either an error was collected OR the network
// returned something parseable (both are valid non-panic outcomes).
assert_eq!(report.fetched, 0);
assert_eq!(report.added, 0);
}
}

View file

@ -14,6 +14,7 @@ pub mod chat_log;
pub(crate) mod command_exec;
pub mod commands;
pub mod contacts;
pub mod contacts_sync;
pub mod error;
pub mod extractor;
pub mod machine;
@ -37,6 +38,7 @@ pub mod serve_telegram;
pub use chat_log::ChatLog;
pub use commands::{parse_command, execute_command, Command, CommandStores};
pub use contacts_sync::{sync_from_apple, sync_from_google, SyncReport};
pub use contacts::Contacts;
pub use error::BuddyError;
pub use extractor::LlmExtractor;

View file

@ -3,6 +3,7 @@
//! [`ICloudCardDavClient`] — CardDAV client for iCloud Contacts.
use crate::contact::AppleContact;
use crate::discovery::discover_addressbook;
use crate::error::ContactsError;
use crate::xml::{addressbook_query_xml, extract_contacts_from_multistatus};
use reqwest::{Client, Method};
@ -55,6 +56,22 @@ impl ICloudCardDavClient {
self
}
/// Discover the addressbook URL via three successive PROPFIND requests.
///
/// Implements RFC 6764 §6:
/// 1. `.well-known/carddav` → principal URL
/// 2. principal → addressbook-home-set
/// 3. home-set (depth=1) → first addressbook resource href
pub async fn discover_addressbook_url(&self) -> Result<String, ContactsError> {
discover_addressbook(
&self.client,
&self.apple_id,
&self.app_specific_password,
&self.base_url,
)
.await
}
/// Fetch all contacts from the configured addressbook.
///
/// Issues a CardDAV REPORT `addressbook-query` and returns parsed contacts.

View file

@ -0,0 +1,199 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//! CardDAV auto-discovery via three successive PROPFIND requests.
//!
//! Implements RFC 6764 §6 well-known URI discovery.
use crate::error::ContactsError;
use regex::Regex;
use reqwest::{Client, Method};
use tracing::debug;
// ── XML bodies ────────────────────────────────────────────────────────────────
fn propfind_principal_xml() -> &'static str {
r#"<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:current-user-principal/></D:prop>
</D:propfind>"#
}
fn propfind_home_set_xml() -> &'static str {
r#"<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:prop><C:addressbook-home-set/></D:prop>
</D:propfind>"#
}
fn propfind_resourcetype_xml() -> &'static str {
r#"<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:prop><D:resourcetype/></D:prop>
</D:propfind>"#
}
// ── XML helpers ───────────────────────────────────────────────────────────────
/// Extract the first `<D:href>` child of `tag` using regex.
///
/// Matches namespace-prefixed variants of `tag` (e.g. `D:current-user-principal`
/// or `C:addressbook-home-set`).
pub(crate) fn extract_first_href_under(xml: &str, tag: &str) -> Option<String> {
let pattern = format!(
r"(?si)<(?:[a-zA-Z0-9_-]+:)?{tag}[^>]*>\s*<(?:[a-zA-Z0-9_-]+:)?href[^>]*>([^<]+)</",
tag = regex::escape(tag)
);
let re = Regex::new(&pattern).ok()?;
re.captures(xml)
.and_then(|c| c.get(1))
.map(|m| m.as_str().trim().to_string())
}
/// Extract the first `<D:href>` from a multistatus `<D:response>` whose
/// `<D:resourcetype>` contains `addressbook`.
pub(crate) fn extract_addressbook_href(xml: &str) -> Option<String> {
// Split on <D:response> or <response> boundaries (case-insensitive).
let re_split = Regex::new(r"(?si)<(?:[a-zA-Z0-9_-]+:)?response[^>]*>").ok()?;
let boundaries: Vec<_> = re_split.find_iter(xml).map(|m| m.start()).collect();
for (i, &start) in boundaries.iter().enumerate() {
let end = boundaries.get(i + 1).copied().unwrap_or(xml.len());
let chunk = &xml[start..end];
if chunk.to_ascii_lowercase().contains("addressbook") {
// Extract the href from this response chunk.
let re_href =
Regex::new(r"(?si)<(?:[a-zA-Z0-9_-]+:)?href[^>]*>([^<]+)</").ok()?;
if let Some(cap) = re_href.captures(chunk) {
if let Some(m) = cap.get(1) {
return Some(m.as_str().trim().to_string());
}
}
}
}
None
}
// ── PROPFIND helper ───────────────────────────────────────────────────────────
async fn propfind(
client: &Client,
apple_id: &str,
password: &str,
url: &str,
depth: &str,
body: &'static str,
) -> Result<String, ContactsError> {
debug!(%url, %depth, "PROPFIND");
let resp = client
.request(
Method::from_bytes(b"PROPFIND")
.map_err(|e| ContactsError::Http(e.to_string()))?,
url,
)
.basic_auth(apple_id, Some(password))
.header("Content-Type", "application/xml; charset=utf-8")
.header("Depth", depth)
.body(body)
.send()
.await
.map_err(|e| ContactsError::Http(e.to_string()))?;
let status = resp.status();
if status.as_u16() == 401 || status.as_u16() == 403 {
return Err(ContactsError::Auth(format!("iCloud returned {}", status.as_u16())));
}
if !status.is_success() && status.as_u16() != 207 {
return Err(ContactsError::Http(format!("PROPFIND status={}", status)));
}
resp.text()
.await
.map_err(|e| ContactsError::Parse(e.to_string()))
}
// ── public entry point ────────────────────────────────────────────────────────
// ── tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use reqwest::Client;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn discover_walks_three_propfinds() {
let server = MockServer::start().await;
Mock::given(method("PROPFIND"))
.and(path("/.well-known/carddav"))
.respond_with(ResponseTemplate::new(207).set_body_string(
r#"<D:multistatus xmlns:D="DAV:"><D:response><D:propstat>
<D:current-user-principal><D:href>/principals/users/testuser/</D:href></D:current-user-principal>
</D:propstat></D:response></D:multistatus>"#,
))
.mount(&server)
.await;
Mock::given(method("PROPFIND"))
.and(path("/principals/users/testuser/"))
.respond_with(ResponseTemplate::new(207).set_body_string(
r#"<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav"><D:response><D:propstat>
<C:addressbook-home-set><D:href>/addressbooks/testuser/</D:href></C:addressbook-home-set>
</D:propstat></D:response></D:multistatus>"#,
))
.mount(&server)
.await;
Mock::given(method("PROPFIND"))
.and(path("/addressbooks/testuser/"))
.respond_with(ResponseTemplate::new(207).set_body_string(
r#"<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:response>
<D:href>/addressbooks/testuser/card/</D:href>
<D:propstat><D:resourcetype><D:collection/><C:addressbook/></D:resourcetype></D:propstat>
</D:response>
</D:multistatus>"#,
))
.mount(&server)
.await;
let client = Client::new();
let url = discover_addressbook(&client, "user@icloud.com", "app-pass", &server.uri())
.await
.expect("discovery should succeed");
assert_eq!(url, "/addressbooks/testuser/card/");
}
}
/// Perform CardDAV three-step auto-discovery.
///
/// 1. PROPFIND `/.well-known/carddav` → `current-user-principal`
/// 2. PROPFIND `{principal}` → `addressbook-home-set`
/// 3. PROPFIND `{home-set}` (depth=1) → first `addressbook` resource href
pub(crate) async fn discover_addressbook(
client: &Client,
apple_id: &str,
password: &str,
base_url: &str,
) -> Result<String, ContactsError> {
// Step 1: principal
let url1 = format!("{}/.well-known/carddav", base_url);
let xml1 = propfind(client, apple_id, password, &url1, "0", propfind_principal_xml()).await?;
let principal = extract_first_href_under(&xml1, "current-user-principal")
.ok_or_else(|| ContactsError::Parse("discover step 1: no current-user-principal".into()))?;
// Step 2: home set
let url2 = format!("{}{}", base_url, principal);
let xml2 = propfind(client, apple_id, password, &url2, "0", propfind_home_set_xml()).await?;
let home_set = extract_first_href_under(&xml2, "addressbook-home-set")
.ok_or_else(|| ContactsError::Parse("discover step 2: no addressbook-home-set".into()))?;
// Step 3: addressbook resource
let url3 = format!("{}{}", base_url, home_set);
let xml3 =
propfind(client, apple_id, password, &url3, "1", propfind_resourcetype_xml()).await?;
extract_addressbook_href(&xml3)
.ok_or_else(|| ContactsError::Parse("discover step 3: no addressbook resource".into()))
}

View file

@ -28,6 +28,7 @@ pub mod client;
pub mod contact;
pub mod error;
pub mod vcard;
pub(crate) mod discovery;
pub(crate) mod xml;
pub use client::ICloudCardDavClient;

View file

@ -3,9 +3,6 @@
//! Minimal vCard 3.0 / 4.0 parser.
//!
//! # Limitations (MVP)
//! - **Line-folding** (continuation lines starting with a single space or tab)
//! is **not** handled. Such lines are treated as separate malformed lines and
//! silently skipped. This covers the vast majority of iCloud-generated vCards.
//! - Only a fixed set of properties (FN, N, EMAIL, TEL, ORG, NOTE, UID) is extracted.
//! - Property parameters (e.g. `TYPE=INTERNET`) are stripped; only the value is kept.
//! - Multi-valued ORG (e.g. `ORG:Company;Department`) uses the first segment.
@ -13,22 +10,39 @@
use crate::contact::AppleContact;
use crate::error::ContactsError;
/// Unfold RFC 6350 §3.2 continuation lines.
///
/// A line beginning with a single SPACE or HTAB is a continuation of the
/// preceding line; strip the leading whitespace and concatenate.
fn unfold(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for line in input.lines() {
if let Some(rest) = line.strip_prefix(' ').or_else(|| line.strip_prefix('\t')) {
// continuation — append directly to previous content
out.push_str(rest);
} else {
if !out.is_empty() {
out.push('\n');
}
out.push_str(line);
}
}
out
}
/// Parse a single vCard text into an [`AppleContact`].
///
/// `text` must be the content of one vCard (between `BEGIN:VCARD` and `END:VCARD`
/// inclusive).
/// inclusive). RFC 6350 line-folding is resolved before parsing.
pub fn parse_vcard(text: &str) -> Result<AppleContact, ContactsError> {
let unfolded = unfold(text);
let mut contact = AppleContact {
raw_vcard: text.to_string(),
..Default::default()
};
for line in text.lines() {
for line in unfolded.lines() {
let line = line.trim_end_matches('\r');
// Skip continuation lines (line-folding, not handled in MVP).
if line.starts_with(' ') || line.starts_with('\t') {
continue;
}
let Some((key_full, value)) = line.split_once(':') else {
continue;
};
@ -137,4 +151,24 @@ END:VCARD\r\n";
let text = "NOTACARD:yes\r\n";
assert!(parse_vcard(text).is_err());
}
#[test]
fn parse_folded_vcard() {
// RFC 6350 §3.2 fold: continuation lines start with a single SPACE.
// NOTE spans three physical lines; after unfold they join into one value.
// Use concat! to guarantee the leading spaces are preserved.
let text = concat!(
"BEGIN:VCARD\r\n",
"VERSION:3.0\r\n",
"FN:Alice Smith\r\n",
"UID:uid-folded\r\n",
"NOTE:line one\r\n",
" line two\r\n",
" line three\r\n",
"END:VCARD\r\n",
);
let c = parse_vcard(text).expect("should parse folded vCard");
assert_eq!(c.display_name, "Alice Smith");
assert_eq!(c.note, "line oneline twoline three");
}
}

View file

@ -4,14 +4,10 @@
use crate::contact::GoogleContact;
use crate::error::ContactsError;
use crate::pagination::{fetch_all_pages, fetch_page};
use reqwest::Client;
use serde::Deserialize;
use tracing::debug;
const DEFAULT_BASE_URL: &str = "https://people.googleapis.com";
const PERSON_FIELDS: &str =
"names,emailAddresses,phoneNumbers,organizations,biographies";
const PAGE_SIZE: u32 = 200;
/// Thin client for the Google People API.
///
@ -39,143 +35,22 @@ impl GooglePeopleClient {
self
}
/// Fetch the authenticated user's contacts (first page only, ≤ 200).
/// Fetch the first page of contacts (≤ 200).
///
/// # TODO
/// Pagination via `nextPageToken` is not yet implemented. For users
/// with > 200 contacts only the first 200 are returned.
/// Back-compat API — use [`list_all_connections`] for full pagination.
///
/// [`list_all_connections`]: GooglePeopleClient::list_all_connections
pub async fn list_connections(&self) -> Result<Vec<GoogleContact>, ContactsError> {
let url = format!(
"{}/v1/people/me/connections?personFields={}&pageSize={}",
self.base_url, PERSON_FIELDS, PAGE_SIZE
);
debug!(%url, "GET people/me/connections");
let resp = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.access_token))
.send()
.await
.map_err(|e| ContactsError::Http(e.to_string()))?;
let status = resp.status();
if status.as_u16() == 401 {
return Err(ContactsError::Auth("token expired or invalid".to_string()));
}
if !status.is_success() {
return Err(ContactsError::Http(format!("status={}", status)));
}
let body: ConnectionsResponse = resp
.json()
.await
.map_err(|e| ContactsError::Parse(e.to_string()))?;
let contacts = body
.connections
.unwrap_or_default()
.into_iter()
.map(parse_connection)
.collect();
let (contacts, _) =
fetch_page(&self.client, &self.access_token, &self.base_url, None).await?;
Ok(contacts)
}
}
// ── internal deserialization types ───────────────────────────────────────────
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ConnectionsResponse {
connections: Option<Vec<Connection>>,
// next_page_token intentionally ignored (TODO: pagination)
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct Connection {
resource_name: Option<String>,
names: Option<Vec<Name>>,
email_addresses: Option<Vec<EmailAddress>>,
phone_numbers: Option<Vec<PhoneNumber>>,
organizations: Option<Vec<OrgEntry>>,
biographies: Option<Vec<Biography>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Name {
display_name: Option<String>,
given_name: Option<String>,
family_name: Option<String>,
}
#[derive(Deserialize)]
struct EmailAddress {
value: Option<String>,
}
#[derive(Deserialize)]
struct PhoneNumber {
value: Option<String>,
}
#[derive(Deserialize)]
struct OrgEntry {
name: Option<String>,
}
#[derive(Deserialize)]
struct Biography {
value: Option<String>,
}
fn parse_connection(c: Connection) -> GoogleContact {
let resource_name = c.resource_name.unwrap_or_default();
let (display_name, given_name, family_name) = c
.names
.and_then(|mut v| if v.is_empty() { None } else { Some(v.remove(0)) })
.map(|n| (
n.display_name.unwrap_or_default(),
n.given_name.unwrap_or_default(),
n.family_name.unwrap_or_default(),
))
.unwrap_or_default();
let emails = c
.email_addresses
.unwrap_or_default()
.into_iter()
.filter_map(|e| e.value)
.collect();
let phones = c
.phone_numbers
.unwrap_or_default()
.into_iter()
.filter_map(|p| p.value)
.collect();
let organization = c
.organizations
.and_then(|mut v| v.first_mut().and_then(|o| o.name.take()))
.unwrap_or_default();
let bio = c
.biographies
.and_then(|mut v| v.first_mut().and_then(|b| b.value.take()))
.unwrap_or_default();
GoogleContact {
resource_name,
display_name,
given_name,
family_name,
emails,
phones,
organization,
bio,
/// Fetch ALL contacts across all pages.
///
/// Loops on `nextPageToken` until none is returned. Hard cap at 50 pages
/// (~10 000 contacts) — if hit, returns what was collected and logs a warning.
pub async fn list_all_connections(&self) -> Result<Vec<GoogleContact>, ContactsError> {
fetch_all_pages(&self.client, &self.access_token, &self.base_url).await
}
}

View file

@ -22,6 +22,7 @@
pub mod client;
pub mod contact;
pub mod error;
pub(crate) mod pagination;
pub use client::GooglePeopleClient;
pub use contact::GoogleContact;

View file

@ -0,0 +1,188 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//! Pagination helper for Google People API connections.
use crate::contact::GoogleContact;
use crate::error::ContactsError;
use reqwest::Client;
use serde::Deserialize;
use tracing::{debug, warn};
const PERSON_FIELDS: &str = "names,emailAddresses,phoneNumbers,organizations,biographies";
const PAGE_SIZE: u32 = 200;
/// Safety cap: at most 50 pages (10 000 contacts).
const MAX_PAGES: usize = 50;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ConnectionsResponse {
pub connections: Option<Vec<Connection>>,
pub next_page_token: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Connection {
pub resource_name: Option<String>,
pub names: Option<Vec<Name>>,
pub email_addresses: Option<Vec<EmailAddress>>,
pub phone_numbers: Option<Vec<PhoneNumber>>,
pub organizations: Option<Vec<OrgEntry>>,
pub biographies: Option<Vec<Biography>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Name {
pub display_name: Option<String>,
pub given_name: Option<String>,
pub family_name: Option<String>,
}
#[derive(Deserialize)]
pub(crate) struct EmailAddress {
pub value: Option<String>,
}
#[derive(Deserialize)]
pub(crate) struct PhoneNumber {
pub value: Option<String>,
}
#[derive(Deserialize)]
pub(crate) struct OrgEntry {
pub name: Option<String>,
}
#[derive(Deserialize)]
pub(crate) struct Biography {
pub value: Option<String>,
}
/// Fetch one page of connections.
///
/// Returns `(contacts, next_page_token)`.
pub(crate) async fn fetch_page(
client: &Client,
access_token: &str,
base_url: &str,
page_token: Option<&str>,
) -> Result<(Vec<GoogleContact>, Option<String>), ContactsError> {
let mut url = format!(
"{}/v1/people/me/connections?personFields={}&pageSize={}",
base_url, PERSON_FIELDS, PAGE_SIZE
);
if let Some(tok) = page_token {
url.push_str(&format!("&pageToken={}", tok));
}
debug!(%url, "GET people/me/connections");
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {}", access_token))
.send()
.await
.map_err(|e| ContactsError::Http(e.to_string()))?;
let status = resp.status();
if status.as_u16() == 401 {
return Err(ContactsError::Auth("token expired or invalid".to_string()));
}
if !status.is_success() {
return Err(ContactsError::Http(format!("status={}", status)));
}
let body: ConnectionsResponse = resp
.json()
.await
.map_err(|e| ContactsError::Parse(e.to_string()))?;
let contacts = body
.connections
.unwrap_or_default()
.into_iter()
.map(parse_connection)
.collect();
Ok((contacts, body.next_page_token))
}
/// Fetch ALL pages accumulating contacts, stopping after [`MAX_PAGES`].
pub(crate) async fn fetch_all_pages(
client: &Client,
access_token: &str,
base_url: &str,
) -> Result<Vec<GoogleContact>, ContactsError> {
let mut all: Vec<GoogleContact> = Vec::new();
let mut next_token: Option<String> = None;
for page in 0..MAX_PAGES {
let (contacts, token) =
fetch_page(client, access_token, base_url, next_token.as_deref()).await?;
all.extend(contacts);
next_token = token;
if next_token.is_none() {
break;
}
if page == MAX_PAGES - 1 {
warn!(
"hit {MAX_PAGES}-page safety cap; returning {} contacts so far",
all.len()
);
}
}
Ok(all)
}
pub(crate) fn parse_connection(c: Connection) -> GoogleContact {
let resource_name = c.resource_name.unwrap_or_default();
let (display_name, given_name, family_name) = c
.names
.and_then(|mut v| if v.is_empty() { None } else { Some(v.remove(0)) })
.map(|n| {
(
n.display_name.unwrap_or_default(),
n.given_name.unwrap_or_default(),
n.family_name.unwrap_or_default(),
)
})
.unwrap_or_default();
let emails = c
.email_addresses
.unwrap_or_default()
.into_iter()
.filter_map(|e| e.value)
.collect();
let phones = c
.phone_numbers
.unwrap_or_default()
.into_iter()
.filter_map(|p| p.value)
.collect();
let organization = c
.organizations
.and_then(|mut v| v.first_mut().and_then(|o| o.name.take()))
.unwrap_or_default();
let bio = c
.biographies
.and_then(|mut v| v.first_mut().and_then(|b| b.value.take()))
.unwrap_or_default();
GoogleContact {
resource_name,
display_name,
given_name,
family_name,
emails,
phones,
organization,
bio,
}
}

View file

@ -3,7 +3,7 @@
//! Integration tests for `GooglePeopleClient` against a wiremock server.
use kei_contacts_google::{ContactsError, GooglePeopleClient};
use wiremock::matchers::{header_exists, method, path};
use wiremock::matchers::{header_exists, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
const SAMPLE_RESPONSE: &str = r#"{
@ -71,3 +71,48 @@ async fn auth_error_on_401() {
let err = client.list_connections().await.expect_err("should fail");
assert!(matches!(err, ContactsError::Auth(_)));
}
const PAGE1: &str = r#"{"connections":[
{"resourceName":"people/c1",
"names":[{"displayName":"Alice","givenName":"Alice","familyName":"Smith"}],
"emailAddresses":[{"value":"alice@example.com"}],
"phoneNumbers":[],"organizations":[],"biographies":[]}
],"nextPageToken":"abc"}"#;
const PAGE2: &str = r#"{"connections":[
{"resourceName":"people/c2",
"names":[{"displayName":"Bob","givenName":"Bob","familyName":"Jones"}],
"emailAddresses":[{"value":"bob@example.com"}],
"phoneNumbers":[],"organizations":[],"biographies":[]}
]}"#;
#[tokio::test]
async fn list_all_connections_two_pages() {
let server = MockServer::start().await;
// First request: no pageToken — returns page 1 + nextPageToken="abc"
Mock::given(method("GET"))
.and(path("/v1/people/me/connections"))
.respond_with(ResponseTemplate::new(200).set_body_string(PAGE1))
.up_to_n_times(1)
.mount(&server)
.await;
// Second request: must carry pageToken=abc — returns page 2 (no token)
Mock::given(method("GET"))
.and(path("/v1/people/me/connections"))
.and(query_param("pageToken", "abc"))
.respond_with(ResponseTemplate::new(200).set_body_string(PAGE2))
.mount(&server)
.await;
let client = GooglePeopleClient::new("fake-token".to_string())
.with_base_url(server.uri());
let contacts = client.list_all_connections().await.expect("should succeed");
assert_eq!(contacts.len(), 2, "must collect both pages");
let names: Vec<_> = contacts.iter().map(|c| c.display_name.as_str()).collect();
assert!(names.contains(&"Alice"), "page 1 contact present");
assert!(names.contains(&"Bob"), "page 2 contact present");
}