Skip to main content

fission_command_release/
lib.rs

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    /// Open release configuration in an editor or the Fission terminal UI.
44    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 provider metadata into local release files.
53    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 local release metadata against provider state.
68    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 fission.toml and referenced release files.
77    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 release metadata to a provider.
86    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    /// Record the current provider metadata revision as the local editing baseline.
103    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 a scalar field in fission.toml.
118    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    /// Append a release entry to fission.toml.
131    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    /// Record an explicit skip for a provider-optional or Fission-recommended release check.
148    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    /// Increment the build number used for a target release package.
161    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    /// Report local version/build state against provider-side release state.
176    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    /// Open or create a release metadata sidecar file.
195    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    /// Write a referenced release metadata sidecar file non-interactively.
206    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 screenshots/videos from configured release scenarios.
233    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 store-ready screenshot/video assets from raw captures.
244    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 release-content assets and manifests.
253    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 rendered release-content assets to a provider.
262    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    /// Manage beta groups/flights/tracks.
281    Groups {
282        #[command(subcommand)]
283        command: BetaGroupsCommand,
284    },
285    /// Manage beta testers.
286    Testers {
287        #[command(subcommand)]
288        command: BetaTestersCommand,
289    },
290    /// Distribute an artifact to a beta track/group.
291    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 provider beta groups, flights, or tester-track settings.
314    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 configured beta groups from fission.toml to the provider.
323    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 testers from a CSV into a provider group or track.
342    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 provider testers from a group or track to a CSV file.
361    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    /// Inspect signing configuration and credential references for a target.
380    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    /// Synchronize platform signing metadata without storing secrets in fission.toml.
389    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 signing references into fission.toml from explicit files or environment-backed secrets.
400    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 provider store reviews and recent user feedback.
421    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 to a provider store review after explicit confirmation.
432    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 configured release workflows.
453    List {
454        #[arg(long, default_value = ".")]
455        project_dir: PathBuf,
456        #[arg(long)]
457        json: bool,
458    },
459    /// Run a named release workflow from fission.toml.
460    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    /// Show provider-owned login/setup steps without storing credentials in Fission state.
476    Login {
477        #[arg(value_enum)]
478        provider: Option<DistributionProvider>,
479        #[arg(long)]
480        json: bool,
481    },
482    /// Show provider auth setup requirements.
483    Setup {
484        #[arg(value_enum)]
485        provider: Option<DistributionProvider>,
486        #[arg(long)]
487        json: bool,
488    },
489    /// Report whether required environment/tooling credentials are available.
490    Status {
491        #[arg(value_enum)]
492        provider: Option<DistributionProvider>,
493        #[arg(long)]
494        json: bool,
495    },
496    /// Show how to revoke provider-owned credentials.
497    Logout {
498        #[arg(value_enum)]
499        provider: Option<DistributionProvider>,
500        #[arg(long)]
501        json: bool,
502    },
503    /// Explain safe import paths without copying secrets into fission.toml.
504    Import {
505        #[arg(value_enum)]
506        provider: DistributionProvider,
507        #[arg(long)]
508        from: Option<String>,
509        #[arg(long)]
510        json: bool,
511    },
512    /// Show provider-owned credential rotation steps.
513    Rotate {
514        #[arg(value_enum)]
515        provider: Option<DistributionProvider>,
516        #[arg(long)]
517        json: bool,
518    },
519    /// Audit all provider auth requirements.
520    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;