Skip to main content

zad_cli/cli/
service_status.rs

1//! Aggregate dispatch for `zad service status` (no `--service` filter).
2//!
3//! This is the agent-facing entrypoint: one command that pings every
4//! service registered in [`zad::service::registry::SERVICES`] and
5//! returns a single JSON envelope describing which ones work. Pings
6//! run in parallel so adding a third service doesn't linearly inflate
7//! startup time.
8//!
9//! Each row is a [`lifecycle::ServiceStatusOutput`] — the same shape
10//! emitted by `zad service status --service <svc>` — so an agent that
11//! already handles the single-service form can consume this output
12//! unchanged.
13
14use serde::Serialize;
15
16use crate::cli::lifecycle::{self, ServiceStatusOutput};
17use crate::cli::service::StatusArgs;
18use crate::cli::{
19    service_discord::DiscordLifecycle, service_gcal::GcalLifecycle,
20    service_onepass::OnePassLifecycle, service_telegram::TelegramLifecycle,
21    service_ymusic::YmusicLifecycle,
22};
23use zad::error::Result;
24
25#[derive(Debug, Serialize)]
26struct AggregateOutput {
27    command: &'static str,
28    /// True iff every service that has an effective scope pinged OK.
29    /// Services with no credentials at all (`effective: null`) don't
30    /// affect this value — they're reported but not counted as
31    /// failures, since "not configured" isn't the same as "broken".
32    ok: bool,
33    services: Vec<ServiceStatusOutput>,
34}
35
36pub async fn run_all(args: StatusArgs) -> Result<()> {
37    // Independent network calls — fan them out. Order here matches the
38    // alphabetical order of `zad::service::registry::SERVICES`;
39    // adding a new service means adding one line here and one match
40    // arm in `service::run()`.
41    let (onepass, discord, gcal, telegram, ymusic) = tokio::join!(
42        lifecycle::status_for::<OnePassLifecycle>(),
43        lifecycle::status_for::<DiscordLifecycle>(),
44        lifecycle::status_for::<GcalLifecycle>(),
45        lifecycle::status_for::<TelegramLifecycle>(),
46        lifecycle::status_for::<YmusicLifecycle>(),
47    );
48    let services = vec![onepass?, discord?, gcal?, telegram?, ymusic?];
49
50    let ok = services
51        .iter()
52        .filter(|s| s.effective.is_some())
53        .all(|s| s.ok);
54
55    if args.json {
56        let out = AggregateOutput {
57            command: "service.status",
58            ok,
59            services,
60        };
61        println!("{}", serde_json::to_string_pretty(&out).unwrap());
62    } else {
63        print_human(ok, &services);
64    }
65
66    if !ok {
67        std::process::exit(1);
68    }
69    Ok(())
70}
71
72fn print_human(ok: bool, services: &[ServiceStatusOutput]) {
73    println!(
74        "zad service status: {}",
75        if ok {
76            "all configured services ok"
77        } else {
78            "one or more services FAILED"
79        }
80    );
81    for svc in services {
82        let effective = svc.effective.unwrap_or("(not configured)");
83        let state = match svc.effective {
84            None => "not configured".to_string(),
85            Some(_) if svc.ok => {
86                let name = svc
87                    .global
88                    .check
89                    .as_ref()
90                    .or(svc.local.check.as_ref())
91                    .and_then(|c| c.authenticated_as.as_deref())
92                    .unwrap_or("(unknown)");
93                format!("ok (authenticated as `{name}`)")
94            }
95            Some(_) => {
96                let err = svc
97                    .global
98                    .check
99                    .as_ref()
100                    .or(svc.local.check.as_ref())
101                    .and_then(|c| c.error.as_deref())
102                    .unwrap_or("(no detail)");
103                format!("FAILED ({err})")
104            }
105        };
106        println!("  {:<10} [{effective:<5}]  {state}", svc.service);
107    }
108}