Skip to main content

fission_command_ui/
release_config.rs

1use anyhow::Result;
2use fission::op::{Color, Fill};
3use fission::prelude::*;
4use fission_command_core::{read_project_config, DistributionProvider, Target};
5use fission_command_package::{CheckSeverity, CheckStatus, ReadinessCheck};
6use fission_command_release::{release_config_readiness_checks, release_content_readiness_checks};
7use std::path::PathBuf;
8
9#[derive(Clone, Debug)]
10pub struct ReleaseConfigEditorOptions {
11    pub project_dir: PathBuf,
12    pub provider: Option<DistributionProvider>,
13    pub screenshot: Option<PathBuf>,
14    pub exit_after_render: bool,
15    pub width: Option<u16>,
16    pub height: Option<u16>,
17}
18
19#[derive(Clone, Debug, PartialEq)]
20pub struct ReleaseConfigEditorState {
21    pub project_dir: PathBuf,
22    pub app_name: String,
23    pub app_id: String,
24    pub provider: DistributionProvider,
25    pub current_step: usize,
26    pub config_checks: Vec<EditorCheck>,
27    pub content_checks: Vec<EditorCheck>,
28    pub status_message: String,
29    pub theme_mode: EditorThemeMode,
30}
31
32impl GlobalState for ReleaseConfigEditorState {}
33
34impl ReleaseConfigEditorState {
35    pub fn load(options: ReleaseConfigEditorOptions) -> Self {
36        let project = read_project_config(&options.project_dir).ok();
37        let provider = options
38            .provider
39            .or_else(|| project.as_ref().and_then(default_provider_for_project))
40            .unwrap_or(DistributionProvider::PlayStore);
41        let mut state = Self {
42            project_dir: options.project_dir,
43            app_name: project
44                .as_ref()
45                .map(|project| project.app.name.clone())
46                .unwrap_or_else(|| "workspace".to_string()),
47            app_id: project
48                .as_ref()
49                .map(|project| project.app.app_id.clone())
50                .unwrap_or_else(|| "unknown".to_string()),
51            provider,
52            current_step: 1,
53            config_checks: Vec::new(),
54            content_checks: Vec::new(),
55            status_message: "Loading release configuration".to_string(),
56            theme_mode: EditorThemeMode::Dark,
57        };
58        state.refresh_checks();
59        state
60    }
61
62    pub fn refresh_checks(&mut self) {
63        self.config_checks =
64            release_config_readiness_checks(&self.project_dir, Some(self.provider))
65                .unwrap_or_else(|err| vec![readiness_error("release_config.editor.config", err)])
66                .into_iter()
67                .map(EditorCheck::from)
68                .collect();
69        self.content_checks =
70            release_content_readiness_checks(&self.project_dir, Some(self.provider))
71                .unwrap_or_else(|err| vec![readiness_error("release_config.editor.content", err)])
72                .into_iter()
73                .map(EditorCheck::from)
74                .collect();
75        self.status_message = "Release checks refreshed".to_string();
76    }
77
78    pub fn next_step(&mut self) {
79        self.current_step = (self.current_step + 1).min(RELEASE_CONFIG_STEPS.len());
80    }
81
82    pub fn previous_step(&mut self) {
83        self.current_step = self.current_step.saturating_sub(1).max(1);
84    }
85
86    pub fn handle_key(&mut self, code: &fission::KeyCode) -> bool {
87        match code {
88            fission::KeyCode::Left | fission::KeyCode::Up => {
89                self.previous_step();
90                true
91            }
92            fission::KeyCode::Right | fission::KeyCode::Down | fission::KeyCode::Enter => {
93                self.next_step();
94                true
95            }
96            _ => false,
97        }
98    }
99}
100
101#[derive(Clone, Debug, PartialEq)]
102pub struct EditorCheck {
103    pub id: String,
104    pub severity: CheckSeverity,
105    pub status: CheckStatus,
106    pub summary: String,
107    pub details: Option<String>,
108    pub remediation: Vec<String>,
109}
110
111impl From<ReadinessCheck> for EditorCheck {
112    fn from(check: ReadinessCheck) -> Self {
113        Self {
114            id: check.id,
115            severity: check.severity,
116            status: check.status,
117            summary: check.summary,
118            details: check.details,
119            remediation: check.remediation,
120        }
121    }
122}
123
124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
125pub enum EditorThemeMode {
126    Dark,
127    Light,
128}
129
130impl EditorThemeMode {
131    fn toggle(self) -> Self {
132        match self {
133            Self::Dark => Self::Light,
134            Self::Light => Self::Dark,
135        }
136    }
137}
138
139#[derive(Clone)]
140pub struct ReleaseConfigEditorApp;
141
142impl From<ReleaseConfigEditorApp> for Widget {
143    fn from(_app: ReleaseConfigEditorApp) -> Self {
144        let (_ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
145        let palette = EditorPalette::for_mode(view.state().theme_mode);
146        let viewport = view.env().viewport_size;
147        let width = viewport.width.max(100.0);
148        let height = viewport.height.max(34.0);
149        let body_h = (height - 9.0).max(22.0);
150        Container::new(Column {
151            gap: Some(1.0),
152            children: widgets![
153                EditorHeader,
154                Row {
155                    gap: Some(1.0),
156                    children: widgets![
157                        EditorSidebar {
158                            width: 28.0,
159                            height: body_h
160                        },
161                        EditorBody {
162                            width: (width - 33.0).max(68.0),
163                            height: body_h
164                        },
165                    ],
166                    ..Default::default()
167                },
168                EditorFooter,
169            ],
170            ..Default::default()
171        })
172        .width(width)
173        .height(height)
174        .padding([1.0, 1.0, 1.0, 1.0])
175        .bg(palette.background)
176        .into()
177    }
178}
179
180#[derive(Clone)]
181struct EditorHeader;
182
183impl From<EditorHeader> for Widget {
184    fn from(_header: EditorHeader) -> Self {
185        let (ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
186        let palette = EditorPalette::for_mode(view.state().theme_mode);
187        let refresh = with_reducer!(ctx, ReleaseConfigRefresh, release_config_refresh);
188        let theme = with_reducer!(ctx, ReleaseConfigToggleTheme, release_config_toggle_theme);
189        Container::new(Row {
190            gap: Some(2.0),
191            children: widgets![
192                Column {
193                    gap: Some(0.0),
194                    children: widgets![
195                        Text::new("Fission Release Config").color(palette.accent_text),
196                        Text::new(format!(
197                            "{} - {}",
198                            view.state().app_name,
199                            view.state().app_id
200                        ))
201                        .color(palette.accent_text),
202                    ],
203                    ..Default::default()
204                },
205                Spacer {
206                    width: Some(2.0),
207                    ..Default::default()
208                },
209                EditorButton {
210                    label: "Refresh".into(),
211                    action: Some(refresh),
212                    tone: ButtonTone::Quiet,
213                    width: 12.0
214                },
215                EditorButton {
216                    label: "Theme".into(),
217                    action: Some(theme),
218                    tone: ButtonTone::Quiet,
219                    width: 10.0
220                },
221            ],
222            ..Default::default()
223        })
224        .height(4.0)
225        .padding([1.0, 1.0, 1.0, 1.0])
226        .bg(palette.accent)
227        .into()
228    }
229}
230
231#[derive(Clone)]
232struct EditorSidebar {
233    width: f32,
234    height: f32,
235}
236
237impl From<EditorSidebar> for Widget {
238    fn from(sidebar: EditorSidebar) -> Self {
239        let (_ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
240        let palette = EditorPalette::for_mode(view.state().theme_mode);
241        let mut children = widgets![Text::new("Workflow").color(palette.accent)];
242        for (index, step) in RELEASE_CONFIG_STEPS.iter().enumerate() {
243            let selected = view.state().current_step == index + 1;
244            let marker = if selected { ">" } else { " " };
245            children.push(
246                Text::new(format!("{marker} {}. {}", index + 1, step.title))
247                    .color(if selected {
248                        palette.accent
249                    } else {
250                        palette.text
251                    })
252                    .into(),
253            );
254        }
255        Container::new(Column {
256            gap: Some(1.0),
257            children,
258            ..Default::default()
259        })
260        .width(sidebar.width)
261        .height(sidebar.height)
262        .padding([1.0, 1.0, 1.0, 1.0])
263        .bg(palette.panel)
264        .border(palette.border, 1.0)
265        .into()
266    }
267}
268
269#[derive(Clone)]
270struct EditorBody {
271    width: f32,
272    height: f32,
273}
274
275impl From<EditorBody> for Widget {
276    fn from(body: EditorBody) -> Self {
277        let (_ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
278        let palette = EditorPalette::for_mode(view.state().theme_mode);
279        let step = RELEASE_CONFIG_STEPS[view.state().current_step.saturating_sub(1)];
280        let checks = checks_for_current_step(view.state());
281        Container::new(Scroll {
282            direction: FlexDirection::Column,
283            width: Some((body.width - 2.0).max(1.0)),
284            height: Some((body.height - 2.0).max(1.0)),
285            show_scrollbar: true,
286            child: Some(
287                Column {
288                    gap: Some(1.0),
289                    children: widgets![
290                        Text::new(format!(
291                            "Step {} of {} - {}",
292                            view.state().current_step,
293                            RELEASE_CONFIG_STEPS.len(),
294                            step.title
295                        ))
296                        .color(palette.accent),
297                        Text::new(step.description).color(palette.text),
298                        KeyValue {
299                            label: "Provider".into(),
300                            value: view.state().provider.as_str().into()
301                        },
302                        KeyValue {
303                            label: "Project".into(),
304                            value: view.state().project_dir.display().to_string()
305                        },
306                        CommandList {
307                            commands: step.commands(view.state().provider)
308                        },
309                        CheckList {
310                            title: step.check_title.into(),
311                            checks
312                        },
313                    ],
314                    ..Default::default()
315                }
316                .into(),
317            ),
318            ..Default::default()
319        })
320        .width(body.width)
321        .height(body.height)
322        .padding([1.0, 1.0, 1.0, 1.0])
323        .bg(palette.surface)
324        .border(palette.border, 1.0)
325        .into()
326    }
327}
328
329#[derive(Clone)]
330struct EditorFooter;
331
332impl From<EditorFooter> for Widget {
333    fn from(_footer: EditorFooter) -> Self {
334        let (ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
335        let palette = EditorPalette::for_mode(view.state().theme_mode);
336        let previous = with_reducer!(ctx, ReleaseConfigPreviousStep, release_config_previous_step);
337        let next = with_reducer!(ctx, ReleaseConfigNextStep, release_config_next_step);
338        Container::new(Row {
339            gap: Some(1.0),
340            children: widgets![
341                Text::new(view.state().status_message.clone()).color(palette.muted),
342                Spacer {
343                    width: Some(2.0),
344                    ..Default::default()
345                },
346                EditorButton {
347                    label: "Previous".into(),
348                    action: Some(previous),
349                    tone: ButtonTone::Quiet,
350                    width: 12.0
351                },
352                EditorButton {
353                    label: "Next".into(),
354                    action: Some(next),
355                    tone: ButtonTone::Primary,
356                    width: 10.0
357                },
358                Text::new("Arrows move steps").color(palette.muted),
359            ],
360            ..Default::default()
361        })
362        .height(3.0)
363        .padding([0.0, 1.0, 0.0, 1.0])
364        .bg(palette.panel)
365        .into()
366    }
367}
368
369#[derive(Clone)]
370struct KeyValue {
371    label: String,
372    value: String,
373}
374
375impl From<KeyValue> for Widget {
376    fn from(row: KeyValue) -> Self {
377        let (_ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
378        let palette = EditorPalette::for_mode(view.state().theme_mode);
379        Row {
380            gap: Some(1.0),
381            children: widgets![
382                Text::new(format!("{}:", row.label))
383                    .color(palette.muted)
384                    .width(14.0),
385                Text::new(row.value).color(palette.text),
386            ],
387            ..Default::default()
388        }
389        .into()
390    }
391}
392
393#[derive(Clone)]
394struct CommandList {
395    commands: Vec<String>,
396}
397
398impl From<CommandList> for Widget {
399    fn from(list: CommandList) -> Self {
400        let (_ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
401        let palette = EditorPalette::for_mode(view.state().theme_mode);
402        let mut children = widgets![Text::new("Equivalent CLI").color(palette.accent)];
403        for command in list.commands {
404            children.push(Text::new(command).color(palette.text).into());
405        }
406        Column {
407            gap: Some(0.0),
408            children,
409            ..Default::default()
410        }
411        .into()
412    }
413}
414
415#[derive(Clone)]
416struct CheckList {
417    title: String,
418    checks: Vec<EditorCheck>,
419}
420
421impl From<CheckList> for Widget {
422    fn from(list: CheckList) -> Self {
423        let (_ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
424        let palette = EditorPalette::for_mode(view.state().theme_mode);
425        let mut children = widgets![Text::new(list.title).color(palette.accent)];
426        if list.checks.is_empty() {
427            children.push(
428                Text::new("No checks available for this step yet.")
429                    .color(palette.muted)
430                    .into(),
431            );
432        }
433        for check in list.checks.into_iter().take(12) {
434            let color = match check.status {
435                CheckStatus::Passed => palette.success,
436                CheckStatus::Warning | CheckStatus::Skipped => palette.warning,
437                CheckStatus::Missing | CheckStatus::Failed => palette.error,
438            };
439            children.push(
440                Text::new(format!(
441                    "[{}] {} - {}",
442                    status_label(check.status),
443                    check.id,
444                    check.summary
445                ))
446                .color(color)
447                .into(),
448            );
449            if let Some(details) = check.details {
450                children.push(
451                    Text::new(format!("  {details}"))
452                        .color(palette.muted)
453                        .into(),
454                );
455            }
456        }
457        Column {
458            gap: Some(0.0),
459            children,
460            ..Default::default()
461        }
462        .into()
463    }
464}
465
466#[derive(Clone)]
467struct EditorButton {
468    label: String,
469    action: Option<ActionEnvelope>,
470    tone: ButtonTone,
471    width: f32,
472}
473
474impl From<EditorButton> for Widget {
475    fn from(button: EditorButton) -> Self {
476        let (_ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
477        let palette = EditorPalette::for_mode(view.state().theme_mode);
478        let bg = match button.tone {
479            ButtonTone::Primary => palette.accent,
480            ButtonTone::Quiet => palette.panel,
481        };
482        let fg = match button.tone {
483            ButtonTone::Primary => palette.accent_text,
484            ButtonTone::Quiet => palette.text,
485        };
486        Button {
487            on_press: button.action,
488            width: Some(button.width),
489            height: Some(3.0),
490            padding: Some([0.0, 1.0, 0.0, 1.0]),
491            background_fill: Some(Fill::Solid(bg)),
492            text_color: Some(fg),
493            child: Some(Text::new(button.label).color(fg).into()),
494            ..Default::default()
495        }
496        .into()
497    }
498}
499
500#[derive(Clone, Copy)]
501enum ButtonTone {
502    Primary,
503    Quiet,
504}
505
506#[derive(Clone, Copy)]
507struct EditorStep {
508    title: &'static str,
509    description: &'static str,
510    check_title: &'static str,
511}
512
513impl EditorStep {
514    fn commands(self, provider: DistributionProvider) -> Vec<String> {
515        match self.title {
516            "Manifest & identity" => vec![
517                "fission release-config validate --provider <provider>".to_string(),
518                "fission release-config set app.version <version> --dry-run --json".to_string(),
519            ],
520            "Release files" => vec![
521                "fission release-config write-file --release <id> --kind notes --locale en-US --from-file notes.md --dry-run --json".to_string(),
522                "fission release-content validate --provider <provider>".to_string(),
523            ],
524            "Provider baseline" => vec![
525                format!("fission release-config import --provider {} --dry-run --json", provider.as_str()),
526                format!("fission release-config lock --provider {} --yes", provider.as_str()),
527            ],
528            "Content assets" => vec![
529                format!("fission release-content render --provider {}", provider.as_str()),
530                format!("fission release-content push --provider {} --dry-run --json", provider.as_str()),
531            ],
532            _ => vec![
533                format!("fission release-config push --provider {} --dry-run --json", provider.as_str()),
534                "fission publish --provider <provider> --dry-run --json".to_string(),
535            ],
536        }
537    }
538}
539
540const RELEASE_CONFIG_STEPS: &[EditorStep] = &[
541    EditorStep {
542        title: "Manifest & identity",
543        description: "Check that fission.toml contains reviewable app identity, version/build, provider ids, and no secret material.",
544        check_title: "Release config checks",
545    },
546    EditorStep {
547        title: "Release files",
548        description: "Review the active release entry and sidecar files for notes, review info, privacy, and localized content.",
549        check_title: "Release config checks",
550    },
551    EditorStep {
552        title: "Provider baseline",
553        description: "Import or lock provider state before pushing local metadata, so remote changes are not overwritten accidentally.",
554        check_title: "Release config checks",
555    },
556    EditorStep {
557        title: "Content assets",
558        description: "Validate screenshots, videos, feature graphics, logos, and provider-specific asset directories before upload.",
559        check_title: "Release content checks",
560    },
561    EditorStep {
562        title: "Push & publish",
563        description: "Dry-run metadata and artifact publish paths, then use the shared publish workflow for the final mutation.",
564        check_title: "Release content checks",
565    },
566];
567
568#[derive(Clone, Copy)]
569struct EditorPalette {
570    background: Color,
571    surface: Color,
572    panel: Color,
573    border: Color,
574    text: Color,
575    muted: Color,
576    accent: Color,
577    accent_text: Color,
578    success: Color,
579    warning: Color,
580    error: Color,
581}
582
583impl EditorPalette {
584    fn for_mode(mode: EditorThemeMode) -> Self {
585        match mode {
586            EditorThemeMode::Dark => Self {
587                background: rgb(5, 10, 15),
588                surface: rgb(13, 22, 31),
589                panel: rgb(18, 30, 40),
590                border: rgb(53, 69, 82),
591                text: rgb(242, 247, 246),
592                muted: rgb(162, 177, 187),
593                accent: rgb(93, 213, 112),
594                accent_text: rgb(5, 22, 12),
595                success: rgb(93, 213, 112),
596                warning: rgb(244, 193, 70),
597                error: rgb(246, 84, 80),
598            },
599            EditorThemeMode::Light => Self {
600                background: rgb(238, 243, 240),
601                surface: rgb(250, 252, 249),
602                panel: rgb(235, 242, 238),
603                border: rgb(178, 194, 184),
604                text: rgb(17, 29, 33),
605                muted: rgb(80, 95, 101),
606                accent: rgb(24, 148, 72),
607                accent_text: rgb(245, 255, 248),
608                success: rgb(24, 148, 72),
609                warning: rgb(184, 117, 22),
610                error: rgb(200, 54, 55),
611            },
612        }
613    }
614}
615
616pub fn run_release_config_tui(options: ReleaseConfigEditorOptions) -> Result<()> {
617    let run_options = fission::terminal::TerminalRunOptions {
618        width: options.width,
619        height: options.height,
620        screenshot: options.screenshot.clone(),
621        exit_after_render: options.exit_after_render,
622        ..fission::terminal::TerminalRunOptions::default()
623    };
624    let state = ReleaseConfigEditorState::load(options);
625    fission::terminal::TerminalApp::with_state(ReleaseConfigEditorApp, state)
626        .with_title("Fission release config")
627        .with_env(|env| env.theme = fission::theme::Theme::dark())
628        .with_sync_env(|state, env| {
629            env.theme = match state.theme_mode {
630                EditorThemeMode::Dark => fission::theme::Theme::dark(),
631                EditorThemeMode::Light => fission::theme::Theme::default(),
632            };
633        })
634        .with_key_handler(release_config_key_handler)
635        .run_with_options(run_options)
636}
637
638fn release_config_key_handler(
639    state: &mut ReleaseConfigEditorState,
640    code: &fission::KeyCode,
641    _modifiers: u8,
642) -> bool {
643    state.handle_key(code)
644}
645
646#[fission_reducer(ReleaseConfigRefresh)]
647fn release_config_refresh(state: &mut ReleaseConfigEditorState) {
648    state.refresh_checks();
649}
650
651#[fission_reducer(ReleaseConfigNextStep)]
652fn release_config_next_step(state: &mut ReleaseConfigEditorState) {
653    state.next_step();
654}
655
656#[fission_reducer(ReleaseConfigPreviousStep)]
657fn release_config_previous_step(state: &mut ReleaseConfigEditorState) {
658    state.previous_step();
659}
660
661#[fission_reducer(ReleaseConfigToggleTheme)]
662fn release_config_toggle_theme(state: &mut ReleaseConfigEditorState) {
663    state.theme_mode = state.theme_mode.toggle();
664}
665
666fn checks_for_current_step(state: &ReleaseConfigEditorState) -> Vec<EditorCheck> {
667    match state.current_step {
668        4 | 5 => state.content_checks.clone(),
669        _ => state.config_checks.clone(),
670    }
671}
672
673fn default_provider_for_project(
674    project: &fission_command_core::FissionProject,
675) -> Option<DistributionProvider> {
676    if project.targets.contains(&Target::Android) {
677        Some(DistributionProvider::PlayStore)
678    } else if project.targets.contains(&Target::Ios) {
679        Some(DistributionProvider::AppStore)
680    } else if project.targets.contains(&Target::Windows) {
681        Some(DistributionProvider::MicrosoftStore)
682    } else if project.targets.contains(&Target::Site) {
683        Some(DistributionProvider::GithubPages)
684    } else {
685        None
686    }
687}
688
689fn readiness_error(id: &str, error: anyhow::Error) -> ReadinessCheck {
690    ReadinessCheck {
691        id: id.to_string(),
692        severity: CheckSeverity::Error,
693        status: CheckStatus::Failed,
694        summary: "release-config editor check failed".to_string(),
695        details: Some(error.to_string()),
696        remediation: vec!["Fix the reported error and refresh the editor.".to_string()],
697    }
698}
699
700fn status_label(status: CheckStatus) -> &'static str {
701    match status {
702        CheckStatus::Passed => "ok",
703        CheckStatus::Missing => "missing",
704        CheckStatus::Failed => "failed",
705        CheckStatus::Warning => "warning",
706        CheckStatus::Skipped => "skipped",
707    }
708}
709
710fn rgb(r: u8, g: u8, b: u8) -> Color {
711    Color { r, g, b, a: 255 }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    fn options(project_dir: PathBuf) -> ReleaseConfigEditorOptions {
719        ReleaseConfigEditorOptions {
720            project_dir,
721            provider: Some(DistributionProvider::PlayStore),
722            screenshot: None,
723            exit_after_render: false,
724            width: None,
725            height: None,
726        }
727    }
728
729    #[test]
730    fn release_config_editor_renders_release_config_flow() {
731        let state = ReleaseConfigEditorState::load(options(PathBuf::from(".")));
732        let mut app = fission::terminal::TerminalApp::with_state(ReleaseConfigEditorApp, state);
733        let frame = app
734            .render_frame(160, 60)
735            .expect("release config editor renders");
736        let text = frame.as_plain_text();
737        assert!(text.contains("Fission Release Config"));
738        assert!(text.contains("Step 1 of 5"));
739        assert!(text.contains("Manifest & identity"));
740        assert!(text.contains("Provider"));
741        assert!(text.contains("play-store"));
742    }
743
744    #[test]
745    fn release_config_editor_arrow_keys_move_between_steps() {
746        let mut state = ReleaseConfigEditorState::load(options(PathBuf::from(".")));
747        assert_eq!(state.current_step, 1);
748        assert!(state.handle_key(&fission::KeyCode::Right));
749        assert_eq!(state.current_step, 2);
750        assert!(state.handle_key(&fission::KeyCode::Down));
751        assert_eq!(state.current_step, 3);
752        assert!(state.handle_key(&fission::KeyCode::Left));
753        assert_eq!(state.current_step, 2);
754        assert!(state.handle_key(&fission::KeyCode::Up));
755        assert_eq!(state.current_step, 1);
756    }
757}