Skip to main content

agent_engine/extensions/
manifest.rs

1//! Extension manifest model and validation.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use super::hooks::events::HookKind;
7use super::permissions::PermissionSet;
8
9/// Current extension protocol version supported by SynapsCLI.
10pub const CURRENT_EXTENSION_PROTOCOL_VERSION: u32 = 1;
11
12fn default_protocol_version() -> u32 {
13    CURRENT_EXTENSION_PROTOCOL_VERSION
14}
15
16/// Extension declaration inside a plugin manifest.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ExtensionManifest {
19    /// Extension protocol version. Defaults to v1 for pre-versioned manifests.
20    #[serde(default = "default_protocol_version")]
21    pub protocol_version: u32,
22    /// Runtime type (only "process" in phase 1).
23    pub runtime: ExtensionRuntime,
24    /// Command to start the extension process.
25    pub command: String,
26    /// Optional path to a post-install setup script (relative to plugin
27    /// root). When present, the marketplace install flow runs this
28    /// script after the plugin source is in place — used by source-shipped
29    /// extensions (e.g. Rust binaries) that need to compile a binary
30    /// before [`Self::command`] resolves. Same security model as
31    /// `provides.sidecar.setup` (path must stay inside the plugin dir;
32    /// see [`crate::skills::post_install`] for the runner).
33    #[serde(default)]
34    pub setup: Option<String>,
35    /// Optional per-host-triple prebuilt asset map. When the installer
36    /// can't find [`Self::command`] on disk after the source clone, it
37    /// looks up the current host's triple (e.g. `linux-x86_64`,
38    /// `darwin-arm64`, `windows-x86_64` — see
39    /// [`crate::skills::post_install::host_triple`]) in this map and,
40    /// if a matching [`PrebuiltAsset`] exists, downloads and extracts
41    /// it into the plugin dir as a fast path that skips
42    /// [`Self::setup`]. Empty by default.
43    #[serde(default)]
44    pub prebuilt: std::collections::HashMap<String, PrebuiltAsset>,
45    /// Arguments to pass to the command.
46    #[serde(default)]
47    pub args: Vec<String>,
48    /// Permissions requested by the extension.
49    #[serde(default)]
50    pub permissions: Vec<String>,
51    /// Hooks the extension wants to subscribe to.
52    #[serde(default)]
53    pub hooks: Vec<HookSubscription>,
54    /// Non-secret config declarations resolved by Synaps and passed to initialize.
55    #[serde(default)]
56    pub config: Vec<ExtensionConfigEntry>,
57    /// OPTIONAL (additive, protocol v1-compatible per docs/STABILITY.md §1):
58    /// theme tokens this extension declares, merged by the TUI into the
59    /// active theme under the `ext.<plugin-id>.<token>` namespace at load.
60    /// Keys are token names (no dots/slashes/spaces); values are hex colors
61    /// (`#rgb` or `#rrggbb`). A user theme-TOML key `ext.<plugin-id>.<token>`
62    /// always overrides the manifest value. Absent => empty map => behavior
63    /// identical to pre-P19.2 manifests.
64    #[serde(default)]
65    pub theme_tokens: std::collections::BTreeMap<String, String>,
66}
67
68/// Per-host-triple prebuilt distribution asset for an extension. Lives
69/// inside [`ExtensionManifest::prebuilt`]. When a matching entry exists
70/// for the current host, the installer fetches `url`, verifies its
71/// SHA-256 against `sha256`, and extracts it into the plugin install
72/// directory — letting users skip a (potentially slow) source build.
73///
74/// The archive is expected to lay out files relative to the plugin root
75/// such that [`ExtensionManifest::command`] resolves after extraction.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77pub struct PrebuiltAsset {
78    /// HTTPS URL of the archive (`.tar.gz` or `.zip`). The installer
79    /// refuses non-`https://` schemes and `file://` (except in tests
80    /// gated by `cfg(test)`).
81    pub url: String,
82    /// Hex-encoded SHA-256 of the archive bytes; **required**. The
83    /// installer aborts and surfaces an error if the downloaded bytes
84    /// don't match — same model as the existing marketplace
85    /// `checksum_value` for plugin sources.
86    pub sha256: String,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90#[serde(rename_all = "lowercase")]
91pub enum ExtensionConfigValueKind {
92    String,
93    Bool,
94    Number,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98pub struct ExtensionConfigEntry {
99    pub key: String,
100    #[serde(default, rename = "type")]
101    pub value_type: Option<ExtensionConfigValueKind>,
102    #[serde(default)]
103    pub description: Option<String>,
104    #[serde(default)]
105    pub required: bool,
106    #[serde(default)]
107    pub default: Option<Value>,
108    #[serde(default)]
109    pub secret_env: Option<String>,
110}
111
112/// A validated extension manifest prepared for loading.
113#[derive(Debug, Clone)]
114pub struct ValidatedExtensionManifest {
115    pub permissions: PermissionSet,
116    pub subscriptions: Vec<(HookKind, Option<String>, Option<HookMatcher>)>,
117}
118
119impl ExtensionManifest {
120    /// Validate manifest fields and derive typed permissions/subscriptions.
121    pub fn validate(&self, id: &str) -> Result<ValidatedExtensionManifest, String> {
122        if self.protocol_version != CURRENT_EXTENSION_PROTOCOL_VERSION {
123            return Err(format!(
124                "Extension '{}' uses unsupported protocol_version {} (supported: {})",
125                id, self.protocol_version, CURRENT_EXTENSION_PROTOCOL_VERSION,
126            ));
127        }
128
129        if self.command.trim().is_empty() {
130            return Err(format!("Extension '{}' has empty command", id));
131        }
132
133        let has_capability_permission = self.permissions.iter().any(|permission| {
134            matches!(
135                permission.as_str(),
136                "tools.register" | "providers.register" | "memory.read" | "memory.write"
137                    | "config.write" | "config.subscribe" | "audio.input" | "audio.output"
138            )
139        });
140        if self.hooks.is_empty() && !has_capability_permission {
141            return Err(format!("Extension '{}' must subscribe to at least one hook or request a registration permission", id));
142        }
143
144        for (token, value) in &self.theme_tokens {
145            if token.trim().is_empty()
146                || token.contains('.')
147                || token.contains('/')
148                || token.contains('\\')
149                || token.contains(char::is_whitespace)
150            {
151                return Err(format!(
152                    "Extension '{}' theme token '{}' is invalid: token names must be non-empty and must not contain dots, slashes, or spaces",
153                    id, token,
154                ));
155            }
156            if !is_hex_color(value) {
157                return Err(format!(
158                    "Extension '{}' theme token '{}' has invalid color '{}': expected '#rgb' or '#rrggbb' hex",
159                    id, token, value,
160                ));
161            }
162        }
163
164        let permissions = PermissionSet::try_from_strings(&self.permissions)?;
165        let mut subscriptions = Vec::with_capacity(self.hooks.len());
166        for sub in &self.hooks {
167            let kind = HookKind::from_str(&sub.hook).ok_or_else(|| {
168                format!("Unknown hook kind: '{}' in extension '{}'", sub.hook, id)
169            })?;
170            if !permissions.allows_hook(kind) {
171                return Err(format!(
172                    "Extension '{}' lacks permission '{}' required for hook '{}'",
173                    id,
174                    kind.required_permission().as_str(),
175                    kind.as_str(),
176                ));
177            }
178            if sub.tool.is_some() && !kind.allows_tool_filter() {
179                return Err(format!(
180                    "Extension '{}' hook '{}' does not allow a tool filter",
181                    id,
182                    kind.as_str(),
183                ));
184            }
185            subscriptions.push((kind, sub.tool.clone(), sub.matcher.clone()));
186        }
187
188        Ok(ValidatedExtensionManifest {
189            permissions,
190            subscriptions,
191        })
192    }
193}
194
195/// True when `value` is a `#rgb` or `#rrggbb` hex color literal. Used to
196/// fail-closed on malformed `theme_tokens` values at manifest validation
197/// (before the extension process is spawned) instead of silently rendering
198/// nothing.
199fn is_hex_color(value: &str) -> bool {
200    let hex = match value.trim().strip_prefix('#') {
201        Some(h) => h,
202        None => return false,
203    };
204    matches!(hex.len(), 3 | 6) && hex.chars().all(|c| c.is_ascii_hexdigit())
205}
206
207/// Supported extension runtime types.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "lowercase")]
210pub enum ExtensionRuntime {
211    Process,
212}
213
214/// A hook subscription declared in the manifest.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct HookSubscription {
217    /// Hook name (e.g. "before_tool_call", "on_session_start")
218    pub hook: String,
219    /// Optional tool filter (e.g. "bash" for tool-specific hooks)
220    #[serde(default)]
221    pub tool: Option<String>,
222    /// Optional simple matcher conditions.
223    #[serde(default, rename = "match")]
224    pub matcher: Option<HookMatcher>,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
228#[serde(deny_unknown_fields)]
229pub struct HookMatcher {
230    #[serde(default)]
231    pub input_contains: Option<String>,
232    #[serde(default)]
233    pub input_equals: Option<serde_json::Value>,
234}
235
236impl HookMatcher {
237    pub const SUPPORTED_KEYS: &'static [&'static str] = &["input_contains", "input_equals"];
238
239    pub fn matches(&self, event: &crate::extensions::hooks::events::HookEvent) -> bool {
240        let input = event.tool_input.as_ref().unwrap_or(&serde_json::Value::Null);
241        if let Some(expected) = &self.input_equals {
242            if input != expected {
243                return false;
244            }
245        }
246        if let Some(needle) = &self.input_contains {
247            let haystack = serde_json::to_string(input).unwrap_or_default();
248            if !haystack.contains(needle) {
249                return false;
250            }
251        }
252        true
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    // ── Happy-path deserialisation ──────────────────────────────────────────
261
262    #[test]
263    fn deserialize_full_manifest() {
264        let json = r#"{
265            "protocol_version": 1,
266            "runtime": "process",
267            "command": "/usr/bin/my-ext",
268            "args": ["--port", "0"],
269            "permissions": ["tools.intercept", "session.lifecycle"],
270            "hooks": [
271                {"hook": "before_tool_call", "tool": "bash"},
272                {"hook": "on_session_start"}
273            ]
274        }"#;
275
276        let m: ExtensionManifest = serde_json::from_str(json).unwrap();
277        assert_eq!(m.protocol_version, 1);
278        assert_eq!(m.runtime, ExtensionRuntime::Process);
279        assert_eq!(m.command, "/usr/bin/my-ext");
280        assert_eq!(m.args, vec!["--port", "0"]);
281        assert_eq!(m.permissions, vec!["tools.intercept", "session.lifecycle"]);
282        assert_eq!(m.hooks.len(), 2);
283        assert_eq!(m.hooks[0].hook, "before_tool_call");
284        assert_eq!(m.hooks[0].tool.as_deref(), Some("bash"));
285        assert_eq!(m.hooks[1].hook, "on_session_start");
286        assert_eq!(m.hooks[1].tool, None);
287    }
288
289    // ── Optional fields default correctly ──────────────────────────────────
290
291    #[test]
292    fn missing_optional_fields_get_defaults() {
293        let json = r#"{
294            "runtime": "process",
295            "command": "my-ext"
296        }"#;
297
298        let m: ExtensionManifest = serde_json::from_str(json).unwrap();
299        assert_eq!(m.protocol_version, CURRENT_EXTENSION_PROTOCOL_VERSION);
300        assert_eq!(m.runtime, ExtensionRuntime::Process);
301        assert_eq!(m.command, "my-ext");
302        assert!(m.args.is_empty(), "args should default to []");
303        assert!(m.permissions.is_empty(), "permissions should default to []");
304        assert!(m.hooks.is_empty(), "hooks should default to []");
305    }
306
307    #[test]
308    fn extension_config_entry_deserializes_optional_type() {
309        let json = r#"{
310            "key": "backend",
311            "type": "string",
312            "description": "Backend selector",
313            "default": "auto"
314        }"#;
315
316        let entry: ExtensionConfigEntry = serde_json::from_str(json).unwrap();
317        assert_eq!(entry.key, "backend");
318        assert_eq!(entry.value_type, Some(ExtensionConfigValueKind::String));
319        assert_eq!(entry.description.as_deref(), Some("Backend selector"));
320        assert_eq!(entry.default, Some(serde_json::Value::String("auto".to_string())));
321    }
322
323    #[test]
324    fn extension_config_entry_omitted_type_is_none() {
325        let json = r#"{"key": "backend"}"#;
326
327        let entry: ExtensionConfigEntry = serde_json::from_str(json).unwrap();
328        assert_eq!(entry.key, "backend");
329        assert_eq!(entry.value_type, None);
330    }
331
332    #[test]
333    fn hook_subscription_tool_defaults_to_none() {
334        let json = r#"{
335            "runtime": "process",
336            "command": "ext",
337            "hooks": [{"hook": "on_session_start"}]
338        }"#;
339
340        let m: ExtensionManifest = serde_json::from_str(json).unwrap();
341        assert_eq!(m.hooks[0].tool, None);
342    }
343
344    // ── Required fields ─────────────────────────────────────────────────────
345
346    #[test]
347    fn missing_command_fails() {
348        let json = r#"{"runtime": "process"}"#;
349        let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
350        assert!(result.is_err(), "command is required");
351    }
352
353    #[test]
354    fn missing_runtime_fails() {
355        let json = r#"{"command": "my-ext"}"#;
356        let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
357        assert!(result.is_err(), "runtime is required");
358    }
359
360    // ── Unknown / invalid runtime type ─────────────────────────────────────
361
362    #[test]
363    fn unknown_runtime_type_errors() {
364        let json = r#"{
365            "runtime": "wasm",
366            "command": "my-ext"
367        }"#;
368        let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
369        assert!(result.is_err(), "unknown runtime 'wasm' should be rejected");
370    }
371
372    #[test]
373    fn runtime_is_case_sensitive() {
374        let json = r#"{"runtime": "Process", "command": "ext"}"#;
375        let result: Result<ExtensionManifest, _> = serde_json::from_str(json);
376        assert!(result.is_err(), "runtime matching is lowercase-only");
377    }
378
379    #[test]
380    fn validate_rejects_unsupported_protocol_version() {
381        let manifest = ExtensionManifest {
382            theme_tokens: Default::default(),
383            protocol_version: 999,
384            runtime: ExtensionRuntime::Process,
385            command: "ext".to_string(),
386            setup: None,
387            prebuilt: ::std::collections::HashMap::new(),
388            args: vec![],
389            permissions: vec!["tools.intercept".to_string()],
390            hooks: vec![HookSubscription {
391                hook: "before_tool_call".to_string(),
392                tool: None,
393                matcher: None,
394            }],
395            config: vec![],
396        };
397
398        let err = manifest.validate("bad-version").unwrap_err();
399        assert!(err.contains("unsupported protocol_version 999"));
400    }
401
402    #[test]
403    fn validate_allows_hookless_provider_registration_extensions() {
404        let manifest = ExtensionManifest {
405            theme_tokens: Default::default(),
406            protocol_version: 1,
407            runtime: ExtensionRuntime::Process,
408            command: "ext".to_string(),
409            setup: None,
410            prebuilt: ::std::collections::HashMap::new(),
411            args: vec![],
412            permissions: vec!["providers.register".to_string()],
413            hooks: vec![],
414            config: vec![],
415        };
416
417        manifest.validate("provider-only").unwrap();
418    }
419
420    #[test]
421    fn validate_rejects_tool_filter_on_non_tool_hook() {
422        let manifest = ExtensionManifest {
423            theme_tokens: Default::default(),
424            protocol_version: 1,
425            runtime: ExtensionRuntime::Process,
426            command: "ext".to_string(),
427            setup: None,
428            prebuilt: ::std::collections::HashMap::new(),
429            args: vec![],
430            permissions: vec!["session.lifecycle".to_string()],
431            hooks: vec![HookSubscription {
432                hook: "on_session_start".to_string(),
433                tool: Some("bash".to_string()),
434                matcher: None,
435            }],
436            config: vec![],
437        };
438
439        let err = manifest.validate("bad-filter").unwrap_err();
440        assert!(err.contains("does not allow a tool filter"));
441    }
442
443    // ── Round-trip ─────────────────────────────────────────────────────────
444
445    #[test]
446    fn serialize_roundtrip() {
447        let original = ExtensionManifest {
448            theme_tokens: Default::default(),
449            protocol_version: 1,
450            runtime: ExtensionRuntime::Process,
451            command: "my-ext".to_string(),
452            setup: None,
453            prebuilt: ::std::collections::HashMap::new(),
454            args: vec!["--verbose".to_string()],
455            permissions: vec!["tools.intercept".to_string()],
456            hooks: vec![HookSubscription {
457                hook: "before_tool_call".to_string(),
458                tool: Some("bash".to_string()),
459                matcher: None,
460            }],
461            config: vec![],
462        };
463
464        let json = serde_json::to_string(&original).unwrap();
465        let restored: ExtensionManifest = serde_json::from_str(&json).unwrap();
466
467        assert_eq!(restored.protocol_version, original.protocol_version);
468        assert_eq!(restored.runtime, original.runtime);
469        assert_eq!(restored.command, original.command);
470        assert_eq!(restored.args, original.args);
471        assert_eq!(restored.permissions, original.permissions);
472        assert_eq!(restored.hooks[0].hook, original.hooks[0].hook);
473        assert_eq!(restored.hooks[0].tool, original.hooks[0].tool);
474    }
475
476    // ── Runtime serialises as lowercase string ──────────────────────────────
477
478    #[test]
479    fn matcher_input_equals_requires_exact_tool_input() {
480        let matcher = HookMatcher {
481            input_contains: None,
482            input_equals: Some(serde_json::json!({"command": "echo safe"})),
483        };
484
485        let matching = crate::extensions::hooks::events::HookEvent::before_tool_call(
486            "bash",
487            serde_json::json!({"command": "echo safe"}),
488        );
489        let different = crate::extensions::hooks::events::HookEvent::before_tool_call(
490            "bash",
491            serde_json::json!({"command": "echo safe", "extra": true}),
492        );
493
494        assert!(matcher.matches(&matching));
495        assert!(!matcher.matches(&different));
496    }
497
498    #[test]
499    fn matcher_conditions_are_combined_with_and() {
500        let matcher = HookMatcher {
501            input_contains: Some("safe".to_string()),
502            input_equals: Some(serde_json::json!({"command": "echo safe"})),
503        };
504
505        let matching = crate::extensions::hooks::events::HookEvent::before_tool_call(
506            "bash",
507            serde_json::json!({"command": "echo safe"}),
508        );
509        let equals_but_missing_contains = crate::extensions::hooks::events::HookEvent::before_tool_call(
510            "bash",
511            serde_json::json!({"command": "echo ok"}),
512        );
513
514        assert!(matcher.matches(&matching));
515        assert!(!matcher.matches(&equals_but_missing_contains));
516    }
517
518    #[test]
519    fn runtime_serializes_as_lowercase() {
520        let rt = ExtensionRuntime::Process;
521        let json = serde_json::to_string(&rt).unwrap();
522        assert_eq!(json, r#""process""#);
523    }
524
525    #[test]
526    fn extension_manifest_defaults_prebuilt_to_empty_when_absent() {
527        // Older manifests without `prebuilt` must still parse cleanly.
528        let json = r#"{
529            "runtime": "process",
530            "command": "bin/ext"
531        }"#;
532        let m: ExtensionManifest = serde_json::from_str(json).unwrap();
533        assert!(m.prebuilt.is_empty());
534        assert!(m.setup.is_none());
535    }
536
537    #[test]
538    fn extension_manifest_round_trips_prebuilt_assets() {
539        let json = r#"{
540            "runtime": "process",
541            "command": "bin/ext",
542            "prebuilt": {
543                "linux-x86_64": {
544                    "url": "https://example.com/ext-linux-x86_64.tar.gz",
545                    "sha256": "abc123"
546                },
547                "darwin-arm64": {
548                    "url": "https://example.com/ext-darwin-arm64.tar.gz",
549                    "sha256": "def456"
550                }
551            }
552        }"#;
553        let m: ExtensionManifest = serde_json::from_str(json).unwrap();
554        assert_eq!(m.prebuilt.len(), 2);
555        let linux = m.prebuilt.get("linux-x86_64").expect("linux entry");
556        assert_eq!(linux.url, "https://example.com/ext-linux-x86_64.tar.gz");
557        assert_eq!(linux.sha256, "abc123");
558        // Round-trip
559        let back = serde_json::to_value(&m).unwrap();
560        assert_eq!(
561            back["prebuilt"]["darwin-arm64"]["sha256"],
562            serde_json::Value::String("def456".to_string())
563        );
564    }
565
566    #[test]
567    fn prebuilt_asset_requires_both_url_and_sha256() {
568        // Missing sha256 must error — no silent acceptance of unverified assets.
569        let json = r#"{ "url": "https://example.com/x.tar.gz" }"#;
570        let res: Result<PrebuiltAsset, _> = serde_json::from_str(json);
571        assert!(res.is_err(), "PrebuiltAsset without sha256 must fail to parse");
572    }
573
574    // ── Theme tokens (P19.2, additive-optional per STABILITY.md §1) ─────────
575
576    #[test]
577    fn theme_tokens_absent_defaults_to_empty_and_validates() {
578        // The stability guarantee in one test: a pre-P19.2 manifest (no
579        // theme_tokens key) parses to an empty map and passes validation
580        // exactly as before — additive optional, no protocol bump.
581        let json = r#"{
582            "runtime": "process",
583            "command": "my-ext",
584            "permissions": ["tools.register"]
585        }"#;
586        let m: ExtensionManifest = serde_json::from_str(json).unwrap();
587        assert!(m.theme_tokens.is_empty(), "absent theme_tokens must default to empty");
588        assert!(m.validate("legacy-ext").is_ok(), "legacy manifests must validate unchanged");
589    }
590
591    #[test]
592    fn theme_tokens_deserialize_and_validate() {
593        let json = r##"{
594            "runtime": "process",
595            "command": "my-ext",
596            "permissions": ["tools.register"],
597            "theme_tokens": { "accent": "#22d3ee", "warn": "#fa0" }
598        }"##;
599        let m: ExtensionManifest = serde_json::from_str(json).unwrap();
600        assert_eq!(m.theme_tokens.get("accent").map(String::as_str), Some("#22d3ee"));
601        assert_eq!(m.theme_tokens.get("warn").map(String::as_str), Some("#fa0"));
602        assert!(m.validate("themed-ext").is_ok());
603    }
604
605    #[test]
606    fn theme_tokens_reject_bad_token_names() {
607        for bad in ["with.dot", "with/slash", "with space", ""] {
608            let json = format!(
609                r##"{{
610                    "runtime": "process",
611                    "command": "my-ext",
612                    "permissions": ["tools.register"],
613                    "theme_tokens": {{ "{bad}": "#ffffff" }}
614                }}"##
615            );
616            let m: ExtensionManifest = serde_json::from_str(&json).unwrap();
617            let err = m.validate("bad-ext").unwrap_err();
618            assert!(err.contains("theme token"), "'{bad}' must be rejected: {err}");
619        }
620    }
621
622    #[test]
623    fn theme_tokens_reject_non_hex_colors() {
624        for bad in ["red", "#12345", "#gggggg", "22d3ee", "#"] {
625            let json = format!(
626                r#"{{
627                    "runtime": "process",
628                    "command": "my-ext",
629                    "permissions": ["tools.register"],
630                    "theme_tokens": {{ "accent": "{bad}" }}
631                }}"#
632            );
633            let m: ExtensionManifest = serde_json::from_str(&json).unwrap();
634            let err = m.validate("bad-ext").unwrap_err();
635            assert!(err.contains("invalid color"), "'{bad}' must be rejected: {err}");
636        }
637    }
638
639    #[test]
640    fn is_hex_color_accepts_short_and_long_forms() {
641        assert!(is_hex_color("#22d3ee"));
642        assert!(is_hex_color("#fa0"));
643        assert!(is_hex_color(" #ffffff "));
644        assert!(!is_hex_color("#22d3e"));
645        assert!(!is_hex_color("blue"));
646    }
647}