use std::path::PathBuf;
use crate::env::ConfigEnv;
use crate::error::{ConfigError, ConfigErrors, SourceErrorKind, SourceLocation};
use crate::source::{ConfigValues, Source};
use crate::sources::line_from_offset;
use crate::value::{ConfigValue, Value};
#[derive(Debug, Clone)]
enum JsonSource {
File(PathBuf),
String { content: String, name: String },
}
#[derive(Debug, Clone)]
pub struct Json {
source: JsonSource,
required: bool,
name: Option<String>,
}
impl Json {
pub fn file(path: impl Into<PathBuf>) -> Self {
Self {
source: JsonSource::File(path.into()),
required: true,
name: None,
}
}
pub fn string(content: impl Into<String>) -> Self {
Self {
source: JsonSource::String {
content: content.into(),
name: "<string>".to_string(),
},
required: true,
name: None,
}
}
pub fn optional(mut self) -> Self {
self.required = false;
self
}
pub fn required(mut self) -> Self {
self.required = true;
self
}
pub fn named(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
fn source_name(&self) -> String {
if let Some(ref name) = self.name {
return name.clone();
}
match &self.source {
JsonSource::File(path) => path.display().to_string(),
JsonSource::String { name, .. } => name.clone(),
}
}
}
impl Source for Json {
fn load(&self, env: &dyn ConfigEnv) -> Result<ConfigValues, ConfigErrors> {
let source_name = self.source_name();
let content = match &self.source {
JsonSource::File(path) => match env.read_file(path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if self.required {
return Err(ConfigErrors::single(ConfigError::SourceError {
source_name,
kind: SourceErrorKind::NotFound {
path: path.display().to_string(),
},
}));
} else {
return Ok(ConfigValues::empty());
}
}
Err(e) => {
return Err(ConfigErrors::single(ConfigError::SourceError {
source_name,
kind: SourceErrorKind::IoError {
message: e.to_string(),
},
}));
}
},
JsonSource::String { content, .. } => content.clone(),
};
parse_json(&content, &source_name)
}
fn name(&self) -> &str {
match &self.name {
Some(name) => name,
None => match &self.source {
JsonSource::File(path) => path.to_str().unwrap_or("<file>"),
JsonSource::String { name, .. } => name,
},
}
}
#[cfg(feature = "watch")]
fn watch_path(&self) -> Option<PathBuf> {
match &self.source {
JsonSource::File(path) => Some(path.clone()),
JsonSource::String { .. } => None,
}
}
#[cfg(feature = "watch")]
fn clone_box(&self) -> Box<dyn Source> {
Box::new(self.clone())
}
}
fn parse_json(content: &str, source_name: &str) -> Result<ConfigValues, ConfigErrors> {
let document: serde_json::Value =
serde_json::from_str(content).map_err(|e: serde_json::Error| {
ConfigErrors::single(ConfigError::SourceError {
source_name: source_name.to_string(),
kind: SourceErrorKind::ParseError {
message: e.to_string(),
line: Some(e.line() as u32),
column: Some(e.column() as u32),
},
})
})?;
let mut values = ConfigValues::empty();
flatten_value_with_lines(&document, "", source_name, content, &mut values);
Ok(values)
}
fn flatten_value_with_lines(
value: &serde_json::Value,
prefix: &str,
source_name: &str,
content: &str,
values: &mut ConfigValues,
) {
match value {
serde_json::Value::Object(obj) => {
for (key, val) in obj {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{}.{}", prefix, key)
};
flatten_value_with_lines(val, &path, source_name, content, values);
}
}
serde_json::Value::Array(arr) => {
for (i, val) in arr.iter().enumerate() {
let path = format!("{}[{}]", prefix, i);
flatten_value_with_lines(val, &path, source_name, content, values);
}
let line = find_key_line(content, prefix);
let mut loc = SourceLocation::new(source_name);
if let Some(l) = line {
loc = loc.with_line(l);
}
values.insert(
format!("{}.__len", prefix),
ConfigValue::new(Value::Integer(arr.len() as i64), loc),
);
}
_ => {
let line = find_key_line(content, prefix);
let mut loc = SourceLocation::new(source_name);
if let Some(l) = line {
loc = loc.with_line(l);
}
values.insert(
prefix.to_string(),
ConfigValue::new(json_to_value(value), loc),
);
}
}
}
fn find_key_line(content: &str, path: &str) -> Option<u32> {
let key = path.split('.').next_back().unwrap_or(path);
let key = key.split('[').next().unwrap_or(key);
let pattern = format!("\"{}\"", key);
content
.find(&pattern)
.map(|offset| line_from_offset(content, offset))
}
fn json_to_value(json: &serde_json::Value) -> Value {
match json {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::Integer(i)
} else if let Some(f) = n.as_f64() {
Value::Float(f)
} else {
Value::String(n.to_string())
}
}
serde_json::Value::String(s) => Value::String(s.clone()),
serde_json::Value::Array(arr) => Value::Array(arr.iter().map(json_to_value).collect()),
serde_json::Value::Object(obj) => Value::Table(
obj.iter()
.map(|(k, v)| (k.clone(), json_to_value(v)))
.collect(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::env::MockEnv;
#[test]
fn test_json_file_load() {
let env = MockEnv::new().with_file("config.json", r#"{"host": "localhost", "port": 8080}"#);
let source = Json::file("config.json");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("host").map(|v| v.value.as_str()),
Some(Some("localhost"))
);
assert_eq!(
values.get("port").map(|v| v.value.as_integer()),
Some(Some(8080))
);
}
#[test]
fn test_json_file_missing_required() {
let env = MockEnv::new();
let source = Json::file("missing.json");
let result = source.load(&env);
assert!(result.is_err());
let errors = result.unwrap_err();
match errors.first() {
ConfigError::SourceError { kind, .. } => {
assert!(matches!(kind, SourceErrorKind::NotFound { .. }));
}
_ => panic!("Expected SourceError"),
}
}
#[test]
fn test_json_file_missing_optional() {
let env = MockEnv::new();
let source = Json::file("missing.json").optional();
let values = source.load(&env).expect("should succeed with empty values");
assert!(values.is_empty());
}
#[test]
fn test_json_file_permission_denied() {
let env = MockEnv::new().with_unreadable_file("secret.json");
let source = Json::file("secret.json");
let result = source.load(&env);
assert!(result.is_err());
let errors = result.unwrap_err();
match errors.first() {
ConfigError::SourceError { kind, .. } => {
assert!(matches!(kind, SourceErrorKind::IoError { .. }));
}
_ => panic!("Expected SourceError"),
}
}
#[test]
fn test_json_string_load() {
let env = MockEnv::new();
let source = Json::string(r#"{"host": "localhost", "port": 8080}"#);
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("host").map(|v| v.value.as_str()),
Some(Some("localhost"))
);
assert_eq!(
values.get("port").map(|v| v.value.as_integer()),
Some(Some(8080))
);
}
#[test]
fn test_json_nested_objects() {
let env = MockEnv::new().with_file(
"config.json",
r#"{
"database": {
"host": "localhost",
"port": 5432,
"pool": {
"min_size": 5,
"max_size": 20
}
}
}"#,
);
let source = Json::file("config.json");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("database.host").map(|v| v.value.as_str()),
Some(Some("localhost"))
);
assert_eq!(
values.get("database.port").map(|v| v.value.as_integer()),
Some(Some(5432))
);
assert_eq!(
values
.get("database.pool.min_size")
.map(|v| v.value.as_integer()),
Some(Some(5))
);
assert_eq!(
values
.get("database.pool.max_size")
.map(|v| v.value.as_integer()),
Some(Some(20))
);
}
#[test]
fn test_json_arrays() {
let env =
MockEnv::new().with_file("config.json", r#"{"hosts": ["host1", "host2", "host3"]}"#);
let source = Json::file("config.json");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("hosts[0]").map(|v| v.value.as_str()),
Some(Some("host1"))
);
assert_eq!(
values.get("hosts[1]").map(|v| v.value.as_str()),
Some(Some("host2"))
);
assert_eq!(
values.get("hosts[2]").map(|v| v.value.as_str()),
Some(Some("host3"))
);
assert_eq!(
values.get("hosts.__len").map(|v| v.value.as_integer()),
Some(Some(3))
);
}
#[test]
fn test_json_all_value_types() {
let env = MockEnv::new().with_file(
"config.json",
r#"{
"string_val": "hello",
"int_val": 42,
"float_val": 2.72,
"bool_val": true,
"null_val": null
}"#,
);
let source = Json::file("config.json");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("string_val").map(|v| v.value.as_str()),
Some(Some("hello"))
);
assert_eq!(
values.get("int_val").map(|v| v.value.as_integer()),
Some(Some(42))
);
assert_eq!(
values.get("float_val").map(|v| v.value.as_float()),
Some(Some(2.72))
);
assert_eq!(
values.get("bool_val").map(|v| v.value.as_bool()),
Some(Some(true))
);
assert!(values.get("null_val").is_some());
assert!(values.get("null_val").unwrap().value.is_null());
}
#[test]
fn test_json_parse_error_with_location() {
let env = MockEnv::new().with_file("config.json", r#"{"host": "localhost", "port": }"#);
let source = Json::file("config.json");
let result = source.load(&env);
assert!(result.is_err());
let errors = result.unwrap_err();
match errors.first() {
ConfigError::SourceError { kind, .. } => {
assert!(matches!(
kind,
SourceErrorKind::ParseError {
line: Some(_),
column: Some(_),
..
}
));
}
_ => panic!("Expected SourceError"),
}
}
#[test]
fn test_json_custom_name() {
let env = MockEnv::new().with_file("config.json", r#"{"host": "localhost"}"#);
let source = Json::file("config.json").named("production config");
assert_eq!(source.name(), "production config");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("host").unwrap().source.source,
"production config"
);
}
#[test]
fn test_json_required_method() {
let source = Json::file("config.json").optional().required();
let env = MockEnv::new();
let result = source.load(&env);
assert!(result.is_err());
}
#[test]
fn test_json_source_location_tracking() {
let env = MockEnv::new().with_file("config.json", r#"{"host": "localhost"}"#);
let source = Json::file("config.json");
let values = source.load(&env).expect("should load successfully");
let host_value = values.get("host").expect("host should exist");
assert_eq!(host_value.source.source, "config.json");
}
#[test]
fn test_json_array_of_objects() {
let env = MockEnv::new().with_file(
"config.json",
r#"{
"servers": [
{"name": "server1", "port": 8080},
{"name": "server2", "port": 8081}
]
}"#,
);
let source = Json::file("config.json");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("servers[0].name").map(|v| v.value.as_str()),
Some(Some("server1"))
);
assert_eq!(
values.get("servers[0].port").map(|v| v.value.as_integer()),
Some(Some(8080))
);
assert_eq!(
values.get("servers[1].name").map(|v| v.value.as_str()),
Some(Some("server2"))
);
assert_eq!(
values.get("servers[1].port").map(|v| v.value.as_integer()),
Some(Some(8081))
);
assert_eq!(
values.get("servers.__len").map(|v| v.value.as_integer()),
Some(Some(2))
);
}
#[test]
fn test_json_large_integer() {
let env = MockEnv::new().with_file("config.json", r#"{"large_int": 9007199254740992}"#);
let source = Json::file("config.json");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("large_int").map(|v| v.value.as_integer()),
Some(Some(9007199254740992))
);
}
#[test]
fn test_json_negative_numbers() {
let env = MockEnv::new().with_file(
"config.json",
r#"{"negative_int": -42, "negative_float": -2.75}"#,
);
let source = Json::file("config.json");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("negative_int").map(|v| v.value.as_integer()),
Some(Some(-42))
);
assert_eq!(
values.get("negative_float").map(|v| v.value.as_float()),
Some(Some(-2.75))
);
}
#[test]
fn test_json_line_number_tracking() {
let env = MockEnv::new().with_file(
"config.json",
"{\n \"host\": \"localhost\",\n \"port\": 8080,\n \"debug\": true\n}",
);
let source = Json::file("config.json");
let values = source.load(&env).expect("should load successfully");
let host_value = values.get("host").expect("host should exist");
assert_eq!(host_value.source.source, "config.json");
assert_eq!(host_value.source.line, Some(2));
let port_value = values.get("port").expect("port should exist");
assert_eq!(port_value.source.line, Some(3));
let debug_value = values.get("debug").expect("debug should exist");
assert_eq!(debug_value.source.line, Some(4));
}
#[test]
fn test_json_nested_object_line_tracking() {
let env = MockEnv::new().with_file(
"config.json",
"{\n \"database\": {\n \"host\": \"localhost\",\n \"port\": 5432\n }\n}",
);
let source = Json::file("config.json");
let values = source.load(&env).expect("should load successfully");
let host_value = values.get("database.host").expect("host should exist");
assert_eq!(host_value.source.line, Some(3));
let port_value = values.get("database.port").expect("port should exist");
assert_eq!(port_value.source.line, Some(4));
}
}