Skip to main content

datum_cli/
lib.rs

1#![forbid(unsafe_code)]
2
3use std::{
4    collections::{HashMap, HashSet},
5    fs,
6    io::{self, Cursor, Write},
7    net::SocketAddr,
8    path::{Path, PathBuf},
9    sync::Arc,
10    time::Duration,
11};
12
13use clap::{Parser, Subcommand, error::ErrorKind};
14use datum_agent::dcp::{
15    ClientKind, DcpClient, DcpError, EventSubscription, Hello, MetricSubscription, ResponseStatus,
16    proto::{
17        ClusterJobList, ClusterJobNode, ClusterNodeError, ClusterNodeList, ClusterNodeStatus,
18        Event, JobStatus as WireJobStatus, MetricSample, PlacementSpec, PlacementStrategy,
19        StreamMetric,
20    },
21};
22use datum_net::quic::{
23    crypto::rustls::QuicClientConfig,
24    quinn,
25    rustls::{
26        ClientConfig as RustlsClientConfig, RootCertStore,
27        pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer},
28    },
29};
30use serde::Serialize;
31
32const DEFAULT_ADDR: &str = "127.0.0.1:9555";
33const EXIT_OK: i32 = 0;
34const EXIT_REMOTE: i32 = 1;
35const EXIT_USAGE_OR_CONNECT: i32 = 2;
36const EVENT_INITIAL_IDLE: Duration = Duration::from_millis(500);
37const EVENT_IDLE: Duration = Duration::from_millis(250);
38const METRIC_SNAPSHOT_WAIT: Duration = Duration::from_secs(2);
39const DRAIN_POLL: Duration = Duration::from_millis(20);
40
41#[derive(Debug, Parser)]
42#[command(
43    name = "datum",
44    version,
45    about = "Manage Datum jobs through the Datum Control Protocol"
46)]
47struct Cli {
48    /// DCP address. Defaults to the loopback TCP development listener.
49    #[arg(long, global = true, default_value = DEFAULT_ADDR)]
50    addr: SocketAddr,
51
52    /// CA certificate for QUIC+mTLS mode.
53    #[arg(long, global = true, value_name = "PATH")]
54    tls_ca: Option<PathBuf>,
55
56    /// Client certificate for QUIC+mTLS mode.
57    #[arg(long, global = true, value_name = "PATH")]
58    tls_cert: Option<PathBuf>,
59
60    /// Client private key for QUIC+mTLS mode.
61    #[arg(long, global = true, value_name = "PATH")]
62    tls_key: Option<PathBuf>,
63
64    /// Emit machine-readable JSON instead of human tables.
65    #[arg(long, global = true)]
66    json: bool,
67
68    #[command(subcommand)]
69    command: Command,
70}
71
72#[derive(Debug, Subcommand)]
73enum Command {
74    /// List jobs.
75    Ps {
76        /// Aggregate jobs across cluster nodes.
77        #[arg(long)]
78        cluster: bool,
79    },
80    /// List cluster nodes.
81    Nodes,
82    /// Show one job.
83    Status {
84        #[arg(long)]
85        cluster: bool,
86        job: String,
87    },
88    /// Start a registered factory.
89    Start {
90        factory: String,
91        #[arg(long)]
92        name: Option<String>,
93        #[arg(long = "param", value_name = "k=v")]
94        params: Vec<String>,
95    },
96    /// Submit a job through cluster placement.
97    Submit {
98        #[arg(long)]
99        cluster: bool,
100        #[arg(long)]
101        factory: String,
102        #[arg(long)]
103        name: Option<String>,
104        #[arg(long = "param", value_name = "k=v")]
105        params: Vec<String>,
106        #[arg(long)]
107        role: Option<String>,
108        #[arg(long)]
109        node: Option<String>,
110    },
111    /// Gracefully drain a job.
112    Drain {
113        #[arg(long)]
114        cluster: bool,
115        job: String,
116    },
117    /// Stop a job immediately.
118    Stop {
119        #[arg(long)]
120        cluster: bool,
121        job: String,
122    },
123    /// Restart a job.
124    Restart { job: String },
125    /// Tail lifecycle events.
126    Events {
127        #[arg(long)]
128        follow: bool,
129    },
130    /// Show stream metrics for one job.
131    Metrics {
132        job: String,
133        #[arg(long)]
134        follow: bool,
135    },
136}
137
138#[derive(Debug)]
139enum CliError {
140    Usage(String),
141    Connect { addr: SocketAddr, message: String },
142    Remote { message: String, fix: &'static str },
143}
144
145impl CliError {
146    fn exit_code(&self) -> i32 {
147        match self {
148            Self::Remote { .. } => EXIT_REMOTE,
149            Self::Usage(_) | Self::Connect { .. } => EXIT_USAGE_OR_CONNECT,
150        }
151    }
152
153    fn print(&self) {
154        match self {
155            Self::Usage(message) => {
156                eprintln!("{message}");
157            }
158            Self::Connect { addr, message } => {
159                eprintln!("could not connect to datum-agent at {addr}: {message}");
160                eprintln!(
161                    "start datum-agent or pass --addr host:port; loopback TCP defaults to {DEFAULT_ADDR}"
162                );
163            }
164            Self::Remote { message, fix } => {
165                eprintln!("datum-agent rejected the request: {message}");
166                eprintln!("{fix}");
167            }
168        }
169    }
170}
171
172/// Run the CLI using process arguments and return the requested process exit code.
173pub async fn main_entry() -> i32 {
174    match Cli::try_parse() {
175        Ok(cli) => match run(cli).await {
176            Ok(()) => EXIT_OK,
177            Err(error) => {
178                error.print();
179                error.exit_code()
180            }
181        },
182        Err(error) => {
183            let _ = error.print();
184            clap_error_exit_code(&error)
185        }
186    }
187}
188
189/// clap prints `--help`/`--version`/missing-subcommand text as a "success" `Err`
190/// (the message is already on stdout); only genuine usage errors should exit non-zero.
191fn clap_error_exit_code(error: &clap::Error) -> i32 {
192    match error.kind() {
193        ErrorKind::DisplayHelp
194        | ErrorKind::DisplayVersion
195        | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => EXIT_OK,
196        _ => EXIT_USAGE_OR_CONNECT,
197    }
198}
199
200async fn run(cli: Cli) -> Result<(), CliError> {
201    validate_before_connect(&cli.command)?;
202    let client = connect(&cli).await?;
203    match &cli.command {
204        Command::Ps { cluster } => {
205            if *cluster {
206                cluster_ps(&client, cli.addr, cli.json).await
207            } else {
208                ps(&client, cli.addr, cli.json).await
209            }
210        }
211        Command::Nodes => nodes(&client, cli.addr, cli.json).await,
212        Command::Status { cluster, job } => {
213            status(&client, cli.addr, job, *cluster, cli.json).await
214        }
215        Command::Start {
216            factory,
217            name,
218            params,
219        } => {
220            start(
221                &client,
222                cli.addr,
223                factory,
224                name.as_deref(),
225                params,
226                cli.json,
227            )
228            .await
229        }
230        Command::Submit {
231            cluster,
232            factory,
233            name,
234            params,
235            role,
236            node,
237        } => {
238            submit(
239                &client,
240                cli.addr,
241                SubmitArgs {
242                    cluster: *cluster,
243                    factory,
244                    name: name.as_deref(),
245                    raw_params: params,
246                    role: role.as_deref(),
247                    node: node.as_deref(),
248                },
249                cli.json,
250            )
251            .await
252        }
253        Command::Drain { cluster, job } => drain(&client, cli.addr, job, *cluster, cli.json).await,
254        Command::Stop { cluster, job } => stop(&client, cli.addr, job, *cluster, cli.json).await,
255        Command::Restart { job } => restart(&client, cli.addr, job, cli.json).await,
256        Command::Events { follow } => events(&client, cli.addr, *follow, cli.json).await,
257        Command::Metrics { job, follow } => {
258            metrics(&client, cli.addr, job, *follow, cli.json).await
259        }
260    }
261}
262
263fn validate_before_connect(command: &Command) -> Result<(), CliError> {
264    match command {
265        Command::Start { params, .. } => {
266            parse_params(params)?;
267        }
268        Command::Submit {
269            cluster,
270            params,
271            role,
272            node,
273            ..
274        } => {
275            if !cluster {
276                return Err(CliError::Usage(
277                    "`datum submit` currently requires --cluster; use `datum start` for local jobs."
278                        .to_owned(),
279                ));
280            }
281            if role.is_some() && node.is_some() {
282                return Err(CliError::Usage(
283                    "pass at most one of --role or --node for cluster placement.".to_owned(),
284                ));
285            }
286            parse_params(params)?;
287        }
288        _ => {}
289    }
290    Ok(())
291}
292
293async fn connect(cli: &Cli) -> Result<DcpClient, CliError> {
294    let hello = Hello::new(format!("datum-cli-{}", std::process::id()), ClientKind::Cli);
295    match tls_paths(cli)? {
296        Some(paths) => {
297            let config = load_quic_client_config(paths.ca, paths.cert, paths.key)?;
298            DcpClient::connect_quic(cli.addr, "localhost", config, hello)
299                .await
300                .map_err(|error| connect_error(cli.addr, error))
301        }
302        None => DcpClient::connect_tcp(cli.addr, hello)
303            .await
304            .map_err(|error| connect_error(cli.addr, error)),
305    }
306}
307
308struct TlsPaths<'a> {
309    ca: &'a Path,
310    cert: &'a Path,
311    key: &'a Path,
312}
313
314fn tls_paths(cli: &Cli) -> Result<Option<TlsPaths<'_>>, CliError> {
315    match (&cli.tls_ca, &cli.tls_cert, &cli.tls_key) {
316        (None, None, None) => Ok(None),
317        (Some(ca), Some(cert), Some(key)) => Ok(Some(TlsPaths {
318            ca: ca.as_path(),
319            cert: cert.as_path(),
320            key: key.as_path(),
321        })),
322        _ => Err(CliError::Usage(
323            "QUIC+mTLS mode needs --tls-ca, --tls-cert, and --tls-key; pass all three or omit all three for loopback TCP.".to_owned(),
324        )),
325    }
326}
327
328fn load_quic_client_config(
329    ca_path: &Path,
330    cert_path: &Path,
331    key_path: &Path,
332) -> Result<quinn::ClientConfig, CliError> {
333    let mut roots = RootCertStore::empty();
334    for cert in read_certificates(ca_path)? {
335        roots.add(cert).map_err(|error| {
336            CliError::Usage(format!(
337                "could not load --tls-ca {}: {error}; pass a PEM or DER CA certificate.",
338                ca_path.display()
339            ))
340        })?;
341    }
342    let certs = read_certificates(cert_path)?;
343    let key = read_private_key(key_path)?;
344    let rustls = RustlsClientConfig::builder()
345        .with_root_certificates(roots)
346        .with_client_auth_cert(certs, key)
347        .map_err(|error| {
348            CliError::Usage(format!(
349                "could not build QUIC client identity: {error}; check --tls-cert and --tls-key match."
350            ))
351        })?;
352    let quic = QuicClientConfig::try_from(rustls).map_err(|error| {
353        CliError::Usage(format!(
354            "could not build QUIC client config: {error}; check the TLS files."
355        ))
356    })?;
357    Ok(quinn::ClientConfig::new(Arc::new(quic)))
358}
359
360fn read_certificates(path: &Path) -> Result<Vec<CertificateDer<'static>>, CliError> {
361    let bytes = fs::read(path).map_err(|error| {
362        CliError::Usage(format!(
363            "could not read {}: {error}; pass a readable PEM or DER certificate file.",
364            path.display()
365        ))
366    })?;
367    let mut cursor = Cursor::new(bytes.as_slice());
368    let certs = rustls_pemfile::certs(&mut cursor)
369        .collect::<Result<Vec<_>, _>>()
370        .map_err(|error| {
371            CliError::Usage(format!(
372                "could not parse {}: {error}; pass a PEM or DER certificate file.",
373                path.display()
374            ))
375        })?;
376    if certs.is_empty() {
377        Ok(vec![CertificateDer::from(bytes)])
378    } else {
379        Ok(certs)
380    }
381}
382
383fn read_private_key(path: &Path) -> Result<PrivateKeyDer<'static>, CliError> {
384    let bytes = fs::read(path).map_err(|error| {
385        CliError::Usage(format!(
386            "could not read {}: {error}; pass a readable PEM or DER PKCS#8 private key.",
387            path.display()
388        ))
389    })?;
390    let mut cursor = Cursor::new(bytes.as_slice());
391    match rustls_pemfile::private_key(&mut cursor).map_err(|error| {
392        CliError::Usage(format!(
393            "could not parse {}: {error}; pass a PEM or DER PKCS#8 private key.",
394            path.display()
395        ))
396    })? {
397        Some(key) => Ok(key),
398        None => Ok(PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(bytes))),
399    }
400}
401
402fn connect_error(addr: SocketAddr, error: DcpError) -> CliError {
403    match error {
404        DcpError::Response { status, message } => remote_error(status, message),
405        other => CliError::Connect {
406            addr,
407            message: other.to_string(),
408        },
409    }
410}
411
412fn command_error(addr: SocketAddr, error: DcpError) -> CliError {
413    match error {
414        DcpError::Response { status, message } => remote_error(status, message),
415        other => CliError::Connect {
416            addr,
417            message: other.to_string(),
418        },
419    }
420}
421
422fn remote_error(status: ResponseStatus, message: String) -> CliError {
423    let fix = match status {
424        ResponseStatus::BadRequest => "Fix the command arguments and try again.",
425        ResponseStatus::Unauthorized => {
426            "Check the DCP token or mTLS client certificate configured for datum-agent."
427        }
428        ResponseStatus::NotFound => "Check the job or factory name with `datum ps`.",
429        ResponseStatus::Conflict => {
430            "Pick a different job name, or stop the existing job before starting it again."
431        }
432        ResponseStatus::ProtocolMismatch => {
433            "Upgrade the CLI and datum-agent to compatible versions."
434        }
435        ResponseStatus::DeadlineExceeded => {
436            "Retry the command or check whether datum-agent is overloaded."
437        }
438        ResponseStatus::Ok | ResponseStatus::Failed => {
439            "Check `datum status <job>` and the datum-agent logs, then retry."
440        }
441    };
442    CliError::Remote { message, fix }
443}
444
445async fn ps(client: &DcpClient, addr: SocketAddr, json: bool) -> Result<(), CliError> {
446    let mut jobs = client
447        .list_jobs()
448        .await
449        .map_err(|error| command_error(addr, error))?;
450    jobs.sort_by(|left, right| left.name.cmp(&right.name));
451    if json {
452        print_json(&JobsOutput {
453            jobs: jobs.iter().map(JobView::from).collect(),
454        })?;
455    } else if jobs.is_empty() {
456        println!("no jobs");
457    } else {
458        print!("{}", render_jobs_table(&jobs));
459    }
460    Ok(())
461}
462
463async fn cluster_ps(client: &DcpClient, addr: SocketAddr, json: bool) -> Result<(), CliError> {
464    let mut list = client
465        .list_cluster_jobs(1_000)
466        .await
467        .map_err(|error| command_error(addr, error))?;
468    sort_cluster_jobs(&mut list);
469    if json {
470        print_json(&ClusterJobsOutput::from(&list))?;
471    } else if list.nodes.iter().all(|node| node.jobs.is_empty()) && list.errors.is_empty() {
472        println!("no cluster jobs");
473    } else {
474        print!("{}", render_cluster_jobs_table(&list));
475        if !list.errors.is_empty() {
476            print!("{}", render_cluster_errors_table(&list.errors));
477        }
478    }
479    Ok(())
480}
481
482async fn nodes(client: &DcpClient, addr: SocketAddr, json: bool) -> Result<(), CliError> {
483    let mut list = client
484        .cluster_node_info(1_000)
485        .await
486        .map_err(|error| command_error(addr, error))?;
487    list.nodes
488        .sort_by(|left, right| left.node_id.cmp(&right.node_id));
489    list.errors
490        .sort_by(|left, right| left.node_id.cmp(&right.node_id));
491    let coordinator_node_id = cluster_coordinator_node_id(client).await;
492    if json {
493        print_json(&ClusterNodesOutput::new(&list, coordinator_node_id.clone()))?;
494    } else if list.nodes.is_empty() {
495        println!("no cluster nodes");
496    } else {
497        print!(
498            "{}",
499            render_nodes_table(&list.nodes, coordinator_node_id.as_deref())
500        );
501        if !list.errors.is_empty() {
502            print!("{}", render_cluster_errors_table(&list.errors));
503        }
504    }
505    Ok(())
506}
507
508/// Best-effort placement-coordinator lookup: `ClusterNodeList` carries only membership,
509/// so this reuses the `coordinator_node_id` already recorded on cluster jobs
510/// (`WireJobStatus::coordinator_node_id`). Returns `None` when no cluster job has run yet.
511async fn cluster_coordinator_node_id(client: &DcpClient) -> Option<String> {
512    let jobs = client.list_cluster_jobs(1_000).await.ok()?;
513    jobs.nodes
514        .iter()
515        .flat_map(|node| node.jobs.iter())
516        .map(|job| job.coordinator_node_id.as_str())
517        .find(|id| !id.is_empty())
518        .map(str::to_owned)
519}
520
521async fn status(
522    client: &DcpClient,
523    addr: SocketAddr,
524    job: &str,
525    cluster: bool,
526    json: bool,
527) -> Result<(), CliError> {
528    let status = if cluster {
529        client.cluster_job_status(job, 1_000).await
530    } else {
531        client.job_status(job).await
532    }
533    .map_err(|error| command_error(addr, error))?;
534    if json {
535        print_json(&JobOutput {
536            job: JobView::from(&status),
537        })?;
538    } else {
539        print!("{}", render_status_table(&status));
540    }
541    Ok(())
542}
543
544struct SubmitArgs<'a> {
545    cluster: bool,
546    factory: &'a str,
547    name: Option<&'a str>,
548    raw_params: &'a [String],
549    role: Option<&'a str>,
550    node: Option<&'a str>,
551}
552
553async fn submit(
554    client: &DcpClient,
555    addr: SocketAddr,
556    args: SubmitArgs<'_>,
557    json: bool,
558) -> Result<(), CliError> {
559    if !args.cluster {
560        return Err(CliError::Usage(
561            "`datum submit` currently requires --cluster; use `datum start` for local jobs."
562                .to_owned(),
563        ));
564    }
565    let params = parse_params(args.raw_params)?;
566    let instance_name = args.name.unwrap_or(args.factory);
567    let placement = placement_spec(args.role, args.node);
568    let status = client
569        .submit_cluster_job(args.factory, instance_name, params, placement, 1_000)
570        .await
571        .map_err(|error| command_error(addr, error))?;
572    if json {
573        print_json(&ActionOutput {
574            action: "submitted",
575            message: format!("submitted cluster job '{}'", status.name),
576            job: JobView::from(&status),
577        })?;
578    } else {
579        println!(
580            "submitted cluster job '{}' from factory '{}' to node '{}' — state {}, placement generation {}",
581            status.name,
582            args.factory,
583            empty_dash(&status.placement_node_id),
584            status.state,
585            status.placement_generation
586        );
587    }
588    Ok(())
589}
590
591fn placement_spec(role: Option<&str>, node: Option<&str>) -> PlacementSpec {
592    PlacementSpec {
593        role_constraint: role.unwrap_or_default().to_owned(),
594        strategy: if node.is_some() {
595            PlacementStrategy::Pinned as i32
596        } else {
597            PlacementStrategy::LeastJobs as i32
598        },
599        pinned_node_id: node.unwrap_or_default().to_owned(),
600    }
601}
602
603async fn start(
604    client: &DcpClient,
605    addr: SocketAddr,
606    factory: &str,
607    name: Option<&str>,
608    raw_params: &[String],
609    json: bool,
610) -> Result<(), CliError> {
611    let params = parse_params(raw_params)?;
612    let instance_name = name.unwrap_or(factory);
613    let status = client
614        .start_job(factory, instance_name, params)
615        .await
616        .map_err(|error| command_error(addr, error))?;
617    if json {
618        print_json(&ActionOutput {
619            action: "started",
620            message: format!("started '{}'", status.name),
621            job: JobView::from(&status),
622        })?;
623    } else {
624        println!(
625            "started '{}' from factory '{}' — state {}, generation {}",
626            status.name, factory, status.state, status.generation
627        );
628    }
629    Ok(())
630}
631
632async fn drain(
633    client: &DcpClient,
634    addr: SocketAddr,
635    job: &str,
636    cluster: bool,
637    json: bool,
638) -> Result<(), CliError> {
639    let initial = if cluster {
640        client.drain_cluster_job(job, 1_000).await
641    } else {
642        client.drain_job(job).await
643    }
644    .map_err(|error| command_error(addr, error))?;
645    let status = wait_for_drain(client, job, cluster, initial)
646        .await
647        .map_err(|error| command_error(addr, error))?;
648    if json {
649        print_json(&ActionOutput {
650            action: "drained",
651            message: format!("drained '{}'", status.name),
652            job: JobView::from(&status),
653        })?;
654    } else if status.state == "Drained" {
655        println!("drained '{}' — in-flight streams completed", status.name);
656    } else {
657        println!(
658            "drain requested for '{}' — current state {}; run `datum status {}` to follow it",
659            status.name, status.state, status.name
660        );
661    }
662    Ok(())
663}
664
665async fn wait_for_drain(
666    client: &DcpClient,
667    job: &str,
668    cluster: bool,
669    mut status: WireJobStatus,
670) -> Result<WireJobStatus, DcpError> {
671    let wait = status
672        .drain_remaining_ms
673        .map(|ms| Duration::from_millis(ms.saturating_add(1_000)))
674        .unwrap_or(Duration::from_secs(31))
675        .min(Duration::from_secs(60));
676    let started = tokio::time::Instant::now();
677    while !is_drain_terminal(&status) && started.elapsed() < wait {
678        tokio::time::sleep(DRAIN_POLL).await;
679        status = if cluster {
680            client.cluster_job_status(job, 1_000).await?
681        } else {
682            client.job_status(job).await?
683        };
684    }
685    Ok(status)
686}
687
688fn is_drain_terminal(status: &WireJobStatus) -> bool {
689    matches!(
690        status.state.as_str(),
691        "Completed" | "Drained" | "Stopped" | "Failed"
692    )
693}
694
695async fn stop(
696    client: &DcpClient,
697    addr: SocketAddr,
698    job: &str,
699    cluster: bool,
700    json: bool,
701) -> Result<(), CliError> {
702    let status = if cluster {
703        client.stop_cluster_job(job, 1_000).await
704    } else {
705        client.stop_job(job).await
706    }
707    .map_err(|error| command_error(addr, error))?;
708    if json {
709        print_json(&ActionOutput {
710            action: "stopped",
711            message: format!("stopped '{}'", status.name),
712            job: JobView::from(&status),
713        })?;
714    } else {
715        println!("stopped '{}' — state {}", status.name, status.state);
716    }
717    Ok(())
718}
719
720async fn restart(
721    client: &DcpClient,
722    addr: SocketAddr,
723    job: &str,
724    json: bool,
725) -> Result<(), CliError> {
726    let status = client
727        .restart_job(job)
728        .await
729        .map_err(|error| command_error(addr, error))?;
730    if json {
731        print_json(&ActionOutput {
732            action: "restarted",
733            message: format!("restarted '{}'", status.name),
734            job: JobView::from(&status),
735        })?;
736    } else {
737        println!(
738            "restarted '{}' — state {}, generation {}",
739            status.name, status.state, status.generation
740        );
741    }
742    Ok(())
743}
744
745async fn events(
746    client: &DcpClient,
747    addr: SocketAddr,
748    follow: bool,
749    json: bool,
750) -> Result<(), CliError> {
751    let mut subscription = client
752        .subscribe_events()
753        .await
754        .map_err(|error| command_error(addr, error))?;
755    if follow {
756        while let Some(event) = subscription.recv().await {
757            print_event(&event, json)?;
758        }
759        if let Some(reason) = subscription.disconnect_reason() {
760            return Err(CliError::Connect {
761                addr,
762                message: reason,
763            });
764        }
765        return Ok(());
766    }
767
768    let events = collect_events(&mut subscription).await;
769    if json {
770        print_json(&EventsOutput {
771            events: events.iter().map(EventView::from).collect(),
772        })?;
773    } else if events.is_empty() {
774        println!("no events observed — run `datum events --follow` to keep waiting");
775    } else {
776        print!("{}", render_events_table(&events));
777    }
778    Ok(())
779}
780
781async fn collect_events(subscription: &mut EventSubscription) -> Vec<Event> {
782    let mut events = Vec::new();
783    loop {
784        let timeout = if events.is_empty() {
785            EVENT_INITIAL_IDLE
786        } else {
787            EVENT_IDLE
788        };
789        match tokio::time::timeout(timeout, subscription.recv()).await {
790            Ok(Some(event)) => events.push(event),
791            Ok(None) | Err(_) => break,
792        }
793    }
794    events
795}
796
797fn print_event(event: &Event, json: bool) -> Result<(), CliError> {
798    if json {
799        print_json_line(&EventLine {
800            event: EventView::from(event),
801        })?;
802    } else {
803        println!(
804            "{} {} '{}' generation {}",
805            event.sequence, event.kind, event.name, event.generation
806        );
807        io::stdout().flush().map_err(io_usage_error)?;
808    }
809    Ok(())
810}
811
812async fn metrics(
813    client: &DcpClient,
814    addr: SocketAddr,
815    job: &str,
816    follow: bool,
817    json: bool,
818) -> Result<(), CliError> {
819    let mut subscription = client
820        .subscribe_metrics(0)
821        .await
822        .map_err(|error| command_error(addr, error))?;
823    if follow {
824        while let Some(sample) = subscription.recv().await {
825            let filtered = filter_metric_sample(job, &sample);
826            if filtered.streams.is_empty() {
827                continue;
828            }
829            print_metric_sample_follow(job, &filtered, json)?;
830        }
831        if let Some(reason) = subscription.disconnect_reason() {
832            return Err(CliError::Connect {
833                addr,
834                message: reason,
835            });
836        }
837        return Ok(());
838    }
839
840    let sample = wait_for_metric_sample(job, &mut subscription).await;
841    print_metric_sample(job, &sample, json)
842}
843
844async fn wait_for_metric_sample(
845    job: &str,
846    subscription: &mut MetricSubscription,
847) -> MetricSampleView {
848    let deadline = tokio::time::Instant::now() + METRIC_SNAPSHOT_WAIT;
849    let mut latest = MetricSampleView {
850        timestamp_ms: 0,
851        streams: Vec::new(),
852    };
853    while tokio::time::Instant::now() < deadline {
854        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
855        match tokio::time::timeout(
856            remaining.min(Duration::from_millis(100)),
857            subscription.recv(),
858        )
859        .await
860        {
861            Ok(Some(sample)) => {
862                latest = filter_metric_sample(job, &sample);
863                if !latest.streams.is_empty() {
864                    break;
865                }
866            }
867            Ok(None) => break,
868            Err(_) => {}
869        }
870    }
871    latest
872}
873
874fn print_metric_sample(job: &str, sample: &MetricSampleView, json: bool) -> Result<(), CliError> {
875    if json {
876        print_json(&MetricsOutput { job, sample })?;
877    } else if sample.streams.is_empty() {
878        println!("no metrics for '{job}' yet");
879    } else {
880        print!("{}", render_metrics_table(sample));
881    }
882    Ok(())
883}
884
885fn print_metric_sample_follow(
886    job: &str,
887    sample: &MetricSampleView,
888    json: bool,
889) -> Result<(), CliError> {
890    if json {
891        print_json_line(&MetricsOutput { job, sample })?;
892    } else {
893        print!("{}", render_metrics_table(sample));
894        io::stdout().flush().map_err(io_usage_error)?;
895    }
896    Ok(())
897}
898
899fn filter_metric_sample(job: &str, sample: &MetricSample) -> MetricSampleView {
900    let prefix = format!("{job}:");
901    MetricSampleView {
902        timestamp_ms: sample.timestamp_ms,
903        streams: sample
904            .streams
905            .iter()
906            .filter(|metric| metric.name == job || metric.name.starts_with(&prefix))
907            .map(StreamMetricView::from)
908            .collect(),
909    }
910}
911
912fn sort_cluster_jobs(list: &mut ClusterJobList) {
913    list.nodes
914        .sort_by(|left, right| left.node_id.cmp(&right.node_id));
915    for node in &mut list.nodes {
916        node.jobs.sort_by(|left, right| left.name.cmp(&right.name));
917    }
918    list.errors
919        .sort_by(|left, right| left.node_id.cmp(&right.node_id));
920}
921
922fn parse_params(raw_params: &[String]) -> Result<HashMap<String, String>, CliError> {
923    let mut params = HashMap::new();
924    let mut seen = HashSet::new();
925    for raw in raw_params {
926        let Some((key, value)) = raw.split_once('=') else {
927            return Err(CliError::Usage(format!(
928                "invalid --param '{raw}'; use --param key=value."
929            )));
930        };
931        if key.trim().is_empty() {
932            return Err(CliError::Usage(format!(
933                "invalid --param '{raw}'; the key before '=' cannot be empty."
934            )));
935        }
936        if !seen.insert(key.to_owned()) {
937            return Err(CliError::Usage(format!(
938                "duplicate --param key '{key}'; pass each key once."
939            )));
940        }
941        params.insert(key.to_owned(), value.to_owned());
942    }
943    Ok(params)
944}
945
946#[derive(Serialize)]
947struct JobsOutput {
948    jobs: Vec<JobView>,
949}
950
951#[derive(Serialize)]
952struct ClusterJobsOutput {
953    partial: bool,
954    nodes: Vec<ClusterJobNodeView>,
955    errors: Vec<ClusterNodeErrorView>,
956}
957
958impl From<&ClusterJobList> for ClusterJobsOutput {
959    fn from(list: &ClusterJobList) -> Self {
960        Self {
961            partial: list.partial,
962            nodes: list.nodes.iter().map(ClusterJobNodeView::from).collect(),
963            errors: list.errors.iter().map(ClusterNodeErrorView::from).collect(),
964        }
965    }
966}
967
968#[derive(Serialize)]
969struct ClusterNodesOutput {
970    partial: bool,
971    coordinator_node_id: Option<String>,
972    nodes: Vec<ClusterNodeView>,
973    errors: Vec<ClusterNodeErrorView>,
974}
975
976impl ClusterNodesOutput {
977    fn new(list: &ClusterNodeList, coordinator_node_id: Option<String>) -> Self {
978        Self {
979            partial: list.partial,
980            coordinator_node_id,
981            nodes: list.nodes.iter().map(ClusterNodeView::from).collect(),
982            errors: list.errors.iter().map(ClusterNodeErrorView::from).collect(),
983        }
984    }
985}
986
987#[derive(Serialize)]
988struct JobOutput {
989    job: JobView,
990}
991
992#[derive(Serialize)]
993struct ActionOutput {
994    action: &'static str,
995    message: String,
996    job: JobView,
997}
998
999#[derive(Serialize)]
1000struct EventsOutput {
1001    events: Vec<EventView>,
1002}
1003
1004#[derive(Serialize)]
1005struct EventLine {
1006    event: EventView,
1007}
1008
1009#[derive(Serialize)]
1010struct MetricsOutput<'a> {
1011    job: &'a str,
1012    sample: &'a MetricSampleView,
1013}
1014
1015#[derive(Serialize)]
1016struct JobView {
1017    name: String,
1018    job_id: u64,
1019    state: String,
1020    desired_state: String,
1021    generation: u64,
1022    starts_total: u64,
1023    restarts_total: u64,
1024    last_start_at_ms: Option<u64>,
1025    last_exit_at_ms: Option<u64>,
1026    last_exit_reason: String,
1027    backoff_remaining_ms: Option<u64>,
1028    drain_remaining_ms: Option<u64>,
1029    drain_supported: bool,
1030    active_streams: Option<u64>,
1031    cluster_job: bool,
1032    factory_name: String,
1033    placement_strategy: String,
1034    role_constraint: String,
1035    coordinator_node_id: String,
1036    placement_node_id: String,
1037    placement_generation: u64,
1038    placement_history: Vec<PlacementHistoryView>,
1039}
1040
1041impl From<&WireJobStatus> for JobView {
1042    fn from(status: &WireJobStatus) -> Self {
1043        Self {
1044            name: status.name.clone(),
1045            job_id: status.job_id,
1046            state: status.state.clone(),
1047            desired_state: status.desired_state.clone(),
1048            generation: status.generation,
1049            starts_total: status.starts_total,
1050            restarts_total: status.restarts_total,
1051            last_start_at_ms: status.last_start_at_ms,
1052            last_exit_at_ms: status.last_exit_at_ms,
1053            last_exit_reason: status.last_exit_reason.clone(),
1054            backoff_remaining_ms: status.backoff_remaining_ms,
1055            drain_remaining_ms: status.drain_remaining_ms,
1056            drain_supported: status.drain_supported,
1057            active_streams: status.active_streams,
1058            cluster_job: status.cluster_job,
1059            factory_name: status.factory_name.clone(),
1060            placement_strategy: placement_strategy_text(status),
1061            role_constraint: status
1062                .placement
1063                .as_ref()
1064                .map(|placement| placement.role_constraint.clone())
1065                .unwrap_or_default(),
1066            coordinator_node_id: status.coordinator_node_id.clone(),
1067            placement_node_id: status.placement_node_id.clone(),
1068            placement_generation: status.placement_generation,
1069            placement_history: status
1070                .placement_history
1071                .iter()
1072                .map(PlacementHistoryView::from)
1073                .collect(),
1074        }
1075    }
1076}
1077
1078#[derive(Serialize)]
1079struct PlacementHistoryView {
1080    generation: u64,
1081    from_node_id: String,
1082    to_node_id: String,
1083    reason: String,
1084    timestamp_ms: u64,
1085}
1086
1087impl From<&datum_agent::dcp::proto::ClusterPlacementHistory> for PlacementHistoryView {
1088    fn from(history: &datum_agent::dcp::proto::ClusterPlacementHistory) -> Self {
1089        Self {
1090            generation: history.generation,
1091            from_node_id: history.from_node_id.clone(),
1092            to_node_id: history.to_node_id.clone(),
1093            reason: history.reason.clone(),
1094            timestamp_ms: history.timestamp_ms,
1095        }
1096    }
1097}
1098
1099fn placement_strategy_text(status: &WireJobStatus) -> String {
1100    let Some(placement) = status.placement.as_ref() else {
1101        return String::new();
1102    };
1103    match PlacementStrategy::try_from(placement.strategy).unwrap_or(PlacementStrategy::LeastJobs) {
1104        PlacementStrategy::LeastJobs => "LeastJobs".to_owned(),
1105        PlacementStrategy::Pinned => format!("Pinned({})", placement.pinned_node_id),
1106    }
1107}
1108
1109#[derive(Serialize)]
1110struct ClusterJobNodeView {
1111    node_id: String,
1112    address: String,
1113    local: bool,
1114    jobs: Vec<JobView>,
1115}
1116
1117impl From<&ClusterJobNode> for ClusterJobNodeView {
1118    fn from(node: &ClusterJobNode) -> Self {
1119        Self {
1120            node_id: node.node_id.clone(),
1121            address: node.address.clone(),
1122            local: node.local,
1123            jobs: node.jobs.iter().map(JobView::from).collect(),
1124        }
1125    }
1126}
1127
1128#[derive(Serialize)]
1129struct ClusterNodeView {
1130    node_id: String,
1131    member_state: String,
1132    address: String,
1133    agent_addr: String,
1134    roles: Vec<String>,
1135    unreachable: bool,
1136    local: bool,
1137    session_state: String,
1138}
1139
1140impl From<&ClusterNodeStatus> for ClusterNodeView {
1141    fn from(node: &ClusterNodeStatus) -> Self {
1142        Self {
1143            node_id: node.node_id.clone(),
1144            member_state: node.member_state.clone(),
1145            address: node.address.clone(),
1146            agent_addr: node.agent_addr.clone(),
1147            roles: node.roles.clone(),
1148            unreachable: node.unreachable,
1149            local: node.local,
1150            session_state: node.session_state.clone(),
1151        }
1152    }
1153}
1154
1155#[derive(Serialize)]
1156struct ClusterNodeErrorView {
1157    node_id: String,
1158    message: String,
1159}
1160
1161impl From<&ClusterNodeError> for ClusterNodeErrorView {
1162    fn from(error: &ClusterNodeError) -> Self {
1163        Self {
1164            node_id: error.node_id.clone(),
1165            message: error.message.clone(),
1166        }
1167    }
1168}
1169
1170#[derive(Serialize)]
1171struct EventView {
1172    sequence: u64,
1173    timestamp_ms: u64,
1174    name: String,
1175    job_id: u64,
1176    generation: u64,
1177    kind: String,
1178    detail: String,
1179}
1180
1181impl From<&Event> for EventView {
1182    fn from(event: &Event) -> Self {
1183        Self {
1184            sequence: event.sequence,
1185            timestamp_ms: event.timestamp_ms,
1186            name: event.name.clone(),
1187            job_id: event.job_id,
1188            generation: event.generation,
1189            kind: event.kind.clone(),
1190            detail: event.detail.clone(),
1191        }
1192    }
1193}
1194
1195#[derive(Serialize)]
1196struct MetricSampleView {
1197    timestamp_ms: u64,
1198    streams: Vec<StreamMetricView>,
1199}
1200
1201#[derive(Serialize)]
1202struct StreamMetricView {
1203    id: u64,
1204    name: String,
1205    elements_through: u64,
1206    restarts: u64,
1207    state: String,
1208    started_at_ms: u64,
1209    state_changed_at_ms: u64,
1210    finished_at_ms: Option<u64>,
1211    uptime_ms: u64,
1212}
1213
1214impl From<&StreamMetric> for StreamMetricView {
1215    fn from(metric: &StreamMetric) -> Self {
1216        Self {
1217            id: metric.id,
1218            name: metric.name.clone(),
1219            elements_through: metric.elements_through,
1220            restarts: metric.restarts,
1221            state: metric.state.clone(),
1222            started_at_ms: metric.started_at_ms,
1223            state_changed_at_ms: metric.state_changed_at_ms,
1224            finished_at_ms: metric.finished_at_ms,
1225            uptime_ms: metric.uptime_ms,
1226        }
1227    }
1228}
1229
1230fn print_json<T: Serialize>(value: &T) -> Result<(), CliError> {
1231    let json = serde_json::to_string_pretty(value).map_err(|error| {
1232        CliError::Usage(format!(
1233            "could not encode JSON output: {error}; retry without --json."
1234        ))
1235    })?;
1236    println!("{json}");
1237    Ok(())
1238}
1239
1240fn print_json_line<T: Serialize>(value: &T) -> Result<(), CliError> {
1241    let json = serde_json::to_string(value).map_err(|error| {
1242        CliError::Usage(format!(
1243            "could not encode JSON output: {error}; retry without --json."
1244        ))
1245    })?;
1246    println!("{json}");
1247    io::stdout().flush().map_err(io_usage_error)?;
1248    Ok(())
1249}
1250
1251fn io_usage_error(error: io::Error) -> CliError {
1252    CliError::Usage(format!(
1253        "could not write CLI output: {error}; check stdout/stderr."
1254    ))
1255}
1256
1257fn render_jobs_table(jobs: &[WireJobStatus]) -> String {
1258    let rows = jobs
1259        .iter()
1260        .map(|job| {
1261            vec![
1262                job.name.clone(),
1263                job.job_id.to_string(),
1264                job.state.clone(),
1265                job.desired_state.clone(),
1266                job.generation.to_string(),
1267                format_number(job.starts_total),
1268                format_number(job.restarts_total),
1269                format_optional_number(job.active_streams),
1270            ]
1271        })
1272        .collect::<Vec<_>>();
1273    render_table(
1274        &[
1275            "JOB", "ID", "STATE", "DESIRED", "GEN", "STARTS", "RESTARTS", "ACTIVE",
1276        ],
1277        &rows,
1278    )
1279}
1280
1281fn render_cluster_jobs_table(list: &ClusterJobList) -> String {
1282    let rows = list
1283        .nodes
1284        .iter()
1285        .flat_map(|node| {
1286            if node.jobs.is_empty() {
1287                vec![vec![
1288                    node.node_id.clone(),
1289                    node.address.clone(),
1290                    "-".to_owned(),
1291                    "-".to_owned(),
1292                    "-".to_owned(),
1293                    "-".to_owned(),
1294                    "-".to_owned(),
1295                    "-".to_owned(),
1296                    "-".to_owned(),
1297                    "-".to_owned(),
1298                    "-".to_owned(),
1299                    "-".to_owned(),
1300                ]]
1301            } else {
1302                node.jobs
1303                    .iter()
1304                    .map(|job| {
1305                        vec![
1306                            node.node_id.clone(),
1307                            node.address.clone(),
1308                            job.name.clone(),
1309                            job.job_id.to_string(),
1310                            job.state.clone(),
1311                            job.desired_state.clone(),
1312                            job.generation.to_string(),
1313                            format_number(job.starts_total),
1314                            format_number(job.restarts_total),
1315                            cluster_marker(job),
1316                            empty_dash(&job.placement_node_id).to_owned(),
1317                            placement_generation_text(job),
1318                        ]
1319                    })
1320                    .collect::<Vec<_>>()
1321            }
1322        })
1323        .collect::<Vec<_>>();
1324    render_table(
1325        &[
1326            "NODE",
1327            "ADDRESS",
1328            "JOB",
1329            "ID",
1330            "STATE",
1331            "DESIRED",
1332            "GEN",
1333            "STARTS",
1334            "RESTARTS",
1335            "CLUSTER",
1336            "PLACED_ON",
1337            "PGEN",
1338        ],
1339        &rows,
1340    )
1341}
1342
1343fn render_nodes_table(nodes: &[ClusterNodeStatus], coordinator_node_id: Option<&str>) -> String {
1344    let rows = nodes
1345        .iter()
1346        .map(|node| {
1347            vec![
1348                node.node_id.clone(),
1349                node.member_state.clone(),
1350                coordinator_marker(&node.node_id, coordinator_node_id).to_owned(),
1351                node.unreachable.to_string(),
1352                node.session_state.clone(),
1353                node.agent_addr.clone(),
1354                node.roles.join(","),
1355            ]
1356        })
1357        .collect::<Vec<_>>();
1358    render_table(
1359        &[
1360            "NODE",
1361            "MEMBER",
1362            "COORD",
1363            "UNREACHABLE",
1364            "SESSION",
1365            "AGENT_ADDR",
1366            "ROLES",
1367        ],
1368        &rows,
1369    )
1370}
1371
1372fn coordinator_marker(node_id: &str, coordinator_node_id: Option<&str>) -> &'static str {
1373    if coordinator_node_id.is_some_and(|coordinator| coordinator == node_id) {
1374        "*"
1375    } else {
1376        "-"
1377    }
1378}
1379
1380fn render_cluster_errors_table(errors: &[ClusterNodeError]) -> String {
1381    let rows = errors
1382        .iter()
1383        .map(|error| vec![error.node_id.clone(), error.message.clone()])
1384        .collect::<Vec<_>>();
1385    render_table(&["NODE", "ERROR"], &rows)
1386}
1387
1388fn cluster_marker(job: &WireJobStatus) -> String {
1389    if job.cluster_job {
1390        "yes".to_owned()
1391    } else {
1392        "-".to_owned()
1393    }
1394}
1395
1396fn placement_generation_text(job: &WireJobStatus) -> String {
1397    if job.cluster_job {
1398        job.placement_generation.to_string()
1399    } else {
1400        "-".to_owned()
1401    }
1402}
1403
1404fn render_status_table(status: &WireJobStatus) -> String {
1405    let rows = vec![
1406        vec!["job".to_owned(), status.name.clone()],
1407        vec!["id".to_owned(), status.job_id.to_string()],
1408        vec!["state".to_owned(), status.state.clone()],
1409        vec!["desired".to_owned(), status.desired_state.clone()],
1410        vec!["generation".to_owned(), status.generation.to_string()],
1411        vec!["starts".to_owned(), format_number(status.starts_total)],
1412        vec!["restarts".to_owned(), format_number(status.restarts_total)],
1413        vec![
1414            "last_start_ms".to_owned(),
1415            format_optional_number(status.last_start_at_ms),
1416        ],
1417        vec![
1418            "last_exit_ms".to_owned(),
1419            format_optional_number(status.last_exit_at_ms),
1420        ],
1421        vec![
1422            "last_exit_reason".to_owned(),
1423            empty_dash(&status.last_exit_reason).to_owned(),
1424        ],
1425        vec![
1426            "backoff_remaining_ms".to_owned(),
1427            format_optional_number(status.backoff_remaining_ms),
1428        ],
1429        vec![
1430            "drain_remaining_ms".to_owned(),
1431            format_optional_number(status.drain_remaining_ms),
1432        ],
1433        vec![
1434            "drain_supported".to_owned(),
1435            status.drain_supported.to_string(),
1436        ],
1437        vec![
1438            "active_streams".to_owned(),
1439            format_optional_number(status.active_streams),
1440        ],
1441        vec!["cluster_job".to_owned(), status.cluster_job.to_string()],
1442        vec![
1443            "factory".to_owned(),
1444            empty_dash(&status.factory_name).to_owned(),
1445        ],
1446        vec![
1447            "placement_strategy".to_owned(),
1448            empty_dash(&placement_strategy_text(status)).to_owned(),
1449        ],
1450        vec![
1451            "role_constraint".to_owned(),
1452            status
1453                .placement
1454                .as_ref()
1455                .map(|placement| empty_dash(&placement.role_constraint).to_owned())
1456                .unwrap_or_else(|| "-".to_owned()),
1457        ],
1458        vec![
1459            "coordinator".to_owned(),
1460            empty_dash(&status.coordinator_node_id).to_owned(),
1461        ],
1462        vec![
1463            "placed_on".to_owned(),
1464            empty_dash(&status.placement_node_id).to_owned(),
1465        ],
1466        vec![
1467            "placement_generation".to_owned(),
1468            placement_generation_text(status),
1469        ],
1470    ];
1471    render_table(&["FIELD", "VALUE"], &rows)
1472}
1473
1474fn render_events_table(events: &[Event]) -> String {
1475    let rows = events
1476        .iter()
1477        .map(|event| {
1478            vec![
1479                event.sequence.to_string(),
1480                event.timestamp_ms.to_string(),
1481                event.name.clone(),
1482                event.job_id.to_string(),
1483                event.generation.to_string(),
1484                event.kind.clone(),
1485                empty_dash(&event.detail).to_owned(),
1486            ]
1487        })
1488        .collect::<Vec<_>>();
1489    render_table(
1490        &["SEQ", "TIME_MS", "JOB", "ID", "GEN", "KIND", "DETAIL"],
1491        &rows,
1492    )
1493}
1494
1495fn render_metrics_table(sample: &MetricSampleView) -> String {
1496    let rows = sample
1497        .streams
1498        .iter()
1499        .map(|metric| {
1500            vec![
1501                sample.timestamp_ms.to_string(),
1502                metric.name.clone(),
1503                metric.id.to_string(),
1504                metric.state.clone(),
1505                format_number(metric.elements_through),
1506                format_number(metric.restarts),
1507                metric.uptime_ms.to_string(),
1508            ]
1509        })
1510        .collect::<Vec<_>>();
1511    render_table(
1512        &[
1513            "TIME_MS",
1514            "STREAM",
1515            "ID",
1516            "STATE",
1517            "ELEMENTS",
1518            "RESTARTS",
1519            "UPTIME_MS",
1520        ],
1521        &rows,
1522    )
1523}
1524
1525/// Render a simple left-aligned ASCII table with stable column widths.
1526#[must_use]
1527pub fn render_table(headers: &[&str], rows: &[Vec<String>]) -> String {
1528    let mut widths = headers
1529        .iter()
1530        .map(|header| header.len())
1531        .collect::<Vec<_>>();
1532    for row in rows {
1533        for (index, cell) in row.iter().enumerate().take(widths.len()) {
1534            widths[index] = widths[index].max(cell.len());
1535        }
1536    }
1537
1538    let mut output = String::new();
1539    push_table_row(
1540        &mut output,
1541        &headers
1542            .iter()
1543            .map(|cell| (*cell).to_owned())
1544            .collect::<Vec<_>>(),
1545        &widths,
1546    );
1547    push_separator(&mut output, &widths);
1548    for row in rows {
1549        push_table_row(&mut output, row, &widths);
1550    }
1551    output
1552}
1553
1554fn push_table_row(output: &mut String, row: &[String], widths: &[usize]) {
1555    for (index, width) in widths.iter().enumerate() {
1556        if index > 0 {
1557            output.push_str("  ");
1558        }
1559        let cell = row.get(index).map(String::as_str).unwrap_or("");
1560        output.push_str(cell);
1561        for _ in cell.len()..*width {
1562            output.push(' ');
1563        }
1564    }
1565    output.push('\n');
1566}
1567
1568fn push_separator(output: &mut String, widths: &[usize]) {
1569    for (index, width) in widths.iter().enumerate() {
1570        if index > 0 {
1571            output.push_str("  ");
1572        }
1573        for _ in 0..*width {
1574            output.push('-');
1575        }
1576    }
1577    output.push('\n');
1578}
1579
1580fn empty_dash(value: &str) -> &str {
1581    if value.is_empty() { "-" } else { value }
1582}
1583
1584fn format_optional_number(value: Option<u64>) -> String {
1585    value.map(format_number).unwrap_or_else(|| "-".to_owned())
1586}
1587
1588fn format_number(value: u64) -> String {
1589    let raw = value.to_string();
1590    let mut output = String::with_capacity(raw.len() + raw.len() / 3);
1591    for (index, ch) in raw.chars().rev().enumerate() {
1592        if index > 0 && index % 3 == 0 {
1593            output.push(',');
1594        }
1595        output.push(ch);
1596    }
1597    output.chars().rev().collect()
1598}
1599
1600#[cfg(test)]
1601mod tests {
1602    use super::*;
1603
1604    #[test]
1605    fn table_renderer_pads_columns_stably() {
1606        let output = render_table(
1607            &["JOB", "STATE", "STARTS"],
1608            &[
1609                vec!["ingest".to_owned(), "Running".to_owned(), "1".to_owned()],
1610                vec![
1611                    "daily-rollup".to_owned(),
1612                    "Drained".to_owned(),
1613                    "12,003".to_owned(),
1614                ],
1615            ],
1616        );
1617
1618        assert_eq!(
1619            output,
1620            "JOB           STATE    STARTS\n\
1621             ------------  -------  ------\n\
1622             ingest        Running  1     \n\
1623             daily-rollup  Drained  12,003\n"
1624        );
1625    }
1626
1627    #[test]
1628    fn params_require_key_value_pairs() {
1629        assert!(parse_params(&["threads=4".to_owned()]).is_ok());
1630        assert!(matches!(
1631            parse_params(&["threads".to_owned()]),
1632            Err(CliError::Usage(_))
1633        ));
1634        assert!(matches!(
1635            parse_params(&["threads=4".to_owned(), "threads=8".to_owned()]),
1636            Err(CliError::Usage(_))
1637        ));
1638    }
1639
1640    #[test]
1641    fn numbers_use_operator_grouping() {
1642        assert_eq!(format_number(0), "0");
1643        assert_eq!(format_number(12_003), "12,003");
1644        assert_eq!(format_number(9_876_543_210), "9,876,543,210");
1645    }
1646
1647    #[test]
1648    fn help_and_version_exit_zero() {
1649        let help_err = Cli::try_parse_from(["datum", "--help"]).unwrap_err();
1650        assert_eq!(clap_error_exit_code(&help_err), EXIT_OK);
1651
1652        let subcommand_help_err = Cli::try_parse_from(["datum", "nodes", "--help"]).unwrap_err();
1653        assert_eq!(clap_error_exit_code(&subcommand_help_err), EXIT_OK);
1654
1655        let version_err = Cli::try_parse_from(["datum", "--version"]).unwrap_err();
1656        assert_eq!(clap_error_exit_code(&version_err), EXIT_OK);
1657    }
1658
1659    #[test]
1660    fn unknown_flag_exits_nonzero() {
1661        let error = Cli::try_parse_from(["datum", "--not-a-real-flag"]).unwrap_err();
1662        assert_ne!(clap_error_exit_code(&error), EXIT_OK);
1663
1664        let missing_job = Cli::try_parse_from(["datum", "status"]).unwrap_err();
1665        assert_ne!(clap_error_exit_code(&missing_job), EXIT_OK);
1666    }
1667
1668    fn cluster_node_status(node_id: &str) -> ClusterNodeStatus {
1669        ClusterNodeStatus {
1670            node_id: node_id.to_owned(),
1671            member_state: "Up".to_owned(),
1672            address: "127.0.0.1:9555".to_owned(),
1673            agent_addr: "127.0.0.1:9556".to_owned(),
1674            roles: vec!["worker".to_owned()],
1675            unreachable: false,
1676            local: false,
1677            session_state: "connected".to_owned(),
1678        }
1679    }
1680
1681    #[test]
1682    fn coordinator_marker_flags_only_the_matching_node() {
1683        assert_eq!(coordinator_marker("node-a", Some("node-a")), "*");
1684        assert_eq!(coordinator_marker("node-a", Some("node-b")), "-");
1685        assert_eq!(coordinator_marker("node-a", None), "-");
1686    }
1687
1688    #[test]
1689    fn nodes_table_marks_the_coordinator_row() {
1690        let nodes = vec![cluster_node_status("node-a"), cluster_node_status("node-b")];
1691        let output = render_nodes_table(&nodes, Some("node-b"));
1692
1693        assert!(output.contains("COORD"));
1694        let lines: Vec<&str> = output.lines().collect();
1695        let node_a_row = lines[2];
1696        let node_b_row = lines[3];
1697        assert!(!node_a_row.split_whitespace().any(|token| token == "*"));
1698        assert!(node_b_row.split_whitespace().any(|token| token == "*"));
1699    }
1700
1701    #[test]
1702    fn cluster_nodes_output_json_carries_coordinator_node_id() {
1703        let list = ClusterNodeList {
1704            nodes: vec![cluster_node_status("node-a")],
1705            errors: Vec::new(),
1706            partial: false,
1707        };
1708        let output = ClusterNodesOutput::new(&list, Some("node-a".to_owned()));
1709
1710        let value = serde_json::to_value(&output).expect("serializes");
1711        assert_eq!(value["coordinator_node_id"], "node-a");
1712        assert_eq!(value["nodes"][0]["node_id"], "node-a");
1713    }
1714}