Skip to main content

phi_core/config/
parser.rs

1//! Multi-format config parser with environment variable substitution.
2
3use super::builder::ConfigError;
4use super::schema::AgentConfig;
5use std::path::Path;
6
7/// Supported config file formats.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ConfigFormat {
10    Toml,
11    Json,
12    Yaml,
13}
14
15/// Parse a config string in the specified format.
16pub fn parse_config(input: &str, format: ConfigFormat) -> Result<AgentConfig, ConfigError> {
17    let substituted = substitute_env_vars(input)?;
18    match format {
19        ConfigFormat::Toml => {
20            toml::from_str(&substituted).map_err(|e| ConfigError::Parse(e.to_string()))
21        }
22        ConfigFormat::Json => {
23            serde_json::from_str(&substituted).map_err(|e| ConfigError::Parse(e.to_string()))
24        }
25        ConfigFormat::Yaml => {
26            serde_yaml::from_str(&substituted).map_err(|e| ConfigError::Parse(e.to_string()))
27        }
28    }
29}
30
31/// Parse a config string, auto-detecting the format.
32///
33/// Tries TOML first, then JSON, then YAML. Returns the first successful parse.
34pub fn parse_config_auto(input: &str) -> Result<AgentConfig, ConfigError> {
35    // Try TOML first (most likely for phi-core configs)
36    if let Ok(config) = parse_config(input, ConfigFormat::Toml) {
37        return Ok(config);
38    }
39    // Try JSON
40    if let Ok(config) = parse_config(input, ConfigFormat::Json) {
41        return Ok(config);
42    }
43    // Try YAML
44    parse_config(input, ConfigFormat::Yaml)
45}
46
47/// Parse a config file, detecting format from the file extension.
48///
49/// Supported extensions: `.toml`, `.json`, `.yaml`, `.yml`
50pub fn parse_config_file(path: &Path) -> Result<AgentConfig, ConfigError> {
51    let content = std::fs::read_to_string(path).map_err(ConfigError::Io)?;
52    let format = match path.extension().and_then(|e| e.to_str()) {
53        Some("toml") => ConfigFormat::Toml,
54        Some("json") => ConfigFormat::Json,
55        Some("yaml" | "yml") => ConfigFormat::Yaml,
56        Some(ext) => {
57            return Err(ConfigError::Parse(format!(
58                "Unsupported config file extension: .{ext}"
59            )))
60        }
61        None => {
62            return Err(ConfigError::Parse(
63                "Config file has no extension; use .toml, .json, or .yaml".to_string(),
64            ))
65        }
66    };
67    parse_config(&content, format)
68}
69
70/// Substitute `${VAR}` patterns with environment variable values.
71///
72/// Returns `ConfigError::MissingEnvVar` if a referenced variable is not set.
73fn substitute_env_vars(input: &str) -> Result<String, ConfigError> {
74    let mut result = String::with_capacity(input.len());
75    let mut chars = input.chars().peekable();
76
77    while let Some(c) = chars.next() {
78        if c == '$' && chars.peek() == Some(&'{') {
79            chars.next(); // consume '{'
80            let mut var_name = String::new();
81            let mut found_close = false;
82            for ch in chars.by_ref() {
83                if ch == '}' {
84                    found_close = true;
85                    break;
86                }
87                var_name.push(ch);
88            }
89            if !found_close {
90                // Malformed ${...} — pass through literally
91                result.push('$');
92                result.push('{');
93                result.push_str(&var_name);
94            } else if var_name.is_empty() {
95                // ${} — pass through literally
96                result.push_str("${}");
97            } else {
98                let value = std::env::var(&var_name).map_err(|_| ConfigError::MissingEnvVar {
99                    var: var_name.clone(),
100                })?;
101                result.push_str(&value);
102            }
103        } else {
104            result.push(c);
105        }
106    }
107
108    Ok(result)
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn test_env_var_substitution() {
117        std::env::set_var("PHI_TEST_KEY", "test-value-123");
118        let input = "api_key = \"${PHI_TEST_KEY}\"";
119        let result = substitute_env_vars(input).unwrap();
120        assert_eq!(result, "api_key = \"test-value-123\"");
121        std::env::remove_var("PHI_TEST_KEY");
122    }
123
124    #[test]
125    fn test_missing_env_var() {
126        let input = "key = \"${DEFINITELY_NOT_SET_PHI_TEST}\"";
127        let result = substitute_env_vars(input);
128        assert!(matches!(result, Err(ConfigError::MissingEnvVar { .. })));
129    }
130
131    #[test]
132    fn test_no_substitution_needed() {
133        let input = "key = \"plain value\"";
134        let result = substitute_env_vars(input).unwrap();
135        assert_eq!(result, input);
136    }
137
138    #[test]
139    fn test_malformed_env_var() {
140        let input = "key = \"${UNCLOSED";
141        let result = substitute_env_vars(input).unwrap();
142        assert_eq!(result, "key = \"${UNCLOSED");
143    }
144}