1use 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#[derive(Debug, Args)]
35pub struct GcalArgs {
36 #[command(subcommand)]
37 pub action: Action,
38}
39
40#[derive(Debug, Subcommand)]
41pub enum Action {
42 Calendars(CalendarsArgs),
44 Events(EventsArgs),
46 Permissions(PermissionsArgs),
48 #[command(name = "self")]
50 SelfAction(SelfArgs),
51}
52
53#[derive(Debug, Args)]
58pub struct CalendarsArgs {
59 #[command(subcommand)]
60 pub action: CalendarsAction,
61}
62
63#[derive(Debug, Subcommand)]
64pub enum CalendarsAction {
65 List(CalendarsListArgs),
67 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 pub calendar: String,
81 #[arg(long)]
82 pub json: bool,
83}
84
85#[derive(Debug, Args)]
90pub struct EventsArgs {
91 #[command(subcommand)]
92 pub action: EventsAction,
93}
94
95#[derive(Debug, Subcommand)]
96pub enum EventsAction {
97 List(EventsListArgs),
100 Show(EventsShowArgs),
102 Create(EventsCreateArgs),
104 Update(EventsUpdateArgs),
108 Delete(EventsDeleteArgs),
110}
111
112#[derive(Debug, Args)]
113pub struct EventsListArgs {
114 #[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 #[arg(long)]
156 pub tz: Option<String>,
157 #[arg(long = "attendee")]
158 pub attendees: Vec<String>,
159 #[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 #[arg(long = "recurrence")]
168 pub recurrence: Vec<String>,
169 #[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#[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 Show(PermissionsShowArgs),
247 Path(PermissionsPathArgs),
249 Init(PermissionsInitArgs),
251 Check(PermissionsCheckArgs),
253 #[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 #[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#[derive(Debug, Args)]
311pub struct SelfArgs {
312 #[command(subcommand)]
313 pub action: SelfAction,
314}
315
316#[derive(Debug, Subcommand)]
317pub enum SelfAction {
318 Show(SelfShowArgs),
320 Set(SelfSetArgs),
322 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
346pub 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
372async 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 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, ¶ms).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
556async 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 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 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 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 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 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 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 if permissions.block_shared_calendars(func).is_some() && !args.dry_run {
700 enforce_owner_only(&resolved_cal, func, &permissions).await?;
701 }
702
703 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 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 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 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
973async fn enforce_owner_only(
976 calendar_id: &str,
977 func: GcalFunction,
978 permissions: &EffectivePermissions,
979) -> Result<()> {
980 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
1006fn 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
1028fn 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 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
1222fn 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 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 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#[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
1337pub(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
1372pub(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 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
1433pub(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 let resolved = raw.strip_prefix('@').unwrap_or(&raw).to_string();
1451 Ok((raw, resolved))
1452}