Skip to main content

daemon8_types/
lib.rs

1// SPDX-License-Identifier: LicenseRef-FCL-1.0-ALv2
2// Copyright (c) 2026 Havy.tech, LLC
3
4use std::borrow::Borrow;
5use std::collections::HashMap;
6use std::fmt;
7use std::str::FromStr;
8use std::sync::Arc;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use rmcp::schemars;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15pub const SYSTEM_TAG: &str = "_system";
16
17macro_rules! arc_str_newtype {
18    ($name:ident) => {
19        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20        #[serde(transparent)]
21        pub struct $name(Arc<str>);
22
23        impl $name {
24            pub fn as_str(&self) -> &str {
25                &self.0
26            }
27        }
28
29        impl fmt::Display for $name {
30            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31                f.write_str(&self.0)
32            }
33        }
34
35        impl From<&str> for $name {
36            fn from(s: &str) -> Self {
37                Self(Arc::from(s))
38            }
39        }
40
41        impl From<String> for $name {
42            fn from(s: String) -> Self {
43                Self(Arc::from(s.as_str()))
44            }
45        }
46
47        impl From<Arc<str>> for $name {
48            fn from(s: Arc<str>) -> Self {
49                Self(s)
50            }
51        }
52
53        impl AsRef<str> for $name {
54            fn as_ref(&self) -> &str {
55                &self.0
56            }
57        }
58
59        impl Borrow<str> for $name {
60            fn borrow(&self) -> &str {
61                &self.0
62            }
63        }
64
65        impl PartialEq<str> for $name {
66            fn eq(&self, other: &str) -> bool {
67                self.as_str() == other
68            }
69        }
70
71        impl PartialEq<&str> for $name {
72            fn eq(&self, other: &&str) -> bool {
73                self.as_str() == *other
74            }
75        }
76
77        impl FromStr for $name {
78            type Err = std::convert::Infallible;
79            fn from_str(s: &str) -> Result<Self, Self::Err> {
80                Ok(Self::from(s))
81            }
82        }
83
84        impl JsonSchema for $name {
85            fn schema_name() -> std::borrow::Cow<'static, str> {
86                stringify!($name).into()
87            }
88            fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
89                String::json_schema(generator)
90            }
91        }
92    };
93}
94
95arc_str_newtype!(TabId);
96arc_str_newtype!(SessionId);
97arc_str_newtype!(DeviceSerial);
98arc_str_newtype!(AppName);
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
101#[serde(rename_all = "snake_case")]
102pub enum DebugAction {
103    EvalJs,
104    Screenshot,
105    InjectCss,
106    RevertCss,
107    ListTabs,
108    GetPerfMetrics,
109    GetDom,
110    SetViewport,
111    ClearViewport,
112    NetworkConditions,
113    Navigate,
114    StorageClear,
115    StorageInspect,
116    StorageSet,
117    ElementAtPoint,
118    NewTab,
119    CloseTab,
120}
121
122#[derive(
123    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
124)]
125#[serde(rename_all = "snake_case")]
126pub enum Severity {
127    Trace,
128    Debug,
129    Info,
130    Warn,
131    Error,
132}
133
134impl Severity {
135    pub fn level(self) -> u8 {
136        match self {
137            Self::Trace => 0,
138            Self::Debug => 1,
139            Self::Info => 2,
140            Self::Warn => 3,
141            Self::Error => 4,
142        }
143    }
144}
145
146impl fmt::Display for Severity {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        let s = match self {
149            Self::Trace => "trace",
150            Self::Debug => "debug",
151            Self::Info => "info",
152            Self::Warn => "warn",
153            Self::Error => "error",
154        };
155        f.write_str(s)
156    }
157}
158
159impl std::str::FromStr for Severity {
160    type Err = String;
161
162    fn from_str(s: &str) -> Result<Self, Self::Err> {
163        match s.to_ascii_lowercase().as_str() {
164            "trace" => Ok(Self::Trace),
165            "debug" => Ok(Self::Debug),
166            "info" => Ok(Self::Info),
167            "warn" => Ok(Self::Warn),
168            "error" => Ok(Self::Error),
169            other => Err(format!("unknown severity: {other}")),
170        }
171    }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case", tag = "type")]
176pub enum Origin {
177    Application {
178        name: AppName,
179    },
180    Browser {
181        tab_id: TabId,
182        url: String,
183    },
184    Device {
185        serial: DeviceSerial,
186        platform: DevicePlatform,
187    },
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
191#[serde(rename_all = "snake_case")]
192pub enum DevicePlatform {
193    #[default]
194    Android,
195    Vega,
196}
197
198/// Enum discriminant without payload — use for filtering/matching without carrying variant data.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
200#[serde(rename_all = "snake_case")]
201pub enum ObservationKindTag {
202    Log,
203    Query,
204    HttpExchange,
205    Exception,
206    StateSnapshot,
207    Metric,
208    Custom,
209    JsException,
210    Lifecycle,
211}
212
213impl fmt::Display for ObservationKindTag {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        let s = match self {
216            Self::Log => "log",
217            Self::Query => "query",
218            Self::HttpExchange => "http_exchange",
219            Self::Exception => "exception",
220            Self::StateSnapshot => "state_snapshot",
221            Self::Metric => "metric",
222            Self::Custom => "custom",
223            Self::JsException => "js_exception",
224            Self::Lifecycle => "lifecycle",
225        };
226        f.write_str(s)
227    }
228}
229
230impl std::str::FromStr for ObservationKindTag {
231    type Err = String;
232
233    fn from_str(s: &str) -> Result<Self, Self::Err> {
234        match s.to_ascii_lowercase().as_str() {
235            "log" => Ok(Self::Log),
236            "query" => Ok(Self::Query),
237            "http_exchange" => Ok(Self::HttpExchange),
238            "exception" => Ok(Self::Exception),
239            "state_snapshot" => Ok(Self::StateSnapshot),
240            "metric" => Ok(Self::Metric),
241            "custom" => Ok(Self::Custom),
242            "js_exception" => Ok(Self::JsException),
243            "lifecycle" => Ok(Self::Lifecycle),
244            other => Err(format!("unknown observation kind: {other}")),
245        }
246    }
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(rename_all = "snake_case", tag = "type")]
251pub enum ObservationKind {
252    Log,
253    Query {
254        sql: String,
255        duration_ms: f64,
256    },
257    HttpExchange {
258        method: String,
259        url: String,
260        status: Option<u16>,
261        duration_ms: Option<f64>,
262    },
263    Exception {
264        message: String,
265        trace: Option<String>,
266    },
267    StateSnapshot {
268        label: String,
269    },
270    Metric {
271        name: String,
272        value: f64,
273    },
274    Custom {
275        channel: String,
276    },
277    JsException {
278        message: String,
279        line: Option<u32>,
280        column: Option<u32>,
281    },
282    Lifecycle {
283        event_name: String,
284        frame_id: String,
285    },
286}
287
288impl ObservationKind {
289    pub fn tag(&self) -> ObservationKindTag {
290        match self {
291            Self::Log => ObservationKindTag::Log,
292            Self::Query { .. } => ObservationKindTag::Query,
293            Self::HttpExchange { .. } => ObservationKindTag::HttpExchange,
294            Self::Exception { .. } => ObservationKindTag::Exception,
295            Self::StateSnapshot { .. } => ObservationKindTag::StateSnapshot,
296            Self::Metric { .. } => ObservationKindTag::Metric,
297            Self::Custom { .. } => ObservationKindTag::Custom,
298            Self::JsException { .. } => ObservationKindTag::JsException,
299            Self::Lifecycle { .. } => ObservationKindTag::Lifecycle,
300        }
301    }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct SourceLocation {
306    pub file: String,
307    pub line: u32,
308    pub function: Option<String>,
309}
310
311/// Monotonic sequence number. "I've seen everything up to this ID."
312#[derive(
313    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
314)]
315pub struct Checkpoint(pub u64);
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct Observation {
319    pub id: u64,
320    pub origin: Origin,
321    pub kind: ObservationKind,
322    pub data: serde_json::Value,
323    pub severity: Severity,
324    pub source_location: Option<SourceLocation>,
325    pub timestamp_ns: u64,
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub correlation_id: Option<Arc<str>>,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub parent_id: Option<u64>,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub tags: Option<Vec<String>>,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub session_id: Option<Arc<str>>,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub node_id: Option<Arc<str>>,
336}
337
338impl Observation {
339    /// Build a new observation. `id` starts at 0 (the store assigns the real
340    /// monotonic sequence on insert) and `timestamp_ns` is captured from the
341    /// system clock at call time.
342    pub fn new(
343        origin: Origin,
344        kind: ObservationKind,
345        data: serde_json::Value,
346        severity: Severity,
347        source_location: Option<SourceLocation>,
348    ) -> Self {
349        let timestamp_ns = SystemTime::now()
350            .duration_since(UNIX_EPOCH)
351            .expect("system clock before UNIX epoch")
352            .as_nanos() as u64;
353
354        Self {
355            id: 0,
356            origin,
357            kind,
358            data,
359            severity,
360            source_location,
361            timestamp_ns,
362            correlation_id: None,
363            parent_id: None,
364            tags: None,
365            session_id: None,
366            node_id: None,
367        }
368    }
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
372#[serde(rename_all = "snake_case")]
373pub enum OriginPattern {
374    AnyApplication,
375    ApplicationNamed(AppName),
376    AnyBrowser,
377    BrowserTab(TabId),
378    AnyDevice,
379    DeviceSerial(DeviceSerial),
380}
381
382impl OriginPattern {
383    pub fn parse(s: &str) -> Self {
384        match s.split_once(':') {
385            Some(("app", "*")) | None if s == "app" => Self::AnyApplication,
386            Some(("app", name)) => Self::ApplicationNamed(name.into()),
387            Some(("browser", "*")) | None if s == "browser" => Self::AnyBrowser,
388            Some(("browser", tab_id)) => Self::BrowserTab(tab_id.into()),
389            Some(("device", "*")) | None if s == "device" => Self::AnyDevice,
390            Some(("device", serial)) => Self::DeviceSerial(serial.into()),
391            _ => Self::ApplicationNamed(s.into()),
392        }
393    }
394}
395
396#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
397pub struct Filter {
398    pub kinds: Option<Vec<ObservationKindTag>>,
399    pub severity_min: Option<Severity>,
400    pub origins: Option<Vec<OriginPattern>>,
401    pub text_match: Option<String>,
402    pub since: Option<Checkpoint>,
403    pub limit: Option<usize>,
404    pub correlation_id: Option<String>,
405    pub tags: Option<Vec<String>>,
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub include_system: Option<bool>,
408}
409
410impl Filter {
411    pub fn matches(&self, obs: &Observation) -> bool {
412        if !self.include_system.unwrap_or(false)
413            && let Some(ref tags) = obs.tags
414            && tags.iter().any(|t| t == SYSTEM_TAG)
415        {
416            return false;
417        }
418
419        if let Some(ref cp) = self.since
420            && obs.id <= cp.0
421        {
422            return false;
423        }
424
425        if let Some(min) = self.severity_min
426            && obs.severity.level() < min.level()
427        {
428            return false;
429        }
430
431        if let Some(ref kinds) = self.kinds
432            && !kinds.is_empty()
433            && !kinds.contains(&obs.kind.tag())
434        {
435            return false;
436        }
437
438        if let Some(ref origins) = self.origins
439            && !origins.is_empty()
440            && !origins.iter().any(|p| p.matches(&obs.origin))
441        {
442            return false;
443        }
444
445        if let Some(ref text) = self.text_match {
446            let haystack = obs.data.to_string().to_ascii_lowercase();
447            if !haystack.contains(&text.to_ascii_lowercase()) {
448                return false;
449            }
450        }
451
452        if let Some(ref cid) = self.correlation_id {
453            match &obs.correlation_id {
454                Some(obs_cid) if obs_cid.as_ref() == cid.as_str() => {}
455                _ => return false,
456            }
457        }
458
459        if let Some(ref required_tags) = self.tags
460            && !required_tags.is_empty()
461        {
462            match &obs.tags {
463                Some(obs_tags) => {
464                    if !required_tags.iter().all(|t| obs_tags.contains(t)) {
465                        return false;
466                    }
467                }
468                None => return false,
469            }
470        }
471
472        true
473    }
474
475    pub fn parse_severity(raw: &str) -> Option<Severity> {
476        raw.trim().parse::<Severity>().ok()
477    }
478
479    pub fn parse_kinds(raw: &str) -> Vec<ObservationKindTag> {
480        raw.split(',')
481            .filter_map(|s| s.trim().parse::<ObservationKindTag>().ok())
482            .collect()
483    }
484
485    pub fn parse_origins(raw: &str) -> Vec<OriginPattern> {
486        raw.split(',')
487            .map(|s| OriginPattern::parse(s.trim()))
488            .collect()
489    }
490
491    pub fn parse_tags(raw: &str) -> Vec<String> {
492        raw.split(',')
493            .map(|s| s.trim().to_string())
494            .filter(|s| !s.is_empty())
495            .collect()
496    }
497
498    pub fn kinds_from_vec(v: Vec<String>) -> Vec<ObservationKindTag> {
499        v.into_iter()
500            .filter_map(|s| s.trim().parse::<ObservationKindTag>().ok())
501            .collect()
502    }
503
504    pub fn origins_from_vec(v: Vec<String>) -> Vec<OriginPattern> {
505        v.into_iter()
506            .map(|s| OriginPattern::parse(s.trim()))
507            .collect()
508    }
509}
510
511impl OriginPattern {
512    pub fn matches(&self, origin: &Origin) -> bool {
513        match (self, origin) {
514            (Self::AnyApplication, Origin::Application { .. }) => true,
515            (Self::ApplicationNamed(name), Origin::Application { name: n }) => n == name,
516            (Self::AnyBrowser, Origin::Browser { .. }) => true,
517            (Self::BrowserTab(tab), Origin::Browser { tab_id, .. }) => tab_id == tab,
518            (Self::AnyDevice, Origin::Device { .. }) => true,
519            (Self::DeviceSerial(serial), Origin::Device { serial: s, .. }) => s == serial,
520            _ => false,
521        }
522    }
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
526#[serde(rename_all = "snake_case")]
527pub enum MemoryKind {
528    Pattern,
529    Decision,
530    ErrorSignature,
531    SessionSummary,
532    UserFlagged,
533}
534
535impl fmt::Display for MemoryKind {
536    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537        let s = match self {
538            Self::Pattern => "pattern",
539            Self::Decision => "decision",
540            Self::ErrorSignature => "error_signature",
541            Self::SessionSummary => "session_summary",
542            Self::UserFlagged => "user_flagged",
543        };
544        f.write_str(s)
545    }
546}
547
548impl std::str::FromStr for MemoryKind {
549    type Err = String;
550
551    fn from_str(s: &str) -> Result<Self, Self::Err> {
552        match s.to_ascii_lowercase().as_str() {
553            "pattern" => Ok(Self::Pattern),
554            "decision" => Ok(Self::Decision),
555            "error_signature" => Ok(Self::ErrorSignature),
556            "session_summary" => Ok(Self::SessionSummary),
557            "user_flagged" => Ok(Self::UserFlagged),
558            other => Err(format!("unknown memory kind: {other}")),
559        }
560    }
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct SliceSummary {
565    pub total: usize,
566    pub counts_by_kind: HashMap<String, usize>,
567    pub counts_by_severity: HashMap<String, usize>,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct StateSlice {
572    pub observations: Vec<Observation>,
573    pub checkpoint: Checkpoint,
574    pub summary: SliceSummary,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
578#[serde(rename_all = "snake_case")]
579pub enum HealthStatus {
580    Ok,
581    ErrorsDetected,
582    NoSources,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
586#[serde(rename_all = "snake_case")]
587pub enum ConnectionKind {
588    Application,
589    Browser,
590    Device,
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize)]
594pub struct ConnectionInfo {
595    pub id: String,
596    pub kind: ConnectionKind,
597    pub name: String,
598    pub observation_count: u64,
599}
600
601#[derive(Debug, Clone, Serialize, Deserialize)]
602pub struct RuntimeSummary {
603    pub observation_count: u64,
604    pub error_count_last_60s: u64,
605    pub active_channels: Vec<String>,
606    pub connections: Vec<ConnectionInfo>,
607    pub health: HealthStatus,
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use serde_json::{Value, json};
614
615    fn obs(origin: Origin, kind: ObservationKind) -> Observation {
616        Observation {
617            id: 0,
618            origin,
619            kind,
620            data: json!({"note": "roundtrip fixture"}),
621            severity: Severity::Info,
622            source_location: None,
623            timestamp_ns: 0,
624            correlation_id: None,
625            parent_id: None,
626            tags: None,
627            session_id: None,
628            node_id: None,
629        }
630    }
631
632    fn roundtrip_observation(o: &Observation) {
633        let text = serde_json::to_string(o).expect("serialize");
634        let back: Observation = serde_json::from_str(&text).expect("deserialize");
635        let text_again = serde_json::to_string(&back).expect("reserialize");
636        assert_eq!(
637            text, text_again,
638            "observation should roundtrip identically; first={text} second={text_again}"
639        );
640    }
641
642    #[test]
643    fn origin_application_roundtrip() {
644        roundtrip_observation(&obs(
645            Origin::Application {
646                name: AppName::from("test-app"),
647            },
648            ObservationKind::Log,
649        ));
650    }
651
652    #[test]
653    fn origin_browser_roundtrip() {
654        roundtrip_observation(&obs(
655            Origin::Browser {
656                tab_id: TabId::from("tab-abc-123"),
657                url: "https://example.com/page".into(),
658            },
659            ObservationKind::Log,
660        ));
661    }
662
663    #[test]
664    fn origin_device_roundtrip() {
665        roundtrip_observation(&obs(
666            Origin::Device {
667                serial: DeviceSerial::from("emulator-5554"),
668                platform: DevicePlatform::Android,
669            },
670            ObservationKind::Log,
671        ));
672    }
673
674    #[test]
675    fn observation_kind_variants_all_roundtrip() {
676        let cases = vec![
677            ObservationKind::Log,
678            ObservationKind::Query {
679                sql: "SELECT 1".into(),
680                duration_ms: 3.5,
681            },
682            ObservationKind::HttpExchange {
683                method: "GET".into(),
684                url: "/api/users".into(),
685                status: Some(200),
686                duration_ms: Some(42.0),
687            },
688            ObservationKind::Exception {
689                message: "boom".into(),
690                trace: Some("stack".into()),
691            },
692            ObservationKind::StateSnapshot {
693                label: "before-migration".into(),
694            },
695            ObservationKind::Metric {
696                name: "cpu".into(),
697                value: 0.75,
698            },
699            ObservationKind::Custom {
700                channel: "events".into(),
701            },
702            ObservationKind::JsException {
703                message: "undefined".into(),
704                line: Some(12),
705                column: Some(5),
706            },
707            ObservationKind::Lifecycle {
708                event_name: "ready".into(),
709                frame_id: "frame-1".into(),
710            },
711        ];
712
713        for kind in cases {
714            roundtrip_observation(&obs(Origin::Application { name: "x".into() }, kind));
715        }
716    }
717
718    #[test]
719    fn newtypes_serialize_transparent_as_strings() {
720        let origin = Origin::Browser {
721            tab_id: TabId::from("abc"),
722            url: "https://x".into(),
723        };
724        let v: Value = serde_json::to_value(&origin).unwrap();
725        assert_eq!(
726            v["tab_id"],
727            json!("abc"),
728            "TabId must serialize as a bare string, got {v:#?}"
729        );
730
731        let origin = Origin::Device {
732            serial: DeviceSerial::from("S123"),
733            platform: DevicePlatform::Vega,
734        };
735        let v: Value = serde_json::to_value(&origin).unwrap();
736        assert_eq!(v["serial"], json!("S123"));
737
738        let origin = Origin::Application {
739            name: AppName::from("my-app"),
740        };
741        let v: Value = serde_json::to_value(&origin).unwrap();
742        assert_eq!(v["name"], json!("my-app"));
743    }
744
745    #[test]
746    fn newtypes_deserialize_from_bare_strings() {
747        let v = json!({
748            "type": "browser",
749            "tab_id": "tab-42",
750            "url": "https://example.com"
751        });
752        let origin: Origin = serde_json::from_value(v).unwrap();
753        match origin {
754            Origin::Browser { tab_id, url } => {
755                assert_eq!(tab_id.as_str(), "tab-42");
756                assert_eq!(url, "https://example.com");
757            }
758            _ => panic!("expected Browser origin"),
759        }
760    }
761
762    #[test]
763    fn origin_pattern_roundtrip_all_variants() {
764        let patterns = vec![
765            OriginPattern::AnyApplication,
766            OriginPattern::ApplicationNamed(AppName::from("svc")),
767            OriginPattern::AnyBrowser,
768            OriginPattern::BrowserTab(TabId::from("tab-9")),
769            OriginPattern::AnyDevice,
770            OriginPattern::DeviceSerial(DeviceSerial::from("S-42")),
771        ];
772        for p in patterns {
773            let text = serde_json::to_string(&p).unwrap();
774            let back: OriginPattern = serde_json::from_str(&text).unwrap();
775            let text_again = serde_json::to_string(&back).unwrap();
776            assert_eq!(text, text_again);
777        }
778    }
779
780    #[test]
781    fn filter_matches_honors_newtype_identity() {
782        let matching = obs(
783            Origin::Browser {
784                tab_id: TabId::from("tab-1"),
785                url: "".into(),
786            },
787            ObservationKind::Log,
788        );
789        let other = obs(
790            Origin::Browser {
791                tab_id: TabId::from("tab-2"),
792                url: "".into(),
793            },
794            ObservationKind::Log,
795        );
796        let filter = Filter {
797            origins: Some(vec![OriginPattern::BrowserTab(TabId::from("tab-1"))]),
798            ..Filter::default()
799        };
800        assert!(filter.matches(&matching));
801        assert!(!filter.matches(&other));
802    }
803
804    #[test]
805    fn tab_id_equality_regardless_of_construction_path() {
806        let from_str = TabId::from("abc");
807        let from_string = TabId::from(String::from("abc"));
808        let from_arc: TabId = std::sync::Arc::<str>::from("abc").into();
809        assert_eq!(from_str, from_string);
810        assert_eq!(from_string, from_arc);
811        assert_eq!(from_str, "abc");
812    }
813
814    #[test]
815    fn system_tag_excluded_by_default() {
816        let mut system_obs = obs(
817            Origin::Application {
818                name: AppName::from("hook"),
819            },
820            ObservationKind::Log,
821        );
822        system_obs.tags = Some(vec![SYSTEM_TAG.to_string()]);
823
824        let normal_obs = obs(
825            Origin::Application {
826                name: AppName::from("app"),
827            },
828            ObservationKind::Log,
829        );
830
831        let default_filter = Filter::default();
832        assert!(!default_filter.matches(&system_obs));
833        assert!(default_filter.matches(&normal_obs));
834
835        let include_filter = Filter {
836            include_system: Some(true),
837            ..Filter::default()
838        };
839        assert!(include_filter.matches(&system_obs));
840        assert!(include_filter.matches(&normal_obs));
841    }
842
843    #[test]
844    fn debug_action_roundtrip_snake_case() {
845        let cases = vec![
846            (DebugAction::EvalJs, "eval_js"),
847            (DebugAction::GetPerfMetrics, "get_perf_metrics"),
848            (DebugAction::SetViewport, "set_viewport"),
849            (DebugAction::StorageInspect, "storage_inspect"),
850            (DebugAction::ElementAtPoint, "element_at_point"),
851        ];
852        for (variant, wire) in cases {
853            let text = serde_json::to_string(&variant).unwrap();
854            assert_eq!(text, format!("\"{wire}\""));
855            let back: DebugAction = serde_json::from_str(&text).unwrap();
856            assert_eq!(back, variant);
857        }
858    }
859}