Skip to main content

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;
166pub mod Authentication;
167pub mod CLI;
168pub mod Configuration;
169pub mod Daemon;
170pub mod DevLog;
171pub mod Downloader;
172pub mod HealthCheck;
173pub mod HTTP;
174pub mod Indexing;
175pub mod Logging;
176pub mod Metrics;
177pub mod Mountain;
178pub mod Plugins;
179pub mod Resilience;
180pub mod Security;
181pub mod Tracing;
182pub mod Updates;
183pub mod Vine;
184
185/// Air Daemon version information
186///
187/// This is automatically populated from Cargo.toml at build time
188pub const VERSION:&str = env!("CARGO_PKG_VERSION");
189
190/// Default configuration file name
191///
192/// The daemon searches for this configuration file in:
193/// 1. The path specified via --config flag
194/// 2. ~/.config/Air/Air.toml
195/// 3. /etc/Air/Air.toml
196/// 4. Working directory (Air.toml)
197pub const DefaultConfigFile:&str = "Air.toml";
198
199/// Default gRPC bind address for the Vine server
200///
201/// Note: Port 50053 is used for Air to avoid conflict with Cocoon (port 50052)
202///
203/// Addresses in order of preference:
204/// - `--bind` flag value (if provided)
205/// - DefaultBindAddress constant: `[::1]`:50053
206///
207/// FUTURE: Add support for:
208/// - IPv4-only binding (0.0.0.0:50053)
209/// - IPv6-only binding (`[::]`:50053)
210/// - Wildcard binding for all interfaces
211pub const DefaultBindAddress:&str = "[::1]:50053";
212
213/// Protocol version for Mountain-Air communication
214///
215/// This version is sent in all gRPC messages and checked by clients
216/// to ensure compatibility. Increment this value when breaking
217/// protocol changes are made.
218///
219/// Version history:
220/// - 1: Initial Vine protocol
221pub const ProtocolVersion:u32 = 1;
222
223/// Error type for Air operations
224///
225/// Comprehensive error types for all Air operations with descriptive messages.
226/// All error variants include context to help with debugging and error
227/// recovery.
228// Error handling using thiserror for automatic derive
229#[derive(Debug, thiserror::Error, Clone)]
230pub enum AirError {
231	#[error("Configuration error: {0}")]
232	Configuration(String),
233
234	#[error("Authentication error: {0}")]
235	Authentication(String),
236
237	#[error("Network error: {0}")]
238	Network(String),
239
240	#[error("File system error: {0}")]
241	FileSystem(String),
242
243	#[error("gRPC error: {0}")]
244	gRPC(String),
245
246	#[error("Serialization error: {0}")]
247	Serialization(String),
248
249	#[error("Internal error: {0}")]
250	Internal(String),
251
252	#[error("Resource limit exceeded: {0}")]
253	ResourceLimit(String),
254
255	#[error("Service unavailable: {0}")]
256	ServiceUnavailable(String),
257
258	#[error("Validation error: {0}")]
259	Validation(String),
260
261	#[error("Timeout error: {0}")]
262	Timeout(String),
263
264	#[error("Plugin error: {0}")]
265	Plugin(String),
266
267	#[error("Hot-reload error: {0}")]
268	HotReload(String),
269
270	#[error("Connection error: {0}")]
271	Connection(String),
272
273	#[error("Rate limit exceeded: {0}")]
274	RateLimit(String),
275
276	#[error("Circuit breaker open: {0}")]
277	CircuitBreaker(String),
278}
279
280impl From<config::ConfigError> for AirError {
281	fn from(err:config::ConfigError) -> Self { AirError::Configuration(err.to_string()) }
282}
283
284impl From<reqwest::Error> for AirError {
285	fn from(err:reqwest::Error) -> Self { AirError::Network(err.to_string()) }
286}
287
288impl From<std::io::Error> for AirError {
289	fn from(err:std::io::Error) -> Self { AirError::FileSystem(err.to_string()) }
290}
291
292impl From<tonic::transport::Error> for AirError {
293	fn from(err:tonic::transport::Error) -> Self { AirError::gRPC(err.to_string()) }
294}
295
296impl From<serde_json::Error> for AirError {
297	fn from(err:serde_json::Error) -> Self { AirError::Serialization(err.to_string()) }
298}
299
300impl From<toml::de::Error> for AirError {
301	fn from(err:toml::de::Error) -> Self { AirError::Serialization(err.to_string()) }
302}
303
304impl From<uuid::Error> for AirError {
305	fn from(err:uuid::Error) -> Self { AirError::Internal(format!("UUID error: {}", err)) }
306}
307
308impl From<tokio::task::JoinError> for AirError {
309	fn from(err:tokio::task::JoinError) -> Self { AirError::Internal(format!("Task join error: {}", err)) }
310}
311
312impl From<&str> for AirError {
313	fn from(err:&str) -> Self { AirError::Internal(err.to_string()) }
314}
315
316impl From<String> for AirError {
317	fn from(err:String) -> Self { AirError::Internal(err) }
318}
319
320impl From<(crate::HealthCheck::HealthStatus, Option<String>)> for AirError {
321	fn from((status, message):(crate::HealthCheck::HealthStatus, Option<String>)) -> Self {
322		let msg = message.unwrap_or_else(|| format!("Health check failed: {:?}", status));
323		AirError::ServiceUnavailable(msg)
324	}
325}
326
327/// Result type for Air operations
328///
329/// Convenience type alias for Result<T, AirError>
330pub type Result<T> = std::result::Result<T, AirError>;
331
332/// Common utility functions
333///
334/// These utilities provide defensive helper functions used throughout
335/// the Air library for validation, ID generation, timestamp handling,
336/// and common operations with proper error handling.
337pub mod Utility {
338	use super::*;
339
340	/// Generate a unique request ID
341	///
342	/// Creates a UUID v4 for tracing and correlation of requests.
343	/// The ID is guaranteed to be unique (with extremely high probability).
344	// Using UUID v4 for request ID generation (can be replaced with ULID if
345	// sortable IDs needed)
346	pub fn GenerateRequestId() -> String { uuid::Uuid::new_v4().to_string() }
347
348	/// Generate a unique request ID with a prefix
349	///
350	/// Format: `{prefix}-{uuid}`
351	///
352	/// # Arguments
353	///
354	/// * `prefix` - Prefix to add before the UUID (e.g., "auth", "download")
355	///
356	/// # Example
357	///
358	/// ```
359	/// let id = GenerateRequestIdWithPrefix("auth");
360	/// // Returns: "auth-550e8400-e29b-41d4-a716-446655440000"
361	/// ```
362	pub fn GenerateRequestIdWithPrefix(Prefix:&str) -> String { format!("{}-{}", Prefix, uuid::Uuid::new_v4()) }
363
364	/// Get current timestamp in milliseconds since UNIX epoch
365	///
366	/// Returns the number of milliseconds since January 1, 1970 00:00:00 UTC.
367	/// Returns 0 if the system time is not available or is before the epoch.
368	pub fn CurrentTimestamp() -> u64 {
369		std::time::SystemTime::now()
370			.duration_since(std::time::UNIX_EPOCH)
371			.unwrap_or_default()
372			.as_millis() as u64
373	}
374
375	/// Get current timestamp in seconds since UNIX epoch
376	pub fn CurrentTimestampSeconds() -> u64 {
377		std::time::SystemTime::now()
378			.duration_since(std::time::UNIX_EPOCH)
379			.unwrap_or_default()
380			.as_secs()
381	}
382
383	/// Convert timestamp millis to ISO 8601 string
384	///
385	/// # Arguments
386	///
387	/// * `millis` - Timestamp in milliseconds since UNIX epoch
388	///
389	/// # Returns
390	///
391	/// ISO 8601 formatted string or "Invalid timestamp" on error
392	pub fn TimestampToISO8601(Millis:u64) -> String {
393		match std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(Millis)) {
394			Some(Time) => {
395				use std::time::SystemTime;
396				match SystemTime::try_from(Time) {
397					Ok(SystemTime) => {
398						let DateTime:chrono::DateTime<chrono::Utc> = SystemTime.into();
399						DateTime.to_rfc3339()
400					},
401					Err(_) => "Invalid timestamp".to_string(),
402				}
403			},
404			None => "Invalid timestamp".to_string(),
405		}
406	}
407
408	/// Validate file path security
409	///
410	/// Checks for path traversal attempts and invalid characters.
411	/// This is a security measure to prevent directory traversal attacks.
412	///
413	/// # Arguments
414	///
415	/// * `path` - The file path to validate
416	///
417	/// # Errors
418	///
419	/// Returns an error if the path contains suspicious patterns.
420	// Basic path validation - platform-specific validation can be added as needed
421	pub fn ValidateFilePath(Path:&str) -> Result<()> {
422		// Null check
423		if Path.is_empty() {
424			return Err(AirError::Validation("Path is empty".to_string()));
425		}
426
427		// Length check
428		if Path.len() > 4096 {
429			return Err(AirError::Validation("Path too long (max: 4096 characters)".to_string()));
430		}
431
432		// Path traversal check
433		if Path.contains("..") {
434			return Err(AirError::Validation(
435				"Path contains '..' (potential path traversal)".to_string(),
436			));
437		}
438
439		// Platform-specific checks
440		if cfg!(windows) {
441			// Additional Windows-specific checks could be added here
442		} else if Path.contains('\\') {
443			// On Unix, backslashes are unusual
444			return Err(AirError::Validation("Path contains backslash on Unix".to_string()));
445		}
446
447		// Null character check
448		if Path.contains('\0') {
449			return Err(AirError::Validation("Path contains null character".to_string()));
450		}
451
452		Ok(())
453	}
454
455	/// Validate URL format
456	///
457	/// Performs basic URL validation to prevent malformed URLs from
458	/// causing issues with network operations.
459	///
460	/// # Arguments
461	///
462	/// * `url` - The URL to validate
463	///
464	/// # Errors
465	///
466	/// Returns an error if the URL is invalid.
467	// Basic URL validation using std::uri::Uri for RFC 3986 compliance
468	pub fn ValidateUrl(URL:&str) -> Result<()> {
469		// Null check
470		if URL.is_empty() {
471			return Err(AirError::Validation("URL is empty".to_string()));
472		}
473
474		// Length check
475		if URL.len() > 2048 {
476			return Err(AirError::Validation("URL too long (max: 2048 characters)".to_string()));
477		}
478
479		// Basic scheme check
480		if !URL.starts_with("http://") && !URL.starts_with("https://") {
481			return Err(AirError::Validation("URL must start with http:// or https://".to_string()));
482		}
483
484		// Null character check
485		if URL.contains('\0') {
486			return Err(AirError::Validation("URL contains null character".to_string()));
487		}
488
489		// FUTURE: More comprehensive validation using url crate for full RFC 3986
490		// compliance
491		Ok(())
492	}
493
494	/// Validate string length
495	///
496	/// Defensive utility to validate string length bounds.
497	///
498	/// # Arguments
499	///
500	/// * `value` - The string to validate
501	/// * `min_len` - Minimum allowed length (inclusive)
502	/// * `MaxLength` - Maximum allowed length (inclusive)
503	pub fn ValidateStringLength(Value:&str, MinLen:usize, MaxLen:usize) -> Result<()> {
504		if Value.len() < MinLen {
505			return Err(AirError::Validation(format!(
506				"String too short (min: {}, got: {})",
507				MinLen,
508				Value.len()
509			)));
510		}
511
512		if Value.len() > MaxLen {
513			return Err(AirError::Validation(format!(
514				"String too long (max: {}, got: {})",
515				MaxLen,
516				Value.len()
517			)));
518		}
519
520		Ok(())
521	}
522
523	/// Validate port number
524	///
525	/// Ensures a port number is within the valid range.
526	///
527	/// # Arguments
528	///
529	/// * `port` - The port number to validate
530	///
531	/// # Errors
532	///
533	/// Returns an error if the port is not in the valid range (1-65535).
534	pub fn ValidatePort(Port:u16) -> Result<()> {
535		if Port == 0 {
536			return Err(AirError::Validation("Port cannot be 0".to_string()));
537		}
538
539		// Port 0 is valid for binding (ephemeral), but not for configuration
540		// Port 1024 and below require root/admin privileges
541		// We allow any port 1-65535 for flexibility
542		Ok(())
543	}
544
545	/// Sanitize a string for logging
546	///
547	/// Removes or escapes potentially sensitive information from strings
548	/// before logging to prevent information leakage in logs.
549	///
550	/// # Arguments
551	///
552	/// * `Value` - The string to sanitize
553	/// * `MaxLength` - Maximum length before truncation
554	///
555	/// # Returns
556	///
557	/// Sanitized string safe for logging.
558	pub fn SanitizeForLogging(Value:&str, MaxLength:usize) -> String {
559		// Truncate if too long
560		let Truncated = if Value.len() > MaxLength { &Value[..MaxLength] } else { Value };
561
562		// Remove or escape sensitive patterns
563		let Sanitized = Truncated.replace('\n', " ").replace('\r', " ").replace('\t', " ");
564
565		// If we truncated, add indicator
566		if Value.len() > MaxLength {
567			format!("{}[...]", Sanitized)
568		} else {
569			Sanitized.to_string()
570		}
571	}
572
573	/// Calculate exponential backoff delay
574	///
575	/// Implements exponential backoff with jitter for retry operations.
576	///
577	/// # Arguments
578	///
579	/// * `Attempt` - Current attempt number (0-indexed)
580	/// * `BaseDelayMs` - Base delay in milliseconds
581	/// * `MaxDelayMs` - Maximum delay in milliseconds
582	///
583	/// # Returns
584	///
585	/// Calculated delay in milliseconds with jitter applied.
586	pub fn CalculateBackoffDelay(Attempt:u32, BaseDelayMs:u64, MaxDelayMs:u64) -> u64 {
587		// Calculate exponential delay: base * 2^attempt
588		let ExponentialDelay = BaseDelayMs * 2u64.pow(Attempt);
589
590		// Cap at max delay
591		let CappedDelay = ExponentialDelay.min(MaxDelayMs);
592
593		// Add jitter (±25%)
594		use std::time::SystemTime;
595		let Seed = SystemTime::now()
596			.duration_since(SystemTime::UNIX_EPOCH)
597			.unwrap_or_default()
598			.subsec_nanos() as u64;
599
600		let JitterRange = (CappedDelay / 4).max(1); // 25% of delay, at least 1ms
601		let Jitter = (Seed % (2 * JitterRange)) as i64 - JitterRange as i64;
602
603		// Apply jitter (ensure non-negative)
604		((CappedDelay as i64) + Jitter).max(0) as u64
605	}
606
607	/// Format bytes as human-readable size
608	///
609	/// Converts a byte count to a human-readable format with appropriate units.
610	///
611	/// # Arguments
612	///
613	/// * `Bytes` - Number of bytes
614	///
615	/// # Returns
616	///
617	/// Formatted string (e.g., "1.5 MB", "256 B")
618	pub fn FormatBytes(Bytes:u64) -> String {
619		const KB:u64 = 1024;
620		const MB:u64 = KB * 1024;
621		const GB:u64 = MB * 1024;
622		const TB:u64 = GB * 1024;
623
624		if Bytes >= TB {
625			format!("{:.2} TB", Bytes as f64 / TB as f64)
626		} else if Bytes >= GB {
627			format!("{:.2} GB", Bytes as f64 / GB as f64)
628		} else if Bytes >= MB {
629			format!("{:.2} MB", Bytes as f64 / MB as f64)
630		} else if Bytes >= KB {
631			format!("{:.2} KB", Bytes as f64 / KB as f64)
632		} else {
633			format!("{} B", Bytes)
634		}
635	}
636
637	/// Parse duration string to milliseconds
638	///
639	/// Parses duration strings like "100ms", "1s", "1m", "1h" to milliseconds.
640	///
641	/// # Arguments
642	///
643	/// * `DurationStr` - Duration string (e.g., "1s", "500ms", "1m30s")
644	///
645	/// # Errors
646	///
647	/// Returns an error if the duration string is invalid.
648	///
649	/// # Support
650	///
651	/// Supports:
652	/// - ms, s, m, h suffixes
653	/// - Combined durations like "1h30m" or "1m30s"
654	/// - Decimal values like "1.5s"
655	pub fn ParseDurationToMillis(DurationStr:&str) -> Result<u64> {
656		let input = DurationStr.trim().to_lowercase();
657		let mut total_millis:u64 = 0;
658		let mut pos = 0;
659
660		while pos < input.len() {
661			// Extract the numeric part
662			let start = pos;
663			while pos < input.len()
664				&& (input.chars().nth(pos).unwrap().is_ascii_digit() || input.chars().nth(pos).unwrap() == '.')
665			{
666				pos += 1;
667			}
668
669			if start == pos {
670				return Err(AirError::Internal(format!(
671					"Invalid duration format: expected number at position {} in '{}'",
672					pos, DurationStr
673				)));
674			}
675
676			let num_str = &input[start..pos];
677			let num_value:f64 = num_str.parse().map_err(|_| {
678				AirError::Internal(format!("Invalid number '{}' in duration '{}'", num_str, DurationStr))
679			})?;
680
681			// Extract the unit part
682			let unit_start = pos;
683			while pos < input.len()
684				&& (match input.chars().nth(pos) {
685					Some(c) => c.is_ascii_alphabetic(),
686					None => false,
687				}) {
688				pos += 1;
689			}
690
691			if unit_start == pos || unit_start >= input.len() {
692				return Err(AirError::Internal(format!(
693					"Invalid duration format: missing unit in '{}'",
694					DurationStr
695				)));
696			}
697
698			let unit = &input[unit_start..pos];
699			let multiplier = match unit {
700				"ms" => 1.0,
701				"s" => 1000.0,
702				"m" => 60_000.0,
703				"h" => 3_600_000.0,
704				_ => {
705					return Err(AirError::Internal(format!(
706						"Invalid duration unit '{}', expected one of: ms, s, m, h",
707						unit
708					)));
709				},
710			};
711
712			let component_millis = (num_value * multiplier) as u64;
713			total_millis = total_millis.saturating_add(component_millis);
714		}
715
716		Ok(total_millis)
717	}
718}