Skip to main content

maincopy_cli/
startup.rs

1//! CLI process startup, command execution, and output handling.
2
3mod agents;
4mod nostr_login;
5mod profile;
6mod terminal;
7mod users;
8use profile::ProfileOutput;
9use terminal::prompt_secret;
10
11use std::{
12    collections::HashSet,
13    fs::OpenOptions,
14    future::Future,
15    io::{self, Write as _},
16    path::{Path, PathBuf},
17    process::ExitCode,
18    time::Duration,
19};
20
21use clap::Parser;
22use maincopy_shared::{
23    AdminApiVersion, Capabilities, CapabilityContractVersion,
24    auth_api::AdminSessionResponse,
25    posts::{ListPostsResponse, PostPublicationState, PostSummary},
26    publication::{
27        ChangeReleaseRequest, ListReleasesResponse, PreviewDigest, PublicationApprovalState,
28        PublishNowRequest, PublishNowResponse, ReleaseOperationResource, ReleaseResource,
29        ReleaseState,
30    },
31    source::{
32        BeginSourceSyncResponse, SourceDeployKeyResponse, SourceStatusResponse,
33        SourceSyncAdmission, SourceSyncFailureCode, SourceSyncId, SourceSyncOutcome,
34        SourceSyncResource,
35    },
36};
37use serde::Serialize;
38use serde_json::json;
39use thiserror::Error;
40use time::OffsetDateTime;
41use uuid::Uuid;
42
43use crate::{
44    client::{AdminClient, AdminClientError, AdminProblem, LogoutOutcome, PostPreview},
45    models::{
46        AgentKeyCommand, Arguments, Command, ReleaseCommand, ReleaseTarget, SourceCommand,
47        SourceConfigurationArguments, SourceSyncDisposition, SourceSyncInvocation,
48    },
49    nip98::AgentPublicIdentity,
50    transport::AdditionalRootCertificateError,
51};
52
53const SUCCESS: u8 = 0;
54const VALIDATION: u8 = 65;
55const UNAVAILABLE: u8 = 69;
56const INTERNAL: u8 = 70;
57const CONFLICT: u8 = 75;
58const PERMISSION: u8 = 77;
59const POSTS_PAGE_LIMIT: u16 = 100;
60const MAX_POSTS_PAGES: usize = 10_001;
61const POST_REVISION_PREFIX: &str = "post-b3-v1-";
62const CONTENT_DIGEST_PREFIX: &str = "content-b3-v1-";
63const SOURCE_SYNC_POLL_INTERVAL: Duration = Duration::from_secs(1);
64const SOURCE_SYNC_WAIT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
65const MAX_SOURCE_SYNC_POLLS: usize = 600;
66
67enum CommandOutput {
68    Agents(agents::AgentOutput),
69    Users(users::UserOutput),
70    Profile(ProfileOutput),
71    Login(AdminSessionResponse),
72    Logout(LogoutOutcome),
73    AgentKeyIdentity(Option<AgentPublicIdentity>),
74    AgentKeyRemoved,
75    Capabilities(Capabilities),
76    Posts(ListPostsResponse),
77    Releases(ListReleasesResponse),
78    Release(ReleaseResource),
79    ReleaseOperation(ReleaseOperationResource),
80    SourceStatus(Box<SourceStatusResponse>),
81    SourceDeployKey(SourceDeployKeyResponse),
82    SourceSync {
83        idempotency_key: Uuid,
84        admission: SourceSyncAdmission,
85        sync: SourceSyncResource,
86    },
87    Preview {
88        post_id: Uuid,
89        output: PathBuf,
90        preview: PostPreview,
91    },
92    Publication {
93        idempotency_key: Uuid,
94        response: PublishNowResponse,
95    },
96}
97
98struct PreviewSelection {
99    post_id: Uuid,
100    output: PathBuf,
101    revision: Option<String>,
102    content_digest: Option<String>,
103}
104
105impl PreviewSelection {
106    fn new(
107        post_id: Uuid,
108        output: PathBuf,
109        revision: Option<String>,
110        content_digest: Option<String>,
111    ) -> Result<Self, CliError> {
112        validate_optional_digest(revision.as_deref(), POST_REVISION_PREFIX, "revision")?;
113        validate_optional_digest(
114            content_digest.as_deref(),
115            CONTENT_DIGEST_PREFIX,
116            "content_digest",
117        )?;
118        Ok(Self {
119            post_id,
120            output,
121            revision,
122            content_digest,
123        })
124    }
125
126    fn accept(self, preview: PostPreview) -> Result<CommandOutput, CliError> {
127        if let Some(expected) = self.revision
128            && expected.as_str() != preview.revision.as_ref()
129        {
130            return Err(CliError::PreviewRevisionMismatch {
131                expected: expected.into_boxed_str(),
132                actual: preview.revision.clone(),
133            });
134        }
135        if let Some(expected) = self.content_digest
136            && expected.as_str() != preview.content_digest.as_ref()
137        {
138            return Err(CliError::PreviewContentDigestMismatch {
139                expected: expected.into_boxed_str(),
140                actual: preview.content_digest.clone(),
141            });
142        }
143        write_preview_file(&self.output, &preview.html)?;
144        Ok(CommandOutput::Preview {
145            post_id: self.post_id,
146            output: self.output,
147            preview,
148        })
149    }
150}
151
152#[derive(Debug, Error)]
153enum CliError {
154    #[error("agent operation {idempotency_key} failed: {source}")]
155    AgentChange {
156        idempotency_key: Uuid,
157        #[source]
158        source: AdminClientError,
159    },
160    #[error("account operation {idempotency_key} failed: {source}")]
161    UserChange {
162        user_id: Option<Uuid>,
163        idempotency_key: Uuid,
164        #[source]
165        source: AdminClientError,
166    },
167    #[error("account operation {idempotency_key}: {source}")]
168    AccountInput {
169        user_id: Option<Uuid>,
170        idempotency_key: Uuid,
171        #[source]
172        source: users::CredentialInputError,
173    },
174    #[error("profile or recipient operation {idempotency_key} failed: {source}")]
175    ProfileChange {
176        idempotency_key: Uuid,
177        #[source]
178        source: AdminClientError,
179    },
180    #[error(transparent)]
181    Admin(#[from] AdminClientError),
182
183    #[error("failed to read a secret from the protected terminal: {0}")]
184    SecretInput(#[source] io::Error),
185
186    #[error(
187        "the loaded-post snapshot changed during pagination from content {expected_content_digest}, site {expected_site_digest} (version {expected_site_version}) to content {actual_content_digest}, site {actual_site_digest} (version {actual_site_version}); retry the command"
188    )]
189    PostsSnapshotChanged {
190        expected_content_digest: Box<str>,
191        expected_site_digest: Box<str>,
192        expected_site_version: u64,
193        actual_content_digest: Box<str>,
194        actual_site_digest: Box<str>,
195        actual_site_version: u64,
196    },
197
198    #[error("the admin server returned invalid loaded-post pagination: {message}")]
199    InvalidPostsPagination { message: &'static str },
200
201    #[error("the admin server returned inconsistent publication approval state: {message}")]
202    InvalidPublicationResponse { message: &'static str },
203
204    #[error("{field} must be {prefix} followed by 64 lowercase hexadecimal characters")]
205    InvalidPreviewSelector {
206        field: &'static str,
207        prefix: &'static str,
208    },
209
210    #[error(
211        "the preview revision {actual} does not match requested revision {expected}; no file was created"
212    )]
213    PreviewRevisionMismatch {
214        expected: Box<str>,
215        actual: Box<str>,
216    },
217
218    #[error(
219        "the preview content digest {actual} does not match requested content digest {expected}; no file was created"
220    )]
221    PreviewContentDigestMismatch {
222        expected: Box<str>,
223        actual: Box<str>,
224    },
225
226    #[error("preview output {path:?} already exists; refusing to overwrite it")]
227    PreviewOutputExists { path: PathBuf },
228
229    #[error("failed to create or write preview output {path:?}: {source}")]
230    PreviewOutput {
231        path: PathBuf,
232        #[source]
233        source: io::Error,
234    },
235
236    #[error("publication command {idempotency_key} failed: {source}")]
237    Publication {
238        idempotency_key: Uuid,
239        #[source]
240        source: AdminClientError,
241    },
242
243    #[error("release {publication_id} operation {operation_id} failed: {source}")]
244    ReleaseChange {
245        publication_id: Uuid,
246        operation_id: Uuid,
247        #[source]
248        source: AdminClientError,
249    },
250
251    #[error("source synchronization command {idempotency_key} could not be started: {source}")]
252    SourceSyncStart {
253        idempotency_key: Uuid,
254        #[source]
255        source: AdminClientError,
256    },
257
258    #[error(
259        "source synchronization {source_sync_id} (command {idempotency_key}) could not be followed: {source}"
260    )]
261    SourceSyncFollow {
262        idempotency_key: Uuid,
263        source_sync_id: SourceSyncId,
264        #[source]
265        source: AdminClientError,
266    },
267
268    #[error(
269        "source synchronization {source_sync_id} (command {idempotency_key}) did not finish within the client wait limit"
270    )]
271    SourceSyncTimedOut {
272        idempotency_key: Uuid,
273        source_sync_id: SourceSyncId,
274    },
275
276    #[error(
277        "source synchronization {source_sync_id} (command {idempotency_key}) finished with {outcome}"
278    )]
279    SourceSyncTerminalFailure {
280        idempotency_key: Uuid,
281        source_sync_id: SourceSyncId,
282        outcome: &'static str,
283        failure_code: Option<SourceSyncFailureCode>,
284    },
285
286    #[error(
287        "the admin server returned inconsistent source synchronization {source_sync_id} (command {idempotency_key}): {message}"
288    )]
289    InvalidSourceSyncResponse {
290        idempotency_key: Uuid,
291        source_sync_id: SourceSyncId,
292        message: &'static str,
293    },
294
295    #[error("failed to write command output: {0}")]
296    Output(#[from] io::Error),
297
298    #[error("failed to encode command output: {0}")]
299    Encode(#[from] serde_json::Error),
300}
301
302pub async fn run() -> ExitCode {
303    let arguments = Arguments::parse();
304    let json = arguments.json;
305    let result = execute(arguments)
306        .await
307        .and_then(|output| write_output(std::io::stdout().lock(), output, json));
308
309    match result {
310        Ok(()) => ExitCode::from(SUCCESS),
311        Err(error) => {
312            let exit = error_exit(&error);
313            if report_error(&error, exit, json).is_err() {
314                return ExitCode::from(INTERNAL);
315            }
316            ExitCode::from(exit)
317        }
318    }
319}
320
321async fn execute_releases(
322    client: &AdminClient,
323    command: ReleaseCommand,
324) -> Result<CommandOutput, CliError> {
325    match command {
326        ReleaseCommand::List { cursor } => client
327            .releases(cursor)
328            .await
329            .map(CommandOutput::Releases)
330            .map_err(CliError::from),
331        ReleaseCommand::Inspect { publication_id } => client
332            .release(publication_id)
333            .await
334            .map(CommandOutput::Release)
335            .map_err(CliError::from),
336        ReleaseCommand::Operation { operation_id } => client
337            .release_operation(operation_id)
338            .await
339            .map(CommandOutput::ReleaseOperation)
340            .map_err(CliError::from),
341        ReleaseCommand::Reschedule { target, at } => {
342            let request = ChangeReleaseRequest::Reschedule {
343                expected_version: target.expected_version,
344                scheduled_for: at,
345            };
346            change_release(client, target, request).await
347        }
348        ReleaseCommand::Cancel(target) => {
349            let request = ChangeReleaseRequest::Cancel {
350                expected_version: target.expected_version,
351            };
352            change_release(client, target, request).await
353        }
354        ReleaseCommand::Retry(target) => {
355            let request = ChangeReleaseRequest::Retry {
356                expected_version: target.expected_version,
357            };
358            change_release(client, target, request).await
359        }
360    }
361}
362
363async fn change_release(
364    client: &AdminClient,
365    target: ReleaseTarget,
366    request: ChangeReleaseRequest,
367) -> Result<CommandOutput, CliError> {
368    let publication_id = target.publication_id;
369    let operation_id = target.idempotency_key.unwrap_or_else(Uuid::new_v4);
370    client
371        .change_release(publication_id, operation_id, &request)
372        .await
373        .map(CommandOutput::ReleaseOperation)
374        .map_err(|source| CliError::ReleaseChange {
375            publication_id,
376            operation_id,
377            source,
378        })
379}
380
381async fn execute(arguments: Arguments) -> Result<CommandOutput, CliError> {
382    let client = AdminClient::new(
383        &arguments.admin_origin,
384        arguments.auth_context,
385        arguments.admin_ca_file.as_deref(),
386    )?;
387    match arguments.command {
388        Command::Agents { command } => agents::execute(
389            command,
390            |cursor| client.list_agents(cursor),
391            |id| client.inspect_agent(id),
392            |operation, request| {
393                let client = &client;
394                async move { client.change_agent(operation, &request).await }
395            },
396        )
397        .await
398        .map(CommandOutput::Agents),
399        Command::Users { command } => users::execute(
400            command,
401            |cursor| client.list_users(cursor),
402            |user_id| client.inspect_user(user_id),
403            |operation, request| client.change_account(operation, request),
404            prompt_secret,
405        )
406        .await
407        .map(CommandOutput::Users),
408        Command::Profile { command } => profile::execute(
409            command.into_invocation(),
410            || client.profile(),
411            |operation, request| {
412                let client = &client;
413                async move { client.update_profile(operation, &request).await }
414            },
415        )
416        .await
417        .map(CommandOutput::Profile),
418        Command::TipRecipient { command } => profile::execute_recipient(
419            command.into_invocation(),
420            || client.tip_recipient(),
421            |operation, request| {
422                let client = &client;
423                async move { client.set_tip_recipient(operation, &request).await }
424            },
425        )
426        .await
427        .map(CommandOutput::Profile),
428        Command::Login { username } => login(&client, username).await,
429        Command::LoginNostr => nostr_login::execute(
430            || client.begin_nostr_login(),
431            |login, proof| client.complete_nostr_login(login, proof),
432            |prompt| terminal::prompt_bounded(prompt, 16 * 1024),
433            io::stderr().lock(),
434        )
435        .await
436        .map(CommandOutput::Login),
437        Command::Logout => client
438            .logout()
439            .await
440            .map(CommandOutput::Logout)
441            .map_err(CliError::from),
442        Command::AgentKey {
443            command: AgentKeyCommand::Inspect,
444        } => client
445            .agent_public_identity()
446            .map(CommandOutput::AgentKeyIdentity)
447            .map_err(CliError::from),
448        Command::AgentKey {
449            command: AgentKeyCommand::Set,
450        } => configure_agent_key(&client),
451        Command::AgentKey {
452            command: AgentKeyCommand::Remove,
453        } => client
454            .remove_agent_private_key()
455            .map(|()| CommandOutput::AgentKeyRemoved)
456            .map_err(CliError::from),
457        Command::Capabilities => client
458            .capabilities()
459            .await
460            .map(CommandOutput::Capabilities)
461            .map_err(CliError::from),
462        Command::Posts => list_all_posts(&client).await.map(CommandOutput::Posts),
463        Command::Releases { command } => execute_releases(&client, command).await,
464        Command::Source {
465            command: SourceCommand::DeployKey,
466        } => client
467            .source_deploy_key()
468            .await
469            .map(CommandOutput::SourceDeployKey)
470            .map_err(CliError::from),
471        Command::Source {
472            command: SourceCommand::Configure(arguments),
473        } => configure_source(&client, arguments).await,
474        Command::Source {
475            command: SourceCommand::Status,
476        } => client
477            .source_status()
478            .await
479            .map(Box::new)
480            .map(CommandOutput::SourceStatus)
481            .map_err(CliError::from),
482        Command::Source {
483            command: SourceCommand::Sync(arguments),
484        } => {
485            let SourceSyncInvocation {
486                disposition,
487                idempotency_key,
488            } = arguments.into_invocation();
489            source_sync(&client, disposition, idempotency_key).await
490        }
491        Command::Preview {
492            post_id,
493            output,
494            revision,
495            content_digest,
496        } => preview_post(&client, post_id, output, revision, content_digest).await,
497        Command::PublishNow {
498            post_id,
499            preview_digest,
500            revision,
501            idempotency_key,
502        } => {
503            approve_publication(
504                &client,
505                post_id,
506                preview_digest,
507                revision,
508                None,
509                idempotency_key,
510            )
511            .await
512        }
513        Command::Schedule {
514            post_id,
515            preview_digest,
516            at,
517            revision,
518            idempotency_key,
519        } => {
520            approve_publication(
521                &client,
522                post_id,
523                preview_digest,
524                revision,
525                Some(at),
526                idempotency_key,
527            )
528            .await
529        }
530    }
531}
532
533async fn configure_source(
534    client: &AdminClient,
535    arguments: SourceConfigurationArguments,
536) -> Result<CommandOutput, CliError> {
537    let (request, completion) = arguments.into_invocation();
538    let idempotency_key = completion.idempotency_key.unwrap_or_else(Uuid::new_v4);
539    let BeginSourceSyncResponse { admission, sync } = client
540        .reconfigure_source(&request, idempotency_key)
541        .await
542        .map_err(|source| CliError::SourceSyncStart {
543            idempotency_key,
544            source,
545        })?;
546    let sync = complete_source_sync(client, completion.disposition, sync, idempotency_key).await?;
547    Ok(CommandOutput::SourceSync {
548        idempotency_key,
549        admission,
550        sync,
551    })
552}
553
554async fn source_sync(
555    client: &AdminClient,
556    disposition: SourceSyncDisposition,
557    idempotency_key: Option<Uuid>,
558) -> Result<CommandOutput, CliError> {
559    let idempotency_key = idempotency_key.unwrap_or_else(Uuid::new_v4);
560    let BeginSourceSyncResponse { admission, sync } = client
561        .begin_source_sync(idempotency_key)
562        .await
563        .map_err(|source| CliError::SourceSyncStart {
564            idempotency_key,
565            source,
566        })?;
567
568    let sync = complete_source_sync(client, disposition, sync, idempotency_key).await?;
569
570    Ok(CommandOutput::SourceSync {
571        idempotency_key,
572        admission,
573        sync,
574    })
575}
576
577async fn complete_source_sync(
578    client: &AdminClient,
579    disposition: SourceSyncDisposition,
580    sync: SourceSyncResource,
581    idempotency_key: Uuid,
582) -> Result<SourceSyncResource, CliError> {
583    match disposition {
584        SourceSyncDisposition::Async => {
585            source_sync_finished(&sync, idempotency_key)?;
586            Ok(sync)
587        }
588        SourceSyncDisposition::Wait => wait_for_source_sync(client, sync, idempotency_key).await,
589    }
590}
591
592async fn wait_for_source_sync(
593    client: &AdminClient,
594    initial: SourceSyncResource,
595    idempotency_key: Uuid,
596) -> Result<SourceSyncResource, CliError> {
597    let source_sync_id = initial.source_sync_id;
598    tokio::time::timeout(
599        SOURCE_SYNC_WAIT_TIMEOUT,
600        poll_source_sync(
601            initial,
602            idempotency_key,
603            |source_sync_id| client.source_sync(source_sync_id),
604            source_sync_poll_pause,
605            MAX_SOURCE_SYNC_POLLS,
606        ),
607    )
608    .await
609    .map_err(|_| CliError::SourceSyncTimedOut {
610        idempotency_key,
611        source_sync_id,
612    })?
613}
614
615async fn source_sync_poll_pause() {
616    tokio::time::sleep(SOURCE_SYNC_POLL_INTERVAL).await;
617}
618
619async fn poll_source_sync<Fetch, FetchFuture, Pause, PauseFuture>(
620    initial: SourceSyncResource,
621    idempotency_key: Uuid,
622    mut fetch: Fetch,
623    mut pause: Pause,
624    maximum_polls: usize,
625) -> Result<SourceSyncResource, CliError>
626where
627    Fetch: FnMut(SourceSyncId) -> FetchFuture,
628    FetchFuture: Future<Output = Result<SourceSyncResource, AdminClientError>>,
629    Pause: FnMut() -> PauseFuture,
630    PauseFuture: Future<Output = ()>,
631{
632    let source_sync_id = initial.source_sync_id;
633    let configuration_version = initial.configuration_version;
634    let request_origin = initial.request_origin;
635    let requested_at = initial.requested_at;
636    let mut previous_version = initial.version;
637    let mut previous_updated_at = initial.updated_at;
638    if source_sync_finished(&initial, idempotency_key)? {
639        return Ok(initial);
640    }
641
642    for _ in 0..maximum_polls {
643        pause().await;
644        let current = fetch(source_sync_id)
645            .await
646            .map_err(|source| CliError::SourceSyncFollow {
647                idempotency_key,
648                source_sync_id,
649                source,
650            })?;
651        if current.source_sync_id != source_sync_id
652            || current.configuration_version != configuration_version
653            || current.request_origin != request_origin
654            || current.requested_at != requested_at
655        {
656            return Err(invalid_source_sync(
657                idempotency_key,
658                source_sync_id,
659                "operation identity changed while polling",
660            ));
661        }
662        if current.version < previous_version || current.updated_at < previous_updated_at {
663            return Err(invalid_source_sync(
664                idempotency_key,
665                source_sync_id,
666                "operation version or update time moved backwards",
667            ));
668        }
669        previous_version = current.version;
670        previous_updated_at = current.updated_at;
671        if source_sync_finished(&current, idempotency_key)? {
672            return Ok(current);
673        }
674    }
675
676    Err(CliError::SourceSyncTimedOut {
677        idempotency_key,
678        source_sync_id,
679    })
680}
681
682fn source_sync_finished(
683    sync: &SourceSyncResource,
684    idempotency_key: Uuid,
685) -> Result<bool, CliError> {
686    match sync.outcome {
687        None => Ok(false),
688        Some(SourceSyncOutcome::Applied | SourceSyncOutcome::NoChange) => Ok(true),
689        Some(outcome @ (SourceSyncOutcome::Failed | SourceSyncOutcome::Cancelled)) => {
690            Err(terminal_source_sync_failure(sync, idempotency_key, outcome))
691        }
692    }
693}
694
695fn terminal_source_sync_failure(
696    sync: &SourceSyncResource,
697    idempotency_key: Uuid,
698    outcome: SourceSyncOutcome,
699) -> CliError {
700    CliError::SourceSyncTerminalFailure {
701        idempotency_key,
702        source_sync_id: sync.source_sync_id,
703        outcome: outcome.as_str(),
704        failure_code: sync.failure_code,
705    }
706}
707
708const fn invalid_source_sync(
709    idempotency_key: Uuid,
710    source_sync_id: SourceSyncId,
711    message: &'static str,
712) -> CliError {
713    CliError::InvalidSourceSyncResponse {
714        idempotency_key,
715        source_sync_id,
716        message,
717    }
718}
719
720async fn login(client: &AdminClient, username: Box<str>) -> Result<CommandOutput, CliError> {
721    client.ensure_human_session_absent()?;
722    let password = prompt_secret("Password: ").map_err(CliError::SecretInput)?;
723    client
724        .login_with_password(username, password)
725        .await
726        .map(CommandOutput::Login)
727        .map_err(CliError::from)
728}
729
730fn configure_agent_key(client: &AdminClient) -> Result<CommandOutput, CliError> {
731    let key =
732        prompt_secret("Nostr private key (lowercase hex): ").map_err(CliError::SecretInput)?;
733    client
734        .configure_agent_private_key(key)
735        .map(|identity| CommandOutput::AgentKeyIdentity(Some(identity)))
736        .map_err(CliError::from)
737}
738
739async fn preview_post(
740    client: &AdminClient,
741    post_id: Uuid,
742    output: PathBuf,
743    revision: Option<String>,
744    content_digest: Option<String>,
745) -> Result<CommandOutput, CliError> {
746    let selection = PreviewSelection::new(post_id, output, revision, content_digest)?;
747    let preview = client
748        .preview_post(
749            selection.post_id,
750            selection.revision.as_deref(),
751            selection.content_digest.as_deref(),
752        )
753        .await?;
754    selection.accept(preview)
755}
756
757fn validate_optional_digest(
758    value: Option<&str>,
759    prefix: &'static str,
760    field: &'static str,
761) -> Result<(), CliError> {
762    let Some(value) = value else {
763        return Ok(());
764    };
765    let valid = value.strip_prefix(prefix).is_some_and(|encoded| {
766        encoded.len() == 64
767            && encoded
768                .bytes()
769                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
770    });
771    if valid {
772        Ok(())
773    } else {
774        Err(CliError::InvalidPreviewSelector { field, prefix })
775    }
776}
777
778fn write_preview_file(path: &Path, html: &str) -> Result<(), CliError> {
779    let mut file = OpenOptions::new()
780        .write(true)
781        .create_new(true)
782        .open(path)
783        .map_err(|source| {
784            if source.kind() == io::ErrorKind::AlreadyExists {
785                CliError::PreviewOutputExists {
786                    path: path.to_path_buf(),
787                }
788            } else {
789                CliError::PreviewOutput {
790                    path: path.to_path_buf(),
791                    source,
792                }
793            }
794        })?;
795    file.write_all(html.as_bytes())
796        .and_then(|()| file.flush())
797        .map_err(|source| CliError::PreviewOutput {
798            path: path.to_path_buf(),
799            source,
800        })
801}
802
803async fn approve_publication(
804    client: &AdminClient,
805    post_id: Uuid,
806    preview_digest: PreviewDigest,
807    revision: Option<String>,
808    scheduled_for: Option<OffsetDateTime>,
809    idempotency_key: Option<Uuid>,
810) -> Result<CommandOutput, CliError> {
811    let idempotency_key = idempotency_key.unwrap_or_else(Uuid::new_v4);
812    let response = client
813        .approve_publication(
814            idempotency_key,
815            &PublishNowRequest {
816                post_id,
817                preview_digest,
818                expected_revision: revision.map(String::into_boxed_str),
819                scheduled_for,
820            },
821        )
822        .await
823        .map_err(|source| CliError::Publication {
824            idempotency_key,
825            source,
826        })?;
827    Ok(CommandOutput::Publication {
828        idempotency_key,
829        response,
830    })
831}
832
833async fn list_all_posts(client: &AdminClient) -> Result<ListPostsResponse, CliError> {
834    collect_post_pages(|cursor| client.list_posts_page(cursor, POSTS_PAGE_LIMIT)).await
835}
836
837async fn collect_post_pages<Fetch, FetchFuture>(
838    mut fetch: Fetch,
839) -> Result<ListPostsResponse, CliError>
840where
841    Fetch: FnMut(Option<Uuid>) -> FetchFuture,
842    FetchFuture: Future<Output = Result<ListPostsResponse, AdminClientError>>,
843{
844    let first = fetch(None).await?;
845    let content_digest = first.content_digest.clone();
846    let site_digest = first.site_digest.clone();
847    let site_version = first.site_version;
848    let mut posts = Vec::new();
849    let mut seen_posts = HashSet::new();
850    append_post_page(&mut posts, &mut seen_posts, first.posts)?;
851
852    let mut next_cursor = first.next_cursor;
853    let mut seen_cursors = HashSet::new();
854    let mut page_count = 1_usize;
855    while let Some(cursor) = next_cursor {
856        if page_count >= MAX_POSTS_PAGES {
857            return Err(CliError::InvalidPostsPagination {
858                message: "the page count exceeded the client safety limit",
859            });
860        }
861        if !seen_cursors.insert(cursor) {
862            return Err(CliError::InvalidPostsPagination {
863                message: "next_cursor repeated an earlier cursor",
864            });
865        }
866
867        let page = fetch(Some(cursor)).await?;
868        if page.content_digest != content_digest
869            || page.site_digest != site_digest
870            || page.site_version != site_version
871        {
872            return Err(CliError::PostsSnapshotChanged {
873                expected_content_digest: content_digest,
874                expected_site_digest: site_digest,
875                expected_site_version: site_version,
876                actual_content_digest: page.content_digest,
877                actual_site_digest: page.site_digest,
878                actual_site_version: page.site_version,
879            });
880        }
881        append_post_page(&mut posts, &mut seen_posts, page.posts)?;
882        next_cursor = page.next_cursor;
883        page_count += 1;
884    }
885
886    Ok(ListPostsResponse {
887        content_digest,
888        site_digest,
889        site_version,
890        posts,
891        next_cursor: None,
892    })
893}
894
895fn append_post_page(
896    posts: &mut Vec<PostSummary>,
897    seen: &mut HashSet<Uuid>,
898    page: Vec<PostSummary>,
899) -> Result<(), CliError> {
900    for post in page {
901        if !seen.insert(post.post_id) {
902            return Err(CliError::InvalidPostsPagination {
903                message: "a post UUID appeared more than once",
904            });
905        }
906        posts.push(post);
907    }
908    Ok(())
909}
910
911fn write_output(
912    output: impl io::Write,
913    command: CommandOutput,
914    json: bool,
915) -> Result<(), CliError> {
916    match command {
917        CommandOutput::Agents(result) => agents::write_output(output, result, json),
918        CommandOutput::Users(result) => users::write_output(output, result, json),
919        CommandOutput::Profile(result) => profile::write_output(output, result, json),
920        CommandOutput::Login(session) => write_login(output, session, json),
921        CommandOutput::Logout(revoked) => write_logout(output, revoked, json),
922        CommandOutput::AgentKeyIdentity(identity) => {
923            write_agent_key_identity(output, identity.as_ref(), json)
924        }
925        CommandOutput::AgentKeyRemoved => write_agent_key_removed(output, json),
926        CommandOutput::Capabilities(capabilities) => write_capabilities(output, capabilities, json),
927        CommandOutput::Posts(posts) => write_posts(output, posts, json),
928        CommandOutput::Releases(releases) => write_releases(output, releases, json),
929        CommandOutput::Release(release) => write_release(output, release, json),
930        CommandOutput::ReleaseOperation(operation) => {
931            write_release_operation(output, operation, json)
932        }
933        CommandOutput::SourceDeployKey(identity) => {
934            write_source_deploy_key(output, &identity, json)
935        }
936        CommandOutput::SourceStatus(status) => write_source_status(output, *status, json),
937        CommandOutput::SourceSync {
938            idempotency_key,
939            admission,
940            sync,
941        } => write_source_sync(output, idempotency_key, admission, sync, json),
942        CommandOutput::Preview {
943            post_id,
944            output: path,
945            preview,
946        } => write_preview(output, post_id, &path, preview, json),
947        CommandOutput::Publication {
948            idempotency_key,
949            response,
950        } => write_publication(output, idempotency_key, response, json),
951    }
952}
953
954fn write_releases(
955    mut output: impl io::Write,
956    page: ListReleasesResponse,
957    json: bool,
958) -> Result<(), CliError> {
959    if json {
960        serde_json::to_writer(&mut output, &page)?;
961        writeln!(output)?;
962        return Ok(());
963    }
964    for release in page.releases {
965        writeln!(
966            output,
967            "{}  {}  version {}  post {}",
968            release.publication_id,
969            release_state_name(release.state),
970            release.version,
971            release.post_id
972        )?;
973    }
974    if let Some(cursor) = page.next_cursor {
975        writeln!(
976            output,
977            "Next page: maincopy releases list --cursor {cursor}"
978        )?;
979    }
980    Ok(())
981}
982
983fn write_release(
984    mut output: impl io::Write,
985    release: ReleaseResource,
986    json: bool,
987) -> Result<(), CliError> {
988    if json {
989        serde_json::to_writer(&mut output, &release)?;
990        writeln!(output)?;
991        return Ok(());
992    }
993    writeln!(output, "Release: {}", release.publication_id)?;
994    writeln!(output, "Post: {}", release.post_id)?;
995    writeln!(output, "State: {}", release_state_name(release.state))?;
996    writeln!(output, "Version: {}", release.version)?;
997    writeln!(output, "Revision: {}", release.revision)?;
998    writeln!(output, "Preview digest: {}", release.preview_digest)?;
999    writeln!(output, "Scheduled for: {}", release.scheduled_for)?;
1000    if let Some(published_at) = release.published_at {
1001        writeln!(output, "Published at: {published_at}")?;
1002    }
1003    if let Some(reason) = release.block_reason {
1004        writeln!(output, "Block reason: {}", serde_json::to_value(reason)?)?;
1005    }
1006    Ok(())
1007}
1008
1009fn write_release_operation(
1010    mut output: impl io::Write,
1011    operation: ReleaseOperationResource,
1012    json: bool,
1013) -> Result<(), CliError> {
1014    if json {
1015        serde_json::to_writer(&mut output, &operation)?;
1016        writeln!(output)?;
1017        return Ok(());
1018    }
1019    writeln!(output, "Operation: {}", operation.operation_id)?;
1020    writeln!(output, "Release: {}", operation.publication_id)?;
1021    writeln!(output, "Accepted version: {}", operation.version)?;
1022    writeln!(
1023        output,
1024        "Accepted state: {}",
1025        release_state_name(operation.state)
1026    )?;
1027    writeln!(
1028        output,
1029        "Inspect current state: maincopy releases inspect {}",
1030        operation.publication_id
1031    )?;
1032    Ok(())
1033}
1034
1035const fn release_state_name(state: ReleaseState) -> &'static str {
1036    match state {
1037        ReleaseState::Scheduled => "scheduled",
1038        ReleaseState::Activating => "activating",
1039        ReleaseState::Blocked => "blocked",
1040        ReleaseState::Published => "published",
1041        ReleaseState::Superseded => "superseded",
1042        ReleaseState::Cancelled => "cancelled",
1043    }
1044}
1045
1046fn write_source_status(
1047    mut output: impl io::Write,
1048    status: SourceStatusResponse,
1049    json: bool,
1050) -> Result<(), CliError> {
1051    if json {
1052        serde_json::to_writer(&mut output, &status)?;
1053        writeln!(output)?;
1054        return Ok(());
1055    }
1056
1057    match status {
1058        SourceStatusResponse::ExternalCheckout => {
1059            writeln!(output, "Source mode: external_checkout")?;
1060        }
1061        SourceStatusResponse::ManagedGit {
1062            configuration,
1063            installed_commit,
1064            content_digest,
1065            active_sync,
1066            latest_sync,
1067            next_poll_at,
1068        } => {
1069            writeln!(output, "Source mode: managed_git")?;
1070            writeln!(
1071                output,
1072                "Remote: {}@{}:{}/{}",
1073                configuration.remote.user,
1074                configuration.remote.host,
1075                configuration.remote.port.get(),
1076                configuration.remote.repository_path
1077            )?;
1078            writeln!(output, "Branch: {}", configuration.branch)?;
1079            writeln!(
1080                output,
1081                "Content subdirectory: {}",
1082                configuration.content_subdirectory
1083            )?;
1084            writeln!(output, "Credential: {}", configuration.credential_name)?;
1085            writeln!(
1086                output,
1087                "Poll interval: {} seconds",
1088                configuration.poll_interval_seconds.seconds()
1089            )?;
1090            writeln!(
1091                output,
1092                "Configuration version: {}",
1093                configuration.version.get()
1094            )?;
1095            writeln!(
1096                output,
1097                "Configuration updated at: {}",
1098                configuration.updated_at
1099            )?;
1100            write_optional_line(&mut output, "Installed commit", installed_commit.as_deref())?;
1101            write_optional_line(&mut output, "Content", content_digest.as_deref())?;
1102            write_optional_line(
1103                &mut output,
1104                "Active sync",
1105                active_sync
1106                    .as_ref()
1107                    .map(|sync| sync.source_sync_id.to_string())
1108                    .as_deref(),
1109            )?;
1110            write_optional_line(
1111                &mut output,
1112                "Latest sync",
1113                latest_sync
1114                    .as_ref()
1115                    .map(|sync| sync.source_sync_id.to_string())
1116                    .as_deref(),
1117            )?;
1118            writeln!(
1119                output,
1120                "Next poll at: {}",
1121                next_poll_at
1122                    .map(|timestamp| timestamp.to_string())
1123                    .as_deref()
1124                    .unwrap_or("none")
1125            )?;
1126        }
1127    }
1128    Ok(())
1129}
1130
1131fn write_source_deploy_key(
1132    mut output: impl io::Write,
1133    identity: &SourceDeployKeyResponse,
1134    json: bool,
1135) -> Result<(), CliError> {
1136    if json {
1137        serde_json::to_writer(&mut output, identity)?;
1138        writeln!(output)?;
1139    } else {
1140        writeln!(output, "Deploy credential: {}", identity.credential_name)?;
1141        writeln!(output, "Public key: {}", identity.public_key)?;
1142        writeln!(output, "Fingerprint: {}", identity.fingerprint)?;
1143    }
1144    Ok(())
1145}
1146
1147fn write_source_sync(
1148    mut output: impl io::Write,
1149    idempotency_key: Uuid,
1150    admission: SourceSyncAdmission,
1151    sync: SourceSyncResource,
1152    json: bool,
1153) -> Result<(), CliError> {
1154    source_sync_finished(&sync, idempotency_key)?;
1155    if json {
1156        #[derive(Serialize)]
1157        struct SourceSyncOutput<'sync> {
1158            idempotency_key: Uuid,
1159            admission: SourceSyncAdmission,
1160            sync: &'sync SourceSyncResource,
1161        }
1162
1163        serde_json::to_writer(
1164            &mut output,
1165            &SourceSyncOutput {
1166                idempotency_key,
1167                admission,
1168                sync: &sync,
1169            },
1170        )?;
1171        writeln!(output)?;
1172        return Ok(());
1173    }
1174
1175    writeln!(output, "Source sync: {}", sync.source_sync_id)?;
1176    writeln!(
1177        output,
1178        "Admission: {}",
1179        source_sync_admission_name(admission)
1180    )?;
1181    writeln!(
1182        output,
1183        "Status: {}",
1184        sync.outcome
1185            .map(SourceSyncOutcome::as_str)
1186            .unwrap_or_else(|| sync.stage.as_str())
1187    )?;
1188    writeln!(
1189        output,
1190        "Configuration version: {}",
1191        sync.configuration_version.get()
1192    )?;
1193    writeln!(output, "Requested by: {}", sync.request_origin.as_str())?;
1194    writeln!(output, "Requested at: {}", sync.requested_at)?;
1195    writeln!(output, "Updated at: {}", sync.updated_at)?;
1196    if let Some(finished_at) = sync.finished_at {
1197        writeln!(output, "Finished at: {finished_at}")?;
1198    }
1199    write_optional_line(&mut output, "Source commit", sync.source_commit.as_deref())?;
1200    write_optional_line(&mut output, "Content", sync.content_digest.as_deref())?;
1201    if let Some(code) = sync.failure_code {
1202        writeln!(output, "Failure code: {}", code.as_str())?;
1203    }
1204    writeln!(output, "Idempotency key: {idempotency_key}")?;
1205    Ok(())
1206}
1207
1208fn write_optional_line(
1209    mut output: impl io::Write,
1210    label: &str,
1211    value: Option<&str>,
1212) -> io::Result<()> {
1213    writeln!(output, "{label}: {}", value.unwrap_or("none"))
1214}
1215
1216const fn source_sync_admission_name(admission: SourceSyncAdmission) -> &'static str {
1217    match admission {
1218        SourceSyncAdmission::Created => "created",
1219        SourceSyncAdmission::Coalesced => "coalesced",
1220        SourceSyncAdmission::Replayed => "replayed",
1221    }
1222}
1223
1224fn write_login(
1225    mut output: impl io::Write,
1226    session: AdminSessionResponse,
1227    json: bool,
1228) -> Result<(), CliError> {
1229    if json {
1230        serde_json::to_writer(&mut output, &session)?;
1231        writeln!(output)?;
1232        return Ok(());
1233    }
1234    writeln!(output, "Session: {}", session.session_id)?;
1235    writeln!(output, "User: {}", session.user_id)?;
1236    writeln!(output, "Provider: {}", session.provider.as_str())?;
1237    writeln!(
1238        output,
1239        "Roles: {}",
1240        session
1241            .roles
1242            .iter()
1243            .map(|role| role.as_str())
1244            .collect::<Vec<_>>()
1245            .join(", ")
1246    )?;
1247    writeln!(output, "Expires at: {}", session.expires_at)?;
1248    Ok(())
1249}
1250
1251fn write_logout(
1252    mut output: impl io::Write,
1253    outcome: LogoutOutcome,
1254    json: bool,
1255) -> Result<(), CliError> {
1256    match outcome {
1257        LogoutOutcome::Revoked(revoked) => {
1258            if json {
1259                serde_json::to_writer(&mut output, &revoked)?;
1260                writeln!(output)?;
1261            } else {
1262                writeln!(output, "Revoked session: {}", revoked.session_id)?;
1263            }
1264        }
1265        LogoutOutcome::NoActiveSession => {
1266            if json {
1267                serde_json::to_writer(
1268                    &mut output,
1269                    &json!({"status":"session_not_accepted", "local_credentials_removed":true}),
1270                )?;
1271                writeln!(output)?;
1272            } else {
1273                writeln!(
1274                    output,
1275                    "The server no longer accepts this session. Local credentials removed."
1276                )?;
1277            }
1278        }
1279    }
1280    Ok(())
1281}
1282
1283fn write_agent_key_identity(
1284    mut output: impl io::Write,
1285    identity: Option<&AgentPublicIdentity>,
1286    json: bool,
1287) -> Result<(), CliError> {
1288    if json {
1289        let value = match identity {
1290            Some(identity) => json!({
1291                "public_key": identity.public_key,
1292                "fingerprint": identity.fingerprint,
1293                "configured": true,
1294            }),
1295            None => json!({"configured": false}),
1296        };
1297        writeln!(output, "{value}")?;
1298    } else if let Some(identity) = identity {
1299        writeln!(output, "Agent public key: {}", identity.public_key)?;
1300        writeln!(output, "Fingerprint: {}", identity.fingerprint)?;
1301    } else {
1302        writeln!(
1303            output,
1304            "No local agent key is configured for this admin origin."
1305        )?;
1306        writeln!(output, "Configure a key: maincopy agent-key set")?;
1307    }
1308    Ok(())
1309}
1310
1311fn write_agent_key_removed(mut output: impl io::Write, json: bool) -> Result<(), CliError> {
1312    if json {
1313        writeln!(output, "{}", json!({ "removed": true }))?;
1314    } else {
1315        writeln!(output, "Agent key removed")?;
1316    }
1317    Ok(())
1318}
1319
1320fn write_capabilities(
1321    mut output: impl io::Write,
1322    capabilities: Capabilities,
1323    json: bool,
1324) -> Result<(), CliError> {
1325    if json {
1326        serde_json::to_writer(&mut output, &capabilities)?;
1327        writeln!(output)?;
1328        return Ok(());
1329    }
1330
1331    let api_version = match capabilities.api_version {
1332        AdminApiVersion::V1 => "v1",
1333    };
1334    let capability_version = match capabilities.features.capabilities {
1335        CapabilityContractVersion::V1 => "v1",
1336    };
1337    writeln!(output, "Admin API: {api_version}")?;
1338    writeln!(output, "Capabilities contract: {capability_version}")?;
1339    Ok(())
1340}
1341
1342fn write_posts(
1343    mut output: impl io::Write,
1344    response: ListPostsResponse,
1345    json: bool,
1346) -> Result<(), CliError> {
1347    if json {
1348        serde_json::to_writer(&mut output, &response)?;
1349        writeln!(output)?;
1350        return Ok(());
1351    }
1352
1353    writeln!(
1354        output,
1355        "Site: {} (version {})",
1356        response.site_digest, response.site_version
1357    )?;
1358    writeln!(output, "Content: {}", response.content_digest)?;
1359    writeln!(output, "Posts: {}", response.posts.len())?;
1360    for post in response.posts {
1361        let publication_state = post.publication_state;
1362        writeln!(output)?;
1363        writeln!(
1364            output,
1365            "[{}] {}",
1366            publication_state_name(publication_state),
1367            post.title
1368        )?;
1369        writeln!(output, "  ID: {}", post.post_id)?;
1370        writeln!(output, "  Revision: {}", post.revision)?;
1371        writeln!(output, "  Source: {}", post.source_path)?;
1372        writeln!(output, "  Slug: {}", post.slug)?;
1373        if let Some(published_at) = post.published_at {
1374            let label = match publication_state {
1375                PostPublicationState::UnpublishedChange => "Current publication at",
1376                PostPublicationState::Published => "Published at",
1377                PostPublicationState::Draft | PostPublicationState::Unpublished => "Publication at",
1378            };
1379            writeln!(output, "  {label}: {published_at}")?;
1380        }
1381    }
1382    Ok(())
1383}
1384
1385fn write_preview(
1386    mut output: impl io::Write,
1387    post_id: Uuid,
1388    path: &Path,
1389    preview: PostPreview,
1390    json: bool,
1391) -> Result<(), CliError> {
1392    if json {
1393        serde_json::to_writer(
1394            &mut output,
1395            &json!({
1396                "post_id": post_id,
1397                "preview_digest": preview.preview_digest,
1398                "revision": preview.revision,
1399                "content_digest": preview.content_digest,
1400                "canonical_url": preview.canonical_url,
1401                "output": path.display().to_string(),
1402            }),
1403        )?;
1404        writeln!(output)?;
1405        return Ok(());
1406    }
1407
1408    writeln!(output, "Preview: {}", preview.preview_digest)?;
1409    writeln!(output, "Post: {post_id}")?;
1410    writeln!(output, "Revision: {}", preview.revision)?;
1411    writeln!(output, "Content: {}", preview.content_digest)?;
1412    writeln!(output, "Canonical: {}", preview.canonical_url)?;
1413    writeln!(output, "Output: {}", path.display())?;
1414    Ok(())
1415}
1416
1417const fn publication_state_name(state: PostPublicationState) -> &'static str {
1418    match state {
1419        PostPublicationState::Draft => "draft",
1420        PostPublicationState::Unpublished => "unpublished",
1421        PostPublicationState::UnpublishedChange => "unpublished_change",
1422        PostPublicationState::Published => "published",
1423    }
1424}
1425
1426fn write_publication(
1427    mut output: impl io::Write,
1428    idempotency_key: Uuid,
1429    response: PublishNowResponse,
1430    json: bool,
1431) -> Result<(), CliError> {
1432    validate_publication_response(&response)?;
1433    if json {
1434        #[derive(Serialize)]
1435        struct PublicationOutput<'response> {
1436            #[serde(flatten)]
1437            response: &'response PublishNowResponse,
1438            idempotency_key: Uuid,
1439        }
1440
1441        serde_json::to_writer(
1442            &mut output,
1443            &PublicationOutput {
1444                response: &response,
1445                idempotency_key,
1446            },
1447        )?;
1448        writeln!(output)?;
1449        return Ok(());
1450    }
1451
1452    writeln!(output, "Publication: {}", response.publication_id)?;
1453    writeln!(output, "Status: {}", approval_state_name(response.state))?;
1454    writeln!(output, "Post: {}", response.post_id)?;
1455    writeln!(output, "Preview: {}", response.preview_digest)?;
1456    writeln!(output, "Pinned revision: {}", response.revision)?;
1457    if let Some(scheduled_for) = response.scheduled_for {
1458        writeln!(output, "Scheduled for: {scheduled_for}")?;
1459    }
1460    if let Some(published_at) = response.published_at {
1461        writeln!(output, "Published at: {published_at}")?;
1462    }
1463    writeln!(
1464        output,
1465        "Site: {} (version {})",
1466        response.site_digest, response.site_version
1467    )?;
1468    writeln!(output, "Idempotency key: {idempotency_key}")?;
1469    Ok(())
1470}
1471
1472fn validate_publication_response(response: &PublishNowResponse) -> Result<(), CliError> {
1473    match (
1474        response.state,
1475        response.scheduled_for,
1476        response.published_at,
1477    ) {
1478        (PublicationApprovalState::Scheduled, Some(_), None)
1479        | (PublicationApprovalState::Published, _, Some(_)) => Ok(()),
1480        (PublicationApprovalState::Scheduled, None, _) => {
1481            Err(CliError::InvalidPublicationResponse {
1482                message: "scheduled state requires scheduled_for",
1483            })
1484        }
1485        (PublicationApprovalState::Scheduled, Some(_), Some(_)) => {
1486            Err(CliError::InvalidPublicationResponse {
1487                message: "scheduled state must not contain published_at",
1488            })
1489        }
1490        (PublicationApprovalState::Published, _, None) => {
1491            Err(CliError::InvalidPublicationResponse {
1492                message: "published state requires published_at",
1493            })
1494        }
1495    }
1496}
1497
1498const fn approval_state_name(state: PublicationApprovalState) -> &'static str {
1499    match state {
1500        PublicationApprovalState::Scheduled => "scheduled",
1501        PublicationApprovalState::Published => "published",
1502    }
1503}
1504
1505fn error_exit(error: &CliError) -> u8 {
1506    match error {
1507        CliError::PostsSnapshotChanged { .. }
1508        | CliError::PreviewRevisionMismatch { .. }
1509        | CliError::PreviewContentDigestMismatch { .. }
1510        | CliError::PreviewOutputExists { .. } => return CONFLICT,
1511        CliError::InvalidPreviewSelector { .. } | CliError::AccountInput { .. } => {
1512            return VALIDATION;
1513        }
1514        CliError::SecretInput(source) if source.kind() == io::ErrorKind::PermissionDenied => {
1515            return PERMISSION;
1516        }
1517        CliError::PreviewOutput { source, .. }
1518            if source.kind() == io::ErrorKind::PermissionDenied =>
1519        {
1520            return PERMISSION;
1521        }
1522        CliError::InvalidPostsPagination { .. }
1523        | CliError::InvalidPublicationResponse { .. }
1524        | CliError::InvalidSourceSyncResponse { .. }
1525        | CliError::SecretInput(_)
1526        | CliError::PreviewOutput { .. }
1527        | CliError::Output(_)
1528        | CliError::Encode(_) => {
1529            return INTERNAL;
1530        }
1531        CliError::SourceSyncTimedOut { .. } | CliError::SourceSyncTerminalFailure { .. } => {
1532            return UNAVAILABLE;
1533        }
1534        CliError::Admin(_)
1535        | CliError::AgentChange { .. }
1536        | CliError::UserChange { .. }
1537        | CliError::ProfileChange { .. }
1538        | CliError::Publication { .. }
1539        | CliError::ReleaseChange { .. }
1540        | CliError::SourceSyncStart { .. }
1541        | CliError::SourceSyncFollow { .. } => {}
1542    }
1543    let Some(error) = admin_error(error) else {
1544        return INTERNAL;
1545    };
1546
1547    match error {
1548        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1549        AdminClientError::AdditionalRootCertificates(
1550            AdditionalRootCertificateError::UnsupportedPlatform { .. },
1551        ) => VALIDATION,
1552        AdminClientError::AdditionalRootCertificates(
1553            AdditionalRootCertificateError::Open { source, .. }
1554            | AdditionalRootCertificateError::Read { source, .. },
1555        ) if source.kind() == io::ErrorKind::PermissionDenied => PERMISSION,
1556        AdminClientError::AdditionalRootCertificates(AdditionalRootCertificateError::Open {
1557            source,
1558            ..
1559        }) if source.kind() == io::ErrorKind::NotFound => VALIDATION,
1560        AdminClientError::AdditionalRootCertificates(
1561            AdditionalRootCertificateError::Open { .. }
1562            | AdditionalRootCertificateError::Read { .. },
1563        ) => UNAVAILABLE,
1564        AdminClientError::AdditionalRootCertificates(
1565            AdditionalRootCertificateError::NotRegularFile { .. }
1566            | AdditionalRootCertificateError::ChangedDuringOpen { .. }
1567            | AdditionalRootCertificateError::TooLarge { .. }
1568            | AdditionalRootCertificateError::UnexpectedPemSection { .. }
1569            | AdditionalRootCertificateError::InvalidBundle { .. }
1570            | AdditionalRootCertificateError::InvalidCount { .. },
1571        ) => VALIDATION,
1572        AdminClientError::CredentialStore(_) | AdminClientError::Transport(_) => UNAVAILABLE,
1573        AdminClientError::InvalidAdminOrigin
1574        | AdminClientError::InvalidRequestTarget
1575        | AdminClientError::RequestBodyTooLarge
1576        | AdminClientError::AgentPrivateKey(_)
1577        | AdminClientError::NostrLoginProof { .. } => VALIDATION,
1578        AdminClientError::HumanCredentialsMissing
1579        | AdminClientError::AgentCredentialsMissing
1580        | AdminClientError::StoredCredentialsInvalid
1581        | AdminClientError::HumanContextRequired => PERMISSION,
1582        AdminClientError::HumanSessionAlreadyStored => CONFLICT,
1583        AdminClientError::HttpStatus { status, .. } if matches!(status.as_u16(), 401 | 403) => {
1584            PERMISSION
1585        }
1586        AdminClientError::HttpStatus { status, .. }
1587            if matches!(status.as_u16(), 400 | 404 | 405 | 413 | 415 | 422) =>
1588        {
1589            VALIDATION
1590        }
1591        AdminClientError::HttpStatus { status, .. } if matches!(status.as_u16(), 409 | 412) => {
1592            CONFLICT
1593        }
1594        AdminClientError::HttpStatus { status, .. } if matches!(status.as_u16(), 502..=504) => {
1595            UNAVAILABLE
1596        }
1597        AdminClientError::HttpStatus { status, .. } if status.as_u16() == 429 => UNAVAILABLE,
1598        AdminClientError::HttpStatus { .. }
1599        | AdminClientError::UnexpectedSuccessStatus { .. }
1600        | AdminClientError::InvalidContentType { .. }
1601        | AdminClientError::InvalidAuthenticationResponse { .. }
1602        | AdminClientError::RequestEncoding(_)
1603        | AdminClientError::Nip98Signing(_)
1604        | AdminClientError::InvalidResponse(_)
1605        | AdminClientError::InvalidPreviewResponse { .. }
1606        | AdminClientError::InvalidPublicationResponse { .. }
1607        | AdminClientError::InvalidIdentityResponse { .. }
1608        | AdminClientError::InvalidProfileResponse { .. }
1609        | AdminClientError::InvalidSourceSyncResponse { .. } => INTERNAL,
1610    }
1611}
1612
1613fn report_error(error: &CliError, exit: u8, json_output: bool) -> io::Result<()> {
1614    if json_output {
1615        return write_error(std::io::stdout().lock(), error, exit, true);
1616    }
1617
1618    write_error(std::io::stderr().lock(), error, exit, false)
1619}
1620
1621#[derive(Clone, Copy)]
1622enum ErrorRecovery {
1623    AgentChange(Uuid),
1624    UserChange {
1625        user_id: Option<Uuid>,
1626        idempotency_key: Uuid,
1627    },
1628    ProfileChange(Uuid),
1629    None,
1630    Publication(Uuid),
1631    ReleaseChange {
1632        publication_id: Uuid,
1633        operation_id: Uuid,
1634    },
1635    SourceSyncStart(Uuid),
1636    SourceSync {
1637        idempotency_key: Uuid,
1638        source_sync_id: SourceSyncId,
1639        outcome: Option<&'static str>,
1640        failure_code: Option<SourceSyncFailureCode>,
1641    },
1642}
1643
1644fn write_error(
1645    output: impl io::Write,
1646    error: &CliError,
1647    exit: u8,
1648    json_output: bool,
1649) -> io::Result<()> {
1650    let (problem, request_id) = match admin_error(error) {
1651        Some(AdminClientError::HttpStatus {
1652            problem,
1653            request_id,
1654            ..
1655        }) => (problem.as_ref(), *request_id),
1656        _ => (None, None),
1657    };
1658    let recovery = error_recovery(error);
1659    if json_output {
1660        return write_json_error(output, error, exit, problem, request_id, recovery);
1661    }
1662
1663    write_human_error(output, error, problem, request_id, recovery)
1664}
1665
1666fn write_human_error(
1667    mut output: impl io::Write,
1668    error: &CliError,
1669    problem: Option<&AdminProblem>,
1670    request_id: Option<Uuid>,
1671    recovery: ErrorRecovery,
1672) -> io::Result<()> {
1673    writeln!(output, "maincopy: {error}")?;
1674    if let Some(problem) = problem {
1675        writeln!(output, "maincopy: {}: {}", problem.code, problem.message)?;
1676    }
1677    if let Some(request_id) = request_id {
1678        writeln!(output, "maincopy: request ID: {request_id}")?;
1679    }
1680    recovery.write_guidance(output)
1681}
1682
1683impl ErrorRecovery {
1684    fn write_guidance(self, mut output: impl io::Write) -> io::Result<()> {
1685        match self {
1686            ErrorRecovery::AgentChange(idempotency_key) => {
1687                writeln!(output, "maincopy: idempotency key: {idempotency_key}")?;
1688                writeln!(
1689                    output,
1690                    "maincopy: inspect current state with agents list or agents inspect; retry only the identical command with this key and authorizing session"
1691                )?;
1692            }
1693            ErrorRecovery::UserChange {
1694                user_id,
1695                idempotency_key,
1696            } => {
1697                writeln!(output, "maincopy: idempotency key: {idempotency_key}")?;
1698                match user_id {
1699                    Some(user_id) => writeln!(
1700                        output,
1701                        "maincopy: inspect current state: maincopy users inspect {user_id}"
1702                    )?,
1703                    None => writeln!(
1704                        output,
1705                        "maincopy: inspect current accounts: maincopy users list"
1706                    )?,
1707                }
1708                writeln!(
1709                    output,
1710                    "maincopy: retry only the identical command with this key and authorizing session"
1711                )?;
1712            }
1713            ErrorRecovery::ProfileChange(idempotency_key) => {
1714                writeln!(output, "maincopy: idempotency key: {idempotency_key}")?;
1715                writeln!(
1716                    output,
1717                    "maincopy: inspect current state with profile show or tip-recipient show; retry only the identical command with this key"
1718                )?;
1719            }
1720            ErrorRecovery::None | ErrorRecovery::Publication(_) => {}
1721            ErrorRecovery::ReleaseChange { operation_id, .. } => {
1722                writeln!(
1723                    output,
1724                    "maincopy: recover accepted result: maincopy releases operation {operation_id}"
1725                )?;
1726            }
1727            ErrorRecovery::SourceSyncStart(idempotency_key) => {
1728                writeln!(output, "maincopy: idempotency key: {idempotency_key}")?;
1729            }
1730            ErrorRecovery::SourceSync {
1731                idempotency_key,
1732                source_sync_id,
1733                failure_code,
1734                ..
1735            } => {
1736                writeln!(output, "maincopy: source sync: {source_sync_id}")?;
1737                writeln!(output, "maincopy: idempotency key: {idempotency_key}")?;
1738                if let Some(failure_code) = failure_code {
1739                    writeln!(output, "maincopy: failure code: {}", failure_code.as_str())?;
1740                }
1741            }
1742        }
1743        Ok(())
1744    }
1745}
1746
1747fn write_json_error(
1748    mut output: impl io::Write,
1749    error: &CliError,
1750    exit: u8,
1751    problem: Option<&AdminProblem>,
1752    request_id: Option<Uuid>,
1753    recovery: ErrorRecovery,
1754) -> io::Result<()> {
1755    let mut details = serde_json::Map::from_iter([
1756        ("category".into(), json!(error_category(error, exit))),
1757        ("message".into(), json!(error.to_string())),
1758    ]);
1759    match recovery {
1760        ErrorRecovery::UserChange {
1761            user_id,
1762            idempotency_key,
1763        } => {
1764            details.insert("user_id".into(), json!(user_id));
1765            details.insert("idempotency_key".into(), json!(idempotency_key));
1766        }
1767        ErrorRecovery::None => {}
1768        ErrorRecovery::ReleaseChange {
1769            publication_id,
1770            operation_id,
1771        } => {
1772            details.insert("publication_id".into(), json!(publication_id));
1773            details.insert("operation_id".into(), json!(operation_id));
1774        }
1775        ErrorRecovery::AgentChange(idempotency_key)
1776        | ErrorRecovery::Publication(idempotency_key)
1777        | ErrorRecovery::ProfileChange(idempotency_key)
1778        | ErrorRecovery::SourceSyncStart(idempotency_key) => {
1779            details.insert("idempotency_key".into(), json!(idempotency_key));
1780        }
1781        ErrorRecovery::SourceSync {
1782            idempotency_key,
1783            source_sync_id,
1784            outcome,
1785            failure_code,
1786        } => {
1787            details.insert("idempotency_key".into(), json!(idempotency_key));
1788            details.insert("source_sync_id".into(), json!(source_sync_id));
1789            if let Some(outcome) = outcome {
1790                details.insert("outcome".into(), json!(outcome));
1791            }
1792            if let Some(failure_code) = failure_code {
1793                details.insert("failure_code".into(), json!(failure_code.as_str()));
1794            }
1795        }
1796    }
1797    if let Some(problem) = problem {
1798        details.insert("code".into(), json!(problem.code));
1799        details.insert("server_message".into(), json!(problem.message));
1800    }
1801    if let Some(request_id) = request_id {
1802        details.insert("request_id".into(), json!(request_id));
1803    }
1804    writeln!(output, "{}", json!({ "error": details }))
1805}
1806
1807const fn error_recovery(error: &CliError) -> ErrorRecovery {
1808    match error {
1809        CliError::AgentChange {
1810            idempotency_key, ..
1811        } => ErrorRecovery::AgentChange(*idempotency_key),
1812        CliError::UserChange {
1813            user_id,
1814            idempotency_key,
1815            ..
1816        }
1817        | CliError::AccountInput {
1818            user_id,
1819            idempotency_key,
1820            ..
1821        } => ErrorRecovery::UserChange {
1822            user_id: *user_id,
1823            idempotency_key: *idempotency_key,
1824        },
1825        CliError::ProfileChange {
1826            idempotency_key, ..
1827        } => ErrorRecovery::ProfileChange(*idempotency_key),
1828        CliError::Publication {
1829            idempotency_key, ..
1830        } => ErrorRecovery::Publication(*idempotency_key),
1831        CliError::ReleaseChange {
1832            publication_id,
1833            operation_id,
1834            ..
1835        } => ErrorRecovery::ReleaseChange {
1836            publication_id: *publication_id,
1837            operation_id: *operation_id,
1838        },
1839        CliError::SourceSyncStart {
1840            idempotency_key, ..
1841        } => ErrorRecovery::SourceSyncStart(*idempotency_key),
1842        CliError::SourceSyncFollow {
1843            idempotency_key,
1844            source_sync_id,
1845            ..
1846        }
1847        | CliError::SourceSyncTimedOut {
1848            idempotency_key,
1849            source_sync_id,
1850        }
1851        | CliError::InvalidSourceSyncResponse {
1852            idempotency_key,
1853            source_sync_id,
1854            ..
1855        } => ErrorRecovery::SourceSync {
1856            idempotency_key: *idempotency_key,
1857            source_sync_id: *source_sync_id,
1858            outcome: None,
1859            failure_code: None,
1860        },
1861        CliError::SourceSyncTerminalFailure {
1862            idempotency_key,
1863            source_sync_id,
1864            outcome,
1865            failure_code,
1866        } => ErrorRecovery::SourceSync {
1867            idempotency_key: *idempotency_key,
1868            source_sync_id: *source_sync_id,
1869            outcome: Some(outcome),
1870            failure_code: *failure_code,
1871        },
1872        CliError::Admin(_)
1873        | CliError::SecretInput(_)
1874        | CliError::PostsSnapshotChanged { .. }
1875        | CliError::InvalidPostsPagination { .. }
1876        | CliError::InvalidPublicationResponse { .. }
1877        | CliError::InvalidPreviewSelector { .. }
1878        | CliError::PreviewRevisionMismatch { .. }
1879        | CliError::PreviewContentDigestMismatch { .. }
1880        | CliError::PreviewOutputExists { .. }
1881        | CliError::PreviewOutput { .. }
1882        | CliError::Output(_)
1883        | CliError::Encode(_) => ErrorRecovery::None,
1884    }
1885}
1886
1887fn error_category(error: &CliError, exit: u8) -> &'static str {
1888    match admin_error(error) {
1889        Some(AdminClientError::HttpStatus { status, .. }) if status.as_u16() == 401 => {
1890            "authentication"
1891        }
1892        Some(AdminClientError::HttpStatus { status, .. }) if status.as_u16() == 403 => {
1893            "authorization"
1894        }
1895        _ => match exit {
1896            VALIDATION => "validation",
1897            UNAVAILABLE => "availability",
1898            CONFLICT => "conflict",
1899            PERMISSION => "permission",
1900            _ => "internal",
1901        },
1902    }
1903}
1904
1905fn admin_error(error: &CliError) -> Option<&AdminClientError> {
1906    match error {
1907        CliError::Admin(error)
1908        | CliError::AgentChange { source: error, .. }
1909        | CliError::UserChange { source: error, .. }
1910        | CliError::ProfileChange { source: error, .. }
1911        | CliError::Publication { source: error, .. }
1912        | CliError::ReleaseChange { source: error, .. }
1913        | CliError::SourceSyncStart { source: error, .. }
1914        | CliError::SourceSyncFollow { source: error, .. } => Some(error),
1915        CliError::PostsSnapshotChanged { .. }
1916        | CliError::AccountInput { .. }
1917        | CliError::SecretInput(_)
1918        | CliError::InvalidPostsPagination { .. }
1919        | CliError::InvalidPublicationResponse { .. }
1920        | CliError::SourceSyncTimedOut { .. }
1921        | CliError::SourceSyncTerminalFailure { .. }
1922        | CliError::InvalidSourceSyncResponse { .. }
1923        | CliError::InvalidPreviewSelector { .. }
1924        | CliError::PreviewRevisionMismatch { .. }
1925        | CliError::PreviewContentDigestMismatch { .. }
1926        | CliError::PreviewOutputExists { .. }
1927        | CliError::PreviewOutput { .. }
1928        | CliError::Output(_)
1929        | CliError::Encode(_) => None,
1930    }
1931}
1932
1933#[cfg(test)]
1934mod tests {
1935    use std::{cell::Cell, collections::VecDeque, future::ready};
1936
1937    use maincopy_shared::FeatureVersions;
1938    use serde_json::json;
1939
1940    use super::*;
1941    use crate::client::AdminProblem;
1942
1943    const PREVIEW_DIGEST: &str =
1944        "preview-b3-v1-4444444444444444444444444444444444444444444444444444444444444444";
1945    const SOURCE_SYNC_ID: &str = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
1946    const SOURCE_COMMIT: &str = "git-sha1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1947    const SOURCE_CONTENT_DIGEST: &str =
1948        "content-b3-v1-3333333333333333333333333333333333333333333333333333333333333333";
1949
1950    fn capabilities() -> Capabilities {
1951        Capabilities {
1952            api_version: AdminApiVersion::V1,
1953            features: FeatureVersions {
1954                capabilities: CapabilityContractVersion::V1,
1955            },
1956        }
1957    }
1958
1959    fn publication_response() -> PublishNowResponse {
1960        serde_json::from_value(json!({
1961            "publication_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
1962            "post_id": "11111111-1111-4111-8111-111111111111",
1963            "preview_digest": PREVIEW_DIGEST,
1964            "revision":
1965                "post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111",
1966            "state": "published",
1967            "published_at": "2026-08-30T12:00:00Z",
1968            "site_digest":
1969                "site-b3-v1-2222222222222222222222222222222222222222222222222222222222222222",
1970            "site_version": 2
1971        }))
1972        .unwrap()
1973    }
1974
1975    fn scheduled_response() -> PublishNowResponse {
1976        serde_json::from_value(json!({
1977            "publication_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
1978            "post_id": "11111111-1111-4111-8111-111111111111",
1979            "preview_digest": PREVIEW_DIGEST,
1980            "revision":
1981                "post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111",
1982            "state": "scheduled",
1983            "scheduled_for": "2026-09-01T12:30:00Z",
1984            "published_at": null,
1985            "site_digest":
1986                "site-b3-v1-2222222222222222222222222222222222222222222222222222222222222222",
1987            "site_version": 2
1988        }))
1989        .unwrap()
1990    }
1991
1992    fn posts_response() -> ListPostsResponse {
1993        serde_json::from_value(json!({
1994                "content_digest":
1995                    "content-b3-v1-3333333333333333333333333333333333333333333333333333333333333333",
1996                "site_digest":
1997                    "site-b3-v1-2222222222222222222222222222222222222222222222222222222222222222",
1998                "site_version": 2,
1999                "posts": [
2000                    {
2001                        "post_id": "11111111-1111-4111-8111-111111111111",
2002                        "source_path": "posts/ready.md",
2003                        "title": "Ready to publish",
2004                        "slug": "ready-to-publish",
2005                        "revision":
2006                            "post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111",
2007                        "publication_state": "unpublished_change",
2008                        "published_at": "2026-08-29T12:00:00Z"
2009                    },
2010                    {
2011                        "post_id": "22222222-2222-4222-8222-222222222222",
2012                        "source_path": "posts/already-live.md",
2013                        "title": "Already live",
2014                        "slug": "already-live",
2015                        "revision":
2016                            "post-b3-v1-2222222222222222222222222222222222222222222222222222222222222222",
2017                        "publication_state": "published",
2018                        "published_at": "2026-08-30T12:00:00Z"
2019                    }
2020                ],
2021                "next_cursor": null
2022            }))
2023            .unwrap()
2024    }
2025
2026    fn preview_response() -> PostPreview {
2027        PostPreview {
2028            html: "<!doctype html><title>Ready</title>".into(),
2029            preview_digest: PreviewDigest::parse(PREVIEW_DIGEST).unwrap(),
2030            revision: "post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111"
2031                .into(),
2032            content_digest:
2033                "content-b3-v1-3333333333333333333333333333333333333333333333333333333333333333"
2034                    .into(),
2035            canonical_url: "https://example.test/posts/ready".into(),
2036        }
2037    }
2038
2039    fn session_response() -> AdminSessionResponse {
2040        serde_json::from_value(json!({
2041            "session_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
2042            "user_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
2043            "provider": "password",
2044            "roles": ["owner", "publisher"],
2045            "scopes": ["content_read"],
2046            "fresh_until": "2026-09-03T13:00:00Z",
2047            "expires_at": "2026-09-04T12:00:00Z"
2048        }))
2049        .unwrap()
2050    }
2051
2052    fn source_sync_resource(
2053        stage: &str,
2054        outcome: Option<SourceSyncOutcome>,
2055        version: u64,
2056    ) -> SourceSyncResource {
2057        let (source_commit, content_digest, failure_code) = match outcome {
2058            Some(SourceSyncOutcome::Applied) => {
2059                (Some(SOURCE_COMMIT), Some(SOURCE_CONTENT_DIGEST), None)
2060            }
2061            Some(SourceSyncOutcome::NoChange) => {
2062                (Some(SOURCE_COMMIT), Some(SOURCE_CONTENT_DIGEST), None)
2063            }
2064            Some(SourceSyncOutcome::Failed) => (None, None, Some("remote_unavailable")),
2065            Some(SourceSyncOutcome::Cancelled) | None => (None, None, None),
2066        };
2067        serde_json::from_value(json!({
2068            "source_sync_id": SOURCE_SYNC_ID,
2069            "configuration_version": 3,
2070            "request_origin": "manual",
2071            "stage": stage,
2072            "outcome": outcome,
2073            "source_commit": source_commit,
2074            "content_digest": content_digest,
2075            "failure_code": failure_code,
2076            "version": version,
2077            "requested_at": "2026-09-04T12:00:00Z",
2078            "updated_at": "2026-09-04T12:00:01Z",
2079            "finished_at": outcome.map(|_| "2026-09-04T12:00:01Z")
2080        }))
2081        .unwrap()
2082    }
2083
2084    fn managed_source_status() -> SourceStatusResponse {
2085        serde_json::from_value(json!({
2086            "mode": "managed_git",
2087            "configuration": {
2088                "remote": {
2089                    "user": "git",
2090                    "host": "git.example.test",
2091                    "port": 22,
2092                    "repository_path": "publisher/site.git"
2093                },
2094                "branch": "main",
2095                "content_subdirectory": "publication",
2096                "credential_name": "deploy-key-1",
2097                "poll_interval_seconds": 300,
2098                "version": 3,
2099                "updated_at": "2026-09-04T11:55:00Z"
2100            },
2101            "installed_commit": SOURCE_COMMIT,
2102            "content_digest": SOURCE_CONTENT_DIGEST,
2103            "active_sync": null,
2104            "latest_sync": source_sync_resource(
2105                "reloading",
2106                Some(SourceSyncOutcome::Applied),
2107                4
2108            ),
2109            "next_poll_at": "2026-09-04T12:05:00Z"
2110        }))
2111        .unwrap()
2112    }
2113
2114    fn post_page(posts: Vec<PostSummary>, next_cursor: Option<Uuid>) -> ListPostsResponse {
2115        ListPostsResponse {
2116            content_digest:
2117                "content-b3-v1-3333333333333333333333333333333333333333333333333333333333333333"
2118                    .into(),
2119            site_digest:
2120                "site-b3-v1-2222222222222222222222222222222222222222222222222222222222222222".into(),
2121            site_version: 2,
2122            posts,
2123            next_cursor,
2124        }
2125    }
2126
2127    use maincopy_shared::publication::ReleaseBlockReason;
2128
2129    #[test]
2130    fn release_failures_preserve_recovery_identifiers_and_exit_categories() {
2131        let publication_id = Uuid::from_u128(1);
2132        let operation_id = Uuid::from_u128(2);
2133        for (status, expected_exit) in [
2134            (412, CONFLICT),
2135            (409, CONFLICT),
2136            (400, VALIDATION),
2137            (503, UNAVAILABLE),
2138        ] {
2139            let error = CliError::ReleaseChange {
2140                publication_id,
2141                operation_id,
2142                source: AdminClientError::HttpStatus {
2143                    status: reqwest::StatusCode::from_u16(status).unwrap(),
2144                    problem: None,
2145                    request_id: None,
2146                },
2147            };
2148            assert_eq!(error_exit(&error), expected_exit);
2149            let mut output = Vec::new();
2150            write_error(&mut output, &error, expected_exit, true).unwrap();
2151            let value: serde_json::Value = serde_json::from_slice(&output).unwrap();
2152            assert_eq!(value["error"]["publication_id"], publication_id.to_string());
2153            assert_eq!(value["error"]["operation_id"], operation_id.to_string());
2154            output.clear();
2155            write_error(&mut output, &error, expected_exit, false).unwrap();
2156            assert!(
2157                String::from_utf8(output)
2158                    .unwrap()
2159                    .contains(&format!("maincopy releases operation {operation_id}"))
2160            );
2161        }
2162    }
2163
2164    #[test]
2165    fn release_output_distinguishes_current_state_from_accepted_receipts() {
2166        let release = ReleaseResource {
2167            publication_id: Uuid::from_u128(1),
2168            post_id: Uuid::from_u128(2),
2169            preview_digest: PreviewDigest::parse(&format!("preview-b3-v1-{}", "11".repeat(32)))
2170                .unwrap(),
2171            revision: format!("post-b3-v1-{}", "22".repeat(32)).into_boxed_str(),
2172            state: ReleaseState::Blocked,
2173            version: 3,
2174            scheduled_for: OffsetDateTime::UNIX_EPOCH,
2175            published_at: None,
2176            block_reason: Some(ReleaseBlockReason::RevisionUnavailable),
2177        };
2178        let operation = ReleaseOperationResource {
2179            operation_id: Uuid::from_u128(3),
2180            publication_id: release.publication_id,
2181            version: 2,
2182            state: ReleaseState::Activating,
2183        };
2184        let mut output = Vec::new();
2185        write_release(&mut output, release.clone(), true).unwrap();
2186        assert_eq!(
2187            serde_json::from_slice::<ReleaseResource>(&output).unwrap(),
2188            release
2189        );
2190        output.clear();
2191        write_release(&mut output, release.clone(), false).unwrap();
2192        let text = String::from_utf8(output).unwrap();
2193        assert!(text.contains("State: blocked\nVersion: 3"));
2194        assert!(text.contains("revision_unavailable"));
2195        let mut output = Vec::new();
2196        write_release_operation(&mut output, operation.clone(), true).unwrap();
2197        assert_eq!(
2198            serde_json::from_slice::<ReleaseOperationResource>(&output).unwrap(),
2199            operation
2200        );
2201        output.clear();
2202        write_release_operation(&mut output, operation, false).unwrap();
2203        assert!(
2204            String::from_utf8(output)
2205                .unwrap()
2206                .contains("Accepted version: 2\nAccepted state: activating")
2207        );
2208        let page = ListReleasesResponse {
2209            releases: vec![release],
2210            next_cursor: Some(Uuid::from_u128(1)),
2211        };
2212        let mut output = Vec::new();
2213        write_releases(&mut output, page.clone(), true).unwrap();
2214        assert_eq!(
2215            serde_json::from_slice::<ListReleasesResponse>(&output).unwrap(),
2216            page
2217        );
2218        output.clear();
2219        write_releases(&mut output, page, false).unwrap();
2220        assert!(
2221            String::from_utf8(output)
2222                .unwrap()
2223                .contains("Next page: maincopy releases list --cursor")
2224        );
2225    }
2226
2227    #[test]
2228    fn http_status_failures_use_stable_exit_categories() {
2229        let authentication = CliError::Admin(AdminClientError::HttpStatus {
2230            status: reqwest::StatusCode::UNAUTHORIZED,
2231            problem: None,
2232            request_id: None,
2233        });
2234        let authorization = CliError::Admin(AdminClientError::HttpStatus {
2235            status: reqwest::StatusCode::FORBIDDEN,
2236            problem: None,
2237            request_id: None,
2238        });
2239
2240        assert_eq!(error_exit(&authentication), PERMISSION);
2241        assert_eq!(
2242            error_category(&authentication, PERMISSION),
2243            "authentication"
2244        );
2245        assert_eq!(error_exit(&authorization), PERMISSION);
2246        assert_eq!(error_category(&authorization, PERMISSION), "authorization");
2247
2248        for status in [
2249            reqwest::StatusCode::PAYLOAD_TOO_LARGE,
2250            reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE,
2251        ] {
2252            let invalid_request = CliError::Admin(AdminClientError::HttpStatus {
2253                status,
2254                problem: None,
2255                request_id: None,
2256            });
2257            assert_eq!(error_exit(&invalid_request), VALIDATION);
2258            assert_eq!(error_category(&invalid_request, VALIDATION), "validation");
2259        }
2260    }
2261
2262    #[test]
2263    fn certificate_authority_failures_use_stable_exit_categories() {
2264        let path = PathBuf::from("development-ca.pem");
2265        let error = |source| CliError::Admin(AdminClientError::AdditionalRootCertificates(source));
2266
2267        let permission = error(AdditionalRootCertificateError::Open {
2268            path: path.clone(),
2269            source: io::Error::from(io::ErrorKind::PermissionDenied),
2270        });
2271        assert_eq!(error_exit(&permission), PERMISSION);
2272
2273        let missing = error(AdditionalRootCertificateError::Open {
2274            path: path.clone(),
2275            source: io::Error::from(io::ErrorKind::NotFound),
2276        });
2277        assert_eq!(error_exit(&missing), VALIDATION);
2278
2279        let unavailable = error(AdditionalRootCertificateError::Read {
2280            path: path.clone(),
2281            source: io::Error::from(io::ErrorKind::BrokenPipe),
2282        });
2283        assert_eq!(error_exit(&unavailable), UNAVAILABLE);
2284
2285        for validation in [
2286            AdditionalRootCertificateError::NotRegularFile { path: path.clone() },
2287            AdditionalRootCertificateError::ChangedDuringOpen { path: path.clone() },
2288            AdditionalRootCertificateError::TooLarge { path: path.clone() },
2289            AdditionalRootCertificateError::UnexpectedPemSection { path: path.clone() },
2290            AdditionalRootCertificateError::InvalidCount {
2291                path: path.clone(),
2292                count: 0,
2293            },
2294        ] {
2295            assert_eq!(error_exit(&error(validation)), VALIDATION);
2296        }
2297
2298        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
2299        assert_eq!(
2300            error_exit(&error(
2301                AdditionalRootCertificateError::UnsupportedPlatform { path }
2302            )),
2303            VALIDATION
2304        );
2305    }
2306
2307    #[test]
2308    fn json_output_is_the_shared_wire_contract() {
2309        let mut output = Vec::new();
2310
2311        write_capabilities(&mut output, capabilities(), true).unwrap();
2312
2313        assert_eq!(
2314            serde_json::from_slice::<serde_json::Value>(&output).unwrap(),
2315            json!({
2316                "api_version": "v1",
2317                "features": { "capabilities": "v1" }
2318            })
2319        );
2320    }
2321
2322    #[test]
2323    fn human_output_names_each_version() {
2324        let mut output = Vec::new();
2325
2326        write_capabilities(&mut output, capabilities(), false).unwrap();
2327
2328        assert_eq!(
2329            String::from_utf8(output).unwrap(),
2330            "Admin API: v1\nCapabilities contract: v1\n"
2331        );
2332    }
2333
2334    #[test]
2335    fn deploy_key_output_preserves_the_public_identity_in_human_and_json_modes() {
2336        let identity = SourceDeployKeyResponse {
2337            credential_name: "deploy-key-1".parse().unwrap(),
2338            public_key: format!("ssh-ed25519 {}", "A".repeat(68)).into(),
2339            fingerprint: format!("SHA256:{}", "A".repeat(43)).into(),
2340        };
2341        let mut human = Vec::new();
2342        write_output(
2343            &mut human,
2344            CommandOutput::SourceDeployKey(identity.clone()),
2345            false,
2346        )
2347        .unwrap();
2348        assert_eq!(
2349            String::from_utf8(human).unwrap(),
2350            format!(
2351                "Deploy credential: deploy-key-1\nPublic key: {}\nFingerprint: {}\n",
2352                identity.public_key, identity.fingerprint
2353            )
2354        );
2355        let mut machine = Vec::new();
2356        write_output(
2357            &mut machine,
2358            CommandOutput::SourceDeployKey(identity.clone()),
2359            true,
2360        )
2361        .unwrap();
2362        assert_eq!(machine.last(), Some(&b'\n'));
2363        assert_eq!(
2364            serde_json::from_slice::<SourceDeployKeyResponse>(&machine).unwrap(),
2365            identity
2366        );
2367        for json in [false, true] {
2368            let mut full = [0; 0];
2369            let error = write_output(
2370                &mut full[..],
2371                CommandOutput::SourceDeployKey(identity.clone()),
2372                json,
2373            )
2374            .unwrap_err();
2375            assert!(matches!(error, CliError::Output(_) | CliError::Encode(_)));
2376        }
2377    }
2378
2379    #[test]
2380    fn source_status_has_direct_machine_output_and_bounded_operator_fields() {
2381        let status = managed_source_status();
2382        let mut json_output = Vec::new();
2383        write_source_status(&mut json_output, status.clone(), true).unwrap();
2384        assert_eq!(
2385            serde_json::from_slice::<SourceStatusResponse>(&json_output).unwrap(),
2386            status
2387        );
2388
2389        let mut human_output = Vec::new();
2390        write_source_status(&mut human_output, status, false).unwrap();
2391        let human_output = String::from_utf8(human_output).unwrap();
2392        for expected in [
2393            "Source mode: managed_git\n",
2394            "Remote: git@git.example.test:22/publisher/site.git\n",
2395            "Branch: main\n",
2396            "Content subdirectory: publication\n",
2397            "Credential: deploy-key-1\n",
2398            "Poll interval: 300 seconds\n",
2399            "Configuration version: 3\n",
2400            "Installed commit: git-sha1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
2401            "Content: content-b3-v1-3333333333333333333333333333333333333333333333333333333333333333\n",
2402            "Latest sync: dddddddd-dddd-4ddd-8ddd-dddddddddddd\n",
2403        ] {
2404            assert!(human_output.contains(expected), "missing {expected:?}");
2405        }
2406        assert!(!human_output.contains("private_key"));
2407
2408        let mut external = Vec::new();
2409        write_source_status(&mut external, SourceStatusResponse::ExternalCheckout, false).unwrap();
2410        assert_eq!(
2411            String::from_utf8(external).unwrap(),
2412            "Source mode: external_checkout\n"
2413        );
2414    }
2415
2416    #[test]
2417    fn asynchronous_source_sync_output_preserves_both_recovery_identities() {
2418        let idempotency_key = Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap();
2419        let queued = source_sync_resource("queued", None, 1);
2420
2421        let mut json_output = Vec::new();
2422        write_source_sync(
2423            &mut json_output,
2424            idempotency_key,
2425            SourceSyncAdmission::Created,
2426            queued.clone(),
2427            true,
2428        )
2429        .unwrap();
2430        let document = serde_json::from_slice::<serde_json::Value>(&json_output).unwrap();
2431        assert_eq!(document["idempotency_key"], idempotency_key.to_string());
2432        assert_eq!(document["admission"], "created");
2433        assert_eq!(document["sync"]["source_sync_id"], SOURCE_SYNC_ID);
2434        assert_eq!(document["sync"]["stage"], "queued");
2435
2436        let mut human_output = Vec::new();
2437        write_source_sync(
2438            &mut human_output,
2439            idempotency_key,
2440            SourceSyncAdmission::Coalesced,
2441            queued,
2442            false,
2443        )
2444        .unwrap();
2445        let human_output = String::from_utf8(human_output).unwrap();
2446        assert!(human_output.contains("Source sync: dddddddd-dddd-4ddd-8ddd-dddddddddddd\n"));
2447        assert!(human_output.contains("Admission: coalesced\n"));
2448        assert!(human_output.contains("Status: queued\n"));
2449        assert!(human_output.contains("Idempotency key: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\n"));
2450    }
2451
2452    #[tokio::test]
2453    async fn source_sync_wait_is_bounded_and_stops_on_success() {
2454        let idempotency_key = Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap();
2455        let initial = source_sync_resource("queued", None, 1);
2456        let fetching = source_sync_resource("fetching", None, 2);
2457        let applied = source_sync_resource("reloading", Some(SourceSyncOutcome::Applied), 3);
2458        let mut responses = VecDeque::from([Ok(fetching), Ok(applied.clone())]);
2459        let polls = Cell::new(0);
2460        let pauses = Cell::new(0);
2461
2462        let completed = poll_source_sync(
2463            initial,
2464            idempotency_key,
2465            |source_sync_id| {
2466                assert_eq!(source_sync_id.to_string(), SOURCE_SYNC_ID);
2467                polls.set(polls.get() + 1);
2468                ready(responses.pop_front().unwrap())
2469            },
2470            || {
2471                pauses.set(pauses.get() + 1);
2472                ready(())
2473            },
2474            3,
2475        )
2476        .await
2477        .unwrap();
2478
2479        assert_eq!(completed, applied);
2480        assert_eq!(polls.get(), 2);
2481        assert_eq!(pauses.get(), 2);
2482
2483        let polls = Cell::new(0);
2484        let no_change =
2485            source_sync_resource("resolving_commit", Some(SourceSyncOutcome::NoChange), 2);
2486        let completed = poll_source_sync(
2487            no_change.clone(),
2488            idempotency_key,
2489            |_| {
2490                polls.set(polls.get() + 1);
2491                ready(Ok(no_change.clone()))
2492            },
2493            || ready(()),
2494            3,
2495        )
2496        .await
2497        .unwrap();
2498        assert_eq!(completed, no_change);
2499        assert_eq!(polls.get(), 0);
2500    }
2501
2502    #[tokio::test]
2503    async fn source_sync_wait_reports_terminal_failure_and_poll_limit() {
2504        let idempotency_key = Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap();
2505        let initial = source_sync_resource("queued", None, 1);
2506        let failed = source_sync_resource("fetching", Some(SourceSyncOutcome::Failed), 2);
2507        let mut responses = VecDeque::from([Ok(failed)]);
2508        let error = poll_source_sync(
2509            initial.clone(),
2510            idempotency_key,
2511            |_| ready(responses.pop_front().unwrap()),
2512            || ready(()),
2513            2,
2514        )
2515        .await
2516        .unwrap_err();
2517        assert!(matches!(
2518            error,
2519            CliError::SourceSyncTerminalFailure {
2520                failure_code: Some(SourceSyncFailureCode::RemoteUnavailable),
2521                ..
2522            }
2523        ));
2524        assert_eq!(error_exit(&error), UNAVAILABLE);
2525
2526        let polls = Cell::new(0);
2527        let error = poll_source_sync(
2528            initial.clone(),
2529            idempotency_key,
2530            |_| {
2531                polls.set(polls.get() + 1);
2532                ready(Ok(initial.clone()))
2533            },
2534            || ready(()),
2535            2,
2536        )
2537        .await
2538        .unwrap_err();
2539        assert!(matches!(error, CliError::SourceSyncTimedOut { .. }));
2540        assert_eq!(polls.get(), 2);
2541    }
2542
2543    #[test]
2544    fn source_sync_failures_report_safe_details_and_recovery_identities() {
2545        let idempotency_key = Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap();
2546        let failed = source_sync_resource("fetching", Some(SourceSyncOutcome::Failed), 2);
2547        let error = source_sync_finished(&failed, idempotency_key).unwrap_err();
2548        let exit = error_exit(&error);
2549
2550        let mut json_output = Vec::new();
2551        write_error(&mut json_output, &error, exit, true).unwrap();
2552        let document = serde_json::from_slice::<serde_json::Value>(&json_output).unwrap();
2553        assert_eq!(document["error"]["source_sync_id"], SOURCE_SYNC_ID);
2554        assert_eq!(
2555            document["error"]["idempotency_key"],
2556            idempotency_key.to_string()
2557        );
2558        assert_eq!(document["error"]["failure_code"], "remote_unavailable");
2559        assert!(document["error"].get("diagnostic").is_none());
2560
2561        let mut human_output = Vec::new();
2562        write_error(&mut human_output, &error, exit, false).unwrap();
2563        let human_output = String::from_utf8(human_output).unwrap();
2564        assert!(human_output.contains("source sync: dddddddd-dddd-4ddd-8ddd-dddddddddddd\n"));
2565        assert!(human_output.contains("idempotency key: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\n"));
2566        assert!(human_output.contains("failure code: remote_unavailable\n"));
2567        assert!(!human_output.contains("diagnostic"));
2568    }
2569
2570    #[test]
2571    fn source_sync_outcomes_control_completion() {
2572        let idempotency_key = Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap();
2573        for (stage, outcome, finished) in [
2574            ("queued", None, false),
2575            ("reloading", Some(SourceSyncOutcome::Applied), true),
2576            ("resolving_commit", Some(SourceSyncOutcome::NoChange), true),
2577        ] {
2578            let sync = source_sync_resource(stage, outcome, 2);
2579            assert_eq!(
2580                source_sync_finished(&sync, idempotency_key).unwrap(),
2581                finished
2582            );
2583        }
2584        let cancelled = source_sync_resource("fetching", Some(SourceSyncOutcome::Cancelled), 2);
2585        assert!(matches!(
2586            source_sync_finished(&cancelled, idempotency_key),
2587            Err(CliError::SourceSyncTerminalFailure {
2588                outcome: "cancelled",
2589                failure_code: None,
2590                ..
2591            })
2592        ));
2593    }
2594
2595    #[test]
2596    fn source_sync_errors_preserve_available_recovery_identities() {
2597        let idempotency_key = Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap();
2598        let source_sync_id = SOURCE_SYNC_ID.parse().unwrap();
2599        let errors = [
2600            (
2601                CliError::SourceSyncStart {
2602                    idempotency_key,
2603                    source: AdminClientError::InvalidAdminOrigin,
2604                },
2605                false,
2606            ),
2607            (
2608                CliError::SourceSyncFollow {
2609                    idempotency_key,
2610                    source_sync_id,
2611                    source: AdminClientError::InvalidAdminOrigin,
2612                },
2613                true,
2614            ),
2615            (
2616                CliError::SourceSyncTimedOut {
2617                    idempotency_key,
2618                    source_sync_id,
2619                },
2620                true,
2621            ),
2622            (
2623                CliError::InvalidSourceSyncResponse {
2624                    idempotency_key,
2625                    source_sync_id,
2626                    message: "operation identity changed while polling",
2627                },
2628                true,
2629            ),
2630        ];
2631
2632        for (error, has_source_sync_id) in &errors {
2633            let exit = error_exit(error);
2634            let mut json_output = Vec::new();
2635            write_error(&mut json_output, error, exit, true).unwrap();
2636            let document = serde_json::from_slice::<serde_json::Value>(&json_output).unwrap();
2637            assert_eq!(
2638                document["error"]["idempotency_key"],
2639                idempotency_key.to_string()
2640            );
2641            if *has_source_sync_id {
2642                assert_eq!(document["error"]["source_sync_id"], SOURCE_SYNC_ID);
2643            } else {
2644                assert!(document["error"].get("source_sync_id").is_none());
2645            }
2646
2647            let mut human_output = Vec::new();
2648            write_error(&mut human_output, error, exit, false).unwrap();
2649            let human_output = String::from_utf8(human_output).unwrap();
2650            assert!(
2651                human_output
2652                    .contains("maincopy: idempotency key: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\n")
2653            );
2654            assert_eq!(
2655                human_output
2656                    .contains("maincopy: source sync: dddddddd-dddd-4ddd-8ddd-dddddddddddd\n"),
2657                *has_source_sync_id
2658            );
2659        }
2660    }
2661
2662    #[test]
2663    fn posts_json_output_is_the_combined_shared_response() {
2664        let mut output = Vec::new();
2665
2666        write_posts(&mut output, posts_response(), true).unwrap();
2667
2668        let value = serde_json::from_slice::<serde_json::Value>(&output).unwrap();
2669        assert_eq!(value["site_version"], 2);
2670        assert_eq!(value["posts"].as_array().unwrap().len(), 2);
2671        assert!(value["next_cursor"].is_null());
2672        assert_eq!(value["posts"][0]["source_path"], "posts/ready.md");
2673        assert_eq!(value["posts"][0]["publication_state"], "unpublished_change");
2674    }
2675
2676    #[test]
2677    fn posts_human_output_exposes_operator_selection_fields() {
2678        let mut output = Vec::new();
2679
2680        write_posts(&mut output, posts_response(), false).unwrap();
2681
2682        assert_eq!(
2683            String::from_utf8(output).unwrap(),
2684            concat!(
2685                "Site: site-b3-v1-2222222222222222222222222222222222222222222222222222222222222222 (version 2)\n",
2686                "Content: content-b3-v1-3333333333333333333333333333333333333333333333333333333333333333\n",
2687                "Posts: 2\n",
2688                "\n",
2689                "[unpublished_change] Ready to publish\n",
2690                "  ID: 11111111-1111-4111-8111-111111111111\n",
2691                "  Revision: post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111\n",
2692                "  Source: posts/ready.md\n",
2693                "  Slug: ready-to-publish\n",
2694                "  Current publication at: 2026-08-29 12:00:00.0 +00:00:00\n",
2695                "\n",
2696                "[published] Already live\n",
2697                "  ID: 22222222-2222-4222-8222-222222222222\n",
2698                "  Revision: post-b3-v1-2222222222222222222222222222222222222222222222222222222222222222\n",
2699                "  Source: posts/already-live.md\n",
2700                "  Slug: already-live\n",
2701                "  Published at: 2026-08-30 12:00:00.0 +00:00:00\n"
2702            )
2703        );
2704    }
2705
2706    #[test]
2707    fn preview_output_reports_only_metadata_in_human_and_json_modes() {
2708        let path = PathBuf::from("ready.html");
2709        let post_id = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap();
2710        let mut json_output = Vec::new();
2711        write_preview(&mut json_output, post_id, &path, preview_response(), true).unwrap();
2712        let document = serde_json::from_slice::<serde_json::Value>(&json_output).unwrap();
2713        assert_eq!(document["post_id"], post_id.to_string());
2714        assert_eq!(document["preview_digest"], PREVIEW_DIGEST);
2715        assert_eq!(
2716            document["revision"],
2717            "post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111"
2718        );
2719        assert_eq!(
2720            document["content_digest"],
2721            "content-b3-v1-3333333333333333333333333333333333333333333333333333333333333333"
2722        );
2723        assert_eq!(
2724            document["canonical_url"],
2725            "https://example.test/posts/ready"
2726        );
2727        assert_eq!(document["output"], "ready.html");
2728        assert!(
2729            !String::from_utf8(json_output)
2730                .unwrap()
2731                .contains("<!doctype")
2732        );
2733
2734        let mut human_output = Vec::new();
2735        write_preview(&mut human_output, post_id, &path, preview_response(), false).unwrap();
2736        assert_eq!(
2737            String::from_utf8(human_output).unwrap(),
2738            concat!(
2739                "Preview: preview-b3-v1-4444444444444444444444444444444444444444444444444444444444444444\n",
2740                "Post: 11111111-1111-4111-8111-111111111111\n",
2741                "Revision: post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111\n",
2742                "Content: content-b3-v1-3333333333333333333333333333333333333333333333333333333333333333\n",
2743                "Canonical: https://example.test/posts/ready\n",
2744                "Output: ready.html\n",
2745            )
2746        );
2747    }
2748
2749    #[test]
2750    fn preview_file_creation_never_overwrites_an_existing_path() {
2751        let directory = tempfile::tempdir().unwrap();
2752        let path = directory.path().join("preview.html");
2753        write_preview_file(&path, "first").unwrap();
2754        assert_eq!(std::fs::read_to_string(&path).unwrap(), "first");
2755
2756        let error = write_preview_file(&path, "second").unwrap_err();
2757        assert!(matches!(error, CliError::PreviewOutputExists { .. }));
2758        assert_eq!(error_exit(&error), CONFLICT);
2759        assert_eq!(std::fs::read_to_string(path).unwrap(), "first");
2760    }
2761
2762    #[test]
2763    fn malformed_preview_selectors_are_local_validation_errors() {
2764        for (value, prefix, field) in [
2765            ("post-b3-v1-UPPER", POST_REVISION_PREFIX, "revision"),
2766            (
2767                "content-b3-v1-short",
2768                CONTENT_DIGEST_PREFIX,
2769                "content_digest",
2770            ),
2771        ] {
2772            let error = validate_optional_digest(Some(value), prefix, field).unwrap_err();
2773            assert!(matches!(error, CliError::InvalidPreviewSelector { .. }));
2774            assert_eq!(error_exit(&error), VALIDATION);
2775            assert_eq!(error_category(&error, VALIDATION), "validation");
2776        }
2777    }
2778
2779    #[test]
2780    fn preview_selection_validates_server_identity_before_creating_the_file() {
2781        let directory = tempfile::tempdir().unwrap();
2782        let post_id = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap();
2783        let revision = preview_response().revision.into_string();
2784        let content_digest = preview_response().content_digest.into_string();
2785
2786        let revision_path = directory.path().join("revision-mismatch.html");
2787        let selection = PreviewSelection::new(
2788            post_id,
2789            revision_path.clone(),
2790            Some(format!("{POST_REVISION_PREFIX}{}", "9".repeat(64))),
2791            Some(content_digest.clone()),
2792        )
2793        .unwrap();
2794        assert!(matches!(
2795            selection.accept(preview_response()),
2796            Err(CliError::PreviewRevisionMismatch { .. })
2797        ));
2798        assert!(!revision_path.exists());
2799
2800        let content_path = directory.path().join("content-mismatch.html");
2801        let selection = PreviewSelection::new(
2802            post_id,
2803            content_path.clone(),
2804            Some(revision.clone()),
2805            Some(format!("{CONTENT_DIGEST_PREFIX}{}", "9".repeat(64))),
2806        )
2807        .unwrap();
2808        assert!(matches!(
2809            selection.accept(preview_response()),
2810            Err(CliError::PreviewContentDigestMismatch { .. })
2811        ));
2812        assert!(!content_path.exists());
2813
2814        let output = directory.path().join("accepted.html");
2815        let selection = PreviewSelection::new(
2816            post_id,
2817            output.clone(),
2818            Some(revision),
2819            Some(content_digest),
2820        )
2821        .unwrap();
2822        assert!(matches!(
2823            selection.accept(preview_response()).unwrap(),
2824            CommandOutput::Preview { .. }
2825        ));
2826        assert_eq!(
2827            std::fs::read_to_string(output).unwrap(),
2828            "<!doctype html><title>Ready</title>"
2829        );
2830    }
2831
2832    #[tokio::test]
2833    async fn pagination_combines_stable_pages_in_request_order() {
2834        let mut expected = posts_response();
2835        let second_post = expected.posts.pop().unwrap();
2836        let first_post = expected.posts.pop().unwrap();
2837        expected.posts = vec![first_post.clone(), second_post.clone()];
2838        let cursor = second_post.post_id;
2839        let mut pages = VecDeque::from([
2840            Ok(post_page(vec![first_post], Some(cursor))),
2841            Ok(post_page(vec![second_post], None)),
2842        ]);
2843        let mut requested = Vec::new();
2844
2845        let combined = collect_post_pages(|cursor| {
2846            requested.push(cursor);
2847            ready(pages.pop_front().unwrap())
2848        })
2849        .await
2850        .unwrap();
2851
2852        assert_eq!(requested, [None, Some(cursor)]);
2853        assert_eq!(combined, expected);
2854    }
2855
2856    #[tokio::test]
2857    async fn pagination_rejects_snapshot_changes_repeated_cursors_and_posts() {
2858        let cursor = Uuid::parse_str("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").unwrap();
2859        for change in ["content", "site", "version"] {
2860            let first = post_page(Vec::new(), Some(cursor));
2861            let mut changed = post_page(Vec::new(), None);
2862            match change {
2863                "content" => {
2864                    changed.content_digest =
2865                        format!("{CONTENT_DIGEST_PREFIX}{}", "4".repeat(64)).into()
2866                }
2867                "site" => changed.site_digest = format!("site-b3-v1-{}", "4".repeat(64)).into(),
2868                "version" => changed.site_version += 1,
2869                _ => unreachable!(),
2870            }
2871            let mut pages = VecDeque::from([Ok(first), Ok(changed)]);
2872            let error = collect_post_pages(|_| ready(pages.pop_front().unwrap()))
2873                .await
2874                .unwrap_err();
2875            assert!(
2876                matches!(error, CliError::PostsSnapshotChanged { .. }),
2877                "{change}"
2878            );
2879        }
2880
2881        let repeated = post_page(Vec::new(), Some(cursor));
2882        let mut pages = VecDeque::from([Ok(repeated.clone()), Ok(repeated)]);
2883        let error = collect_post_pages(|_| ready(pages.pop_front().unwrap()))
2884            .await
2885            .unwrap_err();
2886        assert!(matches!(
2887            error,
2888            CliError::InvalidPostsPagination {
2889                message: "next_cursor repeated an earlier cursor"
2890            }
2891        ));
2892
2893        let post = posts_response().posts.remove(0);
2894        let mut pages = VecDeque::from([
2895            Ok(post_page(vec![post.clone()], Some(cursor))),
2896            Ok(post_page(vec![post], None)),
2897        ]);
2898        let error = collect_post_pages(|_| ready(pages.pop_front().unwrap()))
2899            .await
2900            .unwrap_err();
2901        assert!(matches!(
2902            error,
2903            CliError::InvalidPostsPagination {
2904                message: "a post UUID appeared more than once"
2905            }
2906        ));
2907    }
2908
2909    #[tokio::test]
2910    async fn pagination_enforces_the_page_count_safety_limit() {
2911        let mut next = 1_u128;
2912        let error = collect_post_pages(|_| {
2913            let cursor = Uuid::from_u128(next);
2914            next += 1;
2915            ready(Ok(post_page(Vec::new(), Some(cursor))))
2916        })
2917        .await
2918        .unwrap_err();
2919
2920        assert!(matches!(
2921            error,
2922            CliError::InvalidPostsPagination {
2923                message: "the page count exceeded the client safety limit"
2924            }
2925        ));
2926        assert_eq!(next, MAX_POSTS_PAGES as u128 + 1);
2927    }
2928
2929    #[test]
2930    fn authentication_and_agent_key_outputs_have_stable_human_and_json_forms() {
2931        let session = session_response();
2932        let mut json_output = Vec::new();
2933        write_login(&mut json_output, session.clone(), true).unwrap();
2934        assert_eq!(
2935            serde_json::from_slice::<AdminSessionResponse>(&json_output).unwrap(),
2936            session
2937        );
2938
2939        let mut human_output = Vec::new();
2940        write_login(&mut human_output, session, false).unwrap();
2941        assert_eq!(
2942            String::from_utf8(human_output).unwrap(),
2943            concat!(
2944                "Session: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\n",
2945                "User: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\n",
2946                "Provider: password\n",
2947                "Roles: owner, publisher\n",
2948                "Expires at: 2026-09-04 12:00:00.0 +00:00:00\n",
2949            )
2950        );
2951
2952        let revoked = LogoutOutcome::Revoked(
2953            serde_json::from_value(json!({
2954                "session_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
2955            }))
2956            .unwrap(),
2957        );
2958        let mut json_output = Vec::new();
2959        write_logout(&mut json_output, revoked, true).unwrap();
2960        assert_eq!(
2961            serde_json::from_slice::<serde_json::Value>(&json_output).unwrap(),
2962            json!({"session_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"})
2963        );
2964        let mut human_output = Vec::new();
2965        write_logout(&mut human_output, revoked, false).unwrap();
2966        assert_eq!(
2967            String::from_utf8(human_output).unwrap(),
2968            "Revoked session: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\n"
2969        );
2970
2971        let mut expired = Vec::new();
2972        write_logout(&mut expired, LogoutOutcome::NoActiveSession, true).unwrap();
2973        assert_eq!(
2974            serde_json::from_slice::<serde_json::Value>(&expired).unwrap(),
2975            json!({"status":"session_not_accepted", "local_credentials_removed":true})
2976        );
2977        let mut expired = Vec::new();
2978        write_logout(&mut expired, LogoutOutcome::NoActiveSession, false).unwrap();
2979        assert_eq!(
2980            String::from_utf8(expired).unwrap(),
2981            "The server no longer accepts this session. Local credentials removed.\n"
2982        );
2983
2984        let identity = AgentPublicIdentity {
2985            public_key: "public-key".into(),
2986            fingerprint: "SHA256:fingerprint".into(),
2987        };
2988        for json in [false, true] {
2989            let mut configured = Vec::new();
2990            write_agent_key_identity(&mut configured, Some(&identity), json).unwrap();
2991            let configured = String::from_utf8(configured).unwrap();
2992            if json {
2993                assert_eq!(
2994                    serde_json::from_str::<serde_json::Value>(&configured).unwrap(),
2995                    json!({"public_key": "public-key", "fingerprint":"SHA256:fingerprint", "configured": true})
2996                );
2997            } else {
2998                assert_eq!(
2999                    configured,
3000                    "Agent public key: public-key\nFingerprint: SHA256:fingerprint\n"
3001                );
3002            }
3003
3004            let mut absent = Vec::new();
3005            write_agent_key_identity(&mut absent, None, json).unwrap();
3006            if json {
3007                assert_eq!(
3008                    serde_json::from_slice::<serde_json::Value>(&absent).unwrap(),
3009                    json!({"configured":false})
3010                );
3011            } else {
3012                assert_eq!(
3013                    String::from_utf8(absent).unwrap(),
3014                    "No local agent key is configured for this admin origin.\nConfigure a key: maincopy agent-key set\n"
3015                );
3016            }
3017
3018            let mut removed = Vec::new();
3019            write_agent_key_removed(&mut removed, json).unwrap();
3020            let removed = String::from_utf8(removed).unwrap();
3021            if json {
3022                assert_eq!(
3023                    serde_json::from_str::<serde_json::Value>(&removed).unwrap(),
3024                    json!({"removed": true})
3025                );
3026            } else {
3027                assert_eq!(removed, "Agent key removed\n");
3028            }
3029        }
3030    }
3031
3032    #[test]
3033    fn publication_json_output_is_one_direct_machine_document() {
3034        let mut output = Vec::new();
3035        let idempotency_key = Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap();
3036
3037        write_publication(&mut output, idempotency_key, publication_response(), true).unwrap();
3038
3039        assert_eq!(
3040            serde_json::from_slice::<serde_json::Value>(&output).unwrap(),
3041            json!({
3042                "idempotency_key": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
3043                "publication_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
3044                "post_id": "11111111-1111-4111-8111-111111111111",
3045                "preview_digest": PREVIEW_DIGEST,
3046                "revision":
3047                    "post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111",
3048                "state": "published",
3049                "published_at": "2026-08-30T12:00:00Z",
3050                "site_digest":
3051                    "site-b3-v1-2222222222222222222222222222222222222222222222222222222222222222",
3052                "site_version": 2
3053            })
3054        );
3055    }
3056
3057    #[test]
3058    fn publication_human_output_reports_every_retryable_identity() {
3059        let mut output = Vec::new();
3060        let idempotency_key = Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap();
3061
3062        write_publication(&mut output, idempotency_key, publication_response(), false).unwrap();
3063
3064        assert_eq!(
3065            String::from_utf8(output).unwrap(),
3066            "Publication: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\n\
3067Status: published\n\
3068Post: 11111111-1111-4111-8111-111111111111\n\
3069Preview: preview-b3-v1-4444444444444444444444444444444444444444444444444444444444444444\n\
3070Pinned revision: post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111\n\
3071Published at: 2026-08-30 12:00:00.0 +00:00:00\n\
3072Site: site-b3-v1-2222222222222222222222222222222222222222222222222222222222222222 (version 2)\n\
3073Idempotency key: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\n"
3074        );
3075    }
3076
3077    #[test]
3078    fn scheduled_human_output_never_claims_the_revision_is_published() {
3079        let mut output = Vec::new();
3080        let idempotency_key = Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap();
3081
3082        write_publication(&mut output, idempotency_key, scheduled_response(), false).unwrap();
3083
3084        let output = String::from_utf8(output).unwrap();
3085        assert!(output.contains("Status: scheduled\n"));
3086        assert!(output.contains(
3087                "Pinned revision: post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111\n"
3088            ));
3089        assert!(output.contains("Scheduled for: 2026-09-01 12:30:00.0 +00:00:00\n"));
3090        assert!(!output.contains("Published at:"));
3091    }
3092
3093    #[test]
3094    fn publication_failure_reports_the_retry_identity_in_every_output_mode() {
3095        let idempotency_key = Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap();
3096        let error = CliError::Publication {
3097            idempotency_key,
3098            source: AdminClientError::HttpStatus {
3099                status: reqwest::StatusCode::GATEWAY_TIMEOUT,
3100                problem: Some(AdminProblem {
3101                    code: "publication_unavailable".into(),
3102                    message: "publication is temporarily unavailable".into(),
3103                }),
3104                request_id: Some(Uuid::parse_str("cccccccc-cccc-4ccc-8ccc-cccccccccccc").unwrap()),
3105            },
3106        };
3107
3108        let exit = error_exit(&error);
3109        assert_eq!(exit, UNAVAILABLE);
3110        assert_eq!(error_category(&error, exit), "availability");
3111
3112        let mut json_output = Vec::new();
3113        write_error(&mut json_output, &error, exit, true).unwrap();
3114        let document = serde_json::from_slice::<serde_json::Value>(&json_output).unwrap();
3115        assert_eq!(
3116            document["error"]["idempotency_key"],
3117            "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
3118        );
3119        assert_eq!(document["error"]["code"], "publication_unavailable");
3120        assert_eq!(
3121            document["error"]["request_id"],
3122            "cccccccc-cccc-4ccc-8ccc-cccccccccccc"
3123        );
3124        assert!(
3125            document["error"]["message"]
3126                .as_str()
3127                .unwrap()
3128                .contains("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
3129        );
3130
3131        let mut human_output = Vec::new();
3132        write_error(&mut human_output, &error, exit, false).unwrap();
3133        let human_output = String::from_utf8(human_output).unwrap();
3134        assert!(human_output.contains("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"));
3135        assert!(human_output.contains("publication_unavailable"));
3136        assert!(human_output.contains("cccccccc-cccc-4ccc-8ccc-cccccccccccc"));
3137    }
3138}