Skip to main content

zad_cli/cli/
gcal.rs

1//! `zad gcal <verb>` — runtime surface for the Google Calendar
2//! service.
3//!
4//! Wires together:
5//! - per-verb clap args (`calendars list/show`, `events list/show/
6//!   create/update/delete`, plus the `permissions` and `self`
7//!   subgroups every service ships);
8//! - credential + scope resolution from the effective config (local
9//!   wins over global);
10//! - permission gating (time window → calendar → attendees → content
11//!   → numeric caps) executed **before** any network call;
12//! - `--dry-run` wrapping via the [`GcalTransport`] trait so previews
13//!   never hit the keychain.
14
15use std::collections::BTreeSet;
16use std::path::{Path, PathBuf};
17
18use clap::{Args, Subcommand};
19use serde::Serialize;
20
21use crate::cli::lifecycle::leak;
22use zad::config::{self, GcalServiceCfg};
23use zad::error::{Result, ZadError};
24use zad::secrets::{self, Scope};
25use zad::service::default_dry_run_sink;
26use zad::service::gcal::client::{CalendarEntry, Event, EventsListParams, GcalHttp};
27use zad::service::gcal::permissions::{self as perms, EffectivePermissions, GcalFunction};
28use zad::service::gcal::transport::{DryRunGcalTransport, GcalTransport};
29
30// ---------------------------------------------------------------------------
31// top-level args
32// ---------------------------------------------------------------------------
33
34#[derive(Debug, Args)]
35pub struct GcalArgs {
36    #[command(subcommand)]
37    pub action: Action,
38}
39
40#[derive(Debug, Subcommand)]
41pub enum Action {
42    /// Calendar directory operations (list / show).
43    Calendars(CalendarsArgs),
44    /// Event read + write operations.
45    Events(EventsArgs),
46    /// Inspect or scaffold the permissions policy.
47    Permissions(PermissionsArgs),
48    /// Configure the `@me` alias that resolves to your own email.
49    #[command(name = "self")]
50    SelfAction(SelfArgs),
51}
52
53// ---------------------------------------------------------------------------
54// `zad gcal calendars …`
55// ---------------------------------------------------------------------------
56
57#[derive(Debug, Args)]
58pub struct CalendarsArgs {
59    #[command(subcommand)]
60    pub action: CalendarsAction,
61}
62
63#[derive(Debug, Subcommand)]
64pub enum CalendarsAction {
65    /// List every calendar the authenticated user can see.
66    List(CalendarsListArgs),
67    /// Show metadata for one calendar.
68    Show(CalendarsShowArgs),
69}
70
71#[derive(Debug, Args)]
72pub struct CalendarsListArgs {
73    #[arg(long)]
74    pub json: bool,
75}
76
77#[derive(Debug, Args)]
78pub struct CalendarsShowArgs {
79    /// Calendar ID, `primary`, or a directory alias.
80    pub calendar: String,
81    #[arg(long)]
82    pub json: bool,
83}
84
85// ---------------------------------------------------------------------------
86// `zad gcal events …`
87// ---------------------------------------------------------------------------
88
89#[derive(Debug, Args)]
90pub struct EventsArgs {
91    #[command(subcommand)]
92    pub action: EventsAction,
93}
94
95#[derive(Debug, Subcommand)]
96pub enum EventsAction {
97    /// List events on a calendar, optionally filtered by time or
98    /// free-text query.
99    List(EventsListArgs),
100    /// Show one event.
101    Show(EventsShowArgs),
102    /// Create a new event.
103    Create(EventsCreateArgs),
104    /// Patch an existing event. `--add-attendee` and
105    /// `--add-reminder-minutes` are additive; `--remove-attendee` is
106    /// subtractive.
107    Update(EventsUpdateArgs),
108    /// Delete an event.
109    Delete(EventsDeleteArgs),
110}
111
112#[derive(Debug, Args)]
113pub struct EventsListArgs {
114    /// Calendar ID, `primary`, or a directory alias. Defaults to
115    /// `default_calendar` from config if set.
116    #[arg(long)]
117    pub calendar: Option<String>,
118    #[arg(long)]
119    pub time_min: Option<String>,
120    #[arg(long)]
121    pub time_max: Option<String>,
122    #[arg(long)]
123    pub query: Option<String>,
124    #[arg(long, default_value_t = 25)]
125    pub max: u32,
126    #[arg(long)]
127    pub json: bool,
128}
129
130#[derive(Debug, Args)]
131pub struct EventsShowArgs {
132    #[arg(long)]
133    pub id: String,
134    #[arg(long)]
135    pub calendar: Option<String>,
136    #[arg(long)]
137    pub json: bool,
138}
139
140#[derive(Debug, Args)]
141pub struct EventsCreateArgs {
142    #[arg(long)]
143    pub calendar: Option<String>,
144    #[arg(long)]
145    pub summary: Option<String>,
146    #[arg(long)]
147    pub description: Option<String>,
148    #[arg(long)]
149    pub location: Option<String>,
150    #[arg(long)]
151    pub start: Option<String>,
152    #[arg(long)]
153    pub end: Option<String>,
154    /// IANA timezone. Defaults to the calendar's default.
155    #[arg(long)]
156    pub tz: Option<String>,
157    #[arg(long = "attendee")]
158    pub attendees: Vec<String>,
159    /// Reminder override minutes-before-start (repeatable).
160    #[arg(long = "reminder-minutes")]
161    pub reminder_minutes: Vec<u32>,
162    #[arg(long, value_parser = ["default", "public", "private"])]
163    pub visibility: Option<String>,
164    #[arg(long, value_parser = ["none", "external", "all"])]
165    pub send_updates: Option<String>,
166    /// `RRULE:` string (repeatable — emitted as `recurrence` array).
167    #[arg(long = "recurrence")]
168    pub recurrence: Vec<String>,
169    /// Read the full event payload from a file (`-` for stdin).
170    /// Merged with the flag-derived fields; the flags win on
171    /// conflict.
172    #[arg(long)]
173    pub from_json: Option<String>,
174    #[arg(long)]
175    pub dry_run: bool,
176    #[arg(long)]
177    pub json: bool,
178}
179
180#[derive(Debug, Args)]
181pub struct EventsUpdateArgs {
182    #[arg(long)]
183    pub id: String,
184    #[arg(long)]
185    pub calendar: Option<String>,
186    #[arg(long)]
187    pub summary: Option<String>,
188    #[arg(long)]
189    pub description: Option<String>,
190    #[arg(long)]
191    pub location: Option<String>,
192    #[arg(long)]
193    pub start: Option<String>,
194    #[arg(long)]
195    pub end: Option<String>,
196    #[arg(long)]
197    pub tz: Option<String>,
198    #[arg(long = "add-attendee")]
199    pub add_attendees: Vec<String>,
200    #[arg(long = "remove-attendee")]
201    pub remove_attendees: Vec<String>,
202    #[arg(long = "add-reminder-minutes")]
203    pub add_reminder_minutes: Vec<u32>,
204    #[arg(long, value_parser = ["default", "public", "private"])]
205    pub visibility: Option<String>,
206    #[arg(long, value_parser = ["none", "external", "all"])]
207    pub send_updates: Option<String>,
208    #[arg(long)]
209    pub from_json: Option<String>,
210    #[arg(long)]
211    pub dry_run: bool,
212    #[arg(long)]
213    pub json: bool,
214}
215
216#[derive(Debug, Args)]
217pub struct EventsDeleteArgs {
218    #[arg(long)]
219    pub id: String,
220    #[arg(long)]
221    pub calendar: Option<String>,
222    #[arg(long, value_parser = ["none", "external", "all"])]
223    pub send_updates: Option<String>,
224    #[arg(long)]
225    pub dry_run: bool,
226    #[arg(long)]
227    pub json: bool,
228}
229
230// ---------------------------------------------------------------------------
231// `zad gcal permissions …`
232// ---------------------------------------------------------------------------
233
234#[derive(Debug, Args)]
235pub struct PermissionsArgs {
236    #[command(subcommand)]
237    pub action: Option<PermissionsAction>,
238    #[arg(long)]
239    pub json: bool,
240}
241
242#[derive(Debug, Subcommand)]
243#[allow(clippy::large_enum_variant)]
244pub enum PermissionsAction {
245    /// Print the effective policy (both file paths + bodies).
246    Show(PermissionsShowArgs),
247    /// Print the two candidate file paths, one per line.
248    Path(PermissionsPathArgs),
249    /// Write a starter policy to the selected scope.
250    Init(PermissionsInitArgs),
251    /// Dry-run a permissions check without hitting the network.
252    Check(PermissionsCheckArgs),
253    /// Staged-commit workflow: queue mutations in a `.pending` file and
254    /// only sign on `commit`. See `cli::permissions`.
255    #[command(flatten)]
256    Staging(crate::cli::permissions::StagingAction),
257}
258
259#[derive(Debug, Args)]
260pub struct PermissionsShowArgs {
261    #[arg(long)]
262    pub json: bool,
263}
264
265#[derive(Debug, Args)]
266pub struct PermissionsPathArgs {
267    #[arg(long)]
268    pub json: bool,
269}
270
271#[derive(Debug, Args)]
272pub struct PermissionsInitArgs {
273    #[arg(long)]
274    pub local: bool,
275    #[arg(long)]
276    pub force: bool,
277    #[arg(long)]
278    pub json: bool,
279}
280
281#[derive(Debug, Args)]
282pub struct PermissionsCheckArgs {
283    #[arg(long)]
284    pub function: String,
285    #[arg(long)]
286    pub calendar: Option<String>,
287    #[arg(long = "attendee")]
288    pub attendees: Vec<String>,
289    #[arg(long)]
290    pub body: Option<String>,
291    #[arg(long)]
292    pub start: Option<String>,
293    #[arg(long)]
294    pub end: Option<String>,
295    /// Total attendee count after the hypothetical write.
296    #[arg(long = "attendee-count")]
297    pub attendee_count: Option<u32>,
298    #[arg(long, value_parser = ["none", "external", "all"])]
299    pub send_updates: Option<String>,
300    #[arg(long = "reminder-minutes")]
301    pub reminder_minutes: Vec<u32>,
302    #[arg(long)]
303    pub json: bool,
304}
305
306// ---------------------------------------------------------------------------
307// `zad gcal self …`
308// ---------------------------------------------------------------------------
309
310#[derive(Debug, Args)]
311pub struct SelfArgs {
312    #[command(subcommand)]
313    pub action: SelfAction,
314}
315
316#[derive(Debug, Subcommand)]
317pub enum SelfAction {
318    /// Print the currently configured self email.
319    Show(SelfShowArgs),
320    /// Set the self email (overwrites whatever's stored).
321    Set(SelfSetArgs),
322    /// Clear the self email.
323    Clear(SelfClearArgs),
324}
325
326#[derive(Debug, Args)]
327pub struct SelfShowArgs {
328    #[arg(long)]
329    pub json: bool,
330}
331
332#[derive(Debug, Args)]
333pub struct SelfSetArgs {
334    #[arg(long)]
335    pub email: String,
336    #[arg(long)]
337    pub json: bool,
338}
339
340#[derive(Debug, Args)]
341pub struct SelfClearArgs {
342    #[arg(long)]
343    pub json: bool,
344}
345
346// ---------------------------------------------------------------------------
347// dispatch
348// ---------------------------------------------------------------------------
349
350pub async fn run(args: GcalArgs) -> Result<()> {
351    match args.action {
352        Action::Calendars(a) => match a.action {
353            CalendarsAction::List(a) => run_calendars_list(a).await,
354            CalendarsAction::Show(a) => run_calendars_show(a).await,
355        },
356        Action::Events(a) => match a.action {
357            EventsAction::List(a) => run_events_list(a).await,
358            EventsAction::Show(a) => run_events_show(a).await,
359            EventsAction::Create(a) => run_events_create(a).await,
360            EventsAction::Update(a) => run_events_update(a).await,
361            EventsAction::Delete(a) => run_events_delete(a).await,
362        },
363        Action::Permissions(a) => run_permissions(a),
364        Action::SelfAction(a) => match a.action {
365            SelfAction::Show(a) => run_self_show(a),
366            SelfAction::Set(a) => run_self_set(a),
367            SelfAction::Clear(a) => run_self_clear(a),
368        },
369    }
370}
371
372// ---------------------------------------------------------------------------
373// stubs — populated in subsequent parts
374// ---------------------------------------------------------------------------
375
376// ---------------------------------------------------------------------------
377// read-side verbs — calendars list/show, events list/show
378// ---------------------------------------------------------------------------
379
380async fn run_calendars_list(args: CalendarsListArgs) -> Result<()> {
381    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
382    permissions.check_time(GcalFunction::ListCalendars)?;
383    if crate::cli::echo::echo_active() {
384        crate::cli::echo::render_and_clear(args.json);
385        return Ok(());
386    }
387
388    let (transport, _cfg, _config_path) = transport_for(false)?;
389    let items = transport.list_calendars().await?;
390
391    // Per-calendar permission check — filters out calendars the
392    // policy denies. We filter silently (not as a hard error) so
393    // `list` remains useful even when some calendars are restricted.
394    let mut filtered: Vec<CalendarEntry> = Vec::with_capacity(items.len());
395    for c in items {
396        if permissions
397            .check_calendar(GcalFunction::ListCalendars, &c.id, &c.id)
398            .is_ok()
399        {
400            filtered.push(c);
401        }
402    }
403
404    if args.json {
405        println!("{}", serde_json::to_string_pretty(&filtered).unwrap());
406        return Ok(());
407    }
408    if filtered.is_empty() {
409        println!("No calendars visible (or all filtered by permissions).");
410        return Ok(());
411    }
412    for c in &filtered {
413        let primary = if c.primary == Some(true) {
414            " (primary)"
415        } else {
416            ""
417        };
418        let role = c.access_role.as_deref().unwrap_or("?");
419        let tz = c.time_zone.as_deref().unwrap_or("?");
420        println!(
421            "{:40}  role={role:6}  tz={tz}  {}{primary}",
422            c.id, c.summary
423        );
424    }
425    Ok(())
426}
427
428async fn run_calendars_show(args: CalendarsShowArgs) -> Result<()> {
429    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
430    permissions.check_time(GcalFunction::GetCalendar)?;
431    let (raw, resolved) = (args.calendar.clone(), args.calendar.clone());
432    permissions.check_calendar(GcalFunction::GetCalendar, &raw, &resolved)?;
433
434    if crate::cli::echo::echo_active() {
435        crate::cli::echo::render_and_clear(args.json);
436        return Ok(());
437    }
438    let (transport, _cfg, _p) = transport_for(false)?;
439    let cal = transport.get_calendar(&resolved).await?;
440    if args.json {
441        println!("{}", serde_json::to_string_pretty(&cal).unwrap());
442        return Ok(());
443    }
444    println!("id       : {}", cal.id);
445    println!("summary  : {}", cal.summary);
446    if let Some(tz) = &cal.time_zone {
447        println!("timezone : {tz}");
448    }
449    Ok(())
450}
451
452async fn run_events_list(args: EventsListArgs) -> Result<()> {
453    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
454    permissions.check_time(GcalFunction::ListEvents)?;
455    let (cfg, _label, _scope, _path) = effective_config()?;
456    let (raw, resolved) =
457        resolve_calendar(args.calendar.as_deref(), cfg.default_calendar.as_deref())?;
458    permissions.check_calendar(GcalFunction::ListEvents, &raw, &resolved)?;
459
460    let params = EventsListParams {
461        time_min: args.time_min,
462        time_max: args.time_max,
463        query: args.query,
464        max_results: Some(args.max),
465    };
466    if crate::cli::echo::echo_active() {
467        crate::cli::echo::render_and_clear(args.json);
468        return Ok(());
469    }
470    let (transport, _cfg2, _p) = transport_for(false)?;
471    let events = transport.list_events(&resolved, &params).await?;
472
473    if args.json {
474        println!("{}", serde_json::to_string_pretty(&events).unwrap());
475        return Ok(());
476    }
477    if events.is_empty() {
478        println!("No events.");
479        return Ok(());
480    }
481    for e in &events {
482        let summary = e.summary.as_deref().unwrap_or("(no summary)");
483        let when = event_start_string(e);
484        println!("{:25}  {}  {}", when, e.id, summary);
485    }
486    Ok(())
487}
488
489async fn run_events_show(args: EventsShowArgs) -> Result<()> {
490    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
491    permissions.check_time(GcalFunction::GetEvent)?;
492    let (cfg, _label, _scope, _path) = effective_config()?;
493    let (raw, resolved) =
494        resolve_calendar(args.calendar.as_deref(), cfg.default_calendar.as_deref())?;
495    permissions.check_calendar(GcalFunction::GetEvent, &raw, &resolved)?;
496
497    if crate::cli::echo::echo_active() {
498        crate::cli::echo::render_and_clear(args.json);
499        return Ok(());
500    }
501    let (transport, _c, _p) = transport_for(false)?;
502    let event = transport.get_event(&resolved, &args.id).await?;
503    if args.json {
504        println!("{}", serde_json::to_string_pretty(&event).unwrap());
505        return Ok(());
506    }
507    println!("id          : {}", event.id);
508    if let Some(s) = &event.summary {
509        println!("summary     : {s}");
510    }
511    if let Some(s) = &event.description {
512        println!("description : {s}");
513    }
514    if let Some(s) = &event.location {
515        println!("location    : {s}");
516    }
517    println!("start       : {}", event_start_string(&event));
518    println!("end         : {}", event_end_string(&event));
519    if let Some(attendees) = &event.attendees
520        && !attendees.is_empty()
521    {
522        println!("attendees   :");
523        for a in attendees {
524            let name = a.display_name.as_deref().unwrap_or("");
525            let resp = a.response_status.as_deref().unwrap_or("?");
526            println!("  - {} {name} ({resp})", a.email);
527        }
528    }
529    if let Some(link) = &event.html_link {
530        println!("link        : {link}");
531    }
532    Ok(())
533}
534
535fn event_start_string(e: &Event) -> String {
536    match &e.start {
537        Some(dt) => dt
538            .date_time
539            .clone()
540            .or_else(|| dt.date.clone())
541            .unwrap_or_else(|| "(unknown)".into()),
542        None => "(unknown)".into(),
543    }
544}
545fn event_end_string(e: &Event) -> String {
546    match &e.end {
547        Some(dt) => dt
548            .date_time
549            .clone()
550            .or_else(|| dt.date.clone())
551            .unwrap_or_else(|| "(unknown)".into()),
552        None => "(unknown)".into(),
553    }
554}
555
556// ---------------------------------------------------------------------------
557// write-side verbs — create / update / delete
558//
559// Enforcement order, every verb:
560//   1. time window
561//   2. calendar allow/deny + block_shared_calendars
562//   3. attendee allow/deny (for each)
563//   4. content rules on summary + description
564//   5. send_updates allow/deny (when set)
565//   6. reminder minutes hard cap (for each)
566//   7. numeric caps (max_future_days, min_notice_minutes, max_attendees)
567// Only after every check succeeds does the transport touch the network.
568// ---------------------------------------------------------------------------
569
570async fn run_events_create(args: EventsCreateArgs) -> Result<()> {
571    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
572    let func = GcalFunction::CreateEvent;
573    permissions.check_time(func)?;
574
575    // Config is best-effort under dry-run so previews work pre-create.
576    let (cfg, _scope_label, _scope, _path) = if args.dry_run {
577        match effective_config() {
578            Ok(t) => t,
579            Err(_) => (
580                GcalServiceCfg {
581                    scopes: vec![],
582                    default_calendar: None,
583                    self_email: None,
584                },
585                "global",
586                Scope::Global,
587                PathBuf::new(),
588            ),
589        }
590    } else {
591        effective_config()?
592    };
593
594    let (raw_cal, resolved_cal) =
595        resolve_calendar(args.calendar.as_deref(), cfg.default_calendar.as_deref())?;
596    permissions.check_calendar(func, &raw_cal, &resolved_cal)?;
597
598    // Build the event payload. Start from `--from-json` (if any),
599    // then layer flag-derived fields on top.
600    let mut payload = match &args.from_json {
601        Some(p) => read_json_input(p)?,
602        None => serde_json::json!({}),
603    };
604
605    if let Some(s) = &args.summary {
606        payload["summary"] = serde_json::Value::String(s.clone());
607    }
608    if let Some(s) = &args.description {
609        payload["description"] = serde_json::Value::String(s.clone());
610    }
611    if let Some(s) = &args.location {
612        payload["location"] = serde_json::Value::String(s.clone());
613    }
614    if let Some(s) = &args.visibility {
615        payload["visibility"] = serde_json::Value::String(s.clone());
616    }
617    if !args.recurrence.is_empty() {
618        payload["recurrence"] = serde_json::Value::Array(
619            args.recurrence
620                .iter()
621                .map(|r| serde_json::Value::String(r.clone()))
622                .collect(),
623        );
624    }
625
626    // start / end — flag-provided start/end overwrite whatever came
627    // from --from-json. We need these to run the numeric-cap check.
628    let start_et = match &args.start {
629        Some(s) => Some(zad::service::gcal::time::parse_event_time(s)?),
630        None => None,
631    };
632    let end_et = match &args.end {
633        Some(s) => Some(zad::service::gcal::time::parse_event_time(s)?),
634        None => None,
635    };
636    if let Some(t) = &start_et {
637        payload["start"] = t.to_api_json(args.tz.as_deref());
638    }
639    if let Some(t) = &end_et {
640        payload["end"] = t.to_api_json(args.tz.as_deref());
641    }
642
643    // Attendees: build the array (from-json attendees, if any, are
644    // preserved; flag-provided ones append).
645    let mut attendee_list: Vec<serde_json::Value> = match payload.get("attendees") {
646        Some(serde_json::Value::Array(arr)) => arr.clone(),
647        _ => Vec::new(),
648    };
649    for email in &args.attendees {
650        let resolved_email = if email.eq_ignore_ascii_case("@me") {
651            cfg.self_email.clone().ok_or_else(|| {
652                ZadError::Invalid(
653                    "`@me` used but self_email is not set. Run `zad gcal self set --email <addr>`."
654                        .into(),
655                )
656            })?
657        } else {
658            email.clone()
659        };
660        permissions.check_attendee(func, email, cfg.self_email.as_deref())?;
661        permissions.check_attendee(GcalFunction::Invite, email, cfg.self_email.as_deref())?;
662        attendee_list.push(serde_json::json!({ "email": resolved_email }));
663    }
664    if !attendee_list.is_empty() {
665        payload["attendees"] = serde_json::Value::Array(attendee_list.clone());
666    }
667
668    // Content checks on summary + description.
669    if let Some(s) = payload.get("summary").and_then(|v| v.as_str()) {
670        permissions.check_body(func, s)?;
671    }
672    if let Some(s) = payload.get("description").and_then(|v| v.as_str()) {
673        permissions.check_body(func, s)?;
674    }
675
676    // Reminders — override + minutes.
677    if !args.reminder_minutes.is_empty() {
678        for m in &args.reminder_minutes {
679            permissions.check_reminder_minutes(func, *m)?;
680            permissions.check_reminder_minutes(GcalFunction::Remind, *m)?;
681        }
682        let overrides: Vec<serde_json::Value> = args
683            .reminder_minutes
684            .iter()
685            .map(|m| serde_json::json!({ "method": "popup", "minutes": m }))
686            .collect();
687        payload["reminders"] = serde_json::json!({
688            "useDefault": false,
689            "overrides": overrides,
690        });
691    }
692
693    if let Some(su) = args.send_updates.as_deref() {
694        permissions.check_send_updates(func, su)?;
695    }
696
697    // block_shared_calendars — re-use the live transport to fetch the
698    // calendar's `accessRole` unless we're in dry-run mode.
699    if permissions.block_shared_calendars(func).is_some() && !args.dry_run {
700        enforce_owner_only(&resolved_cal, func, &permissions).await?;
701    }
702
703    // Numeric caps.
704    let (days_future, mins_future) = match &start_et {
705        Some(t) => (
706            zad::service::gcal::time::days_from_now(t),
707            zad::service::gcal::time::minutes_from_now(t),
708        ),
709        None => (None, None),
710    };
711    let attendee_count = if attendee_list.is_empty() {
712        None
713    } else {
714        Some(attendee_list.len() as u32)
715    };
716    permissions.check_event_caps(func, days_future, mins_future, attendee_count)?;
717
718    // All checks passed; hit the network (or the dry-run sink).
719    let (transport, _cfg2, _p) = transport_for(args.dry_run)?;
720    let event = transport
721        .create_event(&resolved_cal, &payload, args.send_updates.as_deref())
722        .await?;
723
724    if args.dry_run {
725        return Ok(());
726    }
727    if crate::cli::echo::echo_active() {
728        crate::cli::echo::render_and_clear(args.json);
729        return Ok(());
730    }
731    if args.json {
732        println!("{}", serde_json::to_string_pretty(&event).unwrap());
733    } else {
734        println!("created event {}", event.id);
735        if let Some(link) = &event.html_link {
736            println!("  {link}");
737        }
738    }
739    Ok(())
740}
741
742async fn run_events_update(args: EventsUpdateArgs) -> Result<()> {
743    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
744    let func = GcalFunction::UpdateEvent;
745    permissions.check_time(func)?;
746
747    let (cfg, _scope_label, _scope, _path) = if args.dry_run {
748        match effective_config() {
749            Ok(t) => t,
750            Err(_) => (
751                GcalServiceCfg {
752                    scopes: vec![],
753                    default_calendar: None,
754                    self_email: None,
755                },
756                "global",
757                Scope::Global,
758                PathBuf::new(),
759            ),
760        }
761    } else {
762        effective_config()?
763    };
764    let (raw_cal, resolved_cal) =
765        resolve_calendar(args.calendar.as_deref(), cfg.default_calendar.as_deref())?;
766    permissions.check_calendar(func, &raw_cal, &resolved_cal)?;
767
768    let mut patch = match &args.from_json {
769        Some(p) => read_json_input(p)?,
770        None => serde_json::json!({}),
771    };
772    if let Some(s) = &args.summary {
773        patch["summary"] = serde_json::Value::String(s.clone());
774    }
775    if let Some(s) = &args.description {
776        patch["description"] = serde_json::Value::String(s.clone());
777    }
778    if let Some(s) = &args.location {
779        patch["location"] = serde_json::Value::String(s.clone());
780    }
781    if let Some(s) = &args.visibility {
782        patch["visibility"] = serde_json::Value::String(s.clone());
783    }
784
785    let start_et = match &args.start {
786        Some(s) => Some(zad::service::gcal::time::parse_event_time(s)?),
787        None => None,
788    };
789    let end_et = match &args.end {
790        Some(s) => Some(zad::service::gcal::time::parse_event_time(s)?),
791        None => None,
792    };
793    if let Some(t) = &start_et {
794        patch["start"] = t.to_api_json(args.tz.as_deref());
795    }
796    if let Some(t) = &end_et {
797        patch["end"] = t.to_api_json(args.tz.as_deref());
798    }
799
800    // For attendee adds/removes we need the current attendee list
801    // from the server (unless dry-run — then we work with just the
802    // add delta).
803    let mut current_attendees: Vec<serde_json::Value> = match patch.get("attendees") {
804        Some(serde_json::Value::Array(arr)) => arr.clone(),
805        _ => Vec::new(),
806    };
807    if !args.dry_run && (!args.add_attendees.is_empty() || !args.remove_attendees.is_empty()) {
808        let (transport, _c, _p) = transport_for(false)?;
809        let ev = transport.get_event(&resolved_cal, &args.id).await?;
810        if let Some(list) = ev.attendees {
811            current_attendees = list
812                .into_iter()
813                .map(|a| serde_json::json!({ "email": a.email }))
814                .collect();
815        }
816    }
817    for email in &args.add_attendees {
818        let resolved_email = if email.eq_ignore_ascii_case("@me") {
819            cfg.self_email.clone().ok_or_else(|| {
820                ZadError::Invalid(
821                    "`@me` used but self_email is not set. Run `zad gcal self set --email <addr>`."
822                        .into(),
823                )
824            })?
825        } else {
826            email.clone()
827        };
828        permissions.check_attendee(func, email, cfg.self_email.as_deref())?;
829        permissions.check_attendee(GcalFunction::Invite, email, cfg.self_email.as_deref())?;
830        // Avoid duplicates.
831        if !current_attendees
832            .iter()
833            .any(|v| v.get("email").and_then(|e| e.as_str()) == Some(resolved_email.as_str()))
834        {
835            current_attendees.push(serde_json::json!({ "email": resolved_email }));
836        }
837    }
838    for email in &args.remove_attendees {
839        current_attendees
840            .retain(|v| v.get("email").and_then(|e| e.as_str()) != Some(email.as_str()));
841    }
842    if !args.add_attendees.is_empty() || !args.remove_attendees.is_empty() {
843        patch["attendees"] = serde_json::Value::Array(current_attendees.clone());
844    }
845
846    if let Some(s) = patch.get("summary").and_then(|v| v.as_str()) {
847        permissions.check_body(func, s)?;
848    }
849    if let Some(s) = patch.get("description").and_then(|v| v.as_str()) {
850        permissions.check_body(func, s)?;
851    }
852
853    if !args.add_reminder_minutes.is_empty() {
854        for m in &args.add_reminder_minutes {
855            permissions.check_reminder_minutes(func, *m)?;
856            permissions.check_reminder_minutes(GcalFunction::Remind, *m)?;
857        }
858        let overrides: Vec<serde_json::Value> = args
859            .add_reminder_minutes
860            .iter()
861            .map(|m| serde_json::json!({ "method": "popup", "minutes": m }))
862            .collect();
863        patch["reminders"] = serde_json::json!({
864            "useDefault": false,
865            "overrides": overrides,
866        });
867    }
868
869    if let Some(su) = args.send_updates.as_deref() {
870        permissions.check_send_updates(func, su)?;
871    }
872
873    if permissions.block_shared_calendars(func).is_some() && !args.dry_run {
874        enforce_owner_only(&resolved_cal, func, &permissions).await?;
875    }
876
877    let (days_future, mins_future) = match &start_et {
878        Some(t) => (
879            zad::service::gcal::time::days_from_now(t),
880            zad::service::gcal::time::minutes_from_now(t),
881        ),
882        None => (None, None),
883    };
884    let attendee_count = if current_attendees.is_empty() {
885        None
886    } else {
887        Some(current_attendees.len() as u32)
888    };
889    permissions.check_event_caps(func, days_future, mins_future, attendee_count)?;
890
891    let (transport, _c, _p) = transport_for(args.dry_run)?;
892    let event = transport
893        .update_event(
894            &resolved_cal,
895            &args.id,
896            &patch,
897            args.send_updates.as_deref(),
898        )
899        .await?;
900
901    if args.dry_run {
902        return Ok(());
903    }
904    if crate::cli::echo::echo_active() {
905        crate::cli::echo::render_and_clear(args.json);
906        return Ok(());
907    }
908    if args.json {
909        println!("{}", serde_json::to_string_pretty(&event).unwrap());
910    } else {
911        println!("updated event {}", event.id);
912    }
913    Ok(())
914}
915
916async fn run_events_delete(args: EventsDeleteArgs) -> Result<()> {
917    let permissions = crate::cli::echo::load_effective_or_echo(perms::load_effective)?;
918    let func = GcalFunction::DeleteEvent;
919    permissions.check_time(func)?;
920
921    let (cfg, _scope_label, _scope, _path) = if args.dry_run {
922        match effective_config() {
923            Ok(t) => t,
924            Err(_) => (
925                GcalServiceCfg {
926                    scopes: vec![],
927                    default_calendar: None,
928                    self_email: None,
929                },
930                "global",
931                Scope::Global,
932                PathBuf::new(),
933            ),
934        }
935    } else {
936        effective_config()?
937    };
938    let (raw_cal, resolved_cal) =
939        resolve_calendar(args.calendar.as_deref(), cfg.default_calendar.as_deref())?;
940    permissions.check_calendar(func, &raw_cal, &resolved_cal)?;
941
942    if let Some(su) = args.send_updates.as_deref() {
943        permissions.check_send_updates(func, su)?;
944    }
945
946    if permissions.block_shared_calendars(func).is_some() && !args.dry_run {
947        enforce_owner_only(&resolved_cal, func, &permissions).await?;
948    }
949
950    let (transport, _c, _p) = transport_for(args.dry_run)?;
951    transport
952        .delete_event(&resolved_cal, &args.id, args.send_updates.as_deref())
953        .await?;
954
955    if args.dry_run {
956        return Ok(());
957    }
958    if crate::cli::echo::echo_active() {
959        crate::cli::echo::render_and_clear(args.json);
960        return Ok(());
961    }
962    if args.json {
963        println!(
964            r#"{{"command":"gcal.events.delete","calendar":"{resolved_cal}","event_id":"{}"}}"#,
965            args.id
966        );
967    } else {
968        println!("deleted event {}", args.id);
969    }
970    Ok(())
971}
972
973/// Fetch the calendar's `accessRole` once and fail the verb if it
974/// isn't `owner`. Honours `block_shared_calendars = true`.
975async fn enforce_owner_only(
976    calendar_id: &str,
977    func: GcalFunction,
978    permissions: &EffectivePermissions,
979) -> Result<()> {
980    // The cheapest way to learn `accessRole` is a single calendarList
981    // lookup. We read every entry because the Calendar API's
982    // `calendarList/get` endpoint is keyed by ID but returns the same
983    // `accessRole` field.
984    let (transport, _c, _p) = transport_for(false)?;
985    let entries = transport.list_calendars().await?;
986    let role = entries
987        .iter()
988        .find(|c| c.id == calendar_id)
989        .and_then(|c| c.access_role.clone());
990    match role.as_deref() {
991        Some("owner") => Ok(()),
992        other => {
993            let path = permissions.block_shared_calendars(func).unwrap_or_default();
994            Err(ZadError::PermissionDenied {
995                function: func.name(),
996                reason: format!(
997                    "calendar `{calendar_id}` has accessRole `{}`, not `owner`; block_shared_calendars = true",
998                    other.unwrap_or("unknown")
999                ),
1000                config_path: path,
1001            })
1002        }
1003    }
1004}
1005
1006/// Read a JSON document from `path` (or stdin when `path == "-"`).
1007fn read_json_input(path: &str) -> Result<serde_json::Value> {
1008    let body = if path == "-" {
1009        let mut buf = String::new();
1010        use std::io::Read;
1011        std::io::stdin()
1012            .read_to_string(&mut buf)
1013            .map_err(|e| ZadError::Io {
1014                path: PathBuf::from("<stdin>"),
1015                source: e,
1016            })?;
1017        buf
1018    } else {
1019        std::fs::read_to_string(path).map_err(|e| ZadError::Io {
1020            path: PathBuf::from(path),
1021            source: e,
1022        })?
1023    };
1024    serde_json::from_str(&body)
1025        .map_err(|e| ZadError::Invalid(format!("failed to parse JSON from `{path}`: {e}")))
1026}
1027
1028// ---------------------------------------------------------------------------
1029// permissions subgroup — show / path / init / check
1030// ---------------------------------------------------------------------------
1031
1032fn run_permissions(args: PermissionsArgs) -> Result<()> {
1033    match args.action {
1034        None => run_permissions_show(PermissionsShowArgs { json: args.json }),
1035        Some(PermissionsAction::Show(a)) => run_permissions_show(a),
1036        Some(PermissionsAction::Path(a)) => run_permissions_path(a),
1037        Some(PermissionsAction::Init(a)) => run_permissions_init(a),
1038        Some(PermissionsAction::Check(a)) => run_permissions_check(a),
1039        Some(PermissionsAction::Staging(a)) => {
1040            crate::cli::permissions::run::<perms::PermissionsService>(a)
1041        }
1042    }
1043}
1044
1045#[derive(Debug, Serialize)]
1046struct PermissionsScopeOut {
1047    path: String,
1048    present: bool,
1049}
1050
1051#[derive(Debug, Serialize)]
1052struct PermissionsShowOut {
1053    command: &'static str,
1054    global: PermissionsScopeOut,
1055    local: PermissionsScopeOut,
1056}
1057
1058fn run_permissions_show(args: PermissionsShowArgs) -> Result<()> {
1059    let global_path = perms::global_path()?;
1060    let local_path = perms::local_path_current()?;
1061    // Force compile so syntax errors surface immediately.
1062    let _ = perms::load_effective()?;
1063
1064    if args.json {
1065        let out = PermissionsShowOut {
1066            command: "gcal.permissions.show",
1067            global: PermissionsScopeOut {
1068                path: global_path.display().to_string(),
1069                present: global_path.exists(),
1070            },
1071            local: PermissionsScopeOut {
1072                path: local_path.display().to_string(),
1073                present: local_path.exists(),
1074            },
1075        };
1076        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1077        return Ok(());
1078    }
1079    println!("Google Calendar permissions");
1080    print_scope_block("global", &global_path);
1081    print_scope_block("local", &local_path);
1082    Ok(())
1083}
1084
1085fn print_scope_block(label: &str, path: &Path) {
1086    println!();
1087    println!("  [{label}] {}", path.display());
1088    if !path.exists() {
1089        println!("    status : not present (no restrictions from this scope)");
1090        return;
1091    }
1092    match std::fs::read_to_string(path) {
1093        Ok(body) => {
1094            for line in body.lines() {
1095                println!("    {line}");
1096            }
1097        }
1098        Err(e) => println!("    status : read error — {e}"),
1099    }
1100}
1101
1102#[derive(Debug, Serialize)]
1103struct PermissionsPathOut {
1104    command: &'static str,
1105    global: String,
1106    local: String,
1107}
1108
1109fn run_permissions_path(args: PermissionsPathArgs) -> Result<()> {
1110    let global_path = perms::global_path()?;
1111    let local_path = perms::local_path_current()?;
1112    if args.json {
1113        let out = PermissionsPathOut {
1114            command: "gcal.permissions.path",
1115            global: global_path.display().to_string(),
1116            local: local_path.display().to_string(),
1117        };
1118        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1119        return Ok(());
1120    }
1121    println!("{}", global_path.display());
1122    println!("{}", local_path.display());
1123    Ok(())
1124}
1125
1126#[derive(Debug, Serialize)]
1127struct PermissionsInitOut {
1128    command: &'static str,
1129    scope: &'static str,
1130    path: String,
1131    written: bool,
1132}
1133
1134fn run_permissions_init(args: PermissionsInitArgs) -> Result<()> {
1135    let (path, scope_label): (PathBuf, &'static str) = if args.local {
1136        (perms::local_path_current()?, "local")
1137    } else {
1138        (perms::global_path()?, "global")
1139    };
1140    if path.exists() && !args.force {
1141        return Err(ZadError::Invalid(format!(
1142            "{} already exists — pass --force to overwrite",
1143            path.display()
1144        )));
1145    }
1146    let key = zad::permissions::signing::load_or_create_from_keychain()?;
1147    zad::permissions::signing::write_public_key_cache(&key)?;
1148    perms::save_file(&path, &perms::starter_template(), &key)?;
1149    if args.json {
1150        let out = PermissionsInitOut {
1151            command: "gcal.permissions.init",
1152            scope: scope_label,
1153            path: path.display().to_string(),
1154            written: true,
1155        };
1156        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1157        return Ok(());
1158    }
1159    println!(
1160        "Wrote gcal permissions starter policy to {} ({scope_label}).",
1161        path.display()
1162    );
1163    println!("Signed with key {}.", key.fingerprint());
1164    println!("Edit to narrow further; re-run `zad gcal permissions show` to inspect.");
1165    Ok(())
1166}
1167
1168#[derive(Debug, Serialize)]
1169struct PermissionsCheckOut {
1170    command: &'static str,
1171    function: String,
1172    allowed: bool,
1173    #[serde(skip_serializing_if = "Option::is_none")]
1174    reason: Option<String>,
1175    #[serde(skip_serializing_if = "Option::is_none")]
1176    config_path: Option<String>,
1177}
1178
1179fn run_permissions_check(args: PermissionsCheckArgs) -> Result<()> {
1180    let permissions = perms::load_effective()?;
1181    let func = GcalFunction::parse(&args.function)?;
1182    match check_hypothetical(&permissions, func, &args) {
1183        Ok(()) => {
1184            if args.json {
1185                let out = PermissionsCheckOut {
1186                    command: "gcal.permissions.check",
1187                    function: args.function,
1188                    allowed: true,
1189                    reason: None,
1190                    config_path: None,
1191                };
1192                println!("{}", serde_json::to_string_pretty(&out).unwrap());
1193            } else {
1194                println!("allowed");
1195            }
1196            Ok(())
1197        }
1198        Err(ZadError::PermissionDenied {
1199            function,
1200            reason,
1201            config_path,
1202        }) => {
1203            if args.json {
1204                let out = PermissionsCheckOut {
1205                    command: "gcal.permissions.check",
1206                    function: function.to_string(),
1207                    allowed: false,
1208                    reason: Some(reason.clone()),
1209                    config_path: Some(config_path.display().to_string()),
1210                };
1211                println!("{}", serde_json::to_string_pretty(&out).unwrap());
1212            } else {
1213                println!("denied: {reason}");
1214                println!("  edit: {}", config_path.display());
1215            }
1216            std::process::exit(1);
1217        }
1218        Err(other) => Err(other),
1219    }
1220}
1221
1222/// Body of the check: dry-run every applicable permission predicate
1223/// for `func` against `args`. Mirrors the enforcement sequence the
1224/// write-side verbs use in Part 3.
1225fn check_hypothetical(
1226    permissions: &EffectivePermissions,
1227    func: GcalFunction,
1228    args: &PermissionsCheckArgs,
1229) -> Result<()> {
1230    permissions.check_time(func)?;
1231
1232    if let Some(cal) = args.calendar.as_deref() {
1233        let resolved = cal.strip_prefix('@').unwrap_or(cal);
1234        permissions.check_calendar(func, cal, resolved)?;
1235    }
1236
1237    // Attendee checks — use `self_email` if the effective config has
1238    // one (so `@me` resolves against policy).
1239    let self_email = effective_config()
1240        .ok()
1241        .and_then(|(c, _, _, _)| c.self_email);
1242    for a in &args.attendees {
1243        permissions.check_attendee(func, a, self_email.as_deref())?;
1244    }
1245
1246    if let Some(b) = args.body.as_deref() {
1247        permissions.check_body(func, b)?;
1248    }
1249
1250    if let Some(su) = args.send_updates.as_deref() {
1251        permissions.check_send_updates(func, su)?;
1252    }
1253
1254    for m in &args.reminder_minutes {
1255        permissions.check_reminder_minutes(func, *m)?;
1256    }
1257
1258    // Numeric caps: if caller gave both start and end we compute
1259    // them; otherwise leave the caps check unconstrained.
1260    let (days_future, mins_future) = match args.start.as_deref() {
1261        Some(s) => {
1262            let t = zad::service::gcal::time::parse_event_time(s)?;
1263            (
1264                zad::service::gcal::time::days_from_now(&t),
1265                zad::service::gcal::time::minutes_from_now(&t),
1266            )
1267        }
1268        None => (None, None),
1269    };
1270    permissions.check_event_caps(func, days_future, mins_future, args.attendee_count)?;
1271    Ok(())
1272}
1273
1274// ---------------------------------------------------------------------------
1275// self subgroup
1276// ---------------------------------------------------------------------------
1277
1278#[derive(Debug, Serialize)]
1279struct SelfShowOut {
1280    command: &'static str,
1281    #[serde(skip_serializing_if = "Option::is_none")]
1282    email: Option<String>,
1283    scope: &'static str,
1284    config_path: String,
1285}
1286
1287fn run_self_show(args: SelfShowArgs) -> Result<()> {
1288    let (cfg, scope_label, _scope, path) = effective_config()?;
1289    if args.json {
1290        let out = SelfShowOut {
1291            command: "gcal.self.show",
1292            email: cfg.self_email.clone(),
1293            scope: scope_label,
1294            config_path: path.display().to_string(),
1295        };
1296        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1297        return Ok(());
1298    }
1299    match cfg.self_email {
1300        Some(e) => println!("self email: {e} ({scope_label})"),
1301        None => println!("self email: (unset; use `zad gcal self set --email <addr>`)"),
1302    }
1303    Ok(())
1304}
1305
1306fn run_self_set(args: SelfSetArgs) -> Result<()> {
1307    let (mut cfg, _label, _scope, path) = effective_config()?;
1308    cfg.self_email = Some(args.email.clone());
1309    config::save_flat(&path, &cfg)?;
1310    if args.json {
1311        println!(
1312            r#"{{"command":"gcal.self.set","email":"{}","config_path":"{}"}}"#,
1313            args.email,
1314            path.display()
1315        );
1316        return Ok(());
1317    }
1318    println!("self email set to {} in {}", args.email, path.display());
1319    Ok(())
1320}
1321
1322fn run_self_clear(args: SelfClearArgs) -> Result<()> {
1323    let (mut cfg, _label, _scope, path) = effective_config()?;
1324    cfg.self_email = None;
1325    config::save_flat(&path, &cfg)?;
1326    if args.json {
1327        println!(
1328            r#"{{"command":"gcal.self.clear","config_path":"{}"}}"#,
1329            path.display()
1330        );
1331        return Ok(());
1332    }
1333    println!("self email cleared in {}", path.display());
1334    Ok(())
1335}
1336
1337// ---------------------------------------------------------------------------
1338// shared helpers (used by every verb in parts 2/3)
1339// ---------------------------------------------------------------------------
1340
1341/// Load the effective `GcalServiceCfg` (local wins over global) plus
1342/// the scope label for error messages and the keychain scope to read
1343/// secrets from.
1344pub(crate) fn effective_config() -> Result<(GcalServiceCfg, &'static str, Scope<'static>, PathBuf)>
1345{
1346    let slug = config::path::project_slug()?;
1347    let local_path = config::path::project_service_config_path_for(&slug, "gcal")?;
1348    let global_path = config::path::global_service_config_path("gcal")?;
1349
1350    let project_cfg = config::load()?;
1351    if !project_cfg.has_service("gcal") {
1352        return Err(ZadError::Invalid(format!(
1353            "gcal is not enabled for this project ({}). Run `zad service enable gcal` first.",
1354            config::path::project_config_path()?.display()
1355        )));
1356    }
1357
1358    if let Some(cfg) = config::load_flat::<GcalServiceCfg>(&local_path)? {
1359        let slug_leaked = leak(slug);
1360        return Ok((cfg, "local", Scope::Project(slug_leaked), local_path));
1361    }
1362    if let Some(cfg) = config::load_flat::<GcalServiceCfg>(&global_path)? {
1363        return Ok((cfg, "global", Scope::Global, global_path));
1364    }
1365    Err(ZadError::Invalid(format!(
1366        "no gcal credentials found.\n  looked in:\n    {}\n    {}\n  Run `zad service create gcal`.",
1367        local_path.display(),
1368        global_path.display()
1369    )))
1370}
1371
1372/// Factory that returns either a live [`GcalHttp`]-backed transport
1373/// or a dry-run preview transport. Honours `dry_run`: when true, the
1374/// keychain is never read, so previews work even before credentials
1375/// are registered. When false, credentials are loaded and the scope
1376/// set is pinned to whatever the config declared.
1377pub(crate) fn transport_for(
1378    dry_run: bool,
1379) -> Result<(Box<dyn GcalTransport>, GcalServiceCfg, PathBuf)> {
1380    if dry_run || crate::cli::echo::echo_active() {
1381        // Best-effort config load for dry-run: if we have one, use it
1382        // for default_calendar / self_email; otherwise return a
1383        // synthetic stub so the preview path works uncredentialed.
1384        let cfg = effective_config()
1385            .map(|(c, _, _, p)| (c, p))
1386            .unwrap_or_else(|_| {
1387                (
1388                    GcalServiceCfg {
1389                        scopes: vec![],
1390                        default_calendar: None,
1391                        self_email: None,
1392                    },
1393                    PathBuf::new(),
1394                )
1395            });
1396        let sink = if crate::cli::echo::echo_active() {
1397            crate::cli::echo::dry_run_sink_for_echo()
1398        } else {
1399            default_dry_run_sink()
1400        };
1401        let transport: Box<dyn GcalTransport> = Box::new(DryRunGcalTransport::new(sink));
1402        return Ok((transport, cfg.0, cfg.1));
1403    }
1404
1405    let (cfg, _scope_label, scope, path) = effective_config()?;
1406    let client_id = secrets::load(&secrets::account("gcal", "client-id", scope.clone()))?.ok_or(
1407        ZadError::Service {
1408            name: "gcal",
1409            message: "client-id missing from keychain; re-run `zad service create gcal`".into(),
1410        },
1411    )?;
1412    let client_secret = secrets::load(&secrets::account("gcal", "client-secret", scope.clone()))?
1413        .ok_or(ZadError::Service {
1414        name: "gcal",
1415        message: "client-secret missing from keychain; re-run `zad service create gcal`".into(),
1416    })?;
1417    let refresh_token =
1418        secrets::load(&secrets::account("gcal", "refresh", scope))?.ok_or(ZadError::Service {
1419            name: "gcal",
1420            message: "refresh token missing from keychain; re-run `zad service create gcal`".into(),
1421        })?;
1422    let scope_set: BTreeSet<String> = cfg.scopes.iter().cloned().collect();
1423    let http = GcalHttp::new(
1424        client_id,
1425        client_secret,
1426        refresh_token,
1427        scope_set,
1428        path.clone(),
1429    );
1430    Ok((Box::new(http), cfg, path))
1431}
1432
1433/// Resolve `--calendar <raw>` against `default_calendar` fallback.
1434/// Returns `(raw_input, resolved_id)` — the two aliases every
1435/// permission check wants.
1436pub(crate) fn resolve_calendar(
1437    flag: Option<&str>,
1438    default: Option<&str>,
1439) -> Result<(String, String)> {
1440    let raw = match flag {
1441        Some(v) if !v.is_empty() => v.to_string(),
1442        _ => default.map(str::to_string).ok_or_else(|| {
1443            ZadError::Invalid(
1444                "no calendar specified and no `default_calendar` set — pass --calendar <id>".into(),
1445            )
1446        })?,
1447    };
1448    // Google calendar IDs are already resolved. We just strip an
1449    // optional leading `@` for ergonomic pastes like `@primary`.
1450    let resolved = raw.strip_prefix('@').unwrap_or(&raw).to_string();
1451    Ok((raw, resolved))
1452}