use saphyr::{MarkedYaml, Scalar, ScalarStyle, YamlData, YamlLoader};
use saphyr_parser::Parser;
use std::collections::BTreeMap;
use std::path::Path;
use crate::model::*;
use crate::paths::{malformed, set_source_list, unknown_field};
pub fn parse_manifest_file(path: &Path) -> Result<(Manifest, Vec<EkfDiagnostic>), String> {
let text = std::fs::read_to_string(path)
.map_err(|err| format!("failed to read EKF manifest '{}': {err}", path.display()))?;
Ok(parse_manifest(&text))
}
pub fn parse_manifest(text: &str) -> (Manifest, Vec<EkfDiagnostic>) {
let mut manifest = Manifest::default();
let mut diagnostics = Vec::new();
let mut parser = Parser::new_from_str(text);
let mut loader = YamlLoader::<MarkedYaml>::default();
loader.early_parse(false);
if let Err(err) = parser.load(&mut loader, true) {
diagnostics.push(EkfDiagnostic {
severity: DiagnosticSeverity::Warning,
kind: "yaml_error".into(),
message: format!("EKF manifest is not valid YAML: {err}"),
source: Some(format!("line {}", err.marker().line())),
});
return (manifest, diagnostics);
}
let Some(root) = loader.into_documents().into_iter().next() else {
return (manifest, diagnostics);
};
match &root.data {
YamlData::Mapping(entries) => {
for (key_node, value) in entries {
map_root_entry(&mut manifest, key_node, value, &mut diagnostics);
}
}
_ if is_missing_value(&root) => {}
_ => diagnostics.push(EkfDiagnostic {
severity: DiagnosticSeverity::Warning,
kind: "yaml_error".into(),
message: "EKF manifest root must be a mapping".into(),
source: Some(format!("line {}", node_line(&root))),
}),
}
(manifest, diagnostics)
}
pub(crate) fn map_root_entry(
manifest: &mut Manifest,
key_node: &MarkedYaml,
value: &MarkedYaml,
diagnostics: &mut Vec<EkfDiagnostic>,
) {
let line = node_line(key_node);
let Some(key) = scalar_string(key_node) else {
diagnostics.push(malformed(line, "mapping keys must be scalars"));
return;
};
match key.as_str() {
"ekf_version" => manifest.ekf_version = scalar_value(value, "ekf_version", diagnostics),
"name" => manifest.name = scalar_value(value, "name", diagnostics),
"partitions" => map_partitions(value, manifest, diagnostics),
"sources" => map_sources(value, manifest, diagnostics),
"trust" => map_trust(value, manifest, diagnostics),
"gates" => map_scalar_groups(
value,
&mut manifest.gates,
"gates",
"unsupported_inline_gate",
"gate sections must be block mappings in EKF v0.1",
diagnostics,
),
"relations" => map_scalar_groups(
value,
&mut manifest.relations,
"relations",
"unsupported_inline_relation",
"relation profiles must be block mappings in EKF v0.1",
diagnostics,
),
other => diagnostics.push(unknown_field(line, other)),
}
}
pub(crate) fn map_partitions(
node: &MarkedYaml,
manifest: &mut Manifest,
diagnostics: &mut Vec<EkfDiagnostic>,
) {
let items = match &node.data {
YamlData::Sequence(items) => items,
_ if is_missing_value(node) => return,
_ => {
diagnostics.push(malformed(node_line(node), "partitions must be a list"));
return;
}
};
for item in items {
let YamlData::Mapping(fields) = &item.data else {
diagnostics.push(malformed(
node_line(item),
"partition entries must be mappings",
));
continue;
};
let mut partition = PartitionManifest::default();
for (key_node, value) in fields {
let line = node_line(key_node);
let Some(key) = scalar_string(key_node) else {
diagnostics.push(malformed(line, "mapping keys must be scalars"));
continue;
};
match key.as_str() {
"id" => {
partition.id =
scalar_value(value, "partitions.id", diagnostics).unwrap_or_default();
}
"title" => partition.title = scalar_value(value, "partitions.title", diagnostics),
"source" => {
partition.source =
scalar_value(value, "partitions.source", diagnostics).unwrap_or_default();
}
"roots" => partition.roots = string_list(value, "partitions.roots", diagnostics),
"role" => partition.role = scalar_value(value, "partitions.role", diagnostics),
"languages" => {
partition.languages = string_list(value, "partitions.languages", diagnostics);
}
"tags" => partition.tags = string_list(value, "partitions.tags", diagnostics),
other => diagnostics.push(unknown_field(line, other)),
}
}
if partition_has_content(&partition) {
manifest.partitions.push(partition);
}
}
}
pub(crate) fn map_sources(
node: &MarkedYaml,
manifest: &mut Manifest,
diagnostics: &mut Vec<EkfDiagnostic>,
) {
let entries = match &node.data {
YamlData::Mapping(entries) => entries,
_ if is_missing_value(node) => return,
_ => {
diagnostics.push(malformed(node_line(node), "sources must be a mapping"));
return;
}
};
for (key_node, value) in entries {
let line = node_line(key_node);
let Some(key) = scalar_string(key_node) else {
diagnostics.push(malformed(line, "mapping keys must be scalars"));
continue;
};
match key.as_str() {
"docs" | "skills" | "agents" | "capabilities" | "brief_profiles" | "workflows"
| "evals" => {
let values = string_list(value, &format!("sources.{key}"), diagnostics);
set_source_list(&mut manifest.sources, &key, values);
}
"code" => map_code_sources(value, manifest, line, diagnostics),
other => diagnostics.push(unknown_field(line, other)),
}
}
}
pub(crate) fn map_code_sources(
node: &MarkedYaml,
manifest: &mut Manifest,
line: usize,
diagnostics: &mut Vec<EkfDiagnostic>,
) {
let items = match &node.data {
YamlData::Sequence(items) => items,
_ if is_missing_value(node) => return,
_ => {
diagnostics.push(EkfDiagnostic {
severity: DiagnosticSeverity::Warning,
kind: "unsupported_inline_code_sources".into(),
message: "sources.code must be a list of code entries".into(),
source: Some(format!("line {line}")),
});
return;
}
};
for item in items {
let YamlData::Mapping(fields) = &item.data else {
diagnostics.push(malformed(
node_line(item),
"code source entries must be mappings",
));
continue;
};
let mut code = CodeSource::default();
for (key_node, value) in fields {
let line = node_line(key_node);
let Some(key) = scalar_string(key_node) else {
diagnostics.push(malformed(line, "mapping keys must be scalars"));
continue;
};
match key.as_str() {
"path" => {
code.path =
scalar_value(value, "sources.code.path", diagnostics).unwrap_or_default();
}
"source" => code.source = scalar_value(value, "sources.code.source", diagnostics),
"languages" => {
code.languages = string_list(value, "sources.code.languages", diagnostics);
}
other => diagnostics.push(unknown_field(line, other)),
}
}
if code.path.trim().is_empty() {
diagnostics.push(EkfDiagnostic {
severity: DiagnosticSeverity::Warning,
kind: "code_source_missing_path".into(),
message: "code source entry is missing path".into(),
source: Some(format!("line {}", node_line(item))),
});
} else {
manifest.sources.code.push(code);
}
}
}
pub(crate) fn map_trust(
node: &MarkedYaml,
manifest: &mut Manifest,
diagnostics: &mut Vec<EkfDiagnostic>,
) {
let entries = match &node.data {
YamlData::Mapping(entries) => entries,
_ if is_missing_value(node) => return,
_ => {
diagnostics.push(malformed(node_line(node), "trust must be a mapping"));
return;
}
};
for (key_node, value) in entries {
let line = node_line(key_node);
let Some(key) = scalar_string(key_node) else {
diagnostics.push(malformed(line, "mapping keys must be scalars"));
continue;
};
match scalar_string(value) {
Some(scalar) if !scalar.is_empty() => {
manifest.trust.insert(key, scalar);
}
Some(_) => {}
None => diagnostics.push(malformed(
node_line(value),
&format!("trust.{key} must be a scalar"),
)),
}
}
}
pub(crate) fn map_scalar_groups(
node: &MarkedYaml,
target: &mut BTreeMap<String, BTreeMap<String, String>>,
section: &str,
inline_kind: &str,
inline_message: &str,
diagnostics: &mut Vec<EkfDiagnostic>,
) {
let groups = match &node.data {
YamlData::Mapping(groups) => groups,
_ if is_missing_value(node) => return,
_ => {
diagnostics.push(malformed(
node_line(node),
&format!("{section} must be a mapping"),
));
return;
}
};
for (group_node, group_value) in groups {
let line = node_line(group_node);
let Some(group) = scalar_string(group_node) else {
diagnostics.push(malformed(line, "mapping keys must be scalars"));
continue;
};
if is_missing_value(group_value) {
target.entry(group).or_default();
continue;
}
let YamlData::Mapping(entries) = &group_value.data else {
diagnostics.push(EkfDiagnostic {
severity: DiagnosticSeverity::Warning,
kind: inline_kind.into(),
message: inline_message.into(),
source: Some(format!("line {}", node_line(group_value))),
});
continue;
};
let group_map = target.entry(group).or_default();
for (key_node, value) in entries {
let line = node_line(key_node);
let Some(key) = scalar_string(key_node) else {
diagnostics.push(malformed(line, "mapping keys must be scalars"));
continue;
};
match group_entry_string(value) {
Some(scalar) => {
group_map.insert(key, scalar);
}
None => diagnostics.push(malformed(
node_line(value),
&format!("{section}.{key} must be a scalar or a list of scalars"),
)),
}
}
}
}
pub(crate) fn group_entry_string(node: &MarkedYaml) -> Option<String> {
if let YamlData::Sequence(items) = &node.data {
let items: Option<Vec<String>> = items.iter().map(scalar_string).collect();
return items.map(|items| format!("[{}]", items.join(", ")));
}
scalar_string(node)
}
pub(crate) fn node_line(node: &MarkedYaml) -> usize {
node.span.start.line()
}
pub(crate) fn scalar_string(node: &MarkedYaml) -> Option<String> {
match &node.data {
YamlData::Representation(text, _, _) => Some(text.to_string()),
YamlData::Value(scalar) => Some(match scalar {
Scalar::Null => String::new(),
Scalar::Boolean(value) => value.to_string(),
Scalar::Integer(value) => value.to_string(),
Scalar::FloatingPoint(value) => value.to_string(),
Scalar::String(value) => value.to_string(),
}),
YamlData::Tagged(_, inner) => scalar_string(inner),
_ => None,
}
}
pub(crate) fn is_missing_value(node: &MarkedYaml) -> bool {
match &node.data {
YamlData::Representation(text, ScalarStyle::Plain, _) => text.is_empty(),
YamlData::Value(Scalar::Null) | YamlData::BadValue => true,
_ => false,
}
}
pub(crate) fn scalar_value(
node: &MarkedYaml,
field: &str,
diagnostics: &mut Vec<EkfDiagnostic>,
) -> Option<String> {
let value = scalar_string(node);
if value.is_none() {
diagnostics.push(malformed(
node_line(node),
&format!("{field} must be a scalar"),
));
}
value
}
pub(crate) fn string_list(
node: &MarkedYaml,
field: &str,
diagnostics: &mut Vec<EkfDiagnostic>,
) -> Vec<String> {
match &node.data {
YamlData::Sequence(items) => items
.iter()
.filter_map(|item| {
let scalar = scalar_string(item);
if scalar.is_none() {
diagnostics.push(malformed(
node_line(item),
&format!("{field} entries must be scalars"),
));
}
scalar.filter(|value| !value.is_empty())
})
.collect(),
_ if is_missing_value(node) => Vec::new(),
_ => scalar_value(node, field, diagnostics)
.into_iter()
.filter(|value| !value.is_empty())
.collect(),
}
}
pub(crate) fn partition_has_content(partition: &PartitionManifest) -> bool {
!partition.id.trim().is_empty()
|| !partition.source.trim().is_empty()
|| !partition.roots.is_empty()
|| partition.title.is_some()
|| partition.role.is_some()
|| !partition.languages.is_empty()
|| !partition.tags.is_empty()
}