use serde::Serialize;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
pub const DEFAULT: &str = include_str!("templates/contract.json");
const TEMPLATE_FILE: &str = "template.json";
pub fn path(apic_dir: &Path) -> PathBuf {
apic_dir.join(TEMPLATE_FILE)
}
pub fn seed_if_missing(apic_dir: &Path) -> Result<bool, String> {
let path = path(apic_dir);
if path.exists() {
return Ok(false);
}
fs::write(&path, DEFAULT)
.map(|()| true)
.map_err(|err| format!("Failed to write {}: {}", path.display(), err))
}
pub fn resolve_for_create() -> Result<String, String> {
match crate::config::find_apic_dir() {
Some(apic_dir) => resolve_at(&apic_dir),
None => Ok(DEFAULT.to_string()),
}
}
fn resolve_at(apic_dir: &Path) -> Result<String, String> {
let fallback = |reason: String| {
eprintln!("Warning: {reason}; using the built-in template");
DEFAULT.to_string()
};
if let Err(err) = seed_if_missing(apic_dir) {
return Ok(fallback(err));
}
let path = path(apic_dir);
let overlay = match fs::read_to_string(&path) {
Ok(content) => content,
Err(err) => {
return Ok(fallback(format!(
"failed to read {}: {err}",
path.display()
)));
}
};
match merge_onto_default(&overlay) {
Ok(contract) => Ok(contract),
Err(reason) => Err(format!("{} {reason}", path.display())),
}
}
pub enum TemplateCheck {
Absent,
Valid,
Invalid(String),
}
pub fn check_template() -> TemplateCheck {
match crate::config::find_apic_dir() {
Some(apic_dir) => check_at(&apic_dir),
None => TemplateCheck::Absent,
}
}
fn check_at(apic_dir: &Path) -> TemplateCheck {
let path = path(apic_dir);
let overlay = match fs::read_to_string(&path) {
Ok(content) => content,
Err(_) => return TemplateCheck::Absent,
};
match merge_onto_default(&overlay) {
Ok(_) => TemplateCheck::Valid,
Err(reason) => TemplateCheck::Invalid(reason),
}
}
fn merge_onto_default(overlay: &str) -> Result<String, String> {
let mut base: Value = serde_json::from_str(DEFAULT)
.map_err(|err| format!("built-in template is not valid JSON: {err}"))?;
let overlay: Value =
serde_json::from_str(overlay).map_err(|err| format!("is not valid JSON: {err}"))?;
merge(&mut base, overlay);
let contract = render_pretty(&base)?;
crate::json::validate(&contract)
.map_err(|err| format!("merged with the default is not a valid contract: {err}"))?;
Ok(contract)
}
fn merge(base: &mut Value, overlay: Value) {
match (base, overlay) {
(Value::Object(base_map), Value::Object(overlay_map)) => {
for (key, value) in overlay_map {
merge(base_map.entry(key).or_insert(Value::Null), value);
}
}
(base_slot, overlay_value) => *base_slot = overlay_value,
}
}
pub(crate) fn render_pretty(value: &Value) -> Result<String, String> {
let mut buf = Vec::new();
let formatter = serde_json::ser::PrettyFormatter::with_indent(b" ");
let mut serializer = serde_json::Serializer::with_formatter(&mut buf, formatter);
value
.serialize(&mut serializer)
.map_err(|err| format!("failed to render merged template: {err}"))?;
String::from_utf8(buf).map_err(|err| format!("merged template is not valid UTF-8: {err}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_apic(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("apic_tmpl_{tag}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn path_is_template_json_in_apic_dir() {
let dir = std::path::Path::new("/tmp/.apic");
assert_eq!(path(dir), dir.join("template.json"));
}
#[test]
fn seed_if_missing_writes_default_when_absent() {
let dir = temp_apic("seed_absent");
assert!(seed_if_missing(&dir).unwrap());
let written = fs::read_to_string(dir.join("template.json")).unwrap();
assert_eq!(written, DEFAULT);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn seed_if_missing_does_not_overwrite_existing() {
let dir = temp_apic("seed_existing");
let custom = r#"{ "marker": "mine" }"#;
fs::write(dir.join("template.json"), custom).unwrap();
assert!(!seed_if_missing(&dir).unwrap());
let after = fs::read_to_string(dir.join("template.json")).unwrap();
assert_eq!(after, custom);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn merge_keeps_base_keys_omitted_by_overlay() {
let mut base = serde_json::json!({ "name": "default", "method": "POST" });
merge(&mut base, serde_json::json!({ "name": "custom" }));
assert_eq!(
base,
serde_json::json!({ "name": "custom", "method": "POST" })
);
}
#[test]
fn merge_deep_merges_nested_objects() {
let mut base = serde_json::json!({
"url": { "protocol": "https", "host": "old", "path": ["a"] }
});
merge(&mut base, serde_json::json!({ "url": { "host": "new" } }));
assert_eq!(
base,
serde_json::json!({
"url": { "protocol": "https", "host": "new", "path": ["a"] }
})
);
}
#[test]
fn merge_replaces_arrays_wholesale() {
let mut base = serde_json::json!({ "headers": [{ "name": "A" }, { "name": "B" }] });
merge(
&mut base,
serde_json::json!({ "headers": [{ "name": "C" }] }),
);
assert_eq!(base, serde_json::json!({ "headers": [{ "name": "C" }] }));
}
#[test]
fn merge_onto_default_fills_partial_overlay_and_validates() {
let overlay = r#"{ "headers": [ { "name": "X-Custom", "value": "1" } ] }"#;
let contract = merge_onto_default(overlay).unwrap();
assert!(crate::json::validate(&contract).is_ok());
let value: Value = serde_json::from_str(&contract).unwrap();
assert_eq!(value["name"], serde_json::json!("endpoint-name"));
assert_eq!(
value["headers"],
serde_json::json!([{ "name": "X-Custom", "value": "1" }])
);
}
#[test]
fn merge_onto_default_rejects_invalid_json() {
assert!(merge_onto_default("{ not json").is_err());
}
#[test]
fn resolve_at_returns_ok_for_valid_partial_overlay() {
let dir = temp_apic("resolve_valid");
fs::write(
dir.join("template.json"),
r#"{ "headers": [ { "name": "X-Custom", "value": "1" } ] }"#,
)
.unwrap();
let contract = resolve_at(&dir).unwrap();
assert!(crate::json::validate(&contract).is_ok());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn resolve_at_errors_for_malformed_json() {
let dir = temp_apic("resolve_malformed");
fs::write(dir.join("template.json"), "{ not json").unwrap();
assert!(resolve_at(&dir).is_err());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn resolve_at_errors_when_overlay_merges_to_invalid_contract() {
let dir = temp_apic("resolve_invalid_merge");
fs::write(dir.join("template.json"), r#"{ "method": 123 }"#).unwrap();
assert!(resolve_at(&dir).is_err());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn resolve_at_seeds_and_returns_ok_when_template_missing() {
let dir = temp_apic("resolve_seed");
let contract = resolve_at(&dir).unwrap();
assert!(crate::json::validate(&contract).is_ok());
assert!(dir.join("template.json").exists());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn check_at_reports_valid_for_good_overlay() {
let dir = temp_apic("check_valid");
fs::write(dir.join("template.json"), r#"{ "method": "GET" }"#).unwrap();
assert!(matches!(check_at(&dir), TemplateCheck::Valid));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn check_at_reports_invalid_for_malformed_json() {
let dir = temp_apic("check_malformed");
fs::write(dir.join("template.json"), "{ not json").unwrap();
assert!(matches!(check_at(&dir), TemplateCheck::Invalid(_)));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn check_at_reports_invalid_when_merge_yields_invalid_contract() {
let dir = temp_apic("check_invalid_merge");
fs::write(dir.join("template.json"), r#"{ "method": 123 }"#).unwrap();
assert!(matches!(check_at(&dir), TemplateCheck::Invalid(_)));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn check_at_reports_absent_when_template_missing() {
let dir = temp_apic("check_absent");
assert!(matches!(check_at(&dir), TemplateCheck::Absent));
fs::remove_dir_all(&dir).unwrap();
}
}