use std::collections::{BTreeMap, BTreeSet};
use serde_json::Value;
use super::catalog::{self, CATALOG, LabelClass, MetricSpec};
pub const RUNBOOK_URL: &str =
"https://github.com/Litvue/axond/blob/main/docs/operations/observability-runbook.md";
const SEVERITIES: &[&str] = &["critical", "warning"];
const KEYWORDS: &[&str] = &[
"and",
"or",
"unless",
"by",
"without",
"on",
"ignoring",
"group_left",
"group_right",
"bool",
"offset",
"atan2",
];
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AssetError {
#[error("{asset}: {message}")]
Malformed { asset: String, message: String },
#[error(
"{asset}: expression `{expr}` references `{name}`, which is not a Prometheus spelling of any catalogued metric"
)]
UnknownFamily {
asset: String,
expr: String,
name: String,
},
#[error("{asset}: expression `{expr}` references no catalogued metric at all")]
NoMetricReference { asset: String, expr: String },
#[error("{asset}: {source}")]
Catalog {
asset: String,
#[source]
source: catalog::CatalogError,
},
#[error(
"{asset}: variable `{variable}` drills down on `{label}`, which is `{class}` rather than a configured dimension"
)]
UnboundedDrillDown {
asset: String,
variable: String,
label: String,
class: &'static str,
},
#[error(
"{asset}: alert `{alert}` points at runbook anchor `{anchor}`, which the runbook does not define"
)]
UnknownRunbookAnchor {
asset: String,
alert: String,
anchor: String,
},
#[error(
"runbook failure mode `{anchor}` has no alert rule; every documented failure mode owes a signal, an alert, and a first response"
)]
UncoveredFailureMode { anchor: String },
}
impl AssetError {
fn malformed(asset: &str, message: impl Into<String>) -> Self {
Self::Malformed {
asset: asset.to_owned(),
message: message.into(),
}
}
}
pub fn families(spec: &MetricSpec) -> Vec<String> {
let base = spec.name.replace('.', "_");
match spec.kind {
catalog::InstrumentKind::Histogram => ["_bucket", "_sum", "_count"]
.iter()
.map(|suffix| format!("{base}{suffix}"))
.collect(),
_ => vec![base],
}
}
fn family_index() -> BTreeMap<String, &'static MetricSpec> {
let mut index = BTreeMap::new();
for spec in CATALOG {
for family in families(spec) {
index.insert(family, spec);
}
}
index
}
fn class_name(class: LabelClass) -> &'static str {
match class {
LabelClass::Closed => "closed",
LabelClass::Numeric => "numeric",
LabelClass::Route => "route",
LabelClass::Configured => "configured",
}
}
fn canonical_label(spec: &MetricSpec, prometheus_key: &str) -> Option<&'static str> {
spec.labels
.iter()
.find(|label| label.key.replace('.', "_") == prometheus_key)
.map(|label| label.key)
}
#[derive(Debug, PartialEq, Eq)]
struct Selector {
family: String,
matchers: Vec<Matcher>,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct Expression {
selectors: Vec<Selector>,
grouping: Vec<Grouping>,
}
#[derive(Debug, PartialEq, Eq)]
struct Grouping {
label: String,
selectors: Vec<usize>,
}
#[derive(Debug)]
struct Scope {
labels: Vec<String>,
body_depth: usize,
selectors: Vec<usize>,
}
const MODIFIERS: &[&str] = &["by", "without", "on", "ignoring"];
const RETAINING_MODIFIERS: &[&str] = &["by", "on"];
const AGGREGATIONS: &[&str] = &[
"sum",
"min",
"max",
"avg",
"group",
"stddev",
"stdvar",
"count",
"count_values",
"bottomk",
"topk",
"quantile",
"limitk",
"limit_ratio",
];
const IMPLICIT_LABELS: &[&str] = &["le"];
#[derive(Debug, PartialEq, Eq)]
struct Matcher {
key: String,
literal: Option<String>,
}
fn selectors(expr: &str) -> Result<Expression, String> {
let bytes: Vec<char> = expr.chars().collect();
let mut index = 0;
let mut found = Expression::default();
let mut grouping_depth: Option<usize> = None;
let mut retains_labels = false;
let mut reading: Vec<String> = Vec::new();
let mut pending: Vec<String> = Vec::new();
let mut open: Vec<Scope> = Vec::new();
let mut depth = 0usize;
let mut argument_list = false;
while index < bytes.len() {
let character = bytes[index];
match character {
'(' => {
depth += 1;
let body = !std::mem::take(&mut argument_list);
if body && !pending.is_empty() {
open.push(Scope {
labels: std::mem::take(&mut pending),
body_depth: depth,
selectors: Vec::new(),
});
}
index += 1;
}
')' => {
depth = depth.saturating_sub(1);
if grouping_depth == Some(depth + 1) {
grouping_depth = None;
let labels = std::mem::take(&mut reading);
if retains_labels {
pending = labels;
}
}
while open.last().is_some_and(|scope| scope.body_depth > depth) {
close_scope(&mut open, &mut found.grouping);
}
index += 1;
}
'"' | '\'' => {
let quote = character;
index += 1;
while index < bytes.len() && bytes[index] != quote {
index += if bytes[index] == '\\' { 2 } else { 1 };
}
index += 1;
}
'{' => {
let close = bytes[index..]
.iter()
.position(|character| *character == '}')
.ok_or_else(|| format!("unterminated label matcher in `{expr}`"))?;
let body: String = bytes[index + 1..index + close].iter().collect();
let matchers = parse_matchers(&body)?;
match found.selectors.last_mut() {
Some(selector) if selector.matchers.is_empty() => selector.matchers = matchers,
_ => return Err(format!("label matcher with no metric name in `{expr}`")),
}
index += close + 1;
}
character if character.is_ascii_digit() => {
while index < bytes.len()
&& (bytes[index].is_ascii_alphanumeric() || bytes[index] == '.')
{
index += 1;
}
}
character
if character.is_ascii_alphabetic() || character == '_' || character == ':' =>
{
let start = index;
while index < bytes.len()
&& (bytes[index].is_ascii_alphanumeric()
|| bytes[index] == '_'
|| bytes[index] == ':')
{
index += 1;
}
let word: String = bytes[start..index].iter().collect();
if opens_a_call(&word, &bytes[index..]) {
if MODIFIERS.contains(&word.as_str()) {
grouping_depth = Some(depth + 1);
retains_labels = RETAINING_MODIFIERS.contains(&word.as_str());
} else {
argument_list = true;
}
continue;
}
if grouping_depth.is_some() {
reading.push(word);
continue;
}
if KEYWORDS.contains(&word.as_str()) {
continue;
}
let position = found.selectors.len();
found.selectors.push(Selector {
family: word,
matchers: Vec::new(),
});
for scope in &mut open {
scope.selectors.push(position);
}
}
character if character.is_whitespace() || character.is_ascii_punctuation() => {
index += 1;
}
character => {
return Err(format!(
"unexpected character `{character}` in `{expr}`; a metric or label name is ASCII"
));
}
}
}
while !open.is_empty() {
close_scope(&mut open, &mut found.grouping);
}
if !pending.is_empty() {
let every = (0..found.selectors.len()).collect::<Vec<_>>();
for label in pending {
found.grouping.push(Grouping {
label,
selectors: every.clone(),
});
}
}
Ok(found)
}
fn close_scope(open: &mut Vec<Scope>, grouping: &mut Vec<Grouping>) {
let Some(scope) = open.pop() else {
return;
};
for label in scope.labels {
grouping.push(Grouping {
label,
selectors: scope.selectors.clone(),
});
}
}
fn opens_a_call(word: &str, rest: &[char]) -> bool {
let mut index = 0;
while index < rest.len() && rest[index].is_whitespace() {
index += 1;
}
if rest.get(index) == Some(&'(') {
return true;
}
if !AGGREGATIONS.contains(&word) {
return false;
}
let start = index;
while index < rest.len() && (rest[index].is_ascii_alphabetic() || rest[index] == '_') {
index += 1;
}
let following: String = rest[start..index].iter().collect();
MODIFIERS.contains(&following.as_str())
}
fn parse_matchers(body: &str) -> Result<Vec<Matcher>, String> {
let mut matchers = Vec::new();
for part in split_outside_quotes(body) {
let part = part.trim();
if part.is_empty() {
continue;
}
let operator = ["=~", "!~", "!=", "="]
.into_iter()
.find(|operator| part.contains(operator))
.ok_or_else(|| format!("matcher `{part}` has no operator"))?;
let (key, value) = part
.split_once(operator)
.ok_or_else(|| format!("matcher `{part}` has no operator"))?;
let value = value.trim().trim_matches('"');
let literal = (operator == "=" && !value.contains('$')).then(|| value.to_owned());
matchers.push(Matcher {
key: key.trim().to_owned(),
literal,
});
}
Ok(matchers)
}
fn split_outside_quotes(body: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut quoted = false;
for character in body.chars() {
match character {
'"' => {
quoted = !quoted;
current.push(character);
}
',' if !quoted => parts.push(std::mem::take(&mut current)),
_ => current.push(character),
}
}
parts.push(current);
parts
}
pub fn validate_expression(asset: &str, expr: &str) -> Vec<AssetError> {
let index = family_index();
let mut failures = Vec::new();
let found = match selectors(expr) {
Ok(found) => found,
Err(message) => return vec![AssetError::malformed(asset, message)],
};
let mut spelled_like_ours = 0usize;
let mut selected: BTreeMap<usize, &'static MetricSpec> = BTreeMap::new();
for (position, selector) in found.selectors.iter().enumerate() {
if !selector.family.starts_with("axond") {
continue;
}
spelled_like_ours += 1;
let Some(spec) = index.get(&selector.family) else {
failures.push(AssetError::UnknownFamily {
asset: asset.to_owned(),
expr: expr.to_owned(),
name: selector.family.clone(),
});
continue;
};
selected.insert(position, spec);
for matcher in &selector.matchers {
let Some(canonical) = canonical_label(spec, &matcher.key) else {
failures.push(AssetError::Catalog {
asset: asset.to_owned(),
source: catalog::CatalogError::UndeclaredLabel {
metric: spec.name.to_owned(),
key: matcher.key.clone(),
},
});
continue;
};
if let Some(literal) = &matcher.literal
&& let Err(error) = catalog::validate_label_value(spec.name, canonical, literal)
{
failures.push(AssetError::Catalog {
asset: asset.to_owned(),
source: error,
});
}
}
}
for grouping in &found.grouping {
if IMPLICIT_LABELS.contains(&grouping.label.as_str()) {
continue;
}
for position in &grouping.selectors {
let Some(spec) = selected.get(position) else {
continue;
};
if canonical_label(spec, &grouping.label).is_some() {
continue;
}
failures.push(AssetError::Catalog {
asset: asset.to_owned(),
source: catalog::CatalogError::UndeclaredLabel {
metric: spec.name.to_owned(),
key: grouping.label.clone(),
},
});
}
}
if spelled_like_ours == 0 {
failures.push(AssetError::NoMetricReference {
asset: asset.to_owned(),
expr: expr.to_owned(),
});
}
failures
}
pub fn runbook_anchors(runbook: &str) -> BTreeSet<String> {
let mut anchors = BTreeSet::new();
let mut inside = false;
for line in runbook.lines() {
if let Some(heading) = line.strip_prefix("## ") {
inside = heading.trim() == "Failure modes";
continue;
}
if inside && let Some(heading) = line.strip_prefix("### ") {
anchors.insert(slug(heading.trim()));
}
}
anchors
}
fn slug(heading: &str) -> String {
heading
.chars()
.filter_map(|character| match character {
' ' => Some('-'),
character if character.is_alphanumeric() || character == '-' || character == '_' => {
Some(character.to_ascii_lowercase())
}
_ => None,
})
.collect()
}
fn executed_query(variable: &Value) -> Option<String> {
let query = variable.get("query")?;
query
.as_str()
.or_else(|| query.get("query").and_then(Value::as_str))
.map(ToOwned::to_owned)
}
pub fn validate_dashboard(
asset: &str,
source: &str,
anchors: &BTreeSet<String>,
) -> Vec<AssetError> {
let mut failures = Vec::new();
let dashboard: Value = match serde_json::from_str(source) {
Ok(value) => value,
Err(error) => return vec![AssetError::malformed(asset, error.to_string())],
};
for field in ["uid", "title", "schemaVersion", "panels"] {
if dashboard.get(field).is_none() {
failures.push(AssetError::malformed(
asset,
format!("`{field}` is missing"),
));
}
}
let inputs = dashboard
.get("__inputs")
.and_then(Value::as_array)
.map(|inputs| {
inputs.iter().any(|input| {
input.get("name").and_then(Value::as_str) == Some("DS_PROMETHEUS")
&& input.get("pluginId").and_then(Value::as_str) == Some("prometheus")
})
})
.unwrap_or(false);
if !inputs {
failures.push(AssetError::malformed(
asset,
"no `DS_PROMETHEUS` datasource input, so the dashboard is not portable",
));
}
if !source.contains(RUNBOOK_URL) {
failures.push(AssetError::malformed(
asset,
"no link to the observability runbook",
));
}
for variable in dashboard
.get("templating")
.and_then(|templating| templating.get("list"))
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
{
let name = variable
.get("name")
.and_then(Value::as_str)
.unwrap_or("<unnamed>");
let Some(definition) = variable.get("definition").and_then(Value::as_str) else {
failures.push(AssetError::malformed(
asset,
format!("variable `{name}` has no `label_values` definition"),
));
continue;
};
failures.extend(validate_drill_down(asset, name, definition));
let Some(query) = executed_query(variable) else {
failures.push(AssetError::malformed(
asset,
format!("variable `{name}` has no `query` for Grafana to run"),
));
continue;
};
if query != definition {
failures.push(AssetError::malformed(
asset,
format!(
"variable `{name}` runs `{query}` but displays `{definition}`; the query Grafana runs is the one that must stay bounded"
),
));
failures.extend(validate_drill_down(asset, name, &query));
}
}
for panel in panels(&dashboard) {
let title = panel
.get("title")
.and_then(Value::as_str)
.unwrap_or("<untitled>");
if panel.get("type").and_then(Value::as_str) == Some("row") {
continue;
}
let targets = panel
.get("targets")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
if targets.is_empty() {
failures.push(AssetError::malformed(
asset,
format!("panel `{title}` queries nothing"),
));
}
for target in targets {
match target.get("expr").and_then(Value::as_str) {
Some(expr) => {
failures.extend(validate_expression(&format!("{asset}: {title}"), expr));
}
None => failures.push(AssetError::malformed(
asset,
format!("panel `{title}` has a target with no expression"),
)),
}
}
for link in panel
.get("links")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
{
let url = link.get("url").and_then(Value::as_str).unwrap_or_default();
if let Some(anchor) = url.strip_prefix(&format!("{RUNBOOK_URL}#"))
&& !anchors.contains(anchor)
{
failures.push(AssetError::UnknownRunbookAnchor {
asset: asset.to_owned(),
alert: title.to_owned(),
anchor: anchor.to_owned(),
});
}
}
}
failures
}
fn panels(dashboard: &Value) -> Vec<&Value> {
let mut collected = Vec::new();
let mut queue: Vec<&Value> = dashboard
.get("panels")
.and_then(Value::as_array)
.map(|panels| panels.iter().collect())
.unwrap_or_default();
while let Some(panel) = queue.pop() {
collected.push(panel);
if let Some(children) = panel.get("panels").and_then(Value::as_array) {
queue.extend(children.iter());
}
}
collected
}
fn validate_drill_down(asset: &str, variable: &str, definition: &str) -> Vec<AssetError> {
let Some(arguments) = definition
.trim()
.strip_prefix("label_values(")
.and_then(|rest| rest.strip_suffix(')'))
else {
return vec![AssetError::malformed(
asset,
format!("variable `{variable}` is not a `label_values` query: `{definition}`"),
)];
};
let Some((family, label)) = arguments.split_once(',') else {
return vec![AssetError::malformed(
asset,
format!("variable `{variable}` names no label: `{definition}`"),
)];
};
let family = family.trim();
let label = label.trim();
let index = family_index();
let Some(spec) = index.get(family) else {
return vec![AssetError::UnknownFamily {
asset: asset.to_owned(),
expr: definition.to_owned(),
name: family.to_owned(),
}];
};
let Some(canonical) = canonical_label(spec, label) else {
return vec![AssetError::Catalog {
asset: asset.to_owned(),
source: catalog::CatalogError::UndeclaredLabel {
metric: spec.name.to_owned(),
key: label.to_owned(),
},
}];
};
let class = spec
.label(canonical)
.map(|label| label.class)
.unwrap_or(LabelClass::Closed);
if class != LabelClass::Configured {
return vec![AssetError::UnboundedDrillDown {
asset: asset.to_owned(),
variable: variable.to_owned(),
label: canonical.to_owned(),
class: class_name(class),
}];
}
Vec::new()
}
pub fn validate_rules(
asset: &str,
source: &str,
anchors: &BTreeSet<String>,
) -> (Vec<AssetError>, BTreeSet<String>) {
let mut failures = Vec::new();
let mut covered = BTreeSet::new();
let document = match parse_yaml(source) {
Ok(document) => document,
Err(message) => return (vec![AssetError::malformed(asset, message)], covered),
};
let Some(groups) = document.get("groups").and_then(Yaml::as_sequence) else {
return (
vec![AssetError::malformed(asset, "no `groups` sequence")],
covered,
);
};
let mut names = BTreeSet::new();
for group in groups {
let group_name = group.get("name").and_then(Yaml::as_str).unwrap_or_default();
if group_name.is_empty() {
failures.push(AssetError::malformed(asset, "a group has no `name`"));
}
let Some(rules) = group.get("rules").and_then(Yaml::as_sequence) else {
failures.push(AssetError::malformed(
asset,
format!("group `{group_name}` has no `rules`"),
));
continue;
};
for rule in rules {
let Some(alert) = rule.get("alert").and_then(Yaml::as_str) else {
failures.push(AssetError::malformed(
asset,
format!("group `{group_name}` has a rule that is not an alert"),
));
continue;
};
if !names.insert(alert.to_owned()) {
failures.push(AssetError::malformed(
asset,
format!("alert `{alert}` is declared twice"),
));
}
match rule.get("expr").and_then(Yaml::as_str) {
Some(expr) => {
failures.extend(validate_expression(&format!("{asset}: {alert}"), expr));
if let Some(term) = undefaulted_added_term(expr) {
failures.push(AssetError::malformed(
asset,
format!(
"alert `{alert}` adds `{term}`, which is empty until that counter has been incremented at least once; \
an addition with an empty side is empty, so default each term with `or vector(0)`"
),
));
}
}
None => failures.push(AssetError::malformed(
asset,
format!("alert `{alert}` has no `expr`"),
)),
}
if rule.get("for").and_then(Yaml::as_str).is_none() {
failures.push(AssetError::malformed(
asset,
format!("alert `{alert}` has no `for` window, so a single scrape can page"),
));
}
match rule
.get("labels")
.and_then(|labels| labels.get("severity"))
.and_then(Yaml::as_str)
{
Some(severity) if SEVERITIES.contains(&severity) => {}
Some(severity) => failures.push(AssetError::malformed(
asset,
format!("alert `{alert}` declares unknown severity `{severity}`"),
)),
None => failures.push(AssetError::malformed(
asset,
format!("alert `{alert}` declares no severity"),
)),
}
for annotation in ["summary", "description"] {
if rule
.get("annotations")
.and_then(|annotations| annotations.get(annotation))
.and_then(Yaml::as_str)
.is_none_or(str::is_empty)
{
failures.push(AssetError::malformed(
asset,
format!("alert `{alert}` has no `{annotation}` annotation"),
));
}
}
match rule
.get("annotations")
.and_then(|annotations| annotations.get("runbook_url"))
.and_then(Yaml::as_str)
{
Some(url) => match url.strip_prefix(&format!("{RUNBOOK_URL}#")) {
Some(anchor) if anchors.contains(anchor) => {
covered.insert(anchor.to_owned());
}
Some(anchor) => failures.push(AssetError::UnknownRunbookAnchor {
asset: asset.to_owned(),
alert: alert.to_owned(),
anchor: anchor.to_owned(),
}),
None => failures.push(AssetError::malformed(
asset,
format!("alert `{alert}` links outside the runbook: `{url}`"),
)),
},
None => failures.push(AssetError::malformed(
asset,
format!("alert `{alert}` has no `runbook_url`, so it pages without a response"),
)),
}
}
}
(failures, covered)
}
fn undefaulted_added_term(expr: &str) -> Option<String> {
let mut terms = Vec::new();
let mut term = String::new();
let mut depth = 0usize;
let mut quote = None;
for character in expr.chars() {
match character {
_ if quote == Some(character) => quote = None,
_ if quote.is_some() => {}
'"' | '\'' => quote = Some(character),
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth = depth.saturating_sub(1),
'+' if depth == 0 => {
terms.push(std::mem::take(&mut term));
continue;
}
_ => {}
}
term.push(character);
}
if terms.is_empty() {
return None;
}
terms.push(term);
terms
.into_iter()
.map(|term| term.trim().to_owned())
.find(|term| !term.contains("vector(") && term.parse::<f64>().is_err())
}
pub fn uncovered_failure_modes(
anchors: &BTreeSet<String>,
covered: &BTreeSet<String>,
) -> Vec<AssetError> {
anchors
.difference(covered)
.map(|anchor| AssetError::UncoveredFailureMode {
anchor: anchor.clone(),
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Yaml {
Mapping(BTreeMap<String, Yaml>),
Sequence(Vec<Yaml>),
Scalar(String),
}
impl Yaml {
pub fn get(&self, key: &str) -> Option<&Yaml> {
match self {
Self::Mapping(entries) => entries.get(key),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Self::Scalar(value) => Some(value),
_ => None,
}
}
pub fn as_sequence(&self) -> Option<&[Yaml]> {
match self {
Self::Sequence(items) => Some(items),
_ => None,
}
}
}
struct Line {
indent: usize,
item: bool,
content: String,
number: usize,
}
pub fn parse_yaml(source: &str) -> Result<Yaml, String> {
let mut lines = Vec::new();
for (offset, raw) in source.lines().enumerate() {
let number = offset + 1;
if raw.contains('\t') {
return Err(format!("line {number}: tabs are not valid indentation"));
}
let trimmed = raw.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let indent = raw.len() - trimmed.len();
match trimmed.strip_prefix("- ") {
Some(rest) => lines.push(Line {
indent,
item: true,
content: rest.trim().to_owned(),
number,
}),
None => lines.push(Line {
indent,
item: false,
content: trimmed.to_owned(),
number,
}),
}
}
if lines.is_empty() {
return Err("the document is empty".to_owned());
}
let mut cursor = 0;
let value = parse_block(&lines, &mut cursor, lines[0].indent)?;
if cursor != lines.len() {
let line = &lines[cursor];
return Err(format!(
"line {}: unexpected indentation `{}`",
line.number, line.content
));
}
Ok(value)
}
const ITEM_INDENT: usize = 2;
fn parse_block(lines: &[Line], cursor: &mut usize, indent: usize) -> Result<Yaml, String> {
if lines[*cursor].item {
let mut items = Vec::new();
while *cursor < lines.len() && lines[*cursor].item && lines[*cursor].indent == indent {
*cursor += 1;
let mut entries = BTreeMap::new();
parse_entry(lines, cursor, indent + ITEM_INDENT, &mut entries, true)?;
parse_mapping_entries(lines, cursor, indent + ITEM_INDENT, &mut entries)?;
items.push(Yaml::Mapping(entries));
}
return Ok(Yaml::Sequence(items));
}
let mut entries = BTreeMap::new();
parse_mapping_entries(lines, cursor, indent, &mut entries)?;
Ok(Yaml::Mapping(entries))
}
fn parse_mapping_entries(
lines: &[Line],
cursor: &mut usize,
indent: usize,
entries: &mut BTreeMap<String, Yaml>,
) -> Result<(), String> {
while *cursor < lines.len() && lines[*cursor].indent == indent && !lines[*cursor].item {
parse_entry(lines, cursor, indent, entries, false)?;
}
Ok(())
}
fn parse_entry(
lines: &[Line],
cursor: &mut usize,
indent: usize,
entries: &mut BTreeMap<String, Yaml>,
first: bool,
) -> Result<(), String> {
let line = &lines[*cursor - usize::from(first)];
let number = line.number;
let (key, rest) = split_entry(&line.content).ok_or_else(|| {
format!(
"line {number}: `{}` is not a `key: value` entry",
line.content
)
})?;
if !first {
*cursor += 1;
}
if rest.is_empty() {
if *cursor >= lines.len() || lines[*cursor].indent <= indent {
return Err(format!("line {number}: `{key}` opens an empty block"));
}
let child = parse_block(lines, cursor, lines[*cursor].indent)?;
entries.insert(key, child);
return Ok(());
}
entries.insert(key, Yaml::Scalar(scalar(&rest, number)?));
Ok(())
}
fn split_entry(content: &str) -> Option<(String, String)> {
let bytes = content.as_bytes();
for (index, byte) in bytes.iter().enumerate() {
if *byte != b':' {
continue;
}
let followed_by_space = bytes.get(index + 1).is_none_or(|next| *next == b' ');
if followed_by_space {
let key = content[..index].trim();
if key.is_empty() {
return None;
}
return Some((key.to_owned(), content[index + 1..].trim().to_owned()));
}
}
None
}
fn scalar(raw: &str, number: usize) -> Result<String, String> {
if let Some(rest) = raw.strip_prefix('"') {
let mut value = String::new();
let mut characters = rest.chars();
while let Some(character) = characters.next() {
match character {
'\\' => match characters.next() {
Some('n') => value.push('\n'),
Some(escaped) => value.push(escaped),
None => return Err(format!("line {number}: trailing escape")),
},
'"' => {
let trailing: String = characters.collect();
if !trailing.trim().is_empty() {
return Err(format!(
"line {number}: trailing `{}` after a quoted scalar",
trailing.trim()
));
}
return Ok(value);
}
character => value.push(character),
}
}
return Err(format!("line {number}: unterminated quoted scalar"));
}
if raw.starts_with(['&', '*', '|', '>', '[', '{']) {
return Err(format!(
"line {number}: `{raw}` uses YAML the gate does not accept"
));
}
Ok(raw.to_owned())
}
#[cfg(test)]
mod tests;