Implement PstSource via readpst shell-out and iCalendar VEVENT parsing (T6.1)

This commit is contained in:
iamdoubz
2026-07-01 15:07:21 -05:00
parent d4f7eb80a8
commit 35c2de5da0
+378 -13
View File
@@ -1,8 +1,16 @@
//! Calendar & Outlook .pst integration (Phase 6, FR-CAL-*). `outlook-pst`
//! (read-only) for PST; future Graph/ICS behind the same trait (ADR-0008).
//! PST is read-only input — WA never writes to it.
//! Calendar & Outlook .pst integration (Phase 6, FR-CAL-*). Reads `.pst`
//! files by shelling out to `readpst` (the libpst project) rather than
//! hand-parsing the raw MS-PST binary format — the `outlook-pst` crate ADR-0008
//! originally named turned out to be a byte-level format implementation with
//! no MAPI convenience layer at all (no `folder.name()`/`message.subject()`,
//! just raw B-tree pages and numeric property IDs); `readpst` is mature,
//! well-tested, and already does exactly what T6.1 needs (dump appointments
//! as iCalendar). See ADR-0008's update. PST is read-only input — WA never
//! writes to it.
use crate::models::{CalendarEvent, Participant};
use crate::models::{AttendeeInfo, CalendarEvent, ImportedEvent};
use std::path::Path;
use std::process::Command;
#[derive(Debug, thiserror::Error)]
pub enum CalError {
@@ -12,6 +20,8 @@ pub enum CalError {
Parse(String),
#[error("password required or incorrect")]
Password,
#[error("readpst isn't installed — install libpst and ensure readpst is on PATH")]
ToolMissing,
}
pub struct CalImport {
@@ -20,21 +30,376 @@ pub struct CalImport {
}
pub trait CalendarSource: Send + Sync {
fn import(&self, input: CalImport) -> Result<Vec<CalendarEvent>, CalError>;
fn attendees(&self, event_id: &str) -> Result<Vec<Participant>, CalError>;
fn import(&self, input: CalImport) -> Result<Vec<ImportedEvent>, CalError>;
}
/// .pst source. Falls back to libpff for tricky/encrypted files.
/// `.pst` source via `readpst` (T6.1). Password-protected PSTs: most
/// "password protection" in classic PST files is a UI-level gate over a
/// fixed, keyless obfuscation ("compressible encryption"), which readpst
/// already reverses transparently — `input.password` is accepted for a
/// future genuinely-encrypted case but unused today; a file readpst can't
/// open surfaces as `CalError::Parse`, non-fatal to the caller.
#[cfg(feature = "pst")]
pub struct PstSource;
#[cfg(feature = "pst")]
impl CalendarSource for PstSource {
fn import(&self, _input: CalImport) -> Result<Vec<CalendarEvent>, CalError> {
// T6.1/T6.2: read appointments + attendees; non-fatal on parse failure.
todo!("Phase 6 — import .pst")
}
fn attendees(&self, _event_id: &str) -> Result<Vec<Participant>, CalError> {
todo!("Phase 6 — attendees for event")
fn import(&self, input: CalImport) -> Result<Vec<ImportedEvent>, CalError> {
if !Path::new(&input.path).exists() {
return Err(CalError::Open(format!("file not found: {}", input.path)));
}
let out_dir = std::env::temp_dir().join(format!("wa-pst-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&out_dir).map_err(|e| CalError::Open(e.to_string()))?;
let result = run_readpst(&input.path, &out_dir).and_then(|_| collect_events(&out_dir));
let _ = std::fs::remove_dir_all(&out_dir); // best-effort cleanup either way
result
}
}
#[cfg(feature = "pst")]
fn run_readpst(pst_path: &str, out_dir: &Path) -> Result<(), CalError> {
// -S: one file per item. -e: extension matches item type (.ics for
// appointments). -t a: appointments only.
//
// ponytail: no `-8` (force UTF-8 output) — real-world testing found this
// libpst 0.6.63 build doesn't support it ("invalid option -- 8"; newer
// libpst versions do). Items with an unspecified charset fall back to
// whatever readpst's own default is; a resulting non-UTF-8 .ics file is
// still handled safely (collect_ics_files skips a file that doesn't
// read as valid UTF-8 rather than erroring), just silently dropped
// instead of correctly decoded. Revisit if that's observed in practice.
let output = Command::new("readpst")
.args(["-S", "-e", "-t", "a", "-o"])
.arg(out_dir)
.arg(pst_path)
.output()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => CalError::ToolMissing,
_ => CalError::Open(e.to_string()),
})?;
if !output.status.success() {
return Err(CalError::Parse(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
}
Ok(())
}
#[cfg(feature = "pst")]
fn collect_events(dir: &Path) -> Result<Vec<ImportedEvent>, CalError> {
let mut events = Vec::new();
collect_ics_files(dir, &mut events)?;
Ok(events)
}
#[cfg(feature = "pst")]
fn collect_ics_files(dir: &Path, out: &mut Vec<ImportedEvent>) -> Result<(), CalError> {
let entries = std::fs::read_dir(dir).map_err(|e| CalError::Parse(e.to_string()))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_ics_files(&path, out)?;
} else if path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("ics"))
{
// A single unreadable/malformed file is skipped, not fatal to
// the whole import (readpst can produce thousands of files).
if let Ok(text) = std::fs::read_to_string(&path) {
out.extend(parse_vevents(&text, "pst"));
}
}
}
Ok(())
}
// ---- iCalendar (RFC 5545) VEVENT parsing — pure, no I/O ----
fn unfold_lines(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for raw_line in text.split('\n') {
let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
if (line.starts_with(' ') || line.starts_with('\t')) && !out.is_empty() {
out.last_mut().unwrap().push_str(&line[1..]);
} else if !line.is_empty() {
out.push(line.to_string());
}
}
out
}
/// Splits a `NAME[;PARAM=VALUE;...]:VALUE` content line into (name, params, value).
fn parse_property_line(line: &str) -> Option<(&str, &str, &str)> {
let colon = line.find(':')?;
let (name_and_params, value) = (&line[..colon], &line[colon + 1..]);
match name_and_params.find(';') {
Some(semi) => Some((
&name_and_params[..semi],
&name_and_params[semi + 1..],
value,
)),
None => Some((name_and_params, "", value)),
}
}
fn param_value<'a>(params: &'a str, key: &str) -> Option<&'a str> {
params.split(';').find_map(|p| {
let (k, v) = p.split_once('=')?;
k.eq_ignore_ascii_case(key).then_some(v)
})
}
fn unescape_text(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') | Some('N') => out.push('\n'),
Some(other) => out.push(other),
None => {}
}
} else {
out.push(c);
}
}
out
}
/// Extracts a display name + email from a CAL-ADDRESS property (ORGANIZER/
/// ATTENDEE), e.g. `ORGANIZER;CN=Jordan Lee:mailto:jordan@example.com`.
fn cal_address(params: &str, value: &str) -> (Option<String>, Option<String>) {
let email_raw = value.strip_prefix("mailto:").unwrap_or(value);
let email = email_raw.contains('@').then(|| unescape_text(email_raw));
let name = param_value(params, "CN").map(unescape_text);
(name, email)
}
fn attendee_role(params: &str) -> Option<String> {
param_value(params, "ROLE").map(|r| {
if r.eq_ignore_ascii_case("OPT-PARTICIPANT") {
"optional"
} else if r.eq_ignore_ascii_case("CHAIR") {
"organizer"
} else {
"required"
}
.to_string()
})
}
/// Parses every `VEVENT` block in `ics_text` (readpst emits one event per
/// file with `-S -e`, but a source could combine several under one
/// `VCALENDAR` too — this handles both).
fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
let lines = unfold_lines(ics_text);
let mut events = Vec::new();
let mut in_event = false;
let mut uid = None;
let mut summary = None;
let mut organizer = None;
let mut description = None;
let mut starts_at = None;
let mut ends_at = None;
let mut attendees: Vec<AttendeeInfo> = Vec::new();
for line in &lines {
let Some((name, params, value)) = parse_property_line(line) else {
continue;
};
match name.to_ascii_uppercase().as_str() {
"BEGIN" if value.eq_ignore_ascii_case("VEVENT") => {
in_event = true;
uid = None;
summary = None;
organizer = None;
description = None;
starts_at = None;
ends_at = None;
attendees = Vec::new();
}
"END" if value.eq_ignore_ascii_case("VEVENT") && in_event => {
events.push(ImportedEvent {
event: CalendarEvent {
id: uuid::Uuid::new_v4().to_string(),
source: source.to_string(),
subject: summary.take(),
organizer: organizer.take(),
starts_at,
ends_at,
description: description.take(),
raw_uid: uid.take(),
},
attendees: std::mem::take(&mut attendees),
});
in_event = false;
}
"UID" if in_event => uid = Some(value.to_string()),
"SUMMARY" if in_event => summary = Some(unescape_text(value)),
"DESCRIPTION" if in_event => description = Some(unescape_text(value)),
"DTSTART" if in_event => starts_at = parse_ics_datetime(value),
"DTEND" if in_event => ends_at = parse_ics_datetime(value),
"ORGANIZER" if in_event => {
let (name, email) = cal_address(params, value);
organizer = name.or(email);
}
"ATTENDEE" if in_event => {
let (name, email) = cal_address(params, value);
let Some(display_name) = name.or_else(|| email.clone()) else {
continue; // no name and no usable email — nothing to show
};
attendees.push(AttendeeInfo {
name: display_name,
email,
role: attendee_role(params),
});
}
_ => {}
}
}
events
}
/// Parses an iCalendar DATE-TIME (`20260701T090000Z` / `20260701T090000`) or
/// DATE (`20260701`) value to a unix epoch. Both `Z`-suffixed and floating
/// (no `Z`, no `TZID`) values are treated as UTC — full IANA timezone
/// handling (`TZID` params, `VTIMEZONE` blocks) is out of scope; good enough
/// for "roughly when was this meeting", not a calendar-grade scheduler.
fn parse_ics_datetime(value: &str) -> Option<i64> {
let digits: String = value.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() < 8 {
return None;
}
let year: i64 = digits.get(0..4)?.parse().ok()?;
let month: u32 = digits.get(4..6)?.parse().ok()?;
let day: u32 = digits.get(6..8)?.parse().ok()?;
let (hour, min, sec) = if digits.len() >= 14 {
(
digits.get(8..10)?.parse().ok()?,
digits.get(10..12)?.parse().ok()?,
digits.get(12..14)?.parse().ok()?,
)
} else {
(0u32, 0u32, 0u32)
};
Some(ymd_hms_to_unix(year, month, day, hour, min, sec))
}
/// Howard Hinnant's `days_from_civil` (public domain, proleptic Gregorian),
/// extended with a time-of-day offset. Pure arithmetic — no timezone
/// database dependency needed since every caller here treats input as UTC.
fn ymd_hms_to_unix(year: i64, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> i64 {
let y = if month <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400; // [0, 399]
let mp = (month as i64 + 9) % 12; // [0, 11], Mar=0 .. Feb=11
let doy = (153 * mp + 2) / 5 + day as i64 - 1; // [0, 365]
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
let days = era * 146_097 + doe - 719_468; // days since 1970-01-01
days * 86_400 + hour as i64 * 3600 + min as i64 * 60 + sec as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ymd_hms_to_unix_matches_known_epoch_values() {
assert_eq!(ymd_hms_to_unix(1970, 1, 1, 0, 0, 0), 0);
assert_eq!(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0), 1_782_896_400);
assert_eq!(ymd_hms_to_unix(2000, 2, 29, 0, 0, 0), 951_782_400); // leap day
}
#[test]
fn parse_ics_datetime_handles_utc_floating_and_date_only_forms() {
assert_eq!(
parse_ics_datetime("20260701T090000Z"),
Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0))
);
assert_eq!(
parse_ics_datetime("20260701T090000"),
Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0))
);
assert_eq!(
parse_ics_datetime("20260701"),
Some(ymd_hms_to_unix(2026, 7, 1, 0, 0, 0))
);
assert_eq!(parse_ics_datetime("not-a-date"), None);
}
const SAMPLE_ICS: &str = "BEGIN:VCALENDAR\r\n\
BEGIN:VEVENT\r\n\
UID:abc-123@example.com\r\n\
SUMMARY:Sprint planning\r\n\
DESCRIPTION:Plan the next sprint\\, then review backlog\r\n\
ORGANIZER;CN=Jordan Lee:mailto:jordan@example.com\r\n\
DTSTART:20260701T090000Z\r\n\
DTEND:20260701T100000Z\r\n\
ATTENDEE;CN=Alex Kim;ROLE=REQ-PARTICIPANT:mailto:alex@example.com\r\n\
ATTENDEE;CN=Sam Patel;ROLE=OPT-PARTICIPANT:mailto:sam@example.com\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
#[test]
fn parse_vevents_extracts_a_full_appointment() {
let events = parse_vevents(SAMPLE_ICS, "pst");
assert_eq!(events.len(), 1);
let e = &events[0];
assert_eq!(e.event.source, "pst");
assert_eq!(e.event.raw_uid.as_deref(), Some("abc-123@example.com"));
assert_eq!(e.event.subject.as_deref(), Some("Sprint planning"));
assert_eq!(
e.event.description.as_deref(),
Some("Plan the next sprint, then review backlog")
);
assert_eq!(e.event.organizer.as_deref(), Some("Jordan Lee"));
assert_eq!(
e.event.starts_at,
Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0))
);
assert_eq!(e.event.ends_at, Some(ymd_hms_to_unix(2026, 7, 1, 10, 0, 0)));
assert_eq!(e.attendees.len(), 2);
assert_eq!(e.attendees[0].name, "Alex Kim");
assert_eq!(e.attendees[0].email.as_deref(), Some("alex@example.com"));
assert_eq!(e.attendees[0].role.as_deref(), Some("required"));
assert_eq!(e.attendees[1].name, "Sam Patel");
assert_eq!(e.attendees[1].role.as_deref(), Some("optional"));
}
#[test]
fn parse_vevents_falls_back_to_email_when_no_cn_name() {
let ics = "BEGIN:VEVENT\r\nUID:u1\r\nATTENDEE:mailto:noname@example.com\r\nEND:VEVENT\r\n";
let events = parse_vevents(ics, "pst");
assert_eq!(events[0].attendees[0].name, "noname@example.com");
}
#[test]
fn parse_vevents_handles_folded_continuation_lines() {
// RFC 5545 line folding: a continuation line starts with a single space.
let ics =
"BEGIN:VEVENT\r\nUID:u1\r\nSUMMARY:Long meeting na\r\n me continued\r\nEND:VEVENT\r\n";
let events = parse_vevents(ics, "pst");
assert_eq!(
events[0].event.subject.as_deref(),
Some("Long meeting name continued")
);
}
#[test]
fn parse_vevents_returns_empty_for_text_with_no_events() {
assert!(parse_vevents("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", "pst").is_empty());
assert!(parse_vevents("", "pst").is_empty());
}
#[cfg(feature = "pst")]
#[test]
fn import_reports_a_clear_error_for_a_missing_file() {
let result = PstSource.import(CalImport {
path: "Z:\\no\\such\\file.pst".to_string(),
password: None,
});
assert!(matches!(result, Err(CalError::Open(_))));
}
}