Skip to main content

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}