Skip to main content

ironflow_engine/
handler.rs

1//! [`WorkflowHandler`] trait — dynamic workflows with context chaining.
2//!
3//! Implement this trait to define workflows where steps can reference
4//! outputs from previous steps. The handler receives a [`WorkflowContext`]
5//! that provides step execution methods with automatic persistence.
6//!
7//! # Examples
8//!
9//! ```no_run
10//! use ironflow_engine::handler::WorkflowHandler;
11//! use ironflow_engine::context::WorkflowContext;
12//! use ironflow_engine::config::{ShellConfig, AgentStepConfig};
13//! use ironflow_engine::error::EngineError;
14//! use std::future::Future;
15//! use std::pin::Pin;
16//!
17//! struct DeployWorkflow;
18//!
19//! impl WorkflowHandler for DeployWorkflow {
20//!     fn name(&self) -> &str {
21//!         "deploy"
22//!     }
23//!
24//!     fn execute<'a>(
25//!         &'a self,
26//!         ctx: &'a mut WorkflowContext,
27//!     ) -> Pin<Box<dyn Future<Output = Result<(), EngineError>> + Send + 'a>> {
28//!         Box::pin(async move {
29//!             let build = ctx.shell("build", ShellConfig::new("cargo build --release")).await?;
30//!             let tests = ctx.shell("test", ShellConfig::new("cargo test")).await?;
31//!
32//!             let review = ctx.agent("review", AgentStepConfig::new(
33//!                 &format!("Build:\n{}\nTests:\n{}\nReview.",
34//!                     build.output["stdout"], tests.output["stdout"])
35//!             )).await?;
36//!
37//!             if review.output.as_str().unwrap_or("").contains("LGTM") {
38//!                 ctx.shell("deploy", ShellConfig::new("./deploy.sh")).await?;
39//!             }
40//!
41//!             Ok(())
42//!         })
43//!     }
44//! }
45//! ```
46
47use std::collections::HashMap;
48use std::future::Future;
49use std::pin::Pin;
50
51use rust_decimal::Decimal;
52use schemars::JsonSchema;
53use serde::Serialize;
54use serde_json::Value;
55
56use crate::context::WorkflowContext;
57use crate::error::EngineError;
58use crate::schedule::CronSchedule;
59
60/// Generate a JSON Schema [`Value`] from a type that derives [`JsonSchema`].
61///
62/// Use this in [`WorkflowHandler::input_schema`] to automatically derive the
63/// schema from your input struct instead of writing JSON by hand.
64///
65/// # Examples
66///
67/// ```
68/// use schemars::JsonSchema;
69/// use serde::Deserialize;
70/// use ironflow_engine::handler::input_schema_for;
71///
72/// #[derive(Deserialize, JsonSchema)]
73/// struct DeployInput {
74///     environment: String,
75///     dry_run: Option<bool>,
76/// }
77///
78/// let schema = input_schema_for::<DeployInput>();
79/// assert_eq!(schema["type"], "object");
80/// assert!(schema["properties"]["environment"].is_object());
81/// ```
82pub fn input_schema_for<T: JsonSchema>() -> Value {
83    let schema = schemars::schema_for!(T);
84    serde_json::to_value(schema).expect("schema serialization cannot fail")
85}
86
87/// Boxed future returned by [`WorkflowHandler::execute`].
88pub type HandlerFuture<'a> = Pin<Box<dyn Future<Output = Result<(), EngineError>> + Send + 'a>>;
89
90/// Metadata about a workflow, returned by [`WorkflowHandler::describe`].
91///
92/// Contains a human-readable description and optional Rust source code
93/// for display in the dashboard.
94#[derive(Debug, Clone, Serialize)]
95pub struct WorkflowInfo {
96    /// Human-readable description of what the workflow does.
97    pub description: String,
98    /// Optional Rust source code of the handler (for UI display).
99    pub source_code: Option<String>,
100    /// Names of sub-workflows invoked by this handler.
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub sub_workflows: Vec<String>,
103    /// Optional `/`-separated category path used to group workflows in the UI tree.
104    ///
105    /// A value like `"data/etl"` places the workflow under `data` → `etl`.
106    /// `None` means the workflow is uncategorized.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub category: Option<String>,
109    /// Handler version string, used to trace which code produced a given run.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub version: Option<String>,
112    /// Versions accepted for replay without `force`.
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub compatible_versions: Vec<String>,
115    /// JSON Schema describing the expected input payload.
116    ///
117    /// When present, the dashboard renders a dynamic form from this schema
118    /// and the engine validates the payload before creating a run.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub input_schema: Option<Value>,
121    /// Labels automatically applied to every run of this workflow.
122    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
123    pub default_labels: HashMap<String, String>,
124    /// Optional cron schedule for automatic execution.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub schedule: Option<CronSchedule>,
127    /// Default cumulative cost cap applied to runs of this workflow, in USD.
128    ///
129    /// Overridden by a cap supplied at run creation, and takes precedence over
130    /// the server-wide default. `None` means the handler declares no default.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub default_max_cost_usd: Option<Decimal>,
133}
134
135/// A dynamic workflow handler with context-aware step chaining.
136///
137/// Implement this trait to define workflows where each step can use
138/// the output of previous steps. Register handlers with
139/// [`Engine::register`](crate::engine::Engine::register) and execute
140/// them by name.
141///
142/// # Why `Pin<Box<dyn Future>>` instead of `async fn`?
143///
144/// The handler must be object-safe (`dyn WorkflowHandler`) to allow
145/// registering different handler types in the engine's registry.
146pub trait WorkflowHandler: Send + Sync {
147    /// The workflow name used for registration and lookup.
148    fn name(&self) -> &str;
149
150    /// Handler version string, used to trace which code version produced a run.
151    ///
152    /// Override this to return a meaningful version (semver, git SHA, build
153    /// hash, etc.). The default is `"1"`.
154    ///
155    /// The engine records this value on every run it creates so that retries
156    /// can detect when the handler has changed since the original execution.
157    fn version(&self) -> Option<&str> {
158        Some("1")
159    }
160
161    /// Versions of this handler that can replay payloads produced by an
162    /// older run without requiring `force`.
163    ///
164    /// When a retry targets a run whose `handler_version` differs from
165    /// [`version`](Self::version), the engine checks this list. If the
166    /// run's version appears here, the retry proceeds normally; otherwise
167    /// it is refused with `409 HANDLER_VERSION_MISMATCH` unless the caller
168    /// passes `force=true`.
169    ///
170    /// The default is an empty slice (only the current version is accepted).
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
176    /// # use ironflow_engine::context::WorkflowContext;
177    /// struct MigratedHandler;
178    ///
179    /// impl WorkflowHandler for MigratedHandler {
180    ///     fn name(&self) -> &str { "migrated" }
181    ///     fn version(&self) -> Option<&str> { Some("2.0.0") }
182    ///     fn compatible_versions(&self) -> &[&str] { &["1.0.0", "1.5.0"] }
183    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
184    ///         Box::pin(async move { Ok(()) })
185    ///     }
186    /// }
187    ///
188    /// assert_eq!(MigratedHandler.compatible_versions(), &["1.0.0", "1.5.0"]);
189    /// ```
190    fn compatible_versions(&self) -> &[&str] {
191        &[]
192    }
193
194    /// Optional `/`-separated category path used to group workflows in the UI tree.
195    ///
196    /// Return a value like `"data/etl"` to place the workflow under `data` → `etl`.
197    /// The default is `None` (uncategorized).
198    ///
199    /// Validation (empty segments, leading or trailing `/`, `//`, whitespace
200    /// segments) is enforced at registration time by
201    /// [`Engine::register`](crate::engine::Engine::register).
202    fn category(&self) -> Option<&str> {
203        None
204    }
205
206    /// Return a JSON Schema describing the expected input payload.
207    ///
208    /// When present, the dashboard renders a dynamic form from this schema
209    /// and the engine validates the payload before creating a run.
210    /// The default is `None` (no schema, free-form payload).
211    fn input_schema(&self) -> Option<Value> {
212        None
213    }
214
215    /// Labels automatically applied to every run of this workflow.
216    ///
217    /// These are merged with any labels provided at run creation time.
218    /// User-provided labels take precedence over defaults.
219    fn default_labels(&self) -> HashMap<String, String> {
220        HashMap::new()
221    }
222
223    /// Optional cron schedule for automatic execution.
224    ///
225    /// Return a [`CronSchedule`] built from a cron expression
226    /// (5 or 6 fields, as supported by [`croner`]).
227    ///
228    /// When set, the engine exposes this handler via
229    /// [`Engine::scheduled_handlers`](crate::engine::Engine::scheduled_handlers)
230    /// so the runtime can wire it into a cron scheduler automatically.
231    ///
232    /// The default is `None` (no automatic scheduling).
233    ///
234    /// # Examples
235    ///
236    /// ```
237    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
238    /// # use ironflow_engine::context::WorkflowContext;
239    /// # use ironflow_engine::schedule::CronSchedule;
240    /// struct HourlySync;
241    ///
242    /// impl WorkflowHandler for HourlySync {
243    ///     fn name(&self) -> &str { "hourly-sync" }
244    ///     fn schedule(&self) -> Option<&CronSchedule> {
245    ///         // In practice, store as a field or use `std::sync::LazyLock`.
246    ///         None
247    ///     }
248    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
249    ///         Box::pin(async move { Ok(()) })
250    ///     }
251    /// }
252    /// ```
253    fn schedule(&self) -> Option<&CronSchedule> {
254        None
255    }
256
257    /// Default cumulative cost cap for runs of this workflow, in USD.
258    ///
259    /// Applied when the run creation request does not supply one. Takes
260    /// precedence over the server-wide
261    /// [`IRONFLOW_DEFAULT_RUN_MAX_COST_USD`](crate::budget::DEFAULT_RUN_MAX_COST_ENV).
262    /// The default is `None` (fall back to the server default, or no cap).
263    ///
264    /// # Examples
265    ///
266    /// ```
267    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
268    /// # use ironflow_engine::context::WorkflowContext;
269    /// use rust_decimal::Decimal;
270    ///
271    /// struct ExpensiveAnalysis;
272    ///
273    /// impl WorkflowHandler for ExpensiveAnalysis {
274    ///     fn name(&self) -> &str { "expensive-analysis" }
275    ///     fn default_max_cost_usd(&self) -> Option<Decimal> {
276    ///         Some(Decimal::new(500, 2)) // $5.00
277    ///     }
278    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
279    ///         Box::pin(async move { Ok(()) })
280    ///     }
281    /// }
282    ///
283    /// assert_eq!(ExpensiveAnalysis.default_max_cost_usd(), Some(Decimal::new(500, 2)));
284    /// ```
285    fn default_max_cost_usd(&self) -> Option<Decimal> {
286        None
287    }
288
289    /// Check whether a run carrying `run_version` can be replayed by this
290    /// handler without `force`.
291    ///
292    /// Compatibility rules:
293    /// - `run_version` is `None` (old run predating version tracking): always
294    ///   compatible.
295    /// - `run_version` equals [`version`](Self::version): compatible.
296    /// - `run_version` appears in [`compatible_versions`](Self::compatible_versions):
297    ///   compatible.
298    /// - Otherwise: incompatible.
299    ///
300    /// # Examples
301    ///
302    /// ```
303    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
304    /// # use ironflow_engine::context::WorkflowContext;
305    /// struct MyHandler;
306    ///
307    /// impl WorkflowHandler for MyHandler {
308    ///     fn name(&self) -> &str { "my-handler" }
309    ///     fn version(&self) -> Option<&str> { Some("2.0.0") }
310    ///     fn compatible_versions(&self) -> &[&str] { &["1.0.0"] }
311    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
312    ///         Box::pin(async move { Ok(()) })
313    ///     }
314    /// }
315    ///
316    /// assert!(MyHandler.is_version_compatible(None));
317    /// assert!(MyHandler.is_version_compatible(Some("2.0.0")));
318    /// assert!(MyHandler.is_version_compatible(Some("1.0.0")));
319    /// assert!(!MyHandler.is_version_compatible(Some("0.5.0")));
320    /// ```
321    fn is_version_compatible(&self, run_version: Option<&str>) -> bool {
322        let Some(rv) = run_version else {
323            return true;
324        };
325        if self.version() == Some(rv) {
326            return true;
327        }
328        self.compatible_versions().contains(&rv)
329    }
330
331    /// Return metadata about this workflow (description, source code).
332    ///
333    /// Override this to provide a description and source code for the
334    /// dashboard UI. The default returns an empty description with no source
335    /// but propagates [`WorkflowHandler::category`],
336    /// [`WorkflowHandler::version`], [`WorkflowHandler::input_schema`],
337    /// [`WorkflowHandler::default_labels`],
338    /// [`WorkflowHandler::compatible_versions`],
339    /// and [`WorkflowHandler::schedule`].
340    fn describe(&self) -> WorkflowInfo {
341        WorkflowInfo {
342            description: String::new(),
343            source_code: None,
344            sub_workflows: Vec::new(),
345            category: self.category().map(str::to_string),
346            version: self.version().map(str::to_string),
347            compatible_versions: self
348                .compatible_versions()
349                .iter()
350                .map(|s| s.to_string())
351                .collect(),
352            input_schema: self.input_schema(),
353            default_labels: self.default_labels(),
354            schedule: self.schedule().cloned(),
355            default_max_cost_usd: self.default_max_cost_usd(),
356        }
357    }
358
359    /// Execute the workflow with the given context.
360    ///
361    /// The context provides [`shell`](WorkflowContext::shell),
362    /// [`http`](WorkflowContext::http), and [`agent`](WorkflowContext::agent)
363    /// methods that automatically persist each step.
364    ///
365    /// # Errors
366    ///
367    /// Return [`EngineError`] if any step fails. The engine will mark
368    /// the run as `Failed` and record the error.
369    fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a>;
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use serde::{Deserialize, Serialize};
376
377    #[derive(Debug, Serialize, Deserialize, JsonSchema)]
378    struct TestInput {
379        environment: String,
380        #[serde(default)]
381        dry_run: bool,
382    }
383
384    struct MinimalHandler;
385
386    impl WorkflowHandler for MinimalHandler {
387        fn name(&self) -> &str {
388            "minimal"
389        }
390
391        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
392            Box::pin(async { Ok(()) })
393        }
394    }
395
396    struct FullFeaturedHandler;
397
398    impl WorkflowHandler for FullFeaturedHandler {
399        fn name(&self) -> &str {
400            "full"
401        }
402
403        fn version(&self) -> Option<&str> {
404            Some("1.2.0")
405        }
406
407        fn category(&self) -> Option<&str> {
408            Some("data/etl")
409        }
410
411        fn input_schema(&self) -> Option<Value> {
412            Some(input_schema_for::<TestInput>())
413        }
414
415        fn default_labels(&self) -> HashMap<String, String> {
416            HashMap::from([
417                ("team".to_string(), "platform".to_string()),
418                ("env".to_string(), "prod".to_string()),
419            ])
420        }
421
422        fn default_max_cost_usd(&self) -> Option<Decimal> {
423            Some(Decimal::new(750, 2))
424        }
425
426        fn describe(&self) -> WorkflowInfo {
427            WorkflowInfo {
428                description: "Full-featured test handler".to_string(),
429                source_code: Some("fn test() {}".to_string()),
430                sub_workflows: vec!["helper".to_string()],
431                category: self.category().map(str::to_string),
432                version: self.version().map(str::to_string),
433                compatible_versions: self
434                    .compatible_versions()
435                    .iter()
436                    .map(|s| s.to_string())
437                    .collect(),
438                input_schema: self.input_schema(),
439                default_labels: self.default_labels(),
440                schedule: self.schedule().cloned(),
441                default_max_cost_usd: self.default_max_cost_usd(),
442            }
443        }
444
445        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
446            Box::pin(async { Ok(()) })
447        }
448    }
449
450    #[test]
451    fn minimal_handler_has_required_name() {
452        let handler = MinimalHandler;
453        assert_eq!(handler.name(), "minimal");
454    }
455
456    #[test]
457    fn minimal_handler_defaults_to_version_1() {
458        let handler = MinimalHandler;
459        assert_eq!(handler.version(), Some("1"));
460    }
461
462    #[test]
463    fn minimal_handler_defaults_to_no_compatible_versions() {
464        let handler = MinimalHandler;
465        assert!(handler.compatible_versions().is_empty());
466    }
467
468    #[test]
469    fn minimal_handler_defaults_to_no_category() {
470        let handler = MinimalHandler;
471        assert_eq!(handler.category(), None);
472    }
473
474    #[test]
475    fn minimal_handler_defaults_to_no_schema() {
476        let handler = MinimalHandler;
477        assert_eq!(handler.input_schema(), None);
478    }
479
480    #[test]
481    fn minimal_handler_defaults_to_empty_labels() {
482        let handler = MinimalHandler;
483        let labels = handler.default_labels();
484        assert!(labels.is_empty());
485    }
486
487    #[test]
488    fn minimal_handler_defaults_to_no_schedule() {
489        let handler = MinimalHandler;
490        assert_eq!(handler.schedule(), None);
491    }
492
493    #[test]
494    fn minimal_handler_describe_reflects_defaults() {
495        let handler = MinimalHandler;
496        let info = handler.describe();
497        assert_eq!(info.description, "");
498        assert_eq!(info.source_code, None);
499        assert_eq!(info.sub_workflows, Vec::<String>::new());
500        assert_eq!(info.category, None);
501        assert_eq!(info.version, Some("1".to_string()));
502        assert!(info.compatible_versions.is_empty());
503        assert_eq!(info.input_schema, None);
504        assert!(info.default_labels.is_empty());
505        assert_eq!(info.schedule, None);
506    }
507
508    #[test]
509    fn full_handler_returns_all_metadata() {
510        let handler = FullFeaturedHandler;
511        assert_eq!(handler.name(), "full");
512        assert_eq!(handler.version(), Some("1.2.0"));
513        assert_eq!(handler.category(), Some("data/etl"));
514        assert!(handler.input_schema().is_some());
515    }
516
517    #[test]
518    fn full_handler_default_labels_are_set() {
519        let handler = FullFeaturedHandler;
520        let labels = handler.default_labels();
521        assert_eq!(labels.get("team"), Some(&"platform".to_string()));
522        assert_eq!(labels.get("env"), Some(&"prod".to_string()));
523    }
524
525    #[test]
526    fn full_handler_describe_includes_all_fields() {
527        let handler = FullFeaturedHandler;
528        let info = handler.describe();
529        assert_eq!(info.description, "Full-featured test handler");
530        assert_eq!(info.source_code, Some("fn test() {}".to_string()));
531        assert_eq!(info.sub_workflows, vec!["helper".to_string()]);
532        assert_eq!(info.category, Some("data/etl".to_string()));
533        assert_eq!(info.version, Some("1.2.0".to_string()));
534        assert!(info.input_schema.is_some());
535        assert_eq!(info.default_labels.len(), 2);
536    }
537
538    #[test]
539    fn input_schema_for_generates_json_schema() {
540        let schema = input_schema_for::<TestInput>();
541        assert_eq!(schema["type"], "object");
542        assert!(schema["properties"]["environment"].is_object());
543        assert!(schema["properties"]["dry_run"].is_object());
544    }
545
546    #[test]
547    fn input_schema_for_preserves_serde_attributes() {
548        let schema = input_schema_for::<TestInput>();
549        let properties = &schema["properties"];
550        assert!(properties.is_object());
551        assert!(properties.get("environment").is_some());
552        assert!(properties.get("dry_run").is_some());
553    }
554
555    #[test]
556    fn minimal_handler_defaults_to_no_max_cost() {
557        assert!(MinimalHandler.default_max_cost_usd().is_none());
558        assert!(MinimalHandler.describe().default_max_cost_usd.is_none());
559    }
560
561    #[test]
562    fn describe_propagates_handler_max_cost() {
563        assert_eq!(
564            FullFeaturedHandler.describe().default_max_cost_usd,
565            Some(Decimal::new(750, 2))
566        );
567    }
568
569    #[test]
570    fn workflow_info_omits_absent_max_cost_from_json() {
571        let json = serde_json::to_value(MinimalHandler.describe()).expect("serialize");
572        assert!(json.get("default_max_cost_usd").is_none());
573    }
574
575    #[test]
576    fn workflow_info_serializes_with_skip_empty() {
577        let info = WorkflowInfo {
578            description: "test".to_string(),
579            source_code: None,
580            sub_workflows: Vec::new(),
581            category: None,
582            version: None,
583            compatible_versions: Vec::new(),
584            input_schema: None,
585            default_labels: HashMap::new(),
586            schedule: None,
587            default_max_cost_usd: None,
588        };
589
590        let json = serde_json::to_value(&info).expect("serialize");
591        assert_eq!(json["description"], "test");
592        // Optional fields with skip_serializing_if may still be present or absent
593        // depending on the serde configuration. Just verify the description is there.
594        assert!(json.is_object());
595    }
596
597    #[test]
598    fn workflow_info_serializes_with_values() {
599        let info = WorkflowInfo {
600            description: "test".to_string(),
601            source_code: Some("code".to_string()),
602            sub_workflows: vec!["sub".to_string()],
603            category: Some("cat".to_string()),
604            version: Some("1.0.0".to_string()),
605            compatible_versions: vec!["0.9.0".to_string()],
606            input_schema: Some(serde_json::json!({"type": "object"})),
607            default_labels: HashMap::from([("key".to_string(), "value".to_string())]),
608            schedule: Some(CronSchedule::new("0 0 * * * *").unwrap()),
609            default_max_cost_usd: Some(Decimal::new(750, 2)),
610        };
611
612        let json = serde_json::to_value(&info).expect("serialize");
613        assert_eq!(json["description"], "test");
614        assert_eq!(json["source_code"], "code");
615        assert_eq!(json["sub_workflows"][0], "sub");
616        assert_eq!(json["category"], "cat");
617        assert_eq!(json["version"], "1.0.0");
618        assert_eq!(json["default_labels"]["key"], "value");
619        assert_eq!(json["schedule"], "0 0 * * * *");
620        assert_eq!(json["compatible_versions"][0], "0.9.0");
621    }
622
623    // ---- is_version_compatible ----
624
625    struct VersionedHandler;
626
627    impl WorkflowHandler for VersionedHandler {
628        fn name(&self) -> &str {
629            "versioned"
630        }
631        fn version(&self) -> Option<&str> {
632            Some("2.0.0")
633        }
634        fn compatible_versions(&self) -> &[&str] {
635            &["1.5.0", "1.9.0"]
636        }
637        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
638            Box::pin(async { Ok(()) })
639        }
640    }
641
642    #[test]
643    fn version_compatible_with_same_version() {
644        assert!(VersionedHandler.is_version_compatible(Some("2.0.0")));
645    }
646
647    #[test]
648    fn version_compatible_with_none_run_version() {
649        assert!(VersionedHandler.is_version_compatible(None));
650    }
651
652    #[test]
653    fn version_compatible_with_listed_version() {
654        assert!(VersionedHandler.is_version_compatible(Some("1.5.0")));
655        assert!(VersionedHandler.is_version_compatible(Some("1.9.0")));
656    }
657
658    #[test]
659    fn version_incompatible_with_unlisted_version() {
660        assert!(!VersionedHandler.is_version_compatible(Some("1.0.0")));
661        assert!(!VersionedHandler.is_version_compatible(Some("3.0.0")));
662    }
663
664    #[test]
665    fn minimal_handler_compatible_with_same_default() {
666        assert!(MinimalHandler.is_version_compatible(Some("1")));
667    }
668
669    #[test]
670    fn minimal_handler_incompatible_with_different_version() {
671        assert!(!MinimalHandler.is_version_compatible(Some("2")));
672    }
673}