1use anyhow::{bail, Context, Result};
2use clap::Subcommand;
3use fission_command_core::{
4 normalize_windows_package_version, resolve_release_version_config, DistributionProvider, Target,
5};
6use fission_command_package as publish;
7use serde::Serialize;
8use serde_json::Value as JsonValue;
9use std::env;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::time::{SystemTime, UNIX_EPOCH};
14use toml_edit::{
15 Array as TomlEditArray, DocumentMut, Item as TomlEditItem, Table as TomlEditTable,
16 Value as TomlEditValue,
17};
18
19mod auth_ops;
20mod content;
21mod microsoft_store_ops;
22mod model;
23mod publish_workflow;
24mod signing_ops;
25mod store_ops;
26mod workflow_ops;
27
28pub use publish_workflow::{
29 publish_workflow, readiness_release, release_plan_snapshot, release_readiness_checks,
30 ProviderCapabilitySnapshot, PublishWorkflowOptions, ReleaseContextSnapshot, ReleaseJobSnapshot,
31 ReleasePlanSnapshot, ReleaseRequirementSnapshot, ReleaseStepSnapshot,
32};
33
34fn now_unix_seconds() -> u64 {
35 SystemTime::now()
36 .duration_since(UNIX_EPOCH)
37 .unwrap_or_default()
38 .as_secs()
39}
40
41#[derive(Subcommand, Debug)]
42pub enum ReleaseConfigCommand {
43 Edit {
45 #[arg(long, default_value = ".")]
46 project_dir: PathBuf,
47 #[arg(long)]
48 tui: bool,
49 #[arg(long, value_enum)]
50 provider: Option<DistributionProvider>,
51 },
52 Import {
54 #[arg(long, value_enum)]
55 provider: DistributionProvider,
56 #[arg(long)]
57 locales: Option<String>,
58 #[arg(long)]
59 dry_run: bool,
60 #[arg(long)]
61 yes: bool,
62 #[arg(long, default_value = ".")]
63 project_dir: PathBuf,
64 #[arg(long)]
65 json: bool,
66 },
67 Diff {
69 #[arg(long, value_enum)]
70 provider: DistributionProvider,
71 #[arg(long, default_value = ".")]
72 project_dir: PathBuf,
73 #[arg(long)]
74 json: bool,
75 },
76 Validate {
78 #[arg(long, value_enum)]
79 provider: Option<DistributionProvider>,
80 #[arg(long, default_value = ".")]
81 project_dir: PathBuf,
82 #[arg(long)]
83 json: bool,
84 },
85 Push {
87 #[arg(long, value_enum)]
88 provider: DistributionProvider,
89 #[arg(long)]
90 locales: Option<String>,
91 #[arg(long)]
92 overwrite_remote: bool,
93 #[arg(long)]
94 dry_run: bool,
95 #[arg(long)]
96 yes: bool,
97 #[arg(long, default_value = ".")]
98 project_dir: PathBuf,
99 #[arg(long)]
100 json: bool,
101 },
102 Lock {
104 #[arg(long, value_enum)]
105 provider: DistributionProvider,
106 #[arg(long)]
107 locales: Option<String>,
108 #[arg(long)]
109 dry_run: bool,
110 #[arg(long)]
111 yes: bool,
112 #[arg(long, default_value = ".")]
113 project_dir: PathBuf,
114 #[arg(long)]
115 json: bool,
116 },
117 Set {
119 field: String,
120 value: String,
121 #[arg(long, default_value = ".")]
122 project_dir: PathBuf,
123 #[arg(long)]
124 dry_run: bool,
125 #[arg(long)]
126 yes: bool,
127 #[arg(long)]
128 json: bool,
129 },
130 AddRelease {
132 #[arg(long)]
133 version: String,
134 #[arg(long)]
135 build: u64,
136 #[arg(long)]
137 from: Option<String>,
138 #[arg(long, default_value = ".")]
139 project_dir: PathBuf,
140 #[arg(long)]
141 dry_run: bool,
142 #[arg(long)]
143 yes: bool,
144 #[arg(long)]
145 json: bool,
146 },
147 SkipRequirement {
149 #[arg(long)]
150 id: String,
151 #[arg(long, default_value = ".")]
152 project_dir: PathBuf,
153 #[arg(long)]
154 dry_run: bool,
155 #[arg(long)]
156 yes: bool,
157 #[arg(long)]
158 json: bool,
159 },
160 BumpBuild {
162 #[arg(long, value_enum)]
163 target: Option<Target>,
164 #[arg(long, default_value_t = 1)]
165 by: u64,
166 #[arg(long, default_value = ".")]
167 project_dir: PathBuf,
168 #[arg(long)]
169 dry_run: bool,
170 #[arg(long)]
171 yes: bool,
172 #[arg(long)]
173 json: bool,
174 },
175 VersionState {
177 #[arg(long, value_enum)]
178 provider: DistributionProvider,
179 #[arg(long, value_enum)]
180 target: Option<Target>,
181 #[arg(long, value_enum)]
182 format: Option<publish::PackageFormat>,
183 #[arg(long)]
184 track: Option<String>,
185 #[arg(long, default_value = "production")]
186 site: String,
187 #[arg(long)]
188 artifact: Option<PathBuf>,
189 #[arg(long, default_value = ".")]
190 project_dir: PathBuf,
191 #[arg(long)]
192 json: bool,
193 },
194 EditFile {
196 #[arg(long)]
197 release: String,
198 #[arg(long)]
199 kind: String,
200 #[arg(long)]
201 locale: Option<String>,
202 #[arg(long, default_value = ".")]
203 project_dir: PathBuf,
204 },
205 WriteFile {
207 #[arg(long)]
208 release: String,
209 #[arg(long)]
210 kind: String,
211 #[arg(long, value_enum)]
212 provider: Option<DistributionProvider>,
213 #[arg(long)]
214 locale: Option<String>,
215 #[arg(long)]
216 value: Option<String>,
217 #[arg(long)]
218 from_file: Option<PathBuf>,
219 #[arg(long, default_value = ".")]
220 project_dir: PathBuf,
221 #[arg(long)]
222 dry_run: bool,
223 #[arg(long)]
224 yes: bool,
225 #[arg(long)]
226 json: bool,
227 },
228}
229
230#[derive(Subcommand, Debug)]
231pub enum ReleaseContentCommand {
232 Capture {
234 #[arg(long, value_enum)]
235 target: Target,
236 #[arg(long)]
237 set: String,
238 #[arg(long, default_value = ".")]
239 project_dir: PathBuf,
240 #[arg(long)]
241 json: bool,
242 },
243 Render {
245 #[arg(long, value_enum)]
246 provider: DistributionProvider,
247 #[arg(long, default_value = ".")]
248 project_dir: PathBuf,
249 #[arg(long)]
250 json: bool,
251 },
252 Validate {
254 #[arg(long, value_enum)]
255 provider: Option<DistributionProvider>,
256 #[arg(long, default_value = ".")]
257 project_dir: PathBuf,
258 #[arg(long)]
259 json: bool,
260 },
261 Push {
263 #[arg(long, value_enum)]
264 provider: DistributionProvider,
265 #[arg(long)]
266 locales: Option<String>,
267 #[arg(long)]
268 dry_run: bool,
269 #[arg(long)]
270 yes: bool,
271 #[arg(long, default_value = ".")]
272 project_dir: PathBuf,
273 #[arg(long)]
274 json: bool,
275 },
276}
277
278#[derive(Subcommand, Debug)]
279pub enum BetaCommand {
280 Groups {
282 #[command(subcommand)]
283 command: BetaGroupsCommand,
284 },
285 Testers {
287 #[command(subcommand)]
288 command: BetaTestersCommand,
289 },
290 Distribute {
292 #[arg(long, value_enum)]
293 provider: DistributionProvider,
294 #[arg(long)]
295 artifact: PathBuf,
296 #[arg(long)]
297 group: Option<String>,
298 #[arg(long)]
299 track: Option<String>,
300 #[arg(long, default_value = ".")]
301 project_dir: PathBuf,
302 #[arg(long)]
303 dry_run: bool,
304 #[arg(long)]
305 yes: bool,
306 #[arg(long)]
307 json: bool,
308 },
309}
310
311#[derive(Subcommand, Debug)]
312pub enum BetaGroupsCommand {
313 List {
315 #[arg(long, value_enum)]
316 provider: DistributionProvider,
317 #[arg(long, default_value = ".")]
318 project_dir: PathBuf,
319 #[arg(long)]
320 json: bool,
321 },
322 Sync {
324 #[arg(long, value_enum)]
325 provider: DistributionProvider,
326 #[arg(long, default_value = "fission.toml")]
327 from: PathBuf,
328 #[arg(long, default_value = ".")]
329 project_dir: PathBuf,
330 #[arg(long)]
331 dry_run: bool,
332 #[arg(long)]
333 yes: bool,
334 #[arg(long)]
335 json: bool,
336 },
337}
338
339#[derive(Subcommand, Debug)]
340pub enum BetaTestersCommand {
341 Import {
343 #[arg(long, value_enum)]
344 provider: DistributionProvider,
345 #[arg(long)]
346 group: Option<String>,
347 #[arg(long)]
348 track: Option<String>,
349 #[arg(long)]
350 csv: PathBuf,
351 #[arg(long, default_value = ".")]
352 project_dir: PathBuf,
353 #[arg(long)]
354 dry_run: bool,
355 #[arg(long)]
356 yes: bool,
357 #[arg(long)]
358 json: bool,
359 },
360 Export {
362 #[arg(long, value_enum)]
363 provider: DistributionProvider,
364 #[arg(long)]
365 group: Option<String>,
366 #[arg(long)]
367 track: Option<String>,
368 #[arg(long)]
369 output: PathBuf,
370 #[arg(long, default_value = ".")]
371 project_dir: PathBuf,
372 #[arg(long)]
373 json: bool,
374 },
375}
376
377#[derive(Subcommand, Debug)]
378pub enum SigningCommand {
379 Status {
381 #[arg(long, value_enum)]
382 target: Target,
383 #[arg(long, default_value = ".")]
384 project_dir: PathBuf,
385 #[arg(long)]
386 json: bool,
387 },
388 Sync {
390 #[arg(long, value_enum)]
391 target: Target,
392 #[arg(long)]
393 readonly: bool,
394 #[arg(long, default_value = ".")]
395 project_dir: PathBuf,
396 #[arg(long)]
397 json: bool,
398 },
399 Import {
401 #[arg(long, value_enum)]
402 target: Target,
403 #[arg(long)]
404 keystore: Option<PathBuf>,
405 #[arg(long)]
406 alias: Option<String>,
407 #[arg(long, default_value = ".")]
408 project_dir: PathBuf,
409 #[arg(long)]
410 dry_run: bool,
411 #[arg(long)]
412 yes: bool,
413 #[arg(long)]
414 json: bool,
415 },
416}
417
418#[derive(Subcommand, Debug)]
419pub enum ReviewsCommand {
420 List {
422 #[arg(long, value_enum)]
423 provider: DistributionProvider,
424 #[arg(long)]
425 since: Option<String>,
426 #[arg(long, default_value = ".")]
427 project_dir: PathBuf,
428 #[arg(long)]
429 json: bool,
430 },
431 Reply {
433 #[arg(long, value_enum)]
434 provider: DistributionProvider,
435 #[arg(long)]
436 review: String,
437 #[arg(long)]
438 message_file: PathBuf,
439 #[arg(long, default_value = ".")]
440 project_dir: PathBuf,
441 #[arg(long)]
442 dry_run: bool,
443 #[arg(long)]
444 yes: bool,
445 #[arg(long)]
446 json: bool,
447 },
448}
449
450#[derive(Subcommand, Debug)]
451pub enum ReleaseWorkflowCommand {
452 List {
454 #[arg(long, default_value = ".")]
455 project_dir: PathBuf,
456 #[arg(long)]
457 json: bool,
458 },
459 Run {
461 name: String,
462 #[arg(long, default_value = ".")]
463 project_dir: PathBuf,
464 #[arg(long)]
465 dry_run: bool,
466 #[arg(long)]
467 yes: bool,
468 #[arg(long)]
469 json: bool,
470 },
471}
472
473#[derive(Subcommand, Debug)]
474pub enum AuthCommand {
475 Login {
477 #[arg(value_enum)]
478 provider: Option<DistributionProvider>,
479 #[arg(long)]
480 json: bool,
481 },
482 Setup {
484 #[arg(value_enum)]
485 provider: Option<DistributionProvider>,
486 #[arg(long)]
487 json: bool,
488 },
489 Status {
491 #[arg(value_enum)]
492 provider: Option<DistributionProvider>,
493 #[arg(long)]
494 json: bool,
495 },
496 Logout {
498 #[arg(value_enum)]
499 provider: Option<DistributionProvider>,
500 #[arg(long)]
501 json: bool,
502 },
503 Import {
505 #[arg(value_enum)]
506 provider: DistributionProvider,
507 #[arg(long)]
508 from: Option<String>,
509 #[arg(long)]
510 json: bool,
511 },
512 Rotate {
514 #[arg(value_enum)]
515 provider: Option<DistributionProvider>,
516 #[arg(long)]
517 json: bool,
518 },
519 Audit {
521 #[arg(long)]
522 json: bool,
523 },
524}
525
526#[derive(Debug, Serialize)]
527struct LifecycleReport {
528 area: String,
529 status: String,
530 provider: Option<String>,
531 target: Option<String>,
532 checks: Vec<LifecycleCheck>,
533}
534
535#[derive(Debug, Serialize)]
536struct LifecycleCheck {
537 id: String,
538 status: String,
539 summary: String,
540 details: Option<String>,
541 remediation: Vec<String>,
542}
543
544#[derive(Debug, Serialize)]
545struct ReleaseConfigMutationReceipt {
546 schema_version: u32,
547 action: String,
548 status: String,
549 project_dir: String,
550 target: Option<String>,
551 provider: Option<String>,
552 field: Option<String>,
553 kind: Option<String>,
554 locale: Option<String>,
555 path: Option<String>,
556 old_value: Option<String>,
557 new_value: Option<String>,
558 release_id: Option<String>,
559 requirement_id: Option<String>,
560}
561
562struct BumpBuildPlan {
563 field: &'static str,
564 old_value: String,
565 new_value: String,
566 next_build: u64,
567}
568
569#[derive(Debug, Serialize)]
570struct VersionStateReport {
571 schema_version: u32,
572 created_at_unix_seconds: u64,
573 action: String,
574 status: String,
575 project_dir: String,
576 provider: String,
577 target: String,
578 format: String,
579 track: Option<String>,
580 local: VersionStateLocal,
581 provider_state: VersionStateProviderState,
582 provider_status: Option<JsonValue>,
583 provider_error: Option<String>,
584 monotonic: VersionStateMonotonic,
585 next_action: String,
586}
587
588#[derive(Debug, Serialize)]
589struct VersionStateLocal {
590 version: Option<String>,
591 build: Option<u64>,
592}
593
594#[derive(Debug, Clone, Default, Serialize)]
595struct VersionStateProviderState {
596 latest_uploaded_build: Option<u64>,
597 latest_released_build: Option<u64>,
598 latest_status: Option<String>,
599 review_status: Option<String>,
600 observed_builds: Vec<VersionStateObservedBuild>,
601}
602
603#[derive(Debug, Clone, Serialize)]
604struct VersionStateObservedBuild {
605 build: u64,
606 status: Option<String>,
607 source: String,
608}
609
610#[derive(Debug, Clone, Serialize)]
611struct VersionStateMonotonic {
612 status: String,
613 local_build: Option<u64>,
614 latest_provider_build: Option<u64>,
615 details: Option<String>,
616}
617
618pub fn release_config(command: ReleaseConfigCommand) -> Result<()> {
619 match command {
620 ReleaseConfigCommand::Edit {
621 project_dir,
622 tui,
623 provider: _,
624 } => edit_release_config(&project_dir, tui),
625 ReleaseConfigCommand::Validate {
626 provider,
627 project_dir,
628 json,
629 } => print_report(
630 model::validate_release_config_model(&project_dir, provider)?,
631 json,
632 ),
633 ReleaseConfigCommand::Set {
634 field,
635 value,
636 project_dir,
637 dry_run,
638 yes,
639 json,
640 } => set_release_field_command(&project_dir, &field, &value, dry_run, yes, json),
641 ReleaseConfigCommand::AddRelease {
642 version,
643 build,
644 from,
645 project_dir,
646 dry_run,
647 yes,
648 json,
649 } => add_release_command(
650 &project_dir,
651 &version,
652 build,
653 from.as_deref(),
654 dry_run,
655 yes,
656 json,
657 ),
658 ReleaseConfigCommand::SkipRequirement {
659 id,
660 project_dir,
661 dry_run,
662 yes,
663 json,
664 } => skip_release_requirement_command(&project_dir, &id, dry_run, yes, json),
665 ReleaseConfigCommand::BumpBuild {
666 target,
667 by,
668 project_dir,
669 dry_run,
670 yes,
671 json,
672 } => bump_release_build_command(&project_dir, target, by, dry_run, yes, json),
673 ReleaseConfigCommand::VersionState {
674 provider,
675 target,
676 format,
677 track,
678 site,
679 artifact,
680 project_dir,
681 json,
682 } => version_state_command(
683 &project_dir,
684 provider,
685 target,
686 format,
687 track,
688 &site,
689 artifact.as_deref(),
690 json,
691 ),
692 ReleaseConfigCommand::EditFile {
693 release,
694 kind,
695 locale,
696 project_dir,
697 } => edit_release_file(&project_dir, &release, &kind, locale.as_deref()),
698 ReleaseConfigCommand::WriteFile {
699 release,
700 kind,
701 provider,
702 locale,
703 value,
704 from_file,
705 project_dir,
706 dry_run,
707 yes,
708 json,
709 } => write_release_file_command(
710 &project_dir,
711 &release,
712 &kind,
713 provider,
714 locale.as_deref(),
715 value.as_deref(),
716 from_file.as_deref(),
717 dry_run,
718 yes,
719 json,
720 ),
721 ReleaseConfigCommand::Import {
722 provider,
723 locales,
724 dry_run,
725 yes,
726 project_dir,
727 json,
728 } => store_ops::release_config_import(provider, locales, dry_run, yes, &project_dir, json),
729 ReleaseConfigCommand::Diff {
730 provider,
731 project_dir,
732 json,
733 } => store_ops::release_config_diff(provider, &project_dir, json),
734 ReleaseConfigCommand::Push {
735 provider,
736 locales,
737 overwrite_remote,
738 dry_run,
739 yes,
740 project_dir,
741 json,
742 } => store_ops::release_config_push(
743 provider,
744 locales,
745 overwrite_remote,
746 dry_run,
747 yes,
748 &project_dir,
749 json,
750 ),
751 ReleaseConfigCommand::Lock {
752 provider,
753 locales,
754 dry_run,
755 yes,
756 project_dir,
757 json,
758 } => store_ops::release_config_lock(provider, locales, dry_run, yes, &project_dir, json),
759 }
760}
761
762pub fn release_config_readiness_checks(
763 project_dir: &Path,
764 provider: Option<DistributionProvider>,
765) -> Result<Vec<publish::ReadinessCheck>> {
766 Ok(model::validate_release_config_model(project_dir, provider)?
767 .checks
768 .into_iter()
769 .map(lifecycle_readiness_check)
770 .collect())
771}
772
773pub fn release_content_readiness_checks(
774 project_dir: &Path,
775 provider: Option<DistributionProvider>,
776) -> Result<Vec<publish::ReadinessCheck>> {
777 Ok(
778 content::validate_release_content_model(project_dir, provider)
779 .checks
780 .into_iter()
781 .map(lifecycle_readiness_check)
782 .collect(),
783 )
784}
785
786fn lifecycle_readiness_check(check: LifecycleCheck) -> publish::ReadinessCheck {
787 let status = match check.status.as_str() {
788 "passed" | "ready" | "ok" => publish::CheckStatus::Passed,
789 "missing" => publish::CheckStatus::Missing,
790 "failed" | "blocked" => publish::CheckStatus::Failed,
791 "warning" => publish::CheckStatus::Warning,
792 "skipped" => publish::CheckStatus::Skipped,
793 _ => publish::CheckStatus::Warning,
794 };
795 let severity = match status {
796 publish::CheckStatus::Failed | publish::CheckStatus::Missing => {
797 publish::CheckSeverity::Error
798 }
799 publish::CheckStatus::Warning => publish::CheckSeverity::Warning,
800 publish::CheckStatus::Passed | publish::CheckStatus::Skipped => {
801 publish::CheckSeverity::Info
802 }
803 };
804 publish::ReadinessCheck {
805 id: check.id,
806 severity,
807 status,
808 summary: check.summary,
809 details: check.details,
810 remediation: check.remediation,
811 }
812}
813
814pub fn release_content(command: ReleaseContentCommand) -> Result<()> {
815 match command {
816 ReleaseContentCommand::Validate {
817 provider,
818 project_dir,
819 json,
820 } => print_report(
821 content::validate_release_content_model(&project_dir, provider),
822 json,
823 ),
824 ReleaseContentCommand::Capture {
825 target,
826 set,
827 project_dir,
828 json,
829 } => print_report(
830 content::capture_release_content(&project_dir, target, &set)?,
831 json,
832 ),
833 ReleaseContentCommand::Render {
834 provider,
835 project_dir,
836 json,
837 } => print_report(
838 content::render_release_content(&project_dir, provider)?,
839 json,
840 ),
841 ReleaseContentCommand::Push {
842 provider,
843 locales,
844 dry_run,
845 yes,
846 project_dir,
847 json,
848 } => store_ops::release_content_push(provider, locales, dry_run, yes, &project_dir, json),
849 }
850}
851
852pub fn beta(command: BetaCommand) -> Result<()> {
853 match command {
854 BetaCommand::Groups { command } => match command {
855 BetaGroupsCommand::List {
856 provider,
857 project_dir,
858 json,
859 } => store_ops::beta_groups_list(provider, &project_dir, json),
860 BetaGroupsCommand::Sync {
861 provider,
862 from,
863 project_dir,
864 dry_run,
865 yes,
866 json,
867 } => store_ops::beta_groups_sync(provider, &from, &project_dir, dry_run, yes, json),
868 },
869 BetaCommand::Testers { command } => match command {
870 BetaTestersCommand::Import {
871 provider,
872 group,
873 track,
874 csv,
875 project_dir,
876 dry_run,
877 yes,
878 json,
879 } => store_ops::beta_testers_import(
880 provider,
881 group.as_deref(),
882 track.as_deref(),
883 &csv,
884 &project_dir,
885 dry_run,
886 yes,
887 json,
888 ),
889 BetaTestersCommand::Export {
890 provider,
891 group,
892 track,
893 output,
894 project_dir,
895 json,
896 } => store_ops::beta_testers_export(
897 provider,
898 group.as_deref(),
899 track.as_deref(),
900 &output,
901 &project_dir,
902 json,
903 ),
904 },
905 BetaCommand::Distribute {
906 provider,
907 artifact,
908 group,
909 track,
910 project_dir,
911 dry_run,
912 yes,
913 json,
914 } => {
915 if provider == DistributionProvider::AppStore {
916 store_ops::app_store_beta_distribute(
917 &project_dir,
918 &artifact,
919 group.as_deref(),
920 dry_run,
921 yes,
922 json,
923 )
924 } else {
925 publish::distribute(publish::DistributeOptions {
926 project_dir,
927 provider,
928 action: publish::DistributeAction::Publish,
929 target: None,
930 format: None,
931 artifact: Some(artifact),
932 site: group.unwrap_or_else(|| "beta".to_string()),
933 deploy: None,
934 track,
935 locales: Vec::new(),
936 dry_run,
937 yes,
938 json,
939 })
940 }
941 }
942 }
943}
944
945pub fn signing(command: SigningCommand) -> Result<()> {
946 match command {
947 SigningCommand::Status {
948 target,
949 project_dir,
950 json,
951 } => signing_ops::status(&project_dir, target, json),
952 SigningCommand::Sync {
953 target,
954 readonly,
955 project_dir,
956 json,
957 } => signing_ops::sync(&project_dir, target, readonly, json),
958 SigningCommand::Import {
959 target,
960 keystore,
961 alias,
962 project_dir,
963 dry_run,
964 yes,
965 json,
966 } => signing_ops::import(&project_dir, target, keystore, alias, dry_run, yes, json),
967 }
968}
969
970pub fn reviews(command: ReviewsCommand) -> Result<()> {
971 match command {
972 ReviewsCommand::List {
973 provider,
974 since,
975 project_dir,
976 json,
977 } => store_ops::reviews_list(provider, since, &project_dir, json),
978 ReviewsCommand::Reply {
979 provider,
980 review,
981 message_file,
982 project_dir,
983 dry_run,
984 yes,
985 json,
986 } => store_ops::reviews_reply(
987 provider,
988 &review,
989 &message_file,
990 &project_dir,
991 dry_run,
992 yes,
993 json,
994 ),
995 }
996}
997
998pub fn release_workflow(command: ReleaseWorkflowCommand) -> Result<()> {
999 match command {
1000 ReleaseWorkflowCommand::List { project_dir, json } => {
1001 workflow_ops::list(&project_dir, json)
1002 }
1003 ReleaseWorkflowCommand::Run {
1004 name,
1005 project_dir,
1006 dry_run,
1007 yes,
1008 json,
1009 } => workflow_ops::run(&project_dir, &name, dry_run, yes, json),
1010 }
1011}
1012
1013pub fn auth(command: AuthCommand) -> Result<()> {
1014 auth_ops::auth(command)
1015}
1016
1017fn edit_release_config(project_dir: &Path, tui: bool) -> Result<()> {
1018 let path = project_dir.join("fission.toml");
1019 fs::metadata(&path).with_context(|| format!("{} does not exist", path.display()))?;
1020 if tui {
1021 bail!("release-config edit --tui must be dispatched by the fission command entrypoint");
1022 }
1023 let editor = env::var("VISUAL")
1024 .or_else(|_| env::var("EDITOR"))
1025 .unwrap_or_else(|_| "vi".to_string());
1026 let status = Command::new(editor)
1027 .arg(&path)
1028 .status()
1029 .context("failed to launch editor")?;
1030 if !status.success() {
1031 bail!("editor exited with {status}");
1032 }
1033 Ok(())
1034}
1035
1036fn set_release_field_command(
1037 project_dir: &Path,
1038 field: &str,
1039 value: &str,
1040 dry_run: bool,
1041 yes: bool,
1042 json: bool,
1043) -> Result<()> {
1044 if model::release_config_field_is_forbidden_secret(field, value) {
1045 bail!(
1046 "{field} looks like secret material or a machine-specific secret path; Fission refuses to write it to fission.toml. Store the value in an environment variable, CI secret, provider-owned auth state, or a local file outside the repository and write only the env var name when needed."
1047 );
1048 }
1049 validate_release_field_value(project_dir, field, value)?;
1050 let old_value = toml_field_value(project_dir, field)?;
1051 let mut receipt = mutation_receipt(project_dir, "release-config.set", dry_run);
1052 receipt.field = Some(field.to_string());
1053 receipt.old_value = old_value;
1054 receipt.new_value = Some(value.to_string());
1055 if !dry_run {
1056 set_release_field(project_dir, field, value, yes)?;
1057 receipt.status = "applied".to_string();
1058 }
1059 print_mutation_receipt(&receipt, json)
1060}
1061
1062fn validate_release_field_value(project_dir: &Path, field: &str, value: &str) -> Result<()> {
1063 let path = project_dir.join("fission.toml");
1064 let data =
1065 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1066 let doc = parse_toml_edit_document(&data, &path)?;
1067 toml_edit_value_for_field(&doc, field, value).map(|_| ())
1068}
1069
1070fn set_release_field(project_dir: &Path, field: &str, value: &str, yes: bool) -> Result<()> {
1071 if !yes {
1072 bail!("set rewrites fission.toml; pass --yes after reviewing the field path");
1073 }
1074 if model::release_config_field_is_forbidden_secret(field, value) {
1075 bail!(
1076 "{field} looks like secret material or a machine-specific secret path; Fission refuses to write it to fission.toml. Store the value in an environment variable, CI secret, provider-owned auth state, or a local file outside the repository and write only the env var name when needed."
1077 );
1078 }
1079 let path = project_dir.join("fission.toml");
1080 let data =
1081 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1082 let mut doc = parse_toml_edit_document(&data, &path)?;
1083 let value = toml_edit_value_for_field(&doc, field, value)?;
1084 set_toml_edit_path(&mut doc, field, value)?;
1085 write_toml_edit_document(&path, &doc)?;
1086 Ok(())
1087}
1088
1089fn toml_edit_value_for_field(root: &DocumentMut, field: &str, value: &str) -> Result<TomlEditItem> {
1090 if let Some(existing) = toml_edit_path_item(root, field)? {
1091 if let Some(existing_value) = existing.as_value() {
1092 if existing_value.as_bool().is_some() {
1093 return parse_toml_bool_value(field, value);
1094 }
1095 if existing_value.as_integer().is_some() {
1096 return parse_toml_integer_value(field, value);
1097 }
1098 if existing_value.as_float().is_some() {
1099 return parse_toml_float_value(field, value);
1100 }
1101 }
1102 }
1103
1104 let key = field.rsplit('.').next().unwrap_or(field);
1105 if release_config_integer_field(key) {
1106 return parse_toml_integer_value(field, value);
1107 }
1108 if release_config_bool_field(key) {
1109 return parse_toml_bool_value(field, value);
1110 }
1111
1112 Ok(toml_edit::value(value.to_string()))
1113}
1114
1115fn toml_edit_path_item<'a>(root: &'a DocumentMut, field: &str) -> Result<Option<&'a TomlEditItem>> {
1116 let parts = toml_path_segments(field)?;
1117 let mut current = root.as_table();
1118 for part in &parts[..parts.len() - 1] {
1119 let Some(item) = current.get(part.as_str()) else {
1120 return Ok(None);
1121 };
1122 let Some(table) = item.as_table() else {
1123 return Ok(None);
1124 };
1125 current = table;
1126 }
1127 Ok(current.get(parts[parts.len() - 1].as_str()))
1128}
1129
1130fn release_config_integer_field(key: &str) -> bool {
1131 matches!(
1132 key,
1133 "build"
1134 | "version_code"
1135 | "min_sdk"
1136 | "target_sdk"
1137 | "presign_ttl_seconds"
1138 | "package_rollout_percentage"
1139 )
1140}
1141
1142fn release_config_bool_field(key: &str) -> bool {
1143 matches!(
1144 key,
1145 "allow_upscale"
1146 | "include_desktop_entry"
1147 | "include_appstream_metadata"
1148 | "path_style"
1149 | "overwrite"
1150 | "share"
1151 | "autorename"
1152 | "draft"
1153 | "prerelease"
1154 | "replace_assets"
1155 | "upload_artifact_manifest"
1156 | "enforce_https"
1157 | "submit"
1158 | "is_silent_install"
1159 | "notarize"
1160 )
1161}
1162
1163fn parse_toml_bool_value(field: &str, value: &str) -> Result<TomlEditItem> {
1164 match value.trim() {
1165 "true" => Ok(toml_edit::value(true)),
1166 "false" => Ok(toml_edit::value(false)),
1167 _ => bail!("{field} expects a boolean value: true or false"),
1168 }
1169}
1170
1171fn parse_toml_integer_value(field: &str, value: &str) -> Result<TomlEditItem> {
1172 let parsed = value
1173 .trim()
1174 .parse::<i64>()
1175 .with_context(|| format!("{field} expects an integer value"))?;
1176 Ok(toml_edit::value(parsed))
1177}
1178
1179fn parse_toml_float_value(field: &str, value: &str) -> Result<TomlEditItem> {
1180 let parsed = value
1181 .trim()
1182 .parse::<f64>()
1183 .with_context(|| format!("{field} expects a floating-point value"))?;
1184 Ok(toml_edit::value(parsed))
1185}
1186
1187fn skip_release_requirement_command(
1188 project_dir: &Path,
1189 id: &str,
1190 dry_run: bool,
1191 yes: bool,
1192 json: bool,
1193) -> Result<()> {
1194 let id = id.trim();
1195 if id.is_empty() {
1196 bail!("skip requirement id cannot be empty");
1197 }
1198 let mut receipt = mutation_receipt(project_dir, "release-config.skip-requirement", dry_run);
1199 receipt.requirement_id = Some(id.to_string());
1200 if !dry_run {
1201 skip_release_requirement(project_dir, id, yes)?;
1202 receipt.status = "applied".to_string();
1203 }
1204 print_mutation_receipt(&receipt, json)
1205}
1206
1207pub fn skip_release_requirement(project_dir: &Path, id: &str, yes: bool) -> Result<()> {
1208 if !yes {
1209 bail!(
1210 "skip-requirement rewrites fission.toml; pass --yes after reviewing the requirement id"
1211 );
1212 }
1213 let id = id.trim();
1214 if id.is_empty() {
1215 bail!("skip requirement id cannot be empty");
1216 }
1217 let path = project_dir.join("fission.toml");
1218 let data =
1219 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1220 let mut doc = parse_toml_edit_document(&data, &path)?;
1221 append_release_skip_requirement(&mut doc, id)?;
1222 write_toml_edit_document(&path, &doc)?;
1223 Ok(())
1224}
1225
1226pub fn bump_release_build(
1227 project_dir: &Path,
1228 target: Option<Target>,
1229 by: u64,
1230 yes: bool,
1231) -> Result<()> {
1232 bump_release_build_impl(project_dir, target, by, yes, true).map(|_| ())
1233}
1234
1235fn bump_release_build_command(
1236 project_dir: &Path,
1237 target: Option<Target>,
1238 by: u64,
1239 dry_run: bool,
1240 yes: bool,
1241 json: bool,
1242) -> Result<()> {
1243 if by == 0 {
1244 bail!("--by must be greater than zero");
1245 }
1246 let plan = bump_build_plan(project_dir, target, by)?;
1247 let mut receipt = mutation_receipt(project_dir, "release-config.bump-build", dry_run);
1248 receipt.target = target.map(|target| target.as_str().to_string());
1249 receipt.field = Some(plan.field.to_string());
1250 receipt.old_value = Some(plan.old_value);
1251 receipt.new_value = Some(plan.new_value);
1252 if !dry_run {
1253 bump_release_build_impl(project_dir, target, by, yes, !json)?;
1254 receipt.status = "applied".to_string();
1255 }
1256 print_mutation_receipt(&receipt, json)
1257}
1258
1259fn version_state_command(
1260 project_dir: &Path,
1261 provider: DistributionProvider,
1262 target: Option<Target>,
1263 format: Option<publish::PackageFormat>,
1264 track: Option<String>,
1265 site: &str,
1266 artifact: Option<&Path>,
1267 json_output: bool,
1268) -> Result<()> {
1269 let target = target.unwrap_or_else(|| publish::default_target_for_provider(provider));
1270 let format =
1271 format.unwrap_or_else(|| publish::default_format_for_target_provider(target, provider));
1272 let track = track.or_else(|| publish::default_track_for_provider(provider).map(str::to_string));
1273 let local = resolve_release_version_config(project_dir, Some(target))?;
1274 let local = VersionStateLocal {
1275 version: local.version,
1276 build: local.build,
1277 };
1278 let provider_status = publish::distribute_status_value(publish::DistributeOptions {
1279 project_dir: project_dir.to_path_buf(),
1280 provider,
1281 action: publish::DistributeAction::Status,
1282 target: Some(target),
1283 format: Some(format),
1284 artifact: artifact.map(Path::to_path_buf),
1285 site: site.to_string(),
1286 deploy: None,
1287 track: track.clone(),
1288 locales: Vec::new(),
1289 dry_run: false,
1290 yes: true,
1291 json: true,
1292 });
1293 let (provider_status, provider_error) = match provider_status {
1294 Ok(value) => (Some(value), None),
1295 Err(error) => (None, Some(error.to_string())),
1296 };
1297 let report = version_state_report(
1298 project_dir,
1299 provider,
1300 target,
1301 format,
1302 track,
1303 local,
1304 provider_status,
1305 provider_error,
1306 );
1307 print_version_state_report(&report, json_output)?;
1308 if report.status == "blocked" {
1309 bail!("release-config.version-state is blocked");
1310 }
1311 Ok(())
1312}
1313
1314fn version_state_report(
1315 project_dir: &Path,
1316 provider: DistributionProvider,
1317 target: Target,
1318 format: publish::PackageFormat,
1319 track: Option<String>,
1320 local: VersionStateLocal,
1321 provider_status: Option<JsonValue>,
1322 provider_error: Option<String>,
1323) -> VersionStateReport {
1324 let stdout = provider_status
1325 .as_ref()
1326 .and_then(provider_status_stdout_json);
1327 let provider_state = provider_version_state(provider, stdout.as_ref());
1328 let monotonic = version_state_monotonic(provider, local.build, &provider_state);
1329 let next_action = version_state_next_action(&monotonic, provider_error.as_deref());
1330 let status = if monotonic.status == "failed" {
1331 "blocked"
1332 } else if provider_error.is_some() {
1333 "provider-unavailable"
1334 } else {
1335 "ok"
1336 }
1337 .to_string();
1338 VersionStateReport {
1339 schema_version: 1,
1340 created_at_unix_seconds: now_unix_seconds(),
1341 action: "release-config.version-state".to_string(),
1342 status,
1343 project_dir: project_dir.display().to_string(),
1344 provider: provider.as_str().to_string(),
1345 target: target.as_str().to_string(),
1346 format: format.as_str().to_string(),
1347 track,
1348 local,
1349 provider_state,
1350 provider_status,
1351 provider_error,
1352 monotonic,
1353 next_action,
1354 }
1355}
1356
1357fn provider_status_stdout_json(provider_status: &JsonValue) -> Option<JsonValue> {
1358 provider_status
1359 .get("stdout")
1360 .and_then(JsonValue::as_str)
1361 .and_then(|stdout| serde_json::from_str::<JsonValue>(stdout).ok())
1362}
1363
1364fn provider_version_state(
1365 provider: DistributionProvider,
1366 stdout: Option<&JsonValue>,
1367) -> VersionStateProviderState {
1368 match provider {
1369 DistributionProvider::PlayStore => stdout.map(play_store_version_state).unwrap_or_default(),
1370 DistributionProvider::AppStore => stdout.map(app_store_version_state).unwrap_or_default(),
1371 DistributionProvider::MicrosoftStore => stdout
1372 .map(microsoft_store_version_state)
1373 .unwrap_or_default(),
1374 _ => VersionStateProviderState::default(),
1375 }
1376}
1377
1378fn play_store_version_state(value: &JsonValue) -> VersionStateProviderState {
1379 let mut state = VersionStateProviderState::default();
1380 for release in value
1381 .get("releases")
1382 .and_then(JsonValue::as_array)
1383 .into_iter()
1384 .flatten()
1385 {
1386 let status = release
1387 .get("status")
1388 .and_then(JsonValue::as_str)
1389 .map(str::to_string);
1390 for code in release
1391 .get("versionCodes")
1392 .and_then(JsonValue::as_array)
1393 .into_iter()
1394 .flatten()
1395 .filter_map(json_u64_or_string)
1396 {
1397 state.latest_released_build = max_optional_u64(state.latest_released_build, code);
1398 state.observed_builds.push(VersionStateObservedBuild {
1399 build: code,
1400 status: status.clone(),
1401 source: "play-store.track.release".to_string(),
1402 });
1403 }
1404 if state.latest_status.is_none() {
1405 state.latest_status = status;
1406 }
1407 }
1408 state
1409}
1410
1411fn app_store_version_state(value: &JsonValue) -> VersionStateProviderState {
1412 let mut state = VersionStateProviderState::default();
1413 for build in value
1414 .get("builds")
1415 .and_then(|builds| builds.get("data"))
1416 .and_then(JsonValue::as_array)
1417 .into_iter()
1418 .flatten()
1419 {
1420 let Some(attributes) = build.get("attributes") else {
1421 continue;
1422 };
1423 let status = attributes
1424 .get("processingState")
1425 .and_then(JsonValue::as_str)
1426 .map(str::to_string);
1427 let Some(build_number) = attributes
1428 .get("version")
1429 .and_then(JsonValue::as_str)
1430 .and_then(|value| value.parse::<u64>().ok())
1431 else {
1432 continue;
1433 };
1434 state.latest_uploaded_build = max_optional_u64(state.latest_uploaded_build, build_number);
1435 state.observed_builds.push(VersionStateObservedBuild {
1436 build: build_number,
1437 status: status.clone(),
1438 source: "app-store.build".to_string(),
1439 });
1440 if state.latest_status.is_none() {
1441 state.latest_status = status;
1442 }
1443 }
1444 state.review_status = value
1445 .get("review_submissions")
1446 .and_then(|submissions| submissions.get("data"))
1447 .and_then(JsonValue::as_array)
1448 .and_then(|items| items.first())
1449 .and_then(|item| item.get("attributes"))
1450 .and_then(|attributes| attributes.get("state"))
1451 .and_then(JsonValue::as_str)
1452 .map(str::to_string);
1453 state
1454}
1455
1456fn microsoft_store_version_state(value: &JsonValue) -> VersionStateProviderState {
1457 let mut state = VersionStateProviderState::default();
1458 state.latest_status = value
1459 .get("status")
1460 .and_then(JsonValue::as_str)
1461 .map(str::to_string)
1462 .or_else(|| {
1463 value
1464 .get("submissionStatus")
1465 .and_then(JsonValue::as_str)
1466 .map(str::to_string)
1467 });
1468 state
1469}
1470
1471fn version_state_monotonic(
1472 provider: DistributionProvider,
1473 local_build: Option<u64>,
1474 provider_state: &VersionStateProviderState,
1475) -> VersionStateMonotonic {
1476 let latest_provider_build = max_optional_pair(
1477 provider_state.latest_uploaded_build,
1478 provider_state.latest_released_build,
1479 );
1480 if !matches!(
1481 provider,
1482 DistributionProvider::PlayStore
1483 | DistributionProvider::AppStore
1484 | DistributionProvider::MicrosoftStore
1485 ) {
1486 return VersionStateMonotonic {
1487 status: "not-applicable".to_string(),
1488 local_build,
1489 latest_provider_build,
1490 details: Some(
1491 "provider does not expose an app-store monotonic build constraint".to_string(),
1492 ),
1493 };
1494 }
1495 let Some(local_build) = local_build else {
1496 return VersionStateMonotonic {
1497 status: "unknown".to_string(),
1498 local_build,
1499 latest_provider_build,
1500 details: Some("local build number is not configured".to_string()),
1501 };
1502 };
1503 let Some(latest_provider_build) = latest_provider_build else {
1504 return VersionStateMonotonic {
1505 status: "unknown".to_string(),
1506 local_build: Some(local_build),
1507 latest_provider_build,
1508 details: Some("provider status did not include a comparable build number".to_string()),
1509 };
1510 };
1511 if local_build <= latest_provider_build {
1512 VersionStateMonotonic {
1513 status: "failed".to_string(),
1514 local_build: Some(local_build),
1515 latest_provider_build: Some(latest_provider_build),
1516 details: Some(format!(
1517 "local build {local_build} is not greater than provider build {latest_provider_build}"
1518 )),
1519 }
1520 } else {
1521 VersionStateMonotonic {
1522 status: "passed".to_string(),
1523 local_build: Some(local_build),
1524 latest_provider_build: Some(latest_provider_build),
1525 details: Some(format!(
1526 "local build {local_build} is greater than provider build {latest_provider_build}"
1527 )),
1528 }
1529 }
1530}
1531
1532fn version_state_next_action(
1533 monotonic: &VersionStateMonotonic,
1534 provider_error: Option<&str>,
1535) -> String {
1536 if provider_error.is_some() {
1537 return "inspect-provider-credentials".to_string();
1538 }
1539 match monotonic.status.as_str() {
1540 "failed" => "bump-build".to_string(),
1541 "passed" => "upload-new-build".to_string(),
1542 "unknown" => "inspect-provider-state".to_string(),
1543 _ => "continue".to_string(),
1544 }
1545}
1546
1547fn print_version_state_report(report: &VersionStateReport, json_output: bool) -> Result<()> {
1548 if json_output {
1549 println!("{}", serde_json::to_string_pretty(report)?);
1550 return Ok(());
1551 }
1552 println!("{}: {}", report.action, report.status);
1553 println!("provider: {}", report.provider);
1554 println!("target: {}", report.target);
1555 println!("format: {}", report.format);
1556 if let Some(track) = &report.track {
1557 println!("track: {track}");
1558 }
1559 println!(
1560 "local: version={} build={}",
1561 report.local.version.as_deref().unwrap_or("unknown"),
1562 report
1563 .local
1564 .build
1565 .map(|build| build.to_string())
1566 .unwrap_or_else(|| "unknown".to_string())
1567 );
1568 if let Some(build) = report.provider_state.latest_uploaded_build {
1569 println!("provider latest uploaded build: {build}");
1570 }
1571 if let Some(build) = report.provider_state.latest_released_build {
1572 println!("provider latest released build: {build}");
1573 }
1574 if let Some(status) = &report.provider_state.latest_status {
1575 println!("provider latest status: {status}");
1576 }
1577 if let Some(status) = &report.provider_state.review_status {
1578 println!("provider review status: {status}");
1579 }
1580 println!("monotonic: {}", report.monotonic.status);
1581 if let Some(details) = &report.monotonic.details {
1582 println!("details: {details}");
1583 }
1584 if let Some(error) = &report.provider_error {
1585 println!("provider error: {error}");
1586 }
1587 println!("next action: {}", report.next_action);
1588 Ok(())
1589}
1590
1591fn json_u64_or_string(value: &JsonValue) -> Option<u64> {
1592 value
1593 .as_u64()
1594 .or_else(|| value.as_str().and_then(|value| value.parse::<u64>().ok()))
1595}
1596
1597fn max_optional_u64(current: Option<u64>, candidate: u64) -> Option<u64> {
1598 Some(current.map_or(candidate, |current| current.max(candidate)))
1599}
1600
1601fn max_optional_pair(left: Option<u64>, right: Option<u64>) -> Option<u64> {
1602 match (left, right) {
1603 (Some(left), Some(right)) => Some(left.max(right)),
1604 (Some(value), None) | (None, Some(value)) => Some(value),
1605 (None, None) => None,
1606 }
1607}
1608
1609fn bump_release_build_impl(
1610 project_dir: &Path,
1611 target: Option<Target>,
1612 by: u64,
1613 yes: bool,
1614 emit_human: bool,
1615) -> Result<u64> {
1616 if !yes {
1617 bail!("bump-build rewrites fission.toml; pass --yes after reviewing the target");
1618 }
1619 if by == 0 {
1620 bail!("--by must be greater than zero");
1621 }
1622 let plan = bump_build_plan(project_dir, target, by)?;
1623 let path = project_dir.join("fission.toml");
1624 let data =
1625 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1626 let mut doc = parse_toml_edit_document(&data, &path)?;
1627 let value = if matches!(target, Some(Target::Windows | Target::Ios | Target::Macos)) {
1628 toml_edit::value(plan.new_value.clone())
1629 } else {
1630 toml_edit::value(
1631 i64::try_from(plan.next_build).context("build number is too large for TOML integer")?,
1632 )
1633 };
1634 set_toml_edit_path(&mut doc, plan.field, value)?;
1635 write_toml_edit_document(&path, &doc)?;
1636 if emit_human {
1637 println!(
1638 "Bumped {} from {} to {}",
1639 plan.field, plan.old_value, plan.new_value
1640 );
1641 }
1642 Ok(plan.next_build)
1643}
1644
1645fn build_field_for_target(target: Option<Target>) -> &'static str {
1646 match target {
1647 Some(Target::Android) => "package.android.version_code",
1648 Some(Target::Ios) => "package.ios.build_number",
1649 Some(Target::Macos) => "package.macos.build_number",
1650 Some(Target::Windows) => "package.windows.version",
1651 _ => "app.build",
1652 }
1653}
1654
1655fn bump_build_plan(project_dir: &Path, target: Option<Target>, by: u64) -> Result<BumpBuildPlan> {
1656 let resolved = resolve_release_version_config(project_dir, target)?;
1657 let current = if target == Some(Target::Windows) {
1658 windows_package_version_build(resolved.version.as_deref()).or(resolved.build)
1659 } else {
1660 resolved.build
1661 }
1662 .unwrap_or(0);
1663 let next = current
1664 .checked_add(by)
1665 .context("build number overflow while incrementing release build")?;
1666 if target == Some(Target::Windows) {
1667 let old_version =
1668 normalize_windows_package_version(resolved.version.as_deref(), Some(current))?;
1669 let new_version = windows_package_version_with_build(resolved.version.as_deref(), next)?;
1670 return Ok(BumpBuildPlan {
1671 field: build_field_for_target(target),
1672 old_value: old_version,
1673 new_value: new_version,
1674 next_build: next,
1675 });
1676 }
1677 Ok(BumpBuildPlan {
1678 field: build_field_for_target(target),
1679 old_value: current.to_string(),
1680 new_value: next.to_string(),
1681 next_build: next,
1682 })
1683}
1684
1685fn windows_package_version_build(version: Option<&str>) -> Option<u64> {
1686 version?
1687 .split('.')
1688 .nth(3)
1689 .and_then(|value| value.parse::<u64>().ok())
1690}
1691
1692fn windows_package_version_with_build(version: Option<&str>, build: u64) -> Result<String> {
1693 let version = version.unwrap_or("0.1.0");
1694 let mut parts = version.split('.').collect::<Vec<_>>();
1695 if parts.len() > 4 {
1696 bail!("Windows package version `{version}` must have one to four numeric components");
1697 }
1698 for part in &parts {
1699 part.parse::<u16>()
1700 .with_context(|| format!("Windows package version `{version}` must be numeric"))?;
1701 }
1702 if build > u16::MAX as u64 {
1703 bail!("Windows package build `{build}` must fit in a 16-bit version component");
1704 }
1705 while parts.len() < 3 {
1706 parts.push("0");
1707 }
1708 if parts.len() == 3 {
1709 parts.push("");
1710 }
1711 let mut owned = parts.into_iter().map(str::to_string).collect::<Vec<_>>();
1712 owned[3] = build.to_string();
1713 Ok(owned.join("."))
1714}
1715
1716fn append_release_skip_requirement(doc: &mut DocumentMut, id: &str) -> Result<()> {
1717 let release = doc
1718 .as_table_mut()
1719 .entry("release")
1720 .or_insert(TomlEditItem::Table(TomlEditTable::new()))
1721 .as_table_mut()
1722 .context("release path traversed through a non-table value")?;
1723 let item = release
1724 .entry("skip_requirements")
1725 .or_insert_with(|| TomlEditItem::Value(TomlEditValue::Array(TomlEditArray::default())));
1726 let array = item
1727 .as_array_mut()
1728 .context("release.skip_requirements must be an array")?;
1729 if !array.iter().any(|value| value.as_str() == Some(id)) {
1730 array.push(id);
1731 }
1732 Ok(())
1733}
1734
1735fn add_release(
1736 project_dir: &Path,
1737 version: &str,
1738 build: u64,
1739 from: Option<&str>,
1740 yes: bool,
1741) -> Result<()> {
1742 if !yes {
1743 bail!("add-release appends to fission.toml; pass --yes after reviewing the release id");
1744 }
1745 let path = project_dir.join("fission.toml");
1746 let mut text =
1747 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1748 let id = format!("{version}+{build}");
1749 text.push_str(&format!(
1750 "\n[[releases]]\nid = \"{id}\"\nversion = \"{version}\"\nbuild = {build}\nstatus = \"candidate\"\nmetadata = \"release-content/metadata/{id}/release.toml\"\nrelease_notes = \"release-content/metadata/{id}/notes\"\nreview = \"release-content/metadata/{id}/review.toml\"\nprivacy = \"release-content/metadata/{id}/privacy.toml\"\n"
1751 ));
1752 if let Some(source) = from {
1753 text.push_str(&format!("# copied_from = \"{source}\"\n"));
1754 }
1755 fs::write(&path, text).with_context(|| format!("failed to write {}", path.display()))?;
1756 Ok(())
1757}
1758
1759fn parse_toml_edit_document(text: &str, path: &Path) -> Result<DocumentMut> {
1760 text.parse::<DocumentMut>()
1761 .with_context(|| format!("failed to parse {}", path.display()))
1762}
1763
1764fn toml_field_value(project_dir: &Path, field: &str) -> Result<Option<String>> {
1765 let path = project_dir.join("fission.toml");
1766 let data =
1767 fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
1768 let value: toml::Value =
1769 toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))?;
1770 let parts = toml_path_segments(field)?;
1771 Ok(parts
1772 .iter()
1773 .try_fold(&value, |current, part| current.get(part))
1774 .map(toml_value_summary))
1775}
1776
1777fn toml_value_summary(value: &toml::Value) -> String {
1778 match value {
1779 toml::Value::String(value) => value.clone(),
1780 toml::Value::Integer(value) => value.to_string(),
1781 toml::Value::Float(value) => value.to_string(),
1782 toml::Value::Boolean(value) => value.to_string(),
1783 toml::Value::Datetime(value) => value.to_string(),
1784 _ => value.to_string(),
1785 }
1786}
1787
1788fn write_toml_edit_document(path: &Path, doc: &DocumentMut) -> Result<()> {
1789 fs::write(path, format!("{doc}\n"))
1790 .with_context(|| format!("failed to write {}", path.display()))
1791}
1792
1793fn set_toml_edit_path(root: &mut DocumentMut, path: &str, value: TomlEditItem) -> Result<()> {
1794 let parts = toml_path_segments(path)?;
1795 let mut current = root.as_table_mut();
1796 for part in &parts[..parts.len() - 1] {
1797 current = current
1798 .entry(part.as_str())
1799 .or_insert(TomlEditItem::Table(TomlEditTable::new()))
1800 .as_table_mut()
1801 .context("field path traversed through a non-table value")?;
1802 }
1803 current[parts[parts.len() - 1].as_str()] = value;
1804 Ok(())
1805}
1806
1807fn toml_path_segments(path: &str) -> Result<Vec<String>> {
1808 let mut parts = Vec::new();
1809 let mut current = String::new();
1810 let mut chars = path.chars().peekable();
1811 let mut quote = None;
1812 let mut escaped = false;
1813 let mut closed_quote = false;
1814
1815 while let Some(ch) = chars.next() {
1816 if escaped {
1817 current.push(match ch {
1818 'n' => '\n',
1819 'r' => '\r',
1820 't' => '\t',
1821 other => other,
1822 });
1823 escaped = false;
1824 continue;
1825 }
1826 if let Some(q) = quote {
1827 match ch {
1828 '\\' if q == '"' => escaped = true,
1829 c if c == q => {
1830 quote = None;
1831 closed_quote = true;
1832 }
1833 c => current.push(c),
1834 }
1835 continue;
1836 }
1837 match ch {
1838 '"' | '\'' if current.trim().is_empty() => {
1839 current.clear();
1840 quote = Some(ch);
1841 }
1842 '.' => {
1843 push_toml_path_segment(path, &mut parts, &mut current)?;
1844 closed_quote = false;
1845 }
1846 c if c.is_whitespace() && closed_quote => {}
1847 _ if closed_quote => bail!(
1848 "invalid TOML field path `{path}`: quoted segment must be followed by `.` or end"
1849 ),
1850 c => current.push(c),
1851 }
1852 }
1853 if let Some(q) = quote {
1854 bail!("unterminated {q} quote in TOML field path `{path}`");
1855 }
1856 push_toml_path_segment(path, &mut parts, &mut current)?;
1857 Ok(parts)
1858}
1859
1860fn push_toml_path_segment(
1861 original: &str,
1862 parts: &mut Vec<String>,
1863 current: &mut String,
1864) -> Result<()> {
1865 let part = current.trim();
1866 if part.is_empty() {
1867 bail!("field path must be dot-separated and non-empty: `{original}`");
1868 }
1869 parts.push(part.to_string());
1870 current.clear();
1871 Ok(())
1872}
1873
1874fn toml_edit_string_array(values: impl IntoIterator<Item = String>) -> TomlEditItem {
1875 let mut array = TomlEditArray::default();
1876 for value in values {
1877 array.push(value);
1878 }
1879 TomlEditItem::Value(TomlEditValue::Array(array))
1880}
1881
1882fn add_release_command(
1883 project_dir: &Path,
1884 version: &str,
1885 build: u64,
1886 from: Option<&str>,
1887 dry_run: bool,
1888 yes: bool,
1889 json: bool,
1890) -> Result<()> {
1891 let release_id = format!("{version}+{build}");
1892 let mut receipt = mutation_receipt(project_dir, "release-config.add-release", dry_run);
1893 receipt.release_id = Some(release_id);
1894 receipt.new_value = Some(format!("version={version}, build={build}"));
1895 if let Some(source) = from {
1896 receipt.old_value = Some(format!("copied_from={source}"));
1897 }
1898 if !dry_run {
1899 add_release(project_dir, version, build, from, yes)?;
1900 receipt.status = "applied".to_string();
1901 }
1902 print_mutation_receipt(&receipt, json)
1903}
1904
1905fn mutation_receipt(
1906 project_dir: &Path,
1907 action: impl Into<String>,
1908 dry_run: bool,
1909) -> ReleaseConfigMutationReceipt {
1910 ReleaseConfigMutationReceipt {
1911 schema_version: 1,
1912 action: action.into(),
1913 status: if dry_run { "dry-run" } else { "pending" }.to_string(),
1914 project_dir: project_dir.display().to_string(),
1915 target: None,
1916 provider: None,
1917 field: None,
1918 kind: None,
1919 locale: None,
1920 path: None,
1921 old_value: None,
1922 new_value: None,
1923 release_id: None,
1924 requirement_id: None,
1925 }
1926}
1927
1928fn print_mutation_receipt(receipt: &ReleaseConfigMutationReceipt, json: bool) -> Result<()> {
1929 if json {
1930 println!("{}", serde_json::to_string_pretty(receipt)?);
1931 } else {
1932 println!("{}: {}", receipt.action, receipt.status);
1933 if let Some(field) = &receipt.field {
1934 println!("field: {field}");
1935 }
1936 if let Some(provider) = &receipt.provider {
1937 println!("provider: {provider}");
1938 }
1939 if let Some(kind) = &receipt.kind {
1940 println!("kind: {kind}");
1941 }
1942 if let Some(locale) = &receipt.locale {
1943 println!("locale: {locale}");
1944 }
1945 if let Some(path) = &receipt.path {
1946 println!("path: {path}");
1947 }
1948 if let Some(release_id) = &receipt.release_id {
1949 println!("release: {release_id}");
1950 }
1951 if let Some(requirement_id) = &receipt.requirement_id {
1952 println!("requirement: {requirement_id}");
1953 }
1954 if let Some(old_value) = &receipt.old_value {
1955 println!("old: {old_value}");
1956 }
1957 if let Some(new_value) = &receipt.new_value {
1958 println!("new: {new_value}");
1959 }
1960 }
1961 Ok(())
1962}
1963
1964fn edit_release_file(
1965 project_dir: &Path,
1966 release: &str,
1967 kind: &str,
1968 locale: Option<&str>,
1969) -> Result<()> {
1970 let path = release_file_path(project_dir, release, kind, locale)?;
1971 if let Some(parent) = path.parent() {
1972 fs::create_dir_all(parent)?;
1973 }
1974 if !path.exists() {
1975 fs::write(&path, "")?;
1976 }
1977 let editor = env::var("VISUAL")
1978 .or_else(|_| env::var("EDITOR"))
1979 .unwrap_or_else(|_| "vi".to_string());
1980 let status = Command::new(editor).arg(&path).status()?;
1981 if !status.success() {
1982 bail!("editor exited with {status}");
1983 }
1984 Ok(())
1985}
1986
1987#[allow(clippy::too_many_arguments)]
1988fn write_release_file_command(
1989 project_dir: &Path,
1990 release: &str,
1991 kind: &str,
1992 provider: Option<DistributionProvider>,
1993 locale: Option<&str>,
1994 value: Option<&str>,
1995 from_file: Option<&Path>,
1996 dry_run: bool,
1997 yes: bool,
1998 json: bool,
1999) -> Result<()> {
2000 if !dry_run && !yes {
2001 bail!("write-file rewrites a release metadata file; pass --yes after reviewing the release, kind, locale, and source");
2002 }
2003 let content = release_file_content(project_dir, value, from_file)?;
2004 let path = release_file_path(project_dir, release, kind, locale)?;
2005 let mut receipt = mutation_receipt(project_dir, "release-config.write-file", dry_run);
2006 receipt.provider = provider.map(|provider| provider.as_str().to_string());
2007 receipt.kind = Some(kind.to_string());
2008 receipt.locale = locale.map(str::to_string);
2009 receipt.path = Some(path.display().to_string());
2010 receipt.release_id = Some(release.to_string());
2011 receipt.old_value = release_file_summary(&path)?;
2012 receipt.new_value = Some(format!(
2013 "{} byte(s) from {}",
2014 content.len(),
2015 from_file
2016 .map(|path| path.display().to_string())
2017 .unwrap_or_else(|| "--value".to_string())
2018 ));
2019 if !dry_run {
2020 if let Some(parent) = path.parent() {
2021 fs::create_dir_all(parent)?;
2022 }
2023 fs::write(&path, content).with_context(|| format!("failed to write {}", path.display()))?;
2024 receipt.status = "applied".to_string();
2025 }
2026 print_mutation_receipt(&receipt, json)
2027}
2028
2029fn release_file_content(
2030 project_dir: &Path,
2031 value: Option<&str>,
2032 from_file: Option<&Path>,
2033) -> Result<Vec<u8>> {
2034 match (value, from_file) {
2035 (Some(_), Some(_)) => bail!("write-file accepts either --value or --from-file, not both"),
2036 (Some(value), None) => Ok(value.as_bytes().to_vec()),
2037 (None, Some(path)) => {
2038 let path = if path.is_absolute() {
2039 path.to_path_buf()
2040 } else {
2041 project_dir.join(path)
2042 };
2043 fs::read(&path).with_context(|| format!("failed to read {}", path.display()))
2044 }
2045 (None, None) => bail!("write-file requires --value or --from-file"),
2046 }
2047}
2048
2049fn release_file_summary(path: &Path) -> Result<Option<String>> {
2050 match fs::metadata(path) {
2051 Ok(metadata) => Ok(Some(format!("exists, {} byte(s)", metadata.len()))),
2052 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2053 Err(error) => Err(error).with_context(|| format!("failed to stat {}", path.display())),
2054 }
2055}
2056
2057fn release_file_path(
2058 project_dir: &Path,
2059 release: &str,
2060 kind: &str,
2061 locale: Option<&str>,
2062) -> Result<PathBuf> {
2063 let release = release_path_segment("release", release)?;
2064 let mut path = project_dir
2065 .join("release-content")
2066 .join("metadata")
2067 .join(release);
2068 match kind {
2069 "notes" => {
2070 path = path.join("notes");
2071 path.push(format!(
2072 "{}.md",
2073 release_path_segment("locale", locale.unwrap_or("en-US"))?
2074 ));
2075 }
2076 "review" => path.push("review.toml"),
2077 "privacy" => path.push("privacy.toml"),
2078 "metadata" | "release" => path.push("release.toml"),
2079 other => bail!("unsupported release file kind `{other}`"),
2080 }
2081 Ok(path)
2082}
2083
2084fn release_path_segment<'a>(label: &str, value: &'a str) -> Result<&'a str> {
2085 let value = value.trim();
2086 if value.is_empty()
2087 || value == "."
2088 || value == ".."
2089 || value.contains('/')
2090 || value.contains('\\')
2091 || value.contains('\0')
2092 {
2093 bail!("{label} must be a single path segment");
2094 }
2095 Ok(value)
2096}
2097
2098fn set_toml_path(root: &mut toml::Value, path: &str, value: toml::Value) -> Result<()> {
2099 let mut current = root;
2100 let parts = path.split('.').collect::<Vec<_>>();
2101 if parts.is_empty() || parts.iter().any(|part| part.trim().is_empty()) {
2102 bail!("field path must be dot-separated and non-empty");
2103 }
2104 for part in &parts[..parts.len() - 1] {
2105 let table = current
2106 .as_table_mut()
2107 .context("field path traversed through a non-table value")?;
2108 current = table
2109 .entry((*part).to_string())
2110 .or_insert_with(|| toml::Value::Table(Default::default()));
2111 }
2112 let table = current
2113 .as_table_mut()
2114 .context("field path parent is not a table")?;
2115 table.insert(parts[parts.len() - 1].to_string(), value);
2116 Ok(())
2117}
2118
2119fn base_report(
2120 area: &str,
2121 provider: Option<DistributionProvider>,
2122 target: Option<Target>,
2123) -> LifecycleReport {
2124 LifecycleReport {
2125 area: area.to_string(),
2126 status: "ready".to_string(),
2127 provider: provider.map(|provider| provider.as_str().to_string()),
2128 target: target.map(|target| target.as_str().to_string()),
2129 checks: Vec::new(),
2130 }
2131}
2132
2133fn path_check(id: &str, path: PathBuf, summary: &str) -> LifecycleCheck {
2134 LifecycleCheck {
2135 id: id.to_string(),
2136 status: if path.exists() { "passed" } else { "missing" }.to_string(),
2137 summary: summary.to_string(),
2138 details: Some(path.display().to_string()),
2139 remediation: vec![
2140 "Create the file/directory or update fission.toml to point at the correct path."
2141 .to_string(),
2142 ],
2143 }
2144}
2145
2146fn value_path_check(value: &toml::Value, path: &str, id: &str, summary: &str) -> LifecycleCheck {
2147 let exists = path
2148 .split('.')
2149 .try_fold(value, |current, segment| current.get(segment))
2150 .is_some();
2151 LifecycleCheck {
2152 id: id.to_string(),
2153 status: if exists { "passed" } else { "missing" }.to_string(),
2154 summary: summary.to_string(),
2155 details: Some(path.to_string()),
2156 remediation: vec![
2157 "Add the missing release configuration or use fission release-config add-release/set."
2158 .to_string(),
2159 ],
2160 }
2161}
2162
2163fn ok_check(id: &str, details: impl Into<String>) -> LifecycleCheck {
2164 LifecycleCheck {
2165 id: id.to_string(),
2166 status: "passed".to_string(),
2167 summary: id.replace('_', " "),
2168 details: Some(details.into()),
2169 remediation: Vec::new(),
2170 }
2171}
2172
2173fn warning_check(id: &str, details: String) -> LifecycleCheck {
2174 LifecycleCheck {
2175 id: id.to_string(),
2176 status: "warning".to_string(),
2177 summary: id.replace('_', " "),
2178 details: Some(details),
2179 remediation: vec![
2180 "Wire the provider backend before using this command to mutate remote state."
2181 .to_string(),
2182 ],
2183 }
2184}
2185
2186fn failed_check(id: &str, details: String) -> LifecycleCheck {
2187 LifecycleCheck {
2188 id: id.to_string(),
2189 status: "failed".to_string(),
2190 summary: id.replace('_', " "),
2191 details: Some(details),
2192 remediation: vec!["Fix the reported error and rerun the command.".to_string()],
2193 }
2194}
2195
2196fn finalize_status(report: &mut LifecycleReport) {
2197 report.status = if report
2198 .checks
2199 .iter()
2200 .any(|check| check.status == "failed" || check.status == "missing")
2201 {
2202 "blocked"
2203 } else if report.checks.iter().any(|check| check.status == "warning") {
2204 "warning"
2205 } else {
2206 "ready"
2207 }
2208 .to_string();
2209}
2210
2211fn print_report(mut report: LifecycleReport, json: bool) -> Result<()> {
2212 finalize_status(&mut report);
2213 if json {
2214 println!("{}", serde_json::to_string_pretty(&report)?);
2215 } else {
2216 println!("{}: {}", report.area, report.status);
2217 for check in &report.checks {
2218 println!("[{}] {} - {}", check.status, check.id, check.summary);
2219 if let Some(details) = &check.details {
2220 println!(" {details}");
2221 }
2222 for remediation in &check.remediation {
2223 println!(" fix: {remediation}");
2224 }
2225 }
2226 }
2227 if report.status == "blocked" {
2228 bail!("{} is blocked", report.area);
2229 }
2230 Ok(())
2231}
2232
2233#[cfg(test)]
2234mod release_config_tests;