use std::fs;
use std::path::Path;
use std::sync::LazyLock;
use chrono::Utc;
use regex::Regex;
use tracing::{debug, warn};
use crate::output::errors::WriteError;
use crate::output::types::{Verifier, WriteResult};
use crate::output::verifiers::{run_verifier_chain, YAMLVerifier};
use crate::serializers::annotations_to_value;
use crate::types::ScannedModule;
pub struct YAMLWriter;
impl YAMLWriter {
pub fn write(
&self,
modules: &[ScannedModule],
output_dir: &str,
dry_run: bool,
verify: bool,
verifiers: Option<&[&dyn Verifier]>,
) -> Result<Vec<WriteResult>, WriteError> {
if modules.is_empty() {
return Ok(vec![]);
}
if !dry_run {
fs::create_dir_all(output_dir)
.map_err(|e| WriteError::new(output_dir.into(), e.to_string()))?;
}
let output_path = Path::new(output_dir)
.canonicalize()
.unwrap_or_else(|_| Path::new(output_dir).to_path_buf());
let mut results: Vec<WriteResult> = Vec::new();
let timestamp = Utc::now().to_rfc3339();
for module in modules {
let binding_data = build_binding(module);
if dry_run {
results.push(WriteResult::new(module.module_id.clone()));
continue;
}
let safe_id = sanitize_filename(&module.module_id);
let filename = format!("{safe_id}.binding.yaml");
let file_path = output_path.join(&filename);
if !file_path.starts_with(&output_path) {
warn!(
"Skipping file outside output directory: {}",
file_path.display()
);
continue;
}
if file_path.exists() {
warn!("Overwriting existing file: {}", file_path.display());
}
let header = format!(
"# Auto-generated by apcore-toolkit scanner\n\
# Generated: {timestamp}\n\
# Do not edit manually unless you intend to customize schemas.\n\n"
);
let yaml_content = serde_yaml::to_string(&binding_data)
.map_err(|e| WriteError::new(file_path.display().to_string(), e.to_string()))?;
fs::write(&file_path, format!("{header}{yaml_content}"))
.map_err(|e| WriteError::new(file_path.display().to_string(), e.to_string()))?;
debug!("Written: {}", file_path.display());
let mut result =
WriteResult::with_path(module.module_id.clone(), file_path.display().to_string());
if verify {
result = verify_yaml(&result, &file_path);
}
if result.verified {
if let Some(vs) = verifiers {
let chain_result =
run_verifier_chain(vs, &file_path.display().to_string(), &module.module_id);
if !chain_result.ok {
result = WriteResult::failed(
result.module_id,
result.path,
chain_result.error.unwrap_or_default(),
);
}
}
}
results.push(result);
}
Ok(results)
}
}
static UNSAFE_CHARS_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[^a-zA-Z0-9._-]").expect("static regex"));
static CONSECUTIVE_DOTS_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\.{2,}").expect("static regex"));
fn sanitize_filename(module_id: &str) -> String {
let safe = UNSAFE_CHARS_RE.replace_all(module_id, "_");
CONSECUTIVE_DOTS_RE.replace_all(&safe, "_").to_string()
}
fn build_binding(module: &ScannedModule) -> serde_json::Value {
serde_json::json!({
"bindings": [{
"module_id": module.module_id,
"target": module.target,
"description": module.description,
"documentation": module.documentation,
"tags": module.tags,
"version": module.version,
"annotations": annotations_to_value(module.annotations.as_ref()),
"examples": serde_json::to_value(&module.examples).unwrap_or(serde_json::json!([])),
"metadata": module.metadata,
"input_schema": module.input_schema,
"output_schema": module.output_schema,
}]
})
}
fn verify_yaml(result: &WriteResult, file_path: &Path) -> WriteResult {
let vr = YAMLVerifier.verify(&file_path.display().to_string(), &result.module_id);
if vr.ok {
result.clone()
} else {
WriteResult::failed(
result.module_id.clone(),
result.path.clone(),
vr.error.unwrap_or_default(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
fn sample_module() -> ScannedModule {
ScannedModule::new(
"users.get_user".into(),
"Get a user".into(),
json!({"type": "object", "properties": {"user_id": {"type": "integer"}}}),
json!({"type": "object"}),
vec!["users".into()],
"myapp.views:get_user".into(),
)
}
#[test]
fn test_sanitize_filename_basic() {
assert_eq!(sanitize_filename("users.get_user"), "users.get_user");
}
#[test]
fn test_sanitize_filename_special_chars() {
assert_eq!(sanitize_filename("a/b\\c d"), "a_b_c_d");
}
#[test]
fn test_sanitize_filename_path_traversal() {
let result = sanitize_filename("../../etc/passwd");
assert!(!result.contains(".."));
}
#[test]
fn test_write_empty_modules() {
let writer = YAMLWriter;
let result = writer.write(&[], "/tmp/test", false, false, None).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_write_dry_run() {
let writer = YAMLWriter;
let modules = vec![sample_module()];
let result = writer
.write(&modules, "/tmp/nonexistent", true, false, None)
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].module_id, "users.get_user");
assert!(result[0].path.is_none());
}
#[test]
fn test_write_creates_file() {
let dir = TempDir::new().unwrap();
let writer = YAMLWriter;
let modules = vec![sample_module()];
let result = writer
.write(&modules, dir.path().to_str().unwrap(), false, false, None)
.unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].path.is_some());
let file_path = result[0].path.as_ref().unwrap();
assert!(Path::new(file_path).exists());
let content = fs::read_to_string(file_path).unwrap();
assert!(content.contains("Auto-generated"));
assert!(content.contains("users.get_user"));
}
#[test]
fn test_write_with_verify() {
let dir = TempDir::new().unwrap();
let writer = YAMLWriter;
let modules = vec![sample_module()];
let result = writer
.write(&modules, dir.path().to_str().unwrap(), false, true, None)
.unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].verified);
}
#[test]
fn test_write_multiple_modules() {
let dir = TempDir::new().unwrap();
let writer = YAMLWriter;
let modules = vec![
ScannedModule::new(
"mod_a".into(),
"Module A".into(),
json!({"type": "object"}),
json!({"type": "object"}),
vec![],
"app:a".into(),
),
ScannedModule::new(
"mod_b".into(),
"Module B".into(),
json!({"type": "object"}),
json!({"type": "object"}),
vec![],
"app:b".into(),
),
ScannedModule::new(
"mod_c".into(),
"Module C".into(),
json!({"type": "object"}),
json!({"type": "object"}),
vec![],
"app:c".into(),
),
];
let results = writer
.write(&modules, dir.path().to_str().unwrap(), false, false, None)
.unwrap();
assert_eq!(results.len(), 3);
for result in &results {
let path = result.path.as_ref().expect("path should be set");
assert!(Path::new(path).exists(), "file should exist: {path}");
}
}
#[test]
fn test_binding_contains_all_fields() {
let dir = TempDir::new().unwrap();
let writer = YAMLWriter;
let mut module = sample_module();
module.documentation = Some("Full docs here".into());
module.version = "2.0.0".into();
let modules = vec![module];
let results = writer
.write(&modules, dir.path().to_str().unwrap(), false, false, None)
.unwrap();
let file_path = results[0].path.as_ref().unwrap();
let content = fs::read_to_string(file_path).unwrap();
for field in &[
"module_id",
"target",
"description",
"documentation",
"tags",
"version",
"annotations",
"examples",
"metadata",
"input_schema",
"output_schema",
] {
assert!(
content.contains(field),
"YAML should contain field '{field}'"
);
}
assert!(content.contains("users.get_user"));
assert!(content.contains("Full docs here"));
assert!(content.contains("2.0.0"));
}
#[test]
fn test_creates_nested_output_dir() {
let dir = TempDir::new().unwrap();
let nested = dir.path().join("a").join("b").join("c");
let writer = YAMLWriter;
let modules = vec![sample_module()];
assert!(!nested.exists());
let results = writer
.write(&modules, nested.to_str().unwrap(), false, false, None)
.unwrap();
assert_eq!(results.len(), 1);
assert!(nested.exists(), "nested directory should have been created");
let file_path = results[0].path.as_ref().unwrap();
assert!(Path::new(file_path).exists());
}
#[test]
fn test_filename_sanitization_dots() {
let result = sanitize_filename("foo..bar");
assert!(
!result.contains(".."),
"consecutive dots should be collapsed: got '{result}'"
);
let result2 = sanitize_filename("a...b....c");
assert!(
!result2.contains(".."),
"consecutive dots should be collapsed: got '{result2}'"
);
}
#[test]
fn test_none_annotations_in_binding() {
let dir = TempDir::new().unwrap();
let writer = YAMLWriter;
let mut module = sample_module();
module.annotations = None;
let modules = vec![module];
let results = writer
.write(&modules, dir.path().to_str().unwrap(), false, false, None)
.unwrap();
let file_path = results[0].path.as_ref().unwrap();
let content = fs::read_to_string(file_path).unwrap();
let parsed: serde_yaml::Value = serde_yaml::from_str(&content).unwrap();
let bindings = parsed["bindings"].as_sequence().unwrap();
assert_eq!(bindings.len(), 1);
assert!(bindings[0].get("annotations").is_some());
}
#[test]
fn test_overwrite_existing_file() {
let dir = TempDir::new().unwrap();
let writer = YAMLWriter;
let module_v1 = ScannedModule::new(
"overwrite_test".into(),
"Version 1".into(),
json!({"type": "object"}),
json!({"type": "object"}),
vec![],
"app:v1".into(),
);
let results_v1 = writer
.write(
&[module_v1],
dir.path().to_str().unwrap(),
false,
false,
None,
)
.unwrap();
let file_path = results_v1[0].path.as_ref().unwrap();
let content_v1 = fs::read_to_string(file_path).unwrap();
assert!(content_v1.contains("Version 1"));
let module_v2 = ScannedModule::new(
"overwrite_test".into(),
"Version 2".into(),
json!({"type": "object"}),
json!({"type": "object"}),
vec![],
"app:v2".into(),
);
let results_v2 = writer
.write(
&[module_v2],
dir.path().to_str().unwrap(),
false,
false,
None,
)
.unwrap();
let file_path_v2 = results_v2[0].path.as_ref().unwrap();
let content_v2 = fs::read_to_string(file_path_v2).unwrap();
assert!(content_v2.contains("Version 2"));
assert!(!content_v2.contains("Version 1"));
}
}