phi_core/config/
parser.rs1use super::builder::ConfigError;
4use super::schema::AgentConfig;
5use std::path::Path;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ConfigFormat {
10 Toml,
11 Json,
12 Yaml,
13}
14
15pub 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
31pub fn parse_config_auto(input: &str) -> Result<AgentConfig, ConfigError> {
35 if let Ok(config) = parse_config(input, ConfigFormat::Toml) {
37 return Ok(config);
38 }
39 if let Ok(config) = parse_config(input, ConfigFormat::Json) {
41 return Ok(config);
42 }
43 parse_config(input, ConfigFormat::Yaml)
45}
46
47pub 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
70fn 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(); 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 result.push('$');
92 result.push('{');
93 result.push_str(&var_name);
94 } else if var_name.is_empty() {
95 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}