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	"connections",
37	"console",
38];
39
40/// Custom tab id grammar enforced at every layer (TS Zod, NAPI, Rust). Slashes
41/// are forbidden because the URL splits `/inspector/custom-tabs/<id>/<rest>`
42/// on the first `/`.
43fn is_valid_custom_tab_id(id: &str) -> bool {
44	!id.is_empty()
45		&& id
46			.chars()
47			.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
48}
49
50impl InspectorTabEntry {
51	pub fn id(&self) -> &str {
52		match self {
53			Self::Custom { id, .. } | Self::HideBuiltin { id } => id,
54		}
55	}
56
57	fn validate(&self) -> Result<()> {
58		match self {
59			Self::Custom {
60				id,
61				label,
62				icon,
63				root,
64			} => {
65				if !is_valid_custom_tab_id(id) {
66					bail!(
67						"inspector tab id {id:?} is invalid: must be non-empty and contain only [a-zA-Z0-9_-]"
68					);
69				}
70				if BUILTIN_TAB_IDS.contains(&id.as_str()) {
71					bail!(
72						"inspector tab id {id:?} collides with a built-in tab; use {{ id: {id:?}, hidden: true }} to hide instead"
73					);
74				}
75				if label.is_empty() {
76					bail!("inspector tab {id:?} has an empty label");
77				}
78				if root.as_os_str().is_empty() {
79					bail!("inspector tab {id:?} has an empty source path");
80				}
81				if let Some(icon) = icon
82					&& icon.is_empty()
83				{
84					bail!("inspector tab {id:?} has an empty icon string");
85				}
86				Ok(())
87			}
88			Self::HideBuiltin { id } => {
89				if !BUILTIN_TAB_IDS.contains(&id.as_str()) {
90					bail!(
91						"inspector tab hide id {id:?} is not a known built-in (one of {:?})",
92						BUILTIN_TAB_IDS
93					);
94				}
95				Ok(())
96			}
97		}
98	}
99}
100
101/// Validates a list of `InspectorTabEntry` values: each entry on its own
102/// rules plus pairwise duplicate-id rejection. Runtime authority for
103/// rejecting malformed configs that bypass the TypeScript Zod layer.
104pub fn validate_inspector_tabs(entries: &[InspectorTabEntry]) -> Result<()> {
105	let mut seen = HashSet::new();
106	for entry in entries {
107		entry.validate()?;
108		if !seen.insert(entry.id()) {
109			bail!("inspector tabs contain duplicate id {:?}", entry.id());
110		}
111	}
112	Ok(())
113}
114
115// Test shim keeps moved tests in crate-root tests/ with private-module access.
116#[cfg(test)]
117#[path = "../../tests/inspector_tabs.rs"]
118mod tests;