1use crate::cache::CacheConfig;
5use crate::config::{
6 ConnectionConfig, LogFormat, LogLevel, load_layered_config, normalize_grpc_endpoint,
7};
8use anyhow::Result;
9use clap::Parser;
10use config::ConfigError;
11use serde::{Deserialize, Serialize};
12use std::path::PathBuf;
13
14#[derive(Parser, Debug, Clone)]
37#[command(author, version, about, long_about = None)]
38pub struct ClientArgs {
39 #[arg(short, long, value_name = "FILE")]
41 pub config: Option<PathBuf>,
42
43 #[arg(short, long, env = crate::envs::MODEL_EXPRESS_ENDPOINT)]
45 pub endpoint: Option<String>,
46
47 #[arg(short, long, env = crate::envs::MODEL_EXPRESS_TIMEOUT)]
49 pub timeout: Option<u64>,
50
51 #[arg(long, env = crate::envs::MODEL_EXPRESS_CACHE_DIRECTORY)]
53 pub cache_path: Option<PathBuf>,
54
55 #[arg(long, env = crate::envs::MODEL_EXPRESS_LOG_LEVEL, value_enum)]
57 pub log_level: Option<LogLevel>,
58
59 #[arg(long, env = crate::envs::MODEL_EXPRESS_LOG_FORMAT, value_enum)]
61 pub log_format: Option<LogFormat>,
62
63 #[arg(long, short = 'q')]
65 pub quiet: bool,
66
67 #[arg(long, env = crate::envs::MODEL_EXPRESS_MAX_RETRIES)]
69 pub max_retries: Option<u32>,
70
71 #[arg(long, env = crate::envs::MODEL_EXPRESS_RETRY_DELAY)]
73 pub retry_delay: Option<u64>,
74
75 #[arg(long, env = crate::envs::MODEL_EXPRESS_NO_SHARED_STORAGE)]
77 pub no_shared_storage: bool,
78
79 #[arg(long, env = crate::envs::MODEL_EXPRESS_TRANSFER_CHUNK_SIZE)]
81 pub transfer_chunk_size: Option<usize>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, Default)]
86pub struct ClientConfig {
87 pub connection: ConnectionConfig,
89 pub cache: CacheConfig,
91 pub logging: LoggingConfig,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, Default)]
97pub struct LoggingConfig {
98 #[serde(default)]
100 pub level: LogLevel,
101 #[serde(default)]
103 pub format: LogFormat,
104 pub quiet: bool,
106}
107
108impl ClientConfig {
109 pub fn load(args: ClientArgs) -> Result<Self, ConfigError> {
122 let mut config = load_layered_config(
124 args.config.clone(),
125 crate::envs::MODEL_EXPRESS_PREFIX,
126 Self::default(),
127 )?;
128
129 if let Some(endpoint) = args.endpoint {
136 config.connection.endpoint = normalize_grpc_endpoint(endpoint);
137 }
138
139 if let Some(timeout) = args.timeout {
140 config.connection.timeout_secs = Some(timeout);
141 }
142
143 if let Some(max_retries) = args.max_retries {
144 config.connection.max_retries = Some(max_retries);
145 }
146
147 if let Some(retry_delay) = args.retry_delay {
148 config.connection.retry_delay_secs = Some(retry_delay);
149 }
150
151 if let Some(cache_path) = args.cache_path {
153 config.cache.local_path = cache_path;
154 }
155
156 if args.no_shared_storage {
157 config.cache.shared_storage = false;
158 }
159
160 if let Some(chunk_size) = args.transfer_chunk_size {
161 config.cache.transfer_chunk_size = chunk_size;
162 }
163
164 if let Some(log_level) = args.log_level {
166 config.logging.level = log_level;
167 }
168
169 if let Some(log_format) = args.log_format {
170 config.logging.format = log_format;
171 }
172
173 if args.quiet {
174 config.logging.quiet = true;
175 }
176
177 config.connection.endpoint =
180 normalize_grpc_endpoint(std::mem::take(&mut config.connection.endpoint));
181 config.cache.server_endpoint =
182 normalize_grpc_endpoint(std::mem::take(&mut config.cache.server_endpoint));
183
184 config.validate()?;
186
187 Ok(config)
188 }
189
190 pub fn validate(&self) -> Result<(), ConfigError> {
192 if self.connection.endpoint.is_empty() {
194 return Err(ConfigError::Message(
195 "Server endpoint cannot be empty".to_string(),
196 ));
197 }
198
199 if let Some(timeout) = self.connection.timeout_secs
201 && timeout == 0
202 {
203 return Err(ConfigError::Message(
204 "Timeout must be greater than 0".to_string(),
205 ));
206 }
207
208 if !self.cache.local_path.exists()
210 && let Err(e) = std::fs::create_dir_all(&self.cache.local_path)
211 {
212 return Err(ConfigError::Message(format!(
213 "Cannot create cache directory {:?}: {}",
214 self.cache.local_path, e
215 )));
216 }
217
218 Ok(())
219 }
220
221 pub fn grpc_endpoint(&self) -> &str {
223 &self.connection.endpoint
224 }
225
226 pub fn timeout_secs(&self) -> Option<u64> {
228 self.connection.timeout_secs
229 }
230
231 pub fn for_testing(endpoint: impl Into<String>) -> Self {
233 Self {
234 connection: ConnectionConfig::new(endpoint),
235 cache: CacheConfig::default(),
236 logging: LoggingConfig::default(),
237 }
238 }
239
240 pub fn with_cache_path(mut self, cache_path: Option<PathBuf>) -> Self {
242 if let Some(path) = cache_path {
243 self.cache.local_path = path;
244 }
245 self
246 }
247
248 pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
250 self.connection.timeout_secs = Some(timeout_secs);
251 self
252 }
253
254 pub fn with_endpoint(mut self, endpoint: String) -> Self {
256 let endpoint = normalize_grpc_endpoint(endpoint);
257 self.connection.endpoint = endpoint.clone();
258 self.cache.server_endpoint = endpoint;
259 self
260 }
261}
262
263#[cfg(test)]
264#[allow(clippy::expect_used)]
265mod tests {
266 use super::*;
267 use crate::constants;
268
269 #[test]
270 fn test_client_config_default() {
271 let config = ClientConfig::default();
272 assert!(config.connection.endpoint.contains("8001"));
273 assert_eq!(config.connection.timeout_secs, Some(30));
274 assert!(!config.logging.quiet);
275 }
276
277 #[test]
278 fn test_client_config_for_testing() {
279 let config = ClientConfig::for_testing("http://test.example.com:1234");
280 assert_eq!(config.connection.endpoint, "http://test.example.com:1234");
281 }
282
283 #[test]
284 fn test_client_config_with_endpoint() {
285 let config =
286 ClientConfig::default().with_endpoint("http://custom.example.com:5678".to_string());
287
288 assert_eq!(config.connection.endpoint, "http://custom.example.com:5678");
289 assert_eq!(
290 config.cache.server_endpoint,
291 "http://custom.example.com:5678"
292 );
293 }
294
295 #[test]
296 fn test_client_config_with_endpoint_accepts_bare_host_port() {
297 let config = ClientConfig::default().with_endpoint("custom.example.com:5678".to_string());
298
299 assert_eq!(config.connection.endpoint, "http://custom.example.com:5678");
300 assert_eq!(
301 config.cache.server_endpoint,
302 "http://custom.example.com:5678"
303 );
304 }
305
306 #[test]
307 fn test_client_config_validation() {
308 let mut config = ClientConfig::default();
309 assert!(config.validate().is_ok());
310
311 config.connection.endpoint = String::new();
312 assert!(config.validate().is_err());
313 }
314
315 #[test]
316 fn test_client_config_backward_compatibility() {
317 let config = ClientConfig::for_testing("http://test.com:8080");
318 assert_eq!(config.grpc_endpoint(), "http://test.com:8080");
319 assert_eq!(config.timeout_secs(), Some(30));
320 }
321
322 #[test]
323 fn test_client_config_shared_storage_defaults() {
324 let config = ClientConfig::default();
325 assert!(config.cache.shared_storage);
326 assert_eq!(
327 config.cache.transfer_chunk_size,
328 constants::DEFAULT_TRANSFER_CHUNK_SIZE
329 );
330 }
331
332 #[test]
333 fn test_client_config_shared_storage_override() {
334 let mut config = ClientConfig::default();
335 config.cache.shared_storage = false;
336 config.cache.transfer_chunk_size = 64 * 1024;
337
338 assert!(!config.cache.shared_storage);
339 assert_eq!(config.cache.transfer_chunk_size, 64 * 1024);
340 }
341
342 #[test]
343 fn test_client_args_parse_defaults() {
344 let args = ClientArgs::try_parse_from(["test"]).expect("Failed to parse empty args");
346
347 assert!(args.endpoint.is_none());
348 assert!(args.timeout.is_none());
349 assert!(args.cache_path.is_none());
350 assert!(!args.quiet);
351 assert!(!args.no_shared_storage);
352 assert!(args.transfer_chunk_size.is_none());
353 }
354
355 #[test]
356 fn test_client_args_parse_cli_flags() {
357 let args = ClientArgs::try_parse_from([
359 "test",
360 "--endpoint",
361 "http://custom:9000",
362 "--timeout",
363 "60",
364 "--quiet",
365 "--no-shared-storage",
366 "--transfer-chunk-size",
367 "1048576",
368 ])
369 .expect("Failed to parse CLI args");
370
371 assert_eq!(args.endpoint, Some("http://custom:9000".to_string()));
372 assert_eq!(args.timeout, Some(60));
373 assert!(args.quiet);
374 assert!(args.no_shared_storage);
375 assert_eq!(args.transfer_chunk_size, Some(1048576));
376 }
377
378 #[test]
379 fn test_client_args_short_flags() {
380 let args =
382 ClientArgs::try_parse_from(["test", "-e", "http://short:8000", "-t", "45", "-q"])
383 .expect("Failed to parse short flags");
384
385 assert_eq!(args.endpoint, Some("http://short:8000".to_string()));
386 assert_eq!(args.timeout, Some(45));
387 assert!(args.quiet);
388 }
389
390 #[test]
391 fn test_client_args_log_level() {
392 let args = ClientArgs::try_parse_from(["test", "--log-level", "debug"])
394 .expect("Failed to parse log level");
395
396 assert_eq!(args.log_level, Some(LogLevel::Debug));
397 }
398
399 #[test]
400 fn test_client_config_load_applies_cli_args() {
401 let args = ClientArgs {
403 config: None,
404 endpoint: Some("cli-override:7777".to_string()),
405 timeout: Some(120),
406 cache_path: None,
407 log_level: None,
408 log_format: None,
409 quiet: true,
410 max_retries: Some(5),
411 retry_delay: Some(10),
412 no_shared_storage: true,
413 transfer_chunk_size: Some(2097152),
414 };
415
416 let config = ClientConfig::load(args).expect("Failed to load config");
417
418 assert_eq!(config.connection.endpoint, "http://cli-override:7777");
419 assert_eq!(config.connection.timeout_secs, Some(120));
420 assert!(config.logging.quiet);
421 assert_eq!(config.connection.max_retries, Some(5));
422 assert_eq!(config.connection.retry_delay_secs, Some(10));
423 assert!(!config.cache.shared_storage);
424 assert_eq!(config.cache.transfer_chunk_size, 2097152);
425 }
426}