Skip to main content

rivetkit_core/inspector/
tabs.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3
4use anyhow::{Result, bail};
5
6/// Inspector tab declaration carried on `ActorConfig`. Either a custom tab
7/// (id + label + source root) or a hide modifier for a built-in tab.
8/// Validation of id collisions and source presence happens upstream in the
9/// TypeScript Zod schema or the Rust builder; `validate_inspector_tabs`
10/// below is the runtime authority that closes the gap when direct Rust
11/// callers bypass the upstream layers.
12#[derive(Clone, Debug)]
13pub enum InspectorTabEntry {
14	Custom {
15		id: String,
16		label: String,
17		/// Icon identifier the dashboard maps to a glyph (see the
18		/// dashboard's icon registry). `None` falls back to a generic
19		/// icon on the dashboard side.
20		icon: Option<String>,
21		root: PathBuf,
22	},
23	HideBuiltin {
24		id: String,
25	},
26}
27
28/// Set of built-in inspector tab ids the dashboard ships. The Rust runtime
29/// uses this both to reject custom-tab ids that collide with a built-in and
30/// to validate `HideBuiltin { id }` entries reference a known tab.
31pub const BUILTIN_TAB_IDS: &[&str] = &[
32	"workflow",
33	"database",
34	"state",
35	"queue",
36	"schedules",
37	"connections",
38	"console",
39];
40
41/// Custom tab id grammar enforced at every layer (TS Zod, NAPI, Rust). Slashes
42/// are forbidden because the URL splits `/inspector/custom-tabs/<id>/<rest>`
43/// on the first `/`.
44fn is_valid_custom_tab_id(id: &str) -> bool {
45	!id.is_empty()
46		&& id
47			.chars()
48			.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
49}
50
51impl InspectorTabEntry {
52	pub fn id(&self) -> &str {
53		match self {
54			Self::Custom { id, .. } | Self::HideBuiltin { id } => id,
55		}
56	}
57
58	fn validate(&self) -> Result<()> {
59		match self {
60			Self::Custom {
61				id,
62				label,
63				icon,
64				root,
65			} => {
66				if !is_valid_custom_tab_id(id) {
67					bail!(
68						"inspector tab id {id:?} is invalid: must be non-empty and contain only [a-zA-Z0-9_-]"
69					);
70				}
71				if BUILTIN_TAB_IDS.contains(&id.as_str()) {
72					bail!(
73						"inspector tab id {id:?} collides with a built-in tab; use {{ id: {id:?}, hidden: true }} to hide instead"
74					);
75				}
76				if label.is_empty() {
77					bail!("inspector tab {id:?} has an empty label");
78				}
79				if root.as_os_str().is_empty() {
80					bail!("inspector tab {id:?} has an empty source path");
81				}
82				if let Some(icon) = icon
83					&& icon.is_empty()
84				{
85					bail!("inspector tab {id:?} has an empty icon string");
86				}
87				Ok(())
88			}
89			Self::HideBuiltin { id } => {
90				if !BUILTIN_TAB_IDS.contains(&id.as_str()) {
91					bail!(
92						"inspector tab hide id {id:?} is not a known built-in (one of {:?})",
93						BUILTIN_TAB_IDS
94					);
95				}
96				Ok(())
97			}
98		}
99	}
100}
101
102/// Validates a list of `InspectorTabEntry` values: each entry on its own
103/// rules plus pairwise duplicate-id rejection. Runtime authority for
104/// rejecting malformed configs that bypass the TypeScript Zod layer.
105pub fn validate_inspector_tabs(entries: &[InspectorTabEntry]) -> Result<()> {
106	let mut seen = HashSet::new();
107	for entry in entries {
108		entry.validate()?;
109		if !seen.insert(entry.id()) {
110			bail!("inspector tabs contain duplicate id {:?}", entry.id());
111		}
112	}
113	Ok(())
114}
115
116// Test shim keeps moved tests in crate-root tests/ with private-module access.
117#[cfg(test)]
118#[path = "../../tests/inspector_tabs.rs"]
119mod tests;