Skip to main content

pylon_kernel/
studio.rs

1//! Pylon Studio runtime configuration.
2//!
3//! This is the Rust mirror of `packages/sdk/src/studio.ts`. Authored by
4//! the user as `studio.config.ts`, compiled to JSON by the CLI, read at
5//! runtime, and injected into the Studio HTML as
6//! `window.__PYLON_STUDIO_CONFIG__`.
7//!
8//! Every field is optional — an empty config is valid, and the web shell
9//! falls back to a sensible default (manifest entities → resources,
10//! emerald accent, Used Space footer card).
11//!
12//! Wire format is camelCase JSON to match the TS authoring surface.
13//! `serde(default)` everywhere so partial configs round-trip cleanly.
14
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17
18// ---------------------------------------------------------------------------
19// Top-level
20// ---------------------------------------------------------------------------
21
22#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase", default)]
24pub struct StudioConfig {
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub brand: Option<BrandConfig>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub theme: Option<ThemeConfig>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub sidebar: Option<SidebarConfig>,
31    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
32    pub resources: BTreeMap<String, ResourceConfig>,
33    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
34    pub pages: BTreeMap<String, PageConfig>,
35    /// True when the project ships a `studio.entry.tsx` bundle. Studio
36    /// HTML dynamic-imports `/studio/extensions.js` only when this flag
37    /// is set.
38    #[serde(default)]
39    pub has_extensions: bool,
40}
41
42// ---------------------------------------------------------------------------
43// Brand
44// ---------------------------------------------------------------------------
45
46#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase", default)]
48pub struct BrandConfig {
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub name: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub logo: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub subtitle: Option<String>,
55}
56
57// ---------------------------------------------------------------------------
58// Theme
59// ---------------------------------------------------------------------------
60
61#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase", default)]
63pub struct ThemeConfig {
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub accent: Option<ThemeAccent>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub appearance: Option<ThemeAppearance>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub primary: Option<String>,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
73#[serde(rename_all = "lowercase")]
74pub enum ThemeAccent {
75    Emerald,
76    Blue,
77    Violet,
78    Rose,
79    Amber,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
83#[serde(rename_all = "lowercase")]
84pub enum ThemeAppearance {
85    Dark,
86    Light,
87    System,
88}
89
90// ---------------------------------------------------------------------------
91// Sidebar
92// ---------------------------------------------------------------------------
93
94#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase", default)]
96pub struct SidebarConfig {
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub sections: Vec<SidebarSection>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub footer: Option<SidebarFooter>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub org_switcher: Option<OrgSwitcherConfig>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub collapsible: Option<bool>,
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase", default)]
109pub struct SidebarSection {
110    pub label: String,
111    #[serde(default)]
112    pub items: Vec<SidebarItem>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub default_open: Option<bool>,
115}
116
117/// Discriminated union via `type` field. Matches the TS shape exactly.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119#[serde(tag = "type", rename_all = "lowercase")]
120pub enum SidebarItem {
121    Page(SidebarPageItem),
122    Resource(SidebarResourceItem),
123    Link(SidebarLinkItem),
124    Heading(SidebarHeadingItem),
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase", default)]
129pub struct SidebarPageItem {
130    pub id: String,
131    pub label: String,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub icon: Option<String>,
134    #[serde(default, skip_serializing_if = "is_false")]
135    pub requires_admin: bool,
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub requires_roles: Vec<String>,
138}
139
140#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase", default)]
142pub struct SidebarResourceItem {
143    pub entity: String,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub label: Option<String>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub icon: Option<String>,
148    #[serde(default, skip_serializing_if = "is_false")]
149    pub requires_admin: bool,
150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
151    pub requires_roles: Vec<String>,
152}
153
154#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase", default)]
156pub struct SidebarLinkItem {
157    pub label: String,
158    pub href: String,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub icon: Option<String>,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub external: Option<bool>,
163}
164
165#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase", default)]
167pub struct SidebarHeadingItem {
168    pub label: String,
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[serde(tag = "type", rename_all = "lowercase")]
173pub enum SidebarFooter {
174    Card(SidebarFooterCard),
175    Custom(SidebarFooterCustom),
176}
177
178#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
179#[serde(rename_all = "camelCase", default)]
180pub struct SidebarFooterCard {
181    pub title: String,
182    pub description: String,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub action: Option<FooterAction>,
185    /// 0..1 progress fill. Stored as an `f64` so the JSON round-trips
186    /// the user's original number unchanged.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub progress: Option<f64>,
189}
190
191#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase", default)]
193pub struct FooterAction {
194    pub label: String,
195    pub href: String,
196}
197
198#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
199#[serde(rename_all = "camelCase", default)]
200pub struct SidebarFooterCustom {
201    pub component_id: String,
202}
203
204#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase", default)]
206pub struct OrgSwitcherConfig {
207    pub items: Vec<OrgSwitcherItem>,
208}
209
210#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase", default)]
212pub struct OrgSwitcherItem {
213    pub id: String,
214    pub label: String,
215}
216
217// ---------------------------------------------------------------------------
218// Resources
219// ---------------------------------------------------------------------------
220
221#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase", default)]
223pub struct ResourceConfig {
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub label: Option<String>,
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub plural_label: Option<String>,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub icon: Option<String>,
230    #[serde(default, skip_serializing_if = "is_false")]
231    pub hidden: bool,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub list: Option<ResourceListConfig>,
234}
235
236#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
237#[serde(rename_all = "camelCase", default)]
238pub struct ResourceListConfig {
239    #[serde(default, skip_serializing_if = "Vec::is_empty")]
240    pub columns: Vec<ColumnConfig>,
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub searchable: Option<bool>,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub filterable: Option<bool>,
245    #[serde(default, skip_serializing_if = "Vec::is_empty")]
246    pub bulk_actions: Vec<BulkAction>,
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    pub row_actions: Vec<RowAction>,
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub default_sort: Option<DefaultSort>,
251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
252    pub page_sizes: Vec<u32>,
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub default_page_size: Option<u32>,
255}
256
257#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
258#[serde(rename_all = "camelCase", default)]
259pub struct DefaultSort {
260    pub field: String,
261    pub order: SortOrder,
262}
263
264#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
265#[serde(rename_all = "lowercase")]
266pub enum SortOrder {
267    #[default]
268    Asc,
269    Desc,
270}
271
272#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
273#[serde(rename_all = "camelCase", default)]
274pub struct ColumnConfig {
275    pub field: String,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub label: Option<String>,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub order: Option<i32>,
280    #[serde(default, skip_serializing_if = "is_false")]
281    pub hidden: bool,
282    #[serde(default, skip_serializing_if = "is_false")]
283    pub sortable: bool,
284    #[serde(default, skip_serializing_if = "is_false")]
285    pub searchable: bool,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub filterable: Option<ColumnFilterable>,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub renderer: Option<ColumnRenderer>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub width: Option<String>,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub align: Option<ColumnAlign>,
294}
295
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
297#[serde(untagged)]
298pub enum ColumnFilterable {
299    Bool(bool),
300    Spec(ColumnFilterSpec),
301}
302
303#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
304#[serde(rename_all = "camelCase", default)]
305pub struct ColumnFilterSpec {
306    #[serde(default, skip_serializing_if = "Vec::is_empty")]
307    pub options: Vec<FilterOption>,
308}
309
310/// Filter option value is preserved as a `serde_json::Value` so any
311/// JSON shape (string, number, bool, null, object) round-trips intact.
312#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct FilterOption {
315    pub label: String,
316    pub value: serde_json::Value,
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
320#[serde(rename_all = "lowercase")]
321pub enum ColumnAlign {
322    Left,
323    Center,
324    Right,
325}
326
327// ---------------------------------------------------------------------------
328// Renderers — discriminated union on `kind`
329// ---------------------------------------------------------------------------
330
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332#[serde(tag = "kind", rename_all = "lowercase")]
333pub enum ColumnRenderer {
334    Text(RendererText),
335    Avatar(RendererAvatar),
336    Badge(RendererBadge),
337    Date(RendererDate),
338    Link(RendererLink),
339    Boolean(RendererBoolean),
340    Number(RendererNumber),
341    Json(RendererJson),
342    Custom(RendererCustom),
343}
344
345#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
346#[serde(rename_all = "camelCase", default)]
347pub struct RendererText {
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub truncate: Option<u32>,
350    #[serde(default, skip_serializing_if = "is_false")]
351    pub mono: bool,
352}
353
354#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
355#[serde(rename_all = "camelCase", default)]
356pub struct RendererAvatar {
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub image_field: Option<String>,
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub subtitle_field: Option<String>,
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub name_field: Option<String>,
363}
364
365#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
366#[serde(rename_all = "camelCase", default)]
367pub struct RendererBadge {
368    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
369    pub variants: BTreeMap<String, BadgeVariant>,
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub dot: Option<bool>,
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
375#[serde(rename_all = "lowercase")]
376pub enum BadgeVariant {
377    Green,
378    Red,
379    Amber,
380    Blue,
381    Gray,
382}
383
384#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
385#[serde(rename_all = "camelCase", default)]
386pub struct RendererDate {
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub format: Option<DateFormat>,
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
392#[serde(rename_all = "lowercase")]
393pub enum DateFormat {
394    Relative,
395    Absolute,
396    Iso,
397}
398
399#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
400#[serde(rename_all = "camelCase", default)]
401pub struct RendererLink {
402    pub href: String,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub external: Option<bool>,
405}
406
407#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
408#[serde(rename_all = "camelCase", default)]
409pub struct RendererBoolean {
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub true_label: Option<String>,
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub false_label: Option<String>,
414}
415
416#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
417#[serde(rename_all = "camelCase", default)]
418pub struct RendererNumber {
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub style: Option<NumberStyle>,
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub currency: Option<String>,
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub locale: Option<String>,
425}
426
427#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
428#[serde(rename_all = "lowercase")]
429pub enum NumberStyle {
430    Decimal,
431    Percent,
432    Currency,
433    Bytes,
434}
435
436#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
437#[serde(rename_all = "camelCase", default)]
438pub struct RendererJson {
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub truncate: Option<u32>,
441}
442
443#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
444#[serde(rename_all = "camelCase", default)]
445pub struct RendererCustom {
446    pub component_id: String,
447}
448
449// ---------------------------------------------------------------------------
450// Bulk + row actions
451// ---------------------------------------------------------------------------
452
453#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
454#[serde(rename_all = "camelCase", default)]
455pub struct BulkAction {
456    pub id: String,
457    pub label: String,
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub icon: Option<String>,
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub kind: Option<BulkActionKind>,
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub confirm: Option<String>,
464    #[serde(default, skip_serializing_if = "is_false")]
465    pub requires_admin: bool,
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
469#[serde(rename_all = "lowercase")]
470pub enum BulkActionKind {
471    Delete,
472    Export,
473    Custom,
474}
475
476#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
477#[serde(rename_all = "camelCase", default)]
478pub struct RowAction {
479    pub id: String,
480    pub label: String,
481    #[serde(skip_serializing_if = "Option::is_none")]
482    pub icon: Option<String>,
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub kind: Option<RowActionKind>,
485    #[serde(skip_serializing_if = "Option::is_none")]
486    pub confirm: Option<String>,
487    #[serde(default, skip_serializing_if = "is_false")]
488    pub requires_admin: bool,
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
492#[serde(rename_all = "lowercase")]
493pub enum RowActionKind {
494    Delete,
495    Edit,
496    View,
497    Custom,
498}
499
500// ---------------------------------------------------------------------------
501// Pages
502// ---------------------------------------------------------------------------
503
504#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
505#[serde(rename_all = "camelCase", default)]
506pub struct PageConfig {
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub subtitle: Option<String>,
509    #[serde(skip_serializing_if = "Option::is_none")]
510    pub component_id: Option<String>,
511}
512
513// ---------------------------------------------------------------------------
514// helpers
515// ---------------------------------------------------------------------------
516
517fn is_false(b: &bool) -> bool {
518    !*b
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn empty_config_round_trips() {
527        let cfg = StudioConfig::default();
528        let json = serde_json::to_string(&cfg).unwrap();
529        let back: StudioConfig = serde_json::from_str(&json).unwrap();
530        assert_eq!(cfg, back);
531    }
532
533    #[test]
534    fn parse_minimal_user_config() {
535        let json = r#"{
536            "brand": { "name": "Acme" },
537            "theme": { "accent": "emerald", "appearance": "dark" },
538            "sidebar": {
539                "sections": [{
540                    "label": "RESOURCES",
541                    "items": [
542                        { "type": "resource", "entity": "User", "icon": "users" },
543                        { "type": "page", "id": "overview", "label": "Overview" }
544                    ]
545                }]
546            },
547            "resources": {
548                "User": {
549                    "list": {
550                        "columns": [
551                            {
552                                "field": "status",
553                                "renderer": {
554                                    "kind": "badge",
555                                    "variants": { "active": "green", "blocked": "red" }
556                                }
557                            }
558                        ]
559                    }
560                }
561            }
562        }"#;
563        let cfg: StudioConfig = serde_json::from_str(json).unwrap();
564        assert_eq!(cfg.brand.unwrap().name.unwrap(), "Acme");
565        assert_eq!(cfg.theme.unwrap().accent.unwrap(), ThemeAccent::Emerald);
566        assert_eq!(cfg.sidebar.unwrap().sections.len(), 1);
567        let user = cfg.resources.get("User").unwrap();
568        let col = &user.list.as_ref().unwrap().columns[0];
569        match col.renderer.as_ref().unwrap() {
570            ColumnRenderer::Badge(b) => {
571                assert_eq!(b.variants.get("active"), Some(&BadgeVariant::Green));
572                assert_eq!(b.variants.get("blocked"), Some(&BadgeVariant::Red));
573            }
574            _ => panic!("expected Badge renderer"),
575        }
576    }
577
578    #[test]
579    fn parse_link_and_heading_items() {
580        let json = r#"{
581            "sidebar": {
582                "sections": [{
583                    "label": "ACCOUNTS",
584                    "items": [
585                        { "type": "heading", "label": "External" },
586                        { "type": "link", "label": "Google Analytics", "href": "https://analytics.google.com" }
587                    ]
588                }]
589            }
590        }"#;
591        let cfg: StudioConfig = serde_json::from_str(json).unwrap();
592        let items = &cfg.sidebar.unwrap().sections[0].items;
593        assert!(matches!(items[0], SidebarItem::Heading(_)));
594        match &items[1] {
595            SidebarItem::Link(l) => {
596                assert_eq!(l.label, "Google Analytics");
597                assert_eq!(l.href, "https://analytics.google.com");
598            }
599            _ => panic!("expected Link"),
600        }
601    }
602}