1mod 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(¤t, 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}