Skip to main content

metalcraft_flows/
model.rs

1//! Core data model for the Flow specification.
2//!
3//! See [`SPEC.md`](https://github.com/rust4ai/metalcraft-flows/blob/main/SPEC.md)
4//! for the formal wire format.
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8/// The current spec version this crate emits.
9///
10/// Documents without a `spec_version` field are parsed as version `"1"`.
11/// New v2 node types (`conditional`, `branch` classifier, effectors, pause
12/// nodes) require `spec_version = "2"`; see [`SUPPORTED_SPEC_VERSIONS`].
13pub const SPEC_VERSION: &str = "2";
14
15/// Spec versions this crate can parse and validate. v2 is a superset of v1, so
16/// both are accepted; documents declaring any other version are rejected by
17/// [`crate::validate()`].
18pub const SUPPORTED_SPEC_VERSIONS: &[&str] = &["1", "2"];
19
20/// A single vertex in a [`FlowDefinition`].
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub struct FlowNode {
23    /// Unique identifier within the enclosing [`FlowDefinition`].
24    pub id: String,
25    /// The node kind. See [`FlowNodeType`].
26    pub node_type: FlowNodeType,
27    /// Free-form per-node configuration. Schema depends on `node_type`.
28    pub data: serde_json::Value,
29    /// `[x, y]` coordinates for visual editors. Defaults to `[0.0, 0.0]`.
30    #[serde(default)]
31    pub position: [f64; 2],
32}
33
34/// A node's kind.
35///
36/// Core types are spec-defined and understood by all conformant runtimes.
37/// Custom types are vendor-namespaced (`vendor:name`) and opaque to the spec —
38/// runtimes preserve them but may refuse to execute unknown ones.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum FlowNodeType {
41    /// A spec-defined core node type.
42    Core(CoreNodeType),
43    /// A vendor-namespaced custom node type, e.g. `"slack:send_message"`.
44    ///
45    /// The string is preserved verbatim, including the vendor prefix.
46    Custom(String),
47}
48
49/// The closed set of core node types defined by the spec.
50///
51/// See [`SPEC.md` §5.1](https://github.com/rust4ai/metalcraft-flows/blob/main/SPEC.md).
52///
53/// Variants marked *(v2)* require `spec_version = "2"`.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum CoreNodeType {
56    /// Marks the flow's start. At most one per [`FlowDefinition`].
57    Entry,
58    /// A natural-language instruction run by an LLM agent.
59    Prompt,
60    /// *(v2)* Deterministic routing: evaluate structured predicates against flow
61    /// state and follow the first matching handle. (In v1 the deterministic node
62    /// was `branch`; in v2 it is renamed to `conditional` and `branch` is
63    /// reassigned to the LLM classifier below.)
64    Conditional,
65    /// LLM classifier: the model picks exactly one typed output handle and fills
66    /// its arguments, which become that edge's payload.
67    ///
68    /// In v1 this wire name meant an opaque, non-executable condition stub; in v2
69    /// it is the classifier. The two `data` shapes are disjoint, so validation
70    /// distinguishes them by `spec_version`.
71    Branch,
72    /// *(v2)* Assign a value into flow state (literal, template, or a path into
73    /// the incoming edge payload).
74    SetVariable,
75    /// *(v2)* Call a single registered tool directly (no agent loop).
76    Tool,
77    /// *(v2)* Make a direct HTTP request.
78    Http,
79    /// *(v2)* Delegate a subtask to a scoped sub-agent.
80    SubAgent,
81    /// *(v2)* Pause for human input (human-in-the-loop) and resume on a decision.
82    Approval,
83    /// *(v2)* Pause for a durable delay and resume when it elapses.
84    Wait,
85    /// *(v2)* Fan out over a list, running a sub-body per item.
86    Foreach,
87    /// *(v2)* Explicit terminal node; may publish flow outputs.
88    End,
89    /// **Deprecated (v1).** Branch on the outcome of a tool call. Retained so v1
90    /// documents round-trip; superseded by [`CoreNodeType::Conditional`] +
91    /// [`CoreNodeType::Branch`].
92    BranchTool,
93}
94
95impl CoreNodeType {
96    /// The wire-format string for this core node type.
97    pub fn as_str(self) -> &'static str {
98        match self {
99            CoreNodeType::Entry => "entry",
100            CoreNodeType::Prompt => "prompt",
101            CoreNodeType::Conditional => "conditional",
102            CoreNodeType::Branch => "branch",
103            CoreNodeType::SetVariable => "set_variable",
104            CoreNodeType::Tool => "tool",
105            CoreNodeType::Http => "http",
106            CoreNodeType::SubAgent => "sub_agent",
107            CoreNodeType::Approval => "approval",
108            CoreNodeType::Wait => "wait",
109            CoreNodeType::Foreach => "foreach",
110            CoreNodeType::End => "end",
111            CoreNodeType::BranchTool => "branch_tool",
112        }
113    }
114
115    /// Parse a wire-format string into a core node type, if it matches one.
116    pub fn from_wire(s: &str) -> Option<Self> {
117        match s {
118            "entry" => Some(CoreNodeType::Entry),
119            "prompt" => Some(CoreNodeType::Prompt),
120            "conditional" => Some(CoreNodeType::Conditional),
121            "branch" => Some(CoreNodeType::Branch),
122            "set_variable" => Some(CoreNodeType::SetVariable),
123            "tool" => Some(CoreNodeType::Tool),
124            "http" => Some(CoreNodeType::Http),
125            "sub_agent" => Some(CoreNodeType::SubAgent),
126            "approval" => Some(CoreNodeType::Approval),
127            "wait" => Some(CoreNodeType::Wait),
128            "foreach" => Some(CoreNodeType::Foreach),
129            "end" => Some(CoreNodeType::End),
130            "branch_tool" => Some(CoreNodeType::BranchTool),
131            _ => None,
132        }
133    }
134
135    /// Whether this node type was introduced in spec v2 (and therefore requires
136    /// `spec_version = "2"`).
137    pub fn is_v2(self) -> bool {
138        !matches!(
139            self,
140            CoreNodeType::Entry
141                | CoreNodeType::Prompt
142                | CoreNodeType::BranchTool
143        )
144    }
145}
146
147impl FlowNodeType {
148    /// The wire-format string for this node type.
149    pub fn as_wire(&self) -> &str {
150        match self {
151            FlowNodeType::Core(c) => c.as_str(),
152            FlowNodeType::Custom(s) => s.as_str(),
153        }
154    }
155}
156
157impl Serialize for FlowNodeType {
158    fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
159        ser.serialize_str(self.as_wire())
160    }
161}
162
163impl<'de> Deserialize<'de> for FlowNodeType {
164    fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
165        let s = String::deserialize(de)?;
166        if let Some(core) = CoreNodeType::from_wire(&s) {
167            Ok(FlowNodeType::Core(core))
168        } else {
169            Ok(FlowNodeType::Custom(s))
170        }
171    }
172}
173
174/// A directed arc connecting two nodes in a [`FlowDefinition`].
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
176pub struct FlowEdge {
177    /// Unique identifier within the enclosing [`FlowDefinition`].
178    pub id: String,
179    /// The id of the source [`FlowNode`].
180    pub source: String,
181    /// The id of the target [`FlowNode`].
182    pub target: String,
183    /// Optional named output port on the source node (multi-output nodes
184    /// like [`CoreNodeType::Branch`]).
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub source_handle: Option<String>,
187    /// Optional named input port on the target node.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub target_handle: Option<String>,
190}
191
192/// A graph: nodes and the directed edges between them.
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
194pub struct FlowDefinition {
195    /// All vertices in the graph.
196    pub nodes: Vec<FlowNode>,
197    /// All directed arcs in the graph.
198    pub edges: Vec<FlowEdge>,
199}
200
201/// A persisted flow document — what a `.json` file on disk contains.
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
203pub struct SavedFlow {
204    /// Spec version this document conforms to. Defaults to `"1"` when absent.
205    #[serde(default = "default_spec_version")]
206    pub spec_version: String,
207    /// Stable identifier. Must match `^[A-Za-z0-9-]{1,64}$`.
208    pub id: String,
209    /// Human-readable label.
210    pub name: String,
211    /// ISO-8601 / RFC-3339 creation timestamp.
212    pub created_at: String,
213    /// ISO-8601 / RFC-3339 last-modified timestamp.
214    pub updated_at: String,
215    /// Whether the flow should be executed by a scheduler. Defaults to `false`.
216    ///
217    /// This is the **master switch**: a scheduler must ignore a flow entirely
218    /// when this is `false`, regardless of its [`schedules`](Self::schedules).
219    #[serde(default)]
220    pub enabled: bool,
221    /// Flow-level schedules — **when** the flow runs. Absent/empty on legacy
222    /// documents, whose trigger lives on the entry node's `data.schedule_type`
223    /// instead; see [`Self::effective_schedules`] for the precedence rule.
224    ///
225    /// A flow may declare **any number** of schedules (e.g. one at 08:00 and one
226    /// at 18:00). Published flows may ship default schedules here that seed onto a
227    /// host when the flow is installed.
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub schedules: Vec<FlowScheduleSpec>,
230    /// Declared integration-pack / tool dependencies. Absent on legacy documents;
231    /// see [`crate::requires`].
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub requires: Option<crate::requires::Requires>,
234    /// The graph definition.
235    pub flow: FlowDefinition,
236}
237
238/// A single flow-level schedule: one trigger, plus the toggle and overrides that
239/// apply when it fires.
240///
241/// See [`SavedFlow::schedules`]. A flow may carry many of these; each fires
242/// independently.
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
244pub struct FlowScheduleSpec {
245    /// Stable identifier within the enclosing flow (e.g. `"morning"`). Must be
246    /// unique among the flow's schedules. Author-assigned for published defaults
247    /// so an upgrade can diff schedules by id.
248    pub id: String,
249    /// Whether this individual trigger is active. Defaults to `true`. Distinct
250    /// from [`SavedFlow::enabled`], the flow-wide master switch.
251    #[serde(default = "default_true")]
252    pub enabled: bool,
253    /// The trigger, tagged by `type`: `manual` | `minutes` | `hours` | `cron`.
254    #[serde(flatten)]
255    pub trigger: ScheduleTrigger,
256    /// Human-readable label for editors (`"Morning brief"`).
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub name: Option<String>,
259    /// IANA timezone the `cron` trigger is evaluated in (e.g.
260    /// `"America/Detroit"`). `None` means the host's local/server time. Ignored
261    /// by non-cron triggers.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub timezone: Option<String>,
264    /// Inputs handed to the flow when this schedule fires, so the same flow can
265    /// run with different parameters on different schedules.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub inputs: Option<serde_json::Value>,
268    /// Persona override for runs triggered by this schedule. `None` falls back to
269    /// the flow/host default.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub persona: Option<String>,
272}
273
274/// A schedule's trigger: how its firing times are computed.
275///
276/// Serialized with an internal `type` tag, so a cron schedule is
277/// `{ "type": "cron", "cron": "0 8 * * *" }`. This mirrors the legacy entry-node
278/// `schedule_type` vocabulary so back-compat conversion is mechanical.
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
280#[serde(tag = "type", rename_all = "snake_case")]
281pub enum ScheduleTrigger {
282    /// No scheduled firing; the flow runs only via an explicit run/agent action.
283    Manual,
284    /// Fire every `interval` minutes.
285    Minutes {
286        /// Interval in minutes. Must be positive.
287        interval: u64,
288    },
289    /// Fire every `interval` hours.
290    Hours {
291        /// Interval in hours. Must be positive.
292        interval: u64,
293    },
294    /// Fire on a cron expression. The string is a standard cron expression; this
295    /// crate does not parse it (that is the host runtime's concern).
296    Cron {
297        /// The cron expression, e.g. `"0 8 * * *"`.
298        cron: String,
299    },
300}
301
302fn default_true() -> bool {
303    true
304}
305
306impl SavedFlow {
307    /// The normalized schedule list a runtime should honor.
308    ///
309    /// Precedence:
310    /// 1. If [`schedules`](Self::schedules) is non-empty, it wins verbatim and
311    ///    any entry-node `schedule_type` is ignored.
312    /// 2. Otherwise, if the entry node declares a `schedule_type`, synthesize a
313    ///    single spec from it (the legacy v1 behavior), preserving the entry
314    ///    node's optional `persona`.
315    /// 3. Otherwise, a single [`ScheduleTrigger::Manual`] spec.
316    ///
317    /// This lets existing flows (schedule on the entry node) keep running with no
318    /// migration.
319    pub fn effective_schedules(&self) -> Vec<FlowScheduleSpec> {
320        if !self.schedules.is_empty() {
321            return self.schedules.clone();
322        }
323        if let Some(spec) = self.entry_schedule_from_node() {
324            return vec![spec];
325        }
326        vec![FlowScheduleSpec {
327            id: "default".to_string(),
328            enabled: true,
329            trigger: ScheduleTrigger::Manual,
330            name: None,
331            timezone: None,
332            inputs: None,
333            persona: None,
334        }]
335    }
336
337    /// Synthesize a schedule spec from the legacy entry-node `data` fields, if an
338    /// entry node with a `schedule_type` is present. Returns `None` when there is
339    /// no entry node or it declares no `schedule_type`.
340    fn entry_schedule_from_node(&self) -> Option<FlowScheduleSpec> {
341        let entry = self
342            .flow
343            .nodes
344            .iter()
345            .find(|n| matches!(n.node_type, FlowNodeType::Core(CoreNodeType::Entry)))?;
346        let schedule_type = entry.data.get("schedule_type").and_then(|v| v.as_str())?;
347        let interval = entry
348            .data
349            .get("interval")
350            .and_then(|v| v.as_u64())
351            .unwrap_or(0);
352        let trigger = match schedule_type {
353            "minutes" => ScheduleTrigger::Minutes { interval },
354            "hours" => ScheduleTrigger::Hours { interval },
355            "cron" => ScheduleTrigger::Cron {
356                cron: entry
357                    .data
358                    .get("cron")
359                    .and_then(|v| v.as_str())
360                    .unwrap_or_default()
361                    .to_string(),
362            },
363            // "manual" and any unknown legacy value degrade to manual.
364            _ => ScheduleTrigger::Manual,
365        };
366        let persona = entry
367            .data
368            .get("persona")
369            .and_then(|v| v.as_str())
370            .map(|s| s.to_string());
371        Some(FlowScheduleSpec {
372            id: "default".to_string(),
373            enabled: true,
374            trigger,
375            name: None,
376            timezone: None,
377            inputs: None,
378            persona,
379        })
380    }
381}
382
383/// The version assumed for a document that omits `spec_version`.
384///
385/// Per SPEC §6 a missing field means version `"1"` — this is a back-compat rule
386/// and is intentionally distinct from [`SPEC_VERSION`] (the version this crate
387/// *emits*). Using v2 node types therefore requires setting `spec_version` to
388/// `"2"` explicitly.
389pub const DEFAULT_SPEC_VERSION: &str = "1";
390
391fn default_spec_version() -> String {
392    DEFAULT_SPEC_VERSION.to_string()
393}
394
395/// Lightweight metadata describing a saved flow, without the graph payload.
396///
397/// Returned by directory listings — see [`crate::store::list_flows`].
398#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
399pub struct FlowSummary {
400    /// The flow's stable identifier.
401    pub id: String,
402    /// Human-readable label.
403    pub name: String,
404    /// Number of nodes in the graph.
405    pub node_count: usize,
406    /// ISO-8601 / RFC-3339 creation timestamp.
407    pub created_at: String,
408    /// ISO-8601 / RFC-3339 last-modified timestamp.
409    pub updated_at: String,
410    /// Whether the flow is enabled for scheduling.
411    #[serde(default)]
412    pub enabled: bool,
413    /// Number of effective schedules (from `schedules`, else the legacy
414    /// entry-node trigger). See [`SavedFlow::effective_schedules`].
415    #[serde(default)]
416    pub schedule_count: usize,
417}
418
419/// Whether an id is safe to use as a filename per [`SPEC.md` §1.1].
420pub(crate) fn is_safe_id(id: &str) -> bool {
421    !id.is_empty()
422        && id.len() <= 64
423        && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
424}
425
426/// Whether a vendor namespace conforms to the rules in [`SPEC.md` §5.2].
427pub(crate) fn is_valid_vendor(prefix: &str) -> bool {
428    let mut chars = prefix.chars();
429    let Some(first) = chars.next() else { return false };
430    if !first.is_ascii_lowercase() {
431        return false;
432    }
433    if prefix.len() > 32 {
434        return false;
435    }
436    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use serde_json::json;
443
444    #[test]
445    fn core_node_type_round_trips() {
446        for ct in [
447            CoreNodeType::Entry,
448            CoreNodeType::Prompt,
449            CoreNodeType::Branch,
450            CoreNodeType::BranchTool,
451        ] {
452            let nt = FlowNodeType::Core(ct);
453            let j = serde_json::to_string(&nt).unwrap();
454            let back: FlowNodeType = serde_json::from_str(&j).unwrap();
455            assert_eq!(nt, back);
456        }
457    }
458
459    #[test]
460    fn custom_node_type_round_trips() {
461        let nt = FlowNodeType::Custom("slack:send_message".to_string());
462        let j = serde_json::to_string(&nt).unwrap();
463        assert_eq!(j, "\"slack:send_message\"");
464        let back: FlowNodeType = serde_json::from_str(&j).unwrap();
465        assert_eq!(nt, back);
466    }
467
468    #[test]
469    fn unknown_bare_node_type_becomes_custom() {
470        let back: FlowNodeType = serde_json::from_str("\"future_core_type\"").unwrap();
471        assert_eq!(back, FlowNodeType::Custom("future_core_type".into()));
472    }
473
474    #[test]
475    fn missing_spec_version_defaults_to_v1() {
476        let doc = json!({
477            "id": "x",
478            "name": "X",
479            "created_at": "2026-01-01T00:00:00Z",
480            "updated_at": "2026-01-01T00:00:00Z",
481            "flow": { "nodes": [], "edges": [] }
482        });
483        let parsed: SavedFlow = serde_json::from_value(doc).unwrap();
484        assert_eq!(parsed.spec_version, "1");
485        assert!(!parsed.enabled);
486    }
487
488    #[test]
489    fn saved_flow_round_trips() {
490        let sf = SavedFlow {
491            spec_version: "1".into(),
492            id: "f1".into(),
493            name: "F1".into(),
494            created_at: "2026-01-01T00:00:00Z".into(),
495            updated_at: "2026-01-02T00:00:00Z".into(),
496            enabled: true,
497            schedules: vec![],
498            requires: None,
499            flow: FlowDefinition {
500                nodes: vec![FlowNode {
501                    id: "n1".into(),
502                    node_type: FlowNodeType::Core(CoreNodeType::Entry),
503                    data: json!({"schedule_type": "manual"}),
504                    position: [10.0, 20.0],
505                }],
506                edges: vec![],
507            },
508        };
509        let j = serde_json::to_string(&sf).unwrap();
510        let back: SavedFlow = serde_json::from_str(&j).unwrap();
511        assert_eq!(sf, back);
512    }
513
514    #[test]
515    fn effective_schedules_prefers_top_level_array() {
516        let mut sf: SavedFlow = serde_json::from_value(json!({
517            "id": "f", "name": "F",
518            "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
519            "schedules": [
520                { "id": "morning", "type": "cron", "cron": "0 8 * * *" },
521                { "id": "evening", "type": "cron", "cron": "0 18 * * *", "enabled": false }
522            ],
523            "flow": { "nodes": [
524                { "id": "entry", "node_type": "entry", "data": { "schedule_type": "cron", "cron": "0 0 * * *" }, "position": [0,0] }
525            ], "edges": [] }
526        }))
527        .unwrap();
528        let eff = sf.effective_schedules();
529        assert_eq!(eff.len(), 2, "top-level array wins over the entry node");
530        assert_eq!(eff[0].id, "morning");
531        assert!(eff[0].enabled);
532        assert!(!eff[1].enabled);
533        assert_eq!(eff[0].trigger, ScheduleTrigger::Cron { cron: "0 8 * * *".into() });
534
535        // Clearing the array falls back to the legacy entry-node trigger.
536        sf.schedules.clear();
537        let eff = sf.effective_schedules();
538        assert_eq!(eff.len(), 1);
539        assert_eq!(eff[0].trigger, ScheduleTrigger::Cron { cron: "0 0 * * *".into() });
540    }
541
542    #[test]
543    fn effective_schedules_legacy_entry_and_manual_fallback() {
544        // No schedules, no entry schedule_type → a single manual spec.
545        let sf: SavedFlow = serde_json::from_value(json!({
546            "id": "f", "name": "F",
547            "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
548            "flow": { "nodes": [
549                { "id": "entry", "node_type": "entry", "data": {}, "position": [0,0] }
550            ], "edges": [] }
551        }))
552        .unwrap();
553        let eff = sf.effective_schedules();
554        assert_eq!(eff.len(), 1);
555        assert_eq!(eff[0].trigger, ScheduleTrigger::Manual);
556
557        // Legacy minutes trigger + entry persona carries through.
558        let sf: SavedFlow = serde_json::from_value(json!({
559            "id": "f", "name": "F",
560            "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z",
561            "flow": { "nodes": [
562                { "id": "entry", "node_type": "entry", "data": { "schedule_type": "minutes", "interval": 15, "persona": "briefer" }, "position": [0,0] }
563            ], "edges": [] }
564        }))
565        .unwrap();
566        let eff = sf.effective_schedules();
567        assert_eq!(eff[0].trigger, ScheduleTrigger::Minutes { interval: 15 });
568        assert_eq!(eff[0].persona.as_deref(), Some("briefer"));
569    }
570
571    #[test]
572    fn schedule_trigger_serializes_with_type_tag() {
573        let spec = FlowScheduleSpec {
574            id: "s".into(),
575            enabled: true,
576            trigger: ScheduleTrigger::Cron { cron: "0 8 * * *".into() },
577            name: Some("Morning".into()),
578            timezone: Some("America/Detroit".into()),
579            inputs: None,
580            persona: None,
581        };
582        let v = serde_json::to_value(&spec).unwrap();
583        assert_eq!(v["type"], "cron");
584        assert_eq!(v["cron"], "0 8 * * *");
585        assert_eq!(v["timezone"], "America/Detroit");
586        // enabled defaults to true when omitted on the wire.
587        let back: FlowScheduleSpec =
588            serde_json::from_value(json!({ "id": "s", "type": "manual" })).unwrap();
589        assert!(back.enabled);
590    }
591
592    #[test]
593    fn id_validation() {
594        assert!(is_safe_id("ok-id"));
595        assert!(is_safe_id("a"));
596        assert!(!is_safe_id(""));
597        assert!(!is_safe_id("has space"));
598        assert!(!is_safe_id("../escape"));
599        assert!(!is_safe_id(&"x".repeat(65)));
600    }
601
602    #[test]
603    fn vendor_validation() {
604        assert!(is_valid_vendor("slack"));
605        assert!(is_valid_vendor("my-co"));
606        assert!(is_valid_vendor("my_co"));
607        assert!(is_valid_vendor("co0"));
608        assert!(!is_valid_vendor(""));
609        assert!(!is_valid_vendor("0starts-with-digit"));
610        assert!(!is_valid_vendor("Capital"));
611        assert!(!is_valid_vendor(&"a".repeat(33)));
612    }
613}