AirLibrary/Library.rs
1#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
2#![allow(non_snake_case, non_camel_case_types, non_upper_case_globals)]
3
4//! # Air: Background Daemon for Code Editor Land
5//!
6//! Air runs silently in the background so Land is always up to date and ready
7//! to go. It handles updates, downloads, crypto signing, and file indexing
8//! without blocking the editor.
9//!
10//! ## Architecture & Connections
11//!
12//! Air is the hub that connects various components in the Land ecosystem:
13//!
14//! - **Wind** (Effect-TS): Functional programming patterns for state management
15//! Air integrates with Wind's effect system for predictable state transitions
16//! and error handling patterns
17//!
18//! - **Cocoon** (NodeJS host): The Node.js runtime for web components Air
19//! communicates with Cocoon through the Vine protocol to deliver web assets
20//! and perform frontend build operations. Port: 50052
21//!
22//! - **Mountain** (Tauri bundler): Main desktop application Mountain receives
23//! work from Air through Vine (gRPC) and performs the main application logic.
24//! Mountain's Tauri framework handles the native integration
25//!
26//! - **Vine** (gRPC protocol): Communication layer connecting all components
27//! Air hosts the Vine gRPC server on port 50053, receiving work requests from
28//! Mountain
29//!
30//! ## VSCode Architecture References
31//!
32//! ### Update Service
33//!
34//! Reference: `Dependency/Microsoft/Dependency/Editor/src/vs/platform/update/`
35//!
36//! Air's UpdateManager is inspired by VSCode's update architecture:
37//!
38//! - **AbstractUpdateService** (`common/update.ts`): Base service defining
39//! update interfaces
40//! - Platform-specific implementations:
41//! - `updateService.darwin.ts` - macOS update handling
42//! - `updateService.linux.ts` - Linux update handling
43//! - `updateService.snap.ts` - Snap package updates
44//! - `updateService.win32.ts` - Windows update handling
45//!
46//! Air's UpdateManager abstracts platform differences and provides:
47//! - Update checking with version comparison
48//! - Package download with resumable support
49//! - Checksum verification for integrity
50//! - Signature validation for security
51//! - Staged updates for rollback capability
52//!
53//! ### Lifecycle Management
54//!
55//! Reference:
56//! `Dependency/Microsoft/Dependency/Editor/src/vs/base/common/lifecycle.ts`
57//!
58//! VSCode's lifecycle patterns inform Air's daemon management:
59//!
60//! - **Disposable pattern**: Resources implement cleanup methods
61//! - **EventEmitter**: Async event handling for state changes
62//! - **DisposableStore**: Aggregate resource cleanup
63//!
64//! Air adapts these patterns with:
65//! - `ApplicationState`: Central state management with cleanup
66//! - `DaemonManager`: Single-instance lock management
67//! - Graceful shutdown with resource release
68//!
69//! ## Module Organization
70//!
71//! The Air library is organized into functional modules:
72//!
73//! ### Core Infrastructure
74//! - `ApplicationState`: Central state manager for the daemon
75//! - `Configuration`: Configuration loading and validation
76//! - `Daemon`: Daemon lifecycle and lock management
77//! - `Logging`: Structured logging with filtering
78//! - `Metrics`: Prometheus-style metrics collection
79//! - `Tracing`: Distributed tracing support
80//!
81//! ### Services
82//! - `Authentication`: Token management and cryptographic operations
83//! - `Updates`: Update checking, downloading, and installation
84//! - `Downloader`: Background downloads with retry logic
85//! - `Indexing`: File system indexing for code navigation
86//!
87//! ### Communication
88//! - `Vine`: gRPC server and client implementation
89//! - Generated protobuf code in `Vine/Generated/`
90//! - Server implementation in `Vine/Server/`
91//! - Client utilities in `Vine/Client/`
92//!
93//! ### Reliability
94//! - `Resilience`: Retry policies, circuit breakers, timeouts
95//! - `RetryPolicy`: Configurable retry strategies
96//! - `CircuitBreaker`: Fail-fast for external dependencies
97//! - `BulkheadExecutor`: Concurrency limiting
98//! - `TimeoutManager`: Operation timeout management
99//! - `Security`: Rate limiting, checksums, secure storage
100//! - `HealthCheck`: Service health monitoring
101//!
102//! ### Extensibility
103//! - `Plugins`: Hot-reloadable plugin system
104//! - `CLI`: Command-line interface for daemon control
105//!
106//! ## Protocol Details
107//!
108//! **Vine Protocol (gRPC)**
109//! - **Version**: 1 (Air::ProtocolVersion)
110//! - **Transport**: HTTP/2
111//! - **Serialization**: Protocol Buffers
112//! - **Ports**:
113//! - 50053: Air (background services) - DefaultBindAddress
114//! - 50052: Cocoon (NodeJS/web services)
115//!
116//! TLS/mTLS support for production security is now available via the `mtls`
117//! feature. See the Mountain module for client TLS configuration.
118//! ## FUTURE Enhancements
119//!
120//! ### High Priority
121//! - [ ] Implement metrics HTTP endpoint (/metrics)
122//! - [ ] Add Prometheus metric export with labels
123//! - [ ] Implement TLS/mTLS for gRPC connections
124//! - [ ] Add connection authentication/authorization
125//! - [ ] Implement configuration hot-reload (SIGHUP)
126//! - [ ] Add comprehensive integration tests
127//! - [ ] Implement graceful shutdown with operation completion
128//!
129//! ### Medium Priority
130//! - [ ] Implement plugin hot-reload
131//! - [ ] Add structured logging with correlation IDs
132//! - [ ] Implement distributed tracing (OpenTelemetry)
133//! - [ ] Add health check HTTP endpoint for load balancers
134//! - [ ] Implement connection pooling optimizations
135//! - [ ] Add metrics export to external systems
136//! - [ ] Implement telemetry/observability export
137//!
138//! ### Low Priority
139//! - [ ] Add A/B testing framework for features
140//! - [ ] Implement query optimizer for file index
141//! - [ ] Add caching layer for frequently accessed data
142//! - [ ] Implement adaptive timeout based on load
143//! - [ ] Add predictive scaling based on metrics
144//! - [ ] Implement chaos testing/metrics
145//! ## Error Handling Strategy
146//!
147//! All modules use defensive coding practices:
148//!
149//! 1. **Input Validation**: All public functions validate inputs with
150//! descriptive errors
151//! 2. **Timeout Handling**: Default timeouts with configuration overrides
152//! 3. **Resource Cleanup**: Drop trait + explicit cleanup methods
153//! 4. **Circuit Breaker**: Fail-fast for external dependencies
154//! 5. **Retry Logic**: Exponential backoff for transient failures
155//! 6. **Metrics Recording**: All operations record success/failure metrics
156//! 7. **Panic Recovery**: Catch panics in critical async tasks
157//!
158//! ## Constants
159//!
160//! - **VERSION**: Air daemon version from Cargo.toml
161//! - **DefaultConfigFile**: Default config filename (Air.toml)
162//! - **DefaultBindAddress**: gRPC bind address (`[::1]`:50053)
163//! - **ProtocolVersion**: Vine protocol version (1)
164
165pub mod ApplicationState;
166
167pub mod Authentication;
168
169pub mod CLI;
170
171pub mod Configuration;
172
173pub mod Daemon;
174
175pub mod DevLog;
176
177pub mod Downloader;
178
179pub mod HealthCheck;
180
181pub mod HTTP;
182
183pub mod Indexing;
184
185pub mod Logging;
186
187pub mod Metrics;
188
189pub mod Mountain;
190
191pub mod Plugins;
192
193pub mod Resilience;
194
195pub mod Security;
196
197pub mod Tracing;
198
199pub mod Updates;
200
201pub mod Vine;
202
203/// Air Daemon version information
204///
205/// This is automatically populated from Cargo.toml at build time
206pub const VERSION:&str = env!("CARGO_PKG_VERSION");
207
208/// Default configuration file name
209///
210/// The daemon searches for this configuration file in:
211/// 1. The path specified via --config flag
212/// 2. ~/.config/Air/Air.toml
213/// 3. /etc/Air/Air.toml
214/// 4. Working directory (Air.toml)
215pub const DefaultConfigFile:&str = "Air.toml";
216
217/// Default gRPC bind address for the Vine server
218///
219/// Note: Port 50053 is used for Air to avoid conflict with Cocoon (port 50052)
220///
221/// Addresses in order of preference:
222/// - `--bind` flag value (if provided)
223/// - DefaultBindAddress constant: `[::1]`:50053
224///
225/// FUTURE: Add support for:
226/// - IPv4-only binding (0.0.0.0:50053)
227/// - IPv6-only binding (`[::]`:50053)
228/// - Wildcard binding for all interfaces
229pub const DefaultBindAddress:&str = "[::1]:50053";
230
231/// Protocol version for Mountain-Air communication
232///
233/// This version is sent in all gRPC messages and checked by clients
234/// to ensure compatibility. Increment this value when breaking
235/// protocol changes are made.
236///
237/// Version history:
238/// - 1: Initial Vine protocol
239pub const ProtocolVersion:u32 = 1;
240
241/// Error type for Air operations
242///
243/// Comprehensive error types for all Air operations with descriptive messages.
244/// All error variants include context to help with debugging and error
245/// recovery.
246// Error handling using thiserror for automatic derive
247#[derive(Debug, thiserror::Error, Clone)]
248pub enum AirError {
249 #[error("Configuration error: {0}")]
250 Configuration(String),
251
252 #[error("Authentication error: {0}")]
253 Authentication(String),
254
255 #[error("Network error: {0}")]
256 Network(String),
257
258 #[error("File system error: {0}")]
259 FileSystem(String),
260
261 #[error("gRPC error: {0}")]
262 gRPC(String),
263
264 #[error("Serialization error: {0}")]
265 Serialization(String),
266
267 #[error("Internal error: {0}")]
268 Internal(String),
269
270 #[error("Resource limit exceeded: {0}")]
271 ResourceLimit(String),
272
273 #[error("Service unavailable: {0}")]
274 ServiceUnavailable(String),
275
276 #[error("Validation error: {0}")]
277 Validation(String),
278
279 #[error("Timeout error: {0}")]
280 Timeout(String),
281
282 #[error("Plugin error: {0}")]
283 Plugin(String),
284
285 #[error("Hot-reload error: {0}")]
286 HotReload(String),
287
288 #[error("Connection error: {0}")]
289 Connection(String),
290
291 #[error("Rate limit exceeded: {0}")]
292 RateLimit(String),
293
294 #[error("Circuit breaker open: {0}")]
295 CircuitBreaker(String),
296}
297
298impl From<config::ConfigError> for AirError {
299 fn from(err:config::ConfigError) -> Self { AirError::Configuration(err.to_string()) }
300}
301
302impl From<reqwest::Error> for AirError {
303 fn from(err:reqwest::Error) -> Self { AirError::Network(err.to_string()) }
304}
305
306impl From<std::io::Error> for AirError {
307 fn from(err:std::io::Error) -> Self { AirError::FileSystem(err.to_string()) }
308}
309
310impl From<tonic::transport::Error> for AirError {
311 fn from(err:tonic::transport::Error) -> Self { AirError::gRPC(err.to_string()) }
312}
313
314impl From<serde_json::Error> for AirError {
315 fn from(err:serde_json::Error) -> Self { AirError::Serialization(err.to_string()) }
316}
317
318impl From<toml::de::Error> for AirError {
319 fn from(err:toml::de::Error) -> Self { AirError::Serialization(err.to_string()) }
320}
321
322impl From<uuid::Error> for AirError {
323 fn from(err:uuid::Error) -> Self { AirError::Internal(format!("UUID error: {}", err)) }
324}
325
326impl From<tokio::task::JoinError> for AirError {
327 fn from(err:tokio::task::JoinError) -> Self { AirError::Internal(format!("Task join error: {}", err)) }
328}
329
330impl From<&str> for AirError {
331 fn from(err:&str) -> Self { AirError::Internal(err.to_string()) }
332}
333
334impl From<String> for AirError {
335 fn from(err:String) -> Self { AirError::Internal(err) }
336}
337
338impl From<(crate::HealthCheck::HealthStatus, Option<String>)> for AirError {
339 fn from((status, message):(crate::HealthCheck::HealthStatus, Option<String>)) -> Self {
340 let msg = message.unwrap_or_else(|| format!("Health check failed: {:?}", status));
341
342 AirError::ServiceUnavailable(msg)
343 }
344}
345
346/// Result type for Air operations
347///
348/// Convenience type alias for Result<T, AirError>
349pub type Result<T> = std::result::Result<T, AirError>;
350
351/// Common utility functions
352///
353/// These utilities provide defensive helper functions used throughout
354/// the Air library for validation, ID generation, timestamp handling,
355/// and common operations with proper error handling.
356pub mod Utility {
357
358 use super::*;
359
360 /// Generate a unique request ID
361 ///
362 /// Creates a UUID v4 for tracing and correlation of requests.
363 /// The ID is guaranteed to be unique (with extremely high probability).
364 // Using UUID v4 for request ID generation (can be replaced with ULID if
365 // sortable IDs needed)
366 pub fn GenerateRequestId() -> String { uuid::Uuid::new_v4().to_string() }
367
368 /// Generate a unique request ID with a prefix
369 ///
370 /// Format: `{prefix}-{uuid}`
371 ///
372 /// # Arguments
373 ///
374 /// * `prefix` - Prefix to add before the UUID (e.g., "auth", "download")
375 ///
376 /// # Example
377 ///
378 /// ```
379 /// let id = GenerateRequestIdWithPrefix("auth");
380 /// // Returns: "auth-550e8400-e29b-41d4-a716-446655440000"
381 /// ```
382 pub fn GenerateRequestIdWithPrefix(Prefix:&str) -> String { format!("{}-{}", Prefix, uuid::Uuid::new_v4()) }
383
384 /// Get current timestamp in milliseconds since UNIX epoch
385 ///
386 /// Returns the number of milliseconds since January 1, 1970 00:00:00 UTC.
387 /// Returns 0 if the system time is not available or is before the epoch.
388 pub fn CurrentTimestamp() -> u64 {
389 std::time::SystemTime::now()
390 .duration_since(std::time::UNIX_EPOCH)
391 .unwrap_or_default()
392 .as_millis() as u64
393 }
394
395 /// Get current timestamp in seconds since UNIX epoch
396 pub fn CurrentTimestampSeconds() -> u64 {
397 std::time::SystemTime::now()
398 .duration_since(std::time::UNIX_EPOCH)
399 .unwrap_or_default()
400 .as_secs()
401 }
402
403 /// Convert timestamp millis to ISO 8601 string
404 ///
405 /// # Arguments
406 ///
407 /// * `millis` - Timestamp in milliseconds since UNIX epoch
408 ///
409 /// # Returns
410 ///
411 /// ISO 8601 formatted string or "Invalid timestamp" on error
412 pub fn TimestampToISO8601(Millis:u64) -> String {
413 match std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(Millis)) {
414 Some(Time) => {
415 use std::time::SystemTime;
416
417 match SystemTime::try_from(Time) {
418 Ok(SystemTime) => {
419 let DateTime:chrono::DateTime<chrono::Utc> = SystemTime.into();
420
421 DateTime.to_rfc3339()
422 },
423
424 Err(_) => "Invalid timestamp".to_string(),
425 }
426 },
427
428 None => "Invalid timestamp".to_string(),
429 }
430 }
431
432 /// Validate file path security
433 ///
434 /// Checks for path traversal attempts and invalid characters.
435 /// This is a security measure to prevent directory traversal attacks.
436 ///
437 /// # Arguments
438 ///
439 /// * `path` - The file path to validate
440 ///
441 /// # Errors
442 ///
443 /// Returns an error if the path contains suspicious patterns.
444 // Basic path validation - platform-specific validation can be added as needed
445 pub fn ValidateFilePath(Path:&str) -> Result<()> {
446 // Null check
447 if Path.is_empty() {
448 return Err(AirError::Validation("Path is empty".to_string()));
449 }
450
451 // Length check
452 if Path.len() > 4096 {
453 return Err(AirError::Validation("Path too long (max: 4096 characters)".to_string()));
454 }
455
456 // Path traversal check
457 if Path.contains("..") {
458 return Err(AirError::Validation(
459 "Path contains '..' (potential path traversal)".to_string(),
460 ));
461 }
462
463 // Platform-specific checks
464 if cfg!(windows) {
465
466 // Additional Windows-specific checks could be added here
467 } else if Path.contains('\\') {
468 // On Unix, backslashes are unusual
469 return Err(AirError::Validation("Path contains backslash on Unix".to_string()));
470 }
471
472 // Null character check
473 if Path.contains('\0') {
474 return Err(AirError::Validation("Path contains null character".to_string()));
475 }
476
477 Ok(())
478 }
479
480 /// Validate URL format
481 ///
482 /// Performs basic URL validation to prevent malformed URLs from
483 /// causing issues with network operations.
484 ///
485 /// # Arguments
486 ///
487 /// * `url` - The URL to validate
488 ///
489 /// # Errors
490 ///
491 /// Returns an error if the URL is invalid.
492 // Basic URL validation using std::uri::Uri for RFC 3986 compliance
493 pub fn ValidateUrl(URL:&str) -> Result<()> {
494 // Null check
495 if URL.is_empty() {
496 return Err(AirError::Validation("URL is empty".to_string()));
497 }
498
499 // Length check
500 if URL.len() > 2048 {
501 return Err(AirError::Validation("URL too long (max: 2048 characters)".to_string()));
502 }
503
504 // Basic scheme check
505 if !URL.starts_with("http://") && !URL.starts_with("https://") {
506 return Err(AirError::Validation("URL must start with http:// or https://".to_string()));
507 }
508
509 // Null character check
510 if URL.contains('\0') {
511 return Err(AirError::Validation("URL contains null character".to_string()));
512 }
513
514 // FUTURE: More comprehensive validation using url crate for full RFC 3986
515 // compliance
516 Ok(())
517 }
518
519 /// Validate string length
520 ///
521 /// Defensive utility to validate string length bounds.
522 ///
523 /// # Arguments
524 ///
525 /// * `value` - The string to validate
526 /// * `min_len` - Minimum allowed length (inclusive)
527 /// * `MaxLength` - Maximum allowed length (inclusive)
528 pub fn ValidateStringLength(Value:&str, MinLen:usize, MaxLen:usize) -> Result<()> {
529 if Value.len() < MinLen {
530 return Err(AirError::Validation(format!(
531 "String too short (min: {}, got: {})",
532 MinLen,
533 Value.len()
534 )));
535 }
536
537 if Value.len() > MaxLen {
538 return Err(AirError::Validation(format!(
539 "String too long (max: {}, got: {})",
540 MaxLen,
541 Value.len()
542 )));
543 }
544
545 Ok(())
546 }
547
548 /// Validate port number
549 ///
550 /// Ensures a port number is within the valid range.
551 ///
552 /// # Arguments
553 ///
554 /// * `port` - The port number to validate
555 ///
556 /// # Errors
557 ///
558 /// Returns an error if the port is not in the valid range (1-65535).
559 pub fn ValidatePort(Port:u16) -> Result<()> {
560 if Port == 0 {
561 return Err(AirError::Validation("Port cannot be 0".to_string()));
562 }
563
564 // Port 0 is valid for binding (ephemeral), but not for configuration
565 // Port 1024 and below require root/admin privileges
566 // We allow any port 1-65535 for flexibility
567 Ok(())
568 }
569
570 /// Sanitize a string for logging
571 ///
572 /// Removes or escapes potentially sensitive information from strings
573 /// before logging to prevent information leakage in logs.
574 ///
575 /// # Arguments
576 ///
577 /// * `Value` - The string to sanitize
578 /// * `MaxLength` - Maximum length before truncation
579 ///
580 /// # Returns
581 ///
582 /// Sanitized string safe for logging.
583 pub fn SanitizeForLogging(Value:&str, MaxLength:usize) -> String {
584 // Truncate if too long
585 let Truncated = if Value.len() > MaxLength { &Value[..MaxLength] } else { Value };
586
587 // Remove or escape sensitive patterns
588 let Sanitized = Truncated.replace('\n', " ").replace('\r', " ").replace('\t', " ");
589
590 // If we truncated, add indicator
591 if Value.len() > MaxLength {
592 format!("{}[...]", Sanitized)
593 } else {
594 Sanitized.to_string()
595 }
596 }
597
598 /// Calculate exponential backoff delay
599 ///
600 /// Implements exponential backoff with jitter for retry operations.
601 ///
602 /// # Arguments
603 ///
604 /// * `Attempt` - Current attempt number (0-indexed)
605 /// * `BaseDelayMs` - Base delay in milliseconds
606 /// * `MaxDelayMs` - Maximum delay in milliseconds
607 ///
608 /// # Returns
609 ///
610 /// Calculated delay in milliseconds with jitter applied.
611 pub fn CalculateBackoffDelay(Attempt:u32, BaseDelayMs:u64, MaxDelayMs:u64) -> u64 {
612 // Calculate exponential delay: base * 2^attempt
613 let ExponentialDelay = BaseDelayMs * 2u64.pow(Attempt);
614
615 // Cap at max delay
616 let CappedDelay = ExponentialDelay.min(MaxDelayMs);
617
618 // Add jitter (±25%)
619 use std::time::SystemTime;
620
621 let Seed = SystemTime::now()
622 .duration_since(SystemTime::UNIX_EPOCH)
623 .unwrap_or_default()
624 .subsec_nanos() as u64;
625
626 let JitterRange = (CappedDelay / 4).max(1); // 25% of delay, at least 1ms
627 let Jitter = (Seed % (2 * JitterRange)) as i64 - JitterRange as i64;
628
629 // Apply jitter (ensure non-negative)
630 ((CappedDelay as i64) + Jitter).max(0) as u64
631 }
632
633 /// Format bytes as human-readable size
634 ///
635 /// Converts a byte count to a human-readable format with appropriate units.
636 ///
637 /// # Arguments
638 ///
639 /// * `Bytes` - Number of bytes
640 ///
641 /// # Returns
642 ///
643 /// Formatted string (e.g., "1.5 MB", "256 B")
644 pub fn FormatBytes(Bytes:u64) -> String {
645 const KB:u64 = 1024;
646
647 const MB:u64 = KB * 1024;
648
649 const GB:u64 = MB * 1024;
650
651 const TB:u64 = GB * 1024;
652
653 if Bytes >= TB {
654 format!("{:.2} TB", Bytes as f64 / TB as f64)
655 } else if Bytes >= GB {
656 format!("{:.2} GB", Bytes as f64 / GB as f64)
657 } else if Bytes >= MB {
658 format!("{:.2} MB", Bytes as f64 / MB as f64)
659 } else if Bytes >= KB {
660 format!("{:.2} KB", Bytes as f64 / KB as f64)
661 } else {
662 format!("{} B", Bytes)
663 }
664 }
665
666 /// Parse duration string to milliseconds
667 ///
668 /// Parses duration strings like "100ms", "1s", "1m", "1h" to milliseconds.
669 ///
670 /// # Arguments
671 ///
672 /// * `DurationStr` - Duration string (e.g., "1s", "500ms", "1m30s")
673 ///
674 /// # Errors
675 ///
676 /// Returns an error if the duration string is invalid.
677 ///
678 /// # Support
679 ///
680 /// Supports:
681 /// - ms, s, m, h suffixes
682 /// - Combined durations like "1h30m" or "1m30s"
683 /// - Decimal values like "1.5s"
684 pub fn ParseDurationToMillis(DurationStr:&str) -> Result<u64> {
685 let input = DurationStr.trim().to_lowercase();
686
687 let mut total_millis:u64 = 0;
688
689 let mut pos = 0;
690
691 while pos < input.len() {
692 // Extract the numeric part
693 let start = pos;
694
695 while pos < input.len()
696 && (input.chars().nth(pos).unwrap().is_ascii_digit() || input.chars().nth(pos).unwrap() == '.')
697 {
698 pos += 1;
699 }
700
701 if start == pos {
702 return Err(AirError::Internal(format!(
703 "Invalid duration format: expected number at position {} in '{}'",
704 pos, DurationStr
705 )));
706 }
707
708 let num_str = &input[start..pos];
709
710 let num_value:f64 = num_str.parse().map_err(|_| {
711 AirError::Internal(format!("Invalid number '{}' in duration '{}'", num_str, DurationStr))
712 })?;
713
714 // Extract the unit part
715 let unit_start = pos;
716
717 while pos < input.len()
718 && (match input.chars().nth(pos) {
719 Some(c) => c.is_ascii_alphabetic(),
720 None => false,
721 }) {
722 pos += 1;
723 }
724
725 if unit_start == pos || unit_start >= input.len() {
726 return Err(AirError::Internal(format!(
727 "Invalid duration format: missing unit in '{}'",
728 DurationStr
729 )));
730 }
731
732 let unit = &input[unit_start..pos];
733
734 let multiplier = match unit {
735 "ms" => 1.0,
736
737 "s" => 1000.0,
738
739 "m" => 60_000.0,
740
741 "h" => 3_600_000.0,
742
743 _ => {
744 return Err(AirError::Internal(format!(
745 "Invalid duration unit '{}', expected one of: ms, s, m, h",
746 unit
747 )));
748 },
749 };
750
751 let component_millis = (num_value * multiplier) as u64;
752
753 total_millis = total_millis.saturating_add(component_millis);
754 }
755
756 Ok(total_millis)
757 }
758}