ray/
config.rs

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/// Runtime configuration for the Ray client.
10///
11/// Use `load` to combine defaults, `ray.json`, and environment overrides, or
12/// `load_strict`/`load_validated` to surface invalid `ray.json` or env values.
13/// Build a config directly when you want deterministic settings.
14///
15/// ```rust
16/// use ray::RayConfig;
17///
18/// let mut config = RayConfig::default();
19/// config.enabled = false;
20/// config.validate().unwrap();
21/// ```
22#[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    /// Load configuration from defaults, `ray.json`, and environment variables.
61    pub fn load() -> Self {
62        let mut cfg = Self::default();
63        cfg.apply_ray_json();
64        cfg.apply_env();
65        cfg
66    }
67
68    /// Alias for `load_strict()`.
69    pub fn load_validated() -> Result<Self, ConfigError> {
70        Self::load_strict()
71    }
72
73    /// Load configuration and return errors for invalid `ray.json` or env values.
74    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    /// Validate the current configuration and return a `ConfigError` on failure.
83    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    /// Load configuration only from environment variables.
110    pub fn from_env() -> Self {
111        let mut cfg = Self::default();
112        cfg.apply_env();
113        cfg
114    }
115
116    /// Load configuration only from env and return errors for invalid values.
117    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    /// Return the base Ray URL derived from the configuration.
492    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    /// Return the Ray endpoint URL for sending payloads.
517    pub fn endpoint_url(&self) -> String {
518        format!("{}/", self.base_url().trim_end_matches('/'))
519    }
520}
521
522/// Errors returned by `RayConfig::validate`.
523///
524/// ```rust
525/// use ray::{ConfigError, RayConfig};
526///
527/// let config = RayConfig {
528///     host: "".to_string(),
529///     ..Default::default()
530/// };
531/// assert!(matches!(config.validate(), Err(ConfigError::InvalidHost)));
532/// ```
533#[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}