use crate::error::{ForgeError, ForgeResult};
use crate::types::{Include, ParsedModel, ResolvedInclude};
use serde_yaml_ng::Value;
use std::collections::HashSet;
use std::path::Path;
use super::model::parse_v1_model;
pub fn resolve_includes<S: std::hash::BuildHasher>(
model: &mut ParsedModel,
base_path: &Path,
visited: &mut HashSet<std::path::PathBuf, S>,
) -> ForgeResult<()> {
let base_dir = base_path.parent().unwrap_or_else(|| Path::new("."));
let canonical = base_path
.canonicalize()
.unwrap_or_else(|_| base_path.to_path_buf());
if visited.contains(&canonical) {
return Err(ForgeError::Parse(format!(
"Circular dependency detected: {} is already included",
base_path.display()
)));
}
visited.insert(canonical);
for include in model.includes.clone() {
let include_path = base_dir.join(&include.file);
if !include_path.exists() {
return Err(ForgeError::Parse(format!(
"Included file not found: {} (referenced as '{}')",
include_path.display(),
include.file
)));
}
let content = std::fs::read_to_string(&include_path)?;
let yaml: Value = serde_yaml_ng::from_str(&content)?;
let mut included_model = parse_v1_model(&yaml)?;
if !included_model.includes.is_empty() {
resolve_includes(&mut included_model, &include_path, visited)?;
}
let resolved = ResolvedInclude {
include: include.clone(),
resolved_path: include_path.canonicalize().unwrap_or(include_path),
model: included_model,
};
model
.resolved_includes
.insert(include.namespace.clone(), resolved);
}
Ok(())
}
pub fn parse_includes(includes_seq: &[Value], model: &mut ParsedModel) -> ForgeResult<()> {
for include_val in includes_seq {
if let Value::Mapping(include_map) = include_val {
let file = include_map
.get("file")
.and_then(|v| v.as_str())
.ok_or_else(|| ForgeError::Parse("Include must have a 'file' field".to_string()))?
.to_string();
let namespace = include_map
.get("as")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ForgeError::Parse(format!(
"Include '{file}' must have an 'as' field for the namespace"
))
})?
.to_string();
model.add_include(Include::new(file, namespace));
} else {
return Err(ForgeError::Parse(
"Each include must be a mapping with 'file' and 'as' fields".to_string(),
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::{NamedTempFile, TempDir};
#[test]
fn test_parse_includes_section() {
let temp_dir = TempDir::new().unwrap();
let included_path = temp_dir.path().join("external.yaml");
std::fs::write(
&included_path,
r#"
_forge_version: "5.0.0"
ext_data:
values: [10, 20, 30]
"#,
)
.unwrap();
let main_content = r#"
_forge_version: "5.0.0"
_includes:
- file: "external.yaml"
as: "ext"
main_data:
values: [1, 2, 3]
"#
.to_string();
let main_path = temp_dir.path().join("main.yaml");
std::fs::write(&main_path, main_content).unwrap();
let content = std::fs::read_to_string(&main_path).unwrap();
let yaml: Value = serde_yaml_ng::from_str(&content).unwrap();
let mut model = parse_v1_model(&yaml).unwrap();
resolve_includes(&mut model, &main_path, &mut HashSet::new()).unwrap();
assert!(model.tables.contains_key("main_data"));
assert!(model.resolved_includes.contains_key("ext"));
}
#[test]
fn test_parse_includes_missing_file() {
let yaml_content = r#"
_forge_version: "5.0.0"
_includes:
- file: "nonexistent.yaml"
as: "ext"
data:
values: [1, 2, 3]
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(yaml_content.as_bytes()).unwrap();
let content = std::fs::read_to_string(temp_file.path()).unwrap();
let yaml: Value = serde_yaml_ng::from_str(&content).unwrap();
let mut model = parse_v1_model(&yaml).unwrap();
let result = resolve_includes(&mut model, temp_file.path(), &mut HashSet::new());
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("not found") || err_msg.contains("nonexistent"));
}
#[test]
fn test_parse_includes_missing_as_field() {
let yaml_content = r#"
_forge_version: "5.0.0"
_includes:
- file: "external.yaml"
data:
values: [1, 2, 3]
"#;
let yaml: Value = serde_yaml_ng::from_str(yaml_content).unwrap();
if let Some(Value::Sequence(includes_seq)) = yaml.get("_includes") {
let mut model = ParsedModel::new();
let result = parse_includes(includes_seq, &mut model);
assert!(result.is_err());
}
}
#[test]
fn test_parse_includes_invalid_format() {
let yaml_content = r#"
_forge_version: "5.0.0"
_includes:
- "just a string, not a mapping"
data:
values: [1, 2, 3]
"#;
let yaml: Value = serde_yaml_ng::from_str(yaml_content).unwrap();
if let Some(Value::Sequence(includes_seq)) = yaml.get("_includes") {
let mut model = ParsedModel::new();
let result = parse_includes(includes_seq, &mut model);
assert!(result.is_err());
}
}
}