1use serde::Deserialize;
2use std::env;
3use std::fs;
4use std::num::{NonZeroU16, NonZeroU64};
5use std::path::{Path, PathBuf};
6use thiserror::Error;
7use url::Url;
8
9#[derive(Debug, Clone)]
23pub struct RayConfig {
24 pub enabled: bool,
25 pub host: String,
26 pub port: NonZeroU16,
27
28 pub swallow_errors: bool,
29 pub project_name: Option<String>,
30
31 pub timeout_ms: NonZeroU64,
32 pub canonicalize_paths: bool,
33 pub send_meta: bool,
34 pub always_send_raw_values: bool,
35 pub local_path: Option<String>,
36 pub remote_path: Option<String>,
37}
38
39impl Default for RayConfig {
40 fn default() -> Self {
41 Self {
42 enabled: true,
43 host: "localhost".to_string(),
44 port: NonZeroU16::new(23517).expect("default port is non-zero"),
45
46 swallow_errors: true,
47 project_name: None,
48
49 timeout_ms: NonZeroU64::new(250).expect("default timeout is non-zero"),
50 canonicalize_paths: false,
51 send_meta: true,
52 always_send_raw_values: false,
53 local_path: None,
54 remote_path: None,
55 }
56 }
57}
58
59impl RayConfig {
60 pub fn load() -> Self {
62 let mut cfg = Self::default();
63 cfg.apply_ray_json();
64 cfg.apply_env();
65 cfg
66 }
67
68 pub fn load_validated() -> Result<Self, ConfigError> {
70 Self::load_strict()
71 }
72
73 pub fn load_strict() -> Result<Self, ConfigError> {
75 let mut cfg = Self::default();
76 cfg.apply_ray_json_strict()?;
77 cfg.apply_env_strict()?;
78 cfg.validate()?;
79 Ok(cfg)
80 }
81
82 pub fn validate(&self) -> Result<(), ConfigError> {
84 if self.host.trim().is_empty() {
85 return Err(ConfigError::InvalidHost);
86 }
87
88 if let Some(name) = &self.project_name {
89 if name.trim().is_empty() {
90 return Err(ConfigError::InvalidProjectName);
91 }
92 }
93
94 if let Some(local_path) = &self.local_path {
95 if local_path.trim().is_empty() {
96 return Err(ConfigError::InvalidLocalPath);
97 }
98 }
99
100 if let Some(remote_path) = &self.remote_path {
101 if remote_path.trim().is_empty() {
102 return Err(ConfigError::InvalidRemotePath);
103 }
104 }
105
106 Ok(())
107 }
108
109 pub fn from_env() -> Self {
111 let mut cfg = Self::default();
112 cfg.apply_env();
113 cfg
114 }
115
116 pub fn from_env_strict() -> Result<Self, ConfigError> {
118 let mut cfg = Self::default();
119 cfg.apply_env_strict()?;
120 cfg.validate()?;
121 Ok(cfg)
122 }
123
124 fn apply_ray_json(&mut self) {
125 let Ok(start) = env::current_dir() else {
126 return;
127 };
128
129 let Some(path) = find_ray_json(&start) else {
130 return;
131 };
132
133 let Ok(contents) = fs::read_to_string(path) else {
134 return;
135 };
136
137 let Ok(file_cfg) = serde_json::from_str::<RayJsonConfig>(&contents) else {
138 return;
139 };
140
141 if let Some(enabled) = file_cfg.enabled {
142 self.enabled = enabled;
143 }
144
145 if let Some(host) = file_cfg.host {
146 if !host.trim().is_empty() {
147 self.host = host;
148 }
149 }
150
151 if let Some(port) = file_cfg.port {
152 if let Some(port) = NonZeroU16::new(port) {
153 self.port = port;
154 }
155 }
156
157 if let Some(project_name) = file_cfg.project_name {
158 if !project_name.trim().is_empty() {
159 self.project_name = Some(project_name);
160 }
161 }
162
163 if let Some(swallow_errors) = file_cfg.swallow_errors {
164 self.swallow_errors = swallow_errors;
165 }
166
167 if let Some(strict) = file_cfg.strict {
168 if strict {
169 self.swallow_errors = false;
170 }
171 }
172
173 if let Some(timeout_ms) = file_cfg.timeout_ms {
174 if let Some(timeout_ms) = NonZeroU64::new(timeout_ms) {
175 self.timeout_ms = timeout_ms;
176 }
177 }
178
179 if let Some(canonicalize_paths) = file_cfg.canonicalize_paths {
180 self.canonicalize_paths = canonicalize_paths;
181 }
182
183 if let Some(send_meta) = file_cfg.send_meta {
184 self.send_meta = send_meta;
185 }
186
187 if let Some(always_send_raw_values) = file_cfg.always_send_raw_values {
188 self.always_send_raw_values = always_send_raw_values;
189 }
190
191 if let Some(local_path) = file_cfg.local_path {
192 if !local_path.trim().is_empty() {
193 self.local_path = Some(local_path);
194 }
195 }
196
197 if let Some(remote_path) = file_cfg.remote_path {
198 if !remote_path.trim().is_empty() {
199 self.remote_path = Some(remote_path);
200 }
201 }
202 }
203
204 fn apply_env(&mut self) {
205 if let Ok(v) = env::var("RAY_ENABLED") {
206 if let Some(b) = parse_bool(&v) {
207 self.enabled = b;
208 }
209 }
210
211 if let Ok(v) = env::var("RAY_HOST") {
212 if !v.trim().is_empty() {
213 self.host = v;
214 }
215 }
216
217 if let Ok(v) = env::var("RAY_PORT") {
218 if let Ok(port) = v.parse::<u16>() {
219 if let Some(port) = NonZeroU16::new(port) {
220 self.port = port;
221 }
222 }
223 }
224
225 if let Ok(v) = env::var("RAY_PROJECT_NAME") {
226 if !v.trim().is_empty() {
227 self.project_name = Some(v);
228 }
229 }
230
231 if let Ok(v) = env::var("RAY_CANONICALIZE_PATHS") {
232 if let Some(b) = parse_bool(&v) {
233 self.canonicalize_paths = b;
234 }
235 }
236
237 if let Ok(v) = env::var("RAY_TIMEOUT_MS") {
238 if let Ok(ms) = v.parse::<u64>() {
239 if let Some(ms) = NonZeroU64::new(ms) {
240 self.timeout_ms = ms;
241 }
242 }
243 }
244
245 if let Ok(v) = env::var("RAY_SWALLOW_ERRORS") {
246 if let Some(b) = parse_bool(&v) {
247 self.swallow_errors = b;
248 }
249 }
250
251 if let Ok(v) = env::var("RAY_STRICT") {
252 if let Some(b) = parse_bool(&v) {
253 if b {
254 self.swallow_errors = false;
255 }
256 }
257 }
258
259 if let Ok(v) = env::var("RAY_SEND_META") {
260 if let Some(b) = parse_bool(&v) {
261 self.send_meta = b;
262 }
263 }
264
265 if let Ok(v) = env::var("RAY_ALWAYS_SEND_RAW_VALUES") {
266 if let Some(b) = parse_bool(&v) {
267 self.always_send_raw_values = b;
268 }
269 }
270
271 if let Ok(v) = env::var("RAY_LOCAL_PATH") {
272 if !v.trim().is_empty() {
273 self.local_path = Some(v);
274 }
275 }
276
277 if let Ok(v) = env::var("RAY_REMOTE_PATH") {
278 if !v.trim().is_empty() {
279 self.remote_path = Some(v);
280 }
281 }
282 }
283
284 fn apply_ray_json_strict(&mut self) -> Result<(), ConfigError> {
285 let Ok(start) = env::current_dir() else {
286 return Ok(());
287 };
288
289 let Some(path) = find_ray_json(&start) else {
290 return Ok(());
291 };
292
293 let contents = fs::read_to_string(&path).map_err(|source| ConfigError::RayJsonRead {
294 path: path.clone(),
295 source,
296 })?;
297 let file_cfg = serde_json::from_str::<RayJsonConfig>(&contents).map_err(|source| {
298 ConfigError::InvalidRayJson {
299 path: path.clone(),
300 source,
301 }
302 })?;
303
304 if let Some(enabled) = file_cfg.enabled {
305 self.enabled = enabled;
306 }
307
308 if let Some(host) = file_cfg.host {
309 if host.trim().is_empty() {
310 return Err(ConfigError::InvalidHost);
311 }
312 self.host = host;
313 }
314
315 if let Some(port) = file_cfg.port {
316 let port = NonZeroU16::new(port).ok_or(ConfigError::InvalidPort { value: port })?;
317 self.port = port;
318 }
319
320 if let Some(project_name) = file_cfg.project_name {
321 if project_name.trim().is_empty() {
322 return Err(ConfigError::InvalidProjectName);
323 }
324 self.project_name = Some(project_name);
325 }
326
327 if let Some(swallow_errors) = file_cfg.swallow_errors {
328 self.swallow_errors = swallow_errors;
329 }
330
331 if let Some(strict) = file_cfg.strict {
332 if strict {
333 self.swallow_errors = false;
334 }
335 }
336
337 if let Some(timeout_ms) = file_cfg.timeout_ms {
338 let timeout_ms = NonZeroU64::new(timeout_ms)
339 .ok_or(ConfigError::InvalidTimeout { value: timeout_ms })?;
340 self.timeout_ms = timeout_ms;
341 }
342
343 if let Some(canonicalize_paths) = file_cfg.canonicalize_paths {
344 self.canonicalize_paths = canonicalize_paths;
345 }
346
347 if let Some(send_meta) = file_cfg.send_meta {
348 self.send_meta = send_meta;
349 }
350
351 if let Some(always_send_raw_values) = file_cfg.always_send_raw_values {
352 self.always_send_raw_values = always_send_raw_values;
353 }
354
355 if let Some(local_path) = file_cfg.local_path {
356 if local_path.trim().is_empty() {
357 return Err(ConfigError::InvalidLocalPath);
358 }
359 self.local_path = Some(local_path);
360 }
361
362 if let Some(remote_path) = file_cfg.remote_path {
363 if remote_path.trim().is_empty() {
364 return Err(ConfigError::InvalidRemotePath);
365 }
366 self.remote_path = Some(remote_path);
367 }
368
369 Ok(())
370 }
371
372 fn apply_env_strict(&mut self) -> Result<(), ConfigError> {
373 if let Ok(v) = env::var("RAY_ENABLED") {
374 self.enabled = parse_bool_strict("RAY_ENABLED", &v)?;
375 }
376
377 if let Ok(v) = env::var("RAY_HOST") {
378 if v.trim().is_empty() {
379 return Err(ConfigError::InvalidEnv {
380 var: "RAY_HOST",
381 value: v,
382 message: "must not be empty".to_string(),
383 });
384 }
385 self.host = v;
386 }
387
388 if let Ok(v) = env::var("RAY_PORT") {
389 self.port = parse_u16_strict("RAY_PORT", &v)?;
390 }
391
392 if let Ok(v) = env::var("RAY_PROJECT_NAME") {
393 if v.trim().is_empty() {
394 return Err(ConfigError::InvalidEnv {
395 var: "RAY_PROJECT_NAME",
396 value: v,
397 message: "must not be empty".to_string(),
398 });
399 }
400 self.project_name = Some(v);
401 }
402
403 if let Ok(v) = env::var("RAY_CANONICALIZE_PATHS") {
404 self.canonicalize_paths = parse_bool_strict("RAY_CANONICALIZE_PATHS", &v)?;
405 }
406
407 if let Ok(v) = env::var("RAY_TIMEOUT_MS") {
408 self.timeout_ms = parse_u64_strict("RAY_TIMEOUT_MS", &v)?;
409 }
410
411 if let Ok(v) = env::var("RAY_SWALLOW_ERRORS") {
412 self.swallow_errors = parse_bool_strict("RAY_SWALLOW_ERRORS", &v)?;
413 }
414
415 if let Ok(v) = env::var("RAY_STRICT") {
416 if parse_bool_strict("RAY_STRICT", &v)? {
417 self.swallow_errors = false;
418 }
419 }
420
421 if let Ok(v) = env::var("RAY_SEND_META") {
422 self.send_meta = parse_bool_strict("RAY_SEND_META", &v)?;
423 }
424
425 if let Ok(v) = env::var("RAY_ALWAYS_SEND_RAW_VALUES") {
426 self.always_send_raw_values = parse_bool_strict("RAY_ALWAYS_SEND_RAW_VALUES", &v)?;
427 }
428
429 if let Ok(v) = env::var("RAY_LOCAL_PATH") {
430 if v.trim().is_empty() {
431 return Err(ConfigError::InvalidEnv {
432 var: "RAY_LOCAL_PATH",
433 value: v,
434 message: "must not be empty".to_string(),
435 });
436 }
437 self.local_path = Some(v);
438 }
439
440 if let Ok(v) = env::var("RAY_REMOTE_PATH") {
441 if v.trim().is_empty() {
442 return Err(ConfigError::InvalidEnv {
443 var: "RAY_REMOTE_PATH",
444 value: v,
445 message: "must not be empty".to_string(),
446 });
447 }
448 self.remote_path = Some(v);
449 }
450
451 Ok(())
452 }
453
454 pub(crate) fn map_path(&self, path: &str) -> String {
455 let Some(local_path) = self.local_path.as_ref() else {
456 return path.to_string();
457 };
458 let Some(remote_path) = self.remote_path.as_ref() else {
459 return path.to_string();
460 };
461
462 let local_path = local_path.trim();
463 let remote_path = remote_path.trim();
464 if local_path.is_empty() || remote_path.is_empty() {
465 return path.to_string();
466 }
467
468 let local_path = local_path.trim_end_matches(['/', '\\']);
469 let remote_path = remote_path.trim_end_matches(['/', '\\']);
470 if local_path.is_empty() || remote_path.is_empty() {
471 return path.to_string();
472 }
473
474 let path_buf = Path::new(path);
475 let local_buf = Path::new(local_path);
476
477 if let Ok(stripped) = path_buf.strip_prefix(local_buf) {
478 let rel = stripped.to_string_lossy().replace('\\', "/");
479 return join_remote_path(remote_path, &rel);
480 }
481
482 let normalized_path = path.replace('\\', "/");
483 let normalized_local = local_path.replace('\\', "/");
484 if let Some(stripped) = normalized_path.strip_prefix(&normalized_local) {
485 return join_remote_path(remote_path, stripped);
486 }
487
488 path.to_string()
489 }
490
491 pub fn base_url(&self) -> String {
493 let host = self.host.trim_end_matches('/');
494
495 if host.starts_with("http://") || host.starts_with("https://") {
496 if let Ok(mut url) = Url::parse(host) {
497 if url.port().is_none() {
498 let _ = url.set_port(Some(self.port.get()));
499 }
500 return url.to_string().trim_end_matches('/').to_string();
501 }
502
503 return format!("{}:{}", host, self.port.get());
504 }
505
506 let candidate = format!("http://{}", host);
507 if let Ok(url) = Url::parse(&candidate) {
508 if url.port().is_some() {
509 return url.to_string().trim_end_matches('/').to_string();
510 }
511 }
512
513 format!("http://{}:{}", host, self.port.get())
514 }
515
516 pub fn endpoint_url(&self) -> String {
518 format!("{}/", self.base_url().trim_end_matches('/'))
519 }
520}
521
522#[derive(Debug, Error)]
534pub enum ConfigError {
535 #[error("invalid host")]
536 InvalidHost,
537
538 #[error("invalid port: {value}")]
539 InvalidPort { value: u16 },
540
541 #[error("invalid timeout: {value}")]
542 InvalidTimeout { value: u64 },
543
544 #[error("invalid project name")]
545 InvalidProjectName,
546
547 #[error("invalid local path")]
548 InvalidLocalPath,
549
550 #[error("invalid remote path")]
551 InvalidRemotePath,
552
553 #[error("invalid env var {var}={value}: {message}")]
554 InvalidEnv {
555 var: &'static str,
556 value: String,
557 message: String,
558 },
559
560 #[error("invalid ray.json at {path}: {source}")]
561 InvalidRayJson {
562 path: PathBuf,
563 #[source]
564 source: serde_json::Error,
565 },
566
567 #[error("failed to read ray.json at {path}: {source}")]
568 RayJsonRead {
569 path: PathBuf,
570 #[source]
571 source: std::io::Error,
572 },
573}
574
575#[derive(Debug, Clone, Deserialize, Default)]
576struct RayJsonConfig {
577 enabled: Option<bool>,
578 host: Option<String>,
579 port: Option<u16>,
580
581 swallow_errors: Option<bool>,
582 strict: Option<bool>,
583
584 project_name: Option<String>,
585 timeout_ms: Option<u64>,
586 canonicalize_paths: Option<bool>,
587 send_meta: Option<bool>,
588 always_send_raw_values: Option<bool>,
589 local_path: Option<String>,
590 remote_path: Option<String>,
591}
592
593fn parse_bool(v: &str) -> Option<bool> {
594 match v.trim() {
595 "1" | "true" | "TRUE" | "yes" | "YES" => Some(true),
596 "0" | "false" | "FALSE" | "no" | "NO" => Some(false),
597 _ => None,
598 }
599}
600
601fn parse_bool_strict(var: &'static str, value: &str) -> Result<bool, ConfigError> {
602 parse_bool(value).ok_or_else(|| ConfigError::InvalidEnv {
603 var,
604 value: value.to_string(),
605 message: "expected true/false/yes/no/1/0".to_string(),
606 })
607}
608
609fn parse_u16_strict(var: &'static str, value: &str) -> Result<NonZeroU16, ConfigError> {
610 let port = value.parse::<u16>().map_err(|_| ConfigError::InvalidEnv {
611 var,
612 value: value.to_string(),
613 message: "must be a number between 1 and 65535".to_string(),
614 })?;
615 NonZeroU16::new(port).ok_or_else(|| ConfigError::InvalidEnv {
616 var,
617 value: value.to_string(),
618 message: "must be non-zero".to_string(),
619 })
620}
621
622fn parse_u64_strict(var: &'static str, value: &str) -> Result<NonZeroU64, ConfigError> {
623 let ms = value.parse::<u64>().map_err(|_| ConfigError::InvalidEnv {
624 var,
625 value: value.to_string(),
626 message: "must be a non-zero integer".to_string(),
627 })?;
628 NonZeroU64::new(ms).ok_or_else(|| ConfigError::InvalidEnv {
629 var,
630 value: value.to_string(),
631 message: "must be non-zero".to_string(),
632 })
633}
634
635fn find_ray_json(start: &Path) -> Option<PathBuf> {
636 let mut dir = Some(start);
637
638 while let Some(d) = dir {
639 let candidate = d.join("ray.json");
640 if candidate.is_file() {
641 return Some(candidate);
642 }
643
644 dir = d.parent();
645 }
646
647 None
648}
649
650fn join_remote_path(remote: &str, relative: &str) -> String {
651 let remote = remote.trim_end_matches(['/', '\\']);
652 let relative = relative.trim_start_matches(['/', '\\']);
653
654 if relative.is_empty() {
655 return remote.to_string();
656 }
657
658 format!("{}/{}", remote, relative)
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664 use std::num::NonZeroU16;
665
666 fn config_with_host(host: &str, port: u16) -> RayConfig {
667 RayConfig {
668 host: host.to_string(),
669 port: NonZeroU16::new(port).expect("port must be non-zero"),
670 ..RayConfig::default()
671 }
672 }
673
674 #[test]
675 fn base_url_respects_scheme_and_existing_port() {
676 let config = config_with_host("http://localhost:3000", 23517);
677 assert_eq!(config.base_url(), "http://localhost:3000");
678 }
679
680 #[test]
681 fn base_url_adds_port_when_missing() {
682 let config = config_with_host("https://example.com", 1234);
683 assert_eq!(config.base_url(), "https://example.com:1234");
684 }
685
686 #[test]
687 fn base_url_accepts_host_with_port_without_scheme() {
688 let config = config_with_host("localhost:4321", 23517);
689 assert_eq!(config.base_url(), "http://localhost:4321");
690 }
691
692 #[test]
693 fn map_path_replaces_local_prefix_with_remote() {
694 let config = RayConfig {
695 local_path: Some("/app".to_string()),
696 remote_path: Some("/var/www".to_string()),
697 ..RayConfig::default()
698 };
699
700 assert_eq!(config.map_path("/app/src/main.rs"), "/var/www/src/main.rs");
701 }
702
703 #[test]
704 fn map_path_trims_slashes() {
705 let config = RayConfig {
706 local_path: Some("/app/".to_string()),
707 remote_path: Some("/var/www/".to_string()),
708 ..RayConfig::default()
709 };
710
711 assert_eq!(config.map_path("/app/src/lib.rs"), "/var/www/src/lib.rs");
712 }
713
714 #[test]
715 fn map_path_ignores_unmatched_paths() {
716 let config = RayConfig {
717 local_path: Some("/app".to_string()),
718 remote_path: Some("/var/www".to_string()),
719 ..RayConfig::default()
720 };
721
722 assert_eq!(config.map_path("/other/path.rs"), "/other/path.rs");
723 }
724}