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}