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