use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::domain::profile::{DocsRoot, ProfileId};
pub const DECLARATION_PATH: &str = "instance/projection.toml";
#[derive(Debug, Error, PartialEq, Eq)]
pub enum DeclarationError {
#[error("{DECLARATION_PATH} does not parse: {0}")]
Malformed(String),
#[error("{DECLARATION_PATH} is inconsistent: {0}")]
Inconsistent(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Projection {
pub source: String,
pub destination: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProfileDeclaration {
pub id: ProfileId,
pub docs_root: DocsRoot,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SentinelDeclaration {
pub rule: String,
pub source: String,
pub destination: String,
pub declares: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Declaration {
#[serde(default)]
pub canon_templates: Vec<String>,
pub profiles: Vec<ProfileDeclaration>,
#[serde(default)]
pub managed: Vec<Projection>,
#[serde(default)]
pub adopted: Vec<Projection>,
#[serde(default)]
pub sentinels: Vec<SentinelDeclaration>,
}
impl Declaration {
pub fn parse(bytes: &[u8]) -> Result<Self, DeclarationError> {
let text = std::str::from_utf8(bytes)
.map_err(|source| DeclarationError::Malformed(source.to_string()))?;
let held: Self = toml::from_str(text)
.map_err(|source| DeclarationError::Malformed(source.to_string()))?;
held.consistent()?;
Ok(held)
}
fn consistent(&self) -> Result<(), DeclarationError> {
if self.profiles.is_empty() {
return Err(DeclarationError::Inconsistent(
"no profile is declared".to_string(),
));
}
for entry in &self.managed {
if entry.destination.contains('{') {
return Err(DeclarationError::Inconsistent(format!(
"the managed destination {} is templated, and only an adopted destination may be",
entry.destination
)));
}
}
let mut seen: Vec<&str> = Vec::new();
for entry in self.managed.iter().chain(&self.adopted) {
if seen.contains(&entry.destination.as_str()) {
return Err(DeclarationError::Inconsistent(format!(
"{} is projected twice",
entry.destination
)));
}
seen.push(&entry.destination);
}
Ok(())
}
#[must_use]
pub fn profile(&self, id: ProfileId) -> Option<crate::domain::profile::Profile<'_>> {
Some(crate::domain::profile::Profile {
id,
docs_root: self.docs_root(id)?,
managed: &self.managed,
adopted: &self.adopted,
})
}
#[must_use]
pub fn docs_root(&self, id: ProfileId) -> Option<DocsRoot> {
self.profiles
.iter()
.find(|profile| profile.id == id)
.map(|profile| profile.docs_root)
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
reason = "a test panics as its failure signal, not as control flow"
)]
use super::*;
const MINIMAL: &str = r#"
[[profiles]]
id = "codebase"
docs_root = "docs"
"#;
#[test]
fn a_minimal_declaration_parses() {
let held = Declaration::parse(MINIMAL.as_bytes()).unwrap();
assert_eq!(held.docs_root(ProfileId::Codebase), Some(DocsRoot::Docs));
assert_eq!(held.docs_root(ProfileId::KnowledgeBase), None);
}
#[test]
fn a_declaration_with_no_profile_refuses() {
assert!(matches!(
Declaration::parse(b"").unwrap_err(),
DeclarationError::Malformed(_)
));
assert!(matches!(
Declaration::parse(b"profiles = []\n").unwrap_err(),
DeclarationError::Inconsistent(_)
));
}
#[test]
fn a_templated_managed_destination_is_inconsistent() {
let text =
format!("{MINIMAL}\n[[managed]]\nsource = \"a\"\ndestination = \"{{docs_root}}/a\"\n");
let error = Declaration::parse(text.as_bytes()).unwrap_err();
assert!(error.to_string().contains("is templated"), "{error}");
}
#[test]
fn one_destination_projected_twice_is_inconsistent() {
let text = format!(
"{MINIMAL}\n[[managed]]\nsource = \"a\"\ndestination = \"x\"\n[[adopted]]\nsource = \"b\"\ndestination = \"x\"\n"
);
let error = Declaration::parse(text.as_bytes()).unwrap_err();
assert!(error.to_string().contains("projected twice"), "{error}");
}
#[test]
fn an_unknown_field_refuses_rather_than_being_ignored() {
let text = format!("{MINIMAL}\nsomething_new = 1\n");
assert!(matches!(
Declaration::parse(text.as_bytes()).unwrap_err(),
DeclarationError::Malformed(_)
));
}
}