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