#![allow(clippy::result_large_err)]
use std::collections::HashMap;
use std::path::Path;
use serde_json::{Map, Value as Json};
use crate::datatypes::values::Value;
use crate::error::KgError;
use crate::graph::languages::cypher::executor::load_csv::CsvImportPolicy;
use crate::graph::schema::DirGraph;
use crate::graph::session::{execute_mut, ExecuteOptions};
use crate::graph::storage::GraphRead;
use super::{validate_identifier, RecipeCatalog, RecipeCatalogError, RecipeQueryDefinition};
pub const RECIPE_LABEL: &str = "KgliteRecipe";
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RecipeRecord {
pub recipe: String,
pub name: String,
pub description: String,
pub parameters: Json,
pub cypher: String,
pub recipe_description: String,
pub tool: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SetOutcome {
Created,
Updated,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RecipeWarning {
pub recipe: String,
pub name: String,
pub reason: String,
}
impl std::fmt::Display for RecipeWarning {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"recipe {:?} query {:?} skipped: {}",
self.recipe, self.name, self.reason
)
}
}
fn missing(argument: &str, expected: &str) -> KgError {
KgError::InvalidArgument {
argument: argument.to_string(),
expected: expected.to_string(),
found: "empty".to_string(),
}
}
pub fn validate(record: &RecipeRecord) -> Result<(), KgError> {
validate_identifier(&record.recipe, "recipe")?;
validate_identifier(&record.name, "query")?;
if record.description.trim().is_empty() {
return Err(missing(
"description",
"a non-empty description — it is what an agent reads before calling the query",
));
}
if record.recipe_description.trim().is_empty() {
return Err(missing(
"recipe_description",
"a non-empty group description — the catalogue requires one per recipe",
));
}
if record.cypher.trim().is_empty() {
return Err(missing("cypher", "a non-empty Cypher statement"));
}
if let Some(tool) = &record.tool {
super::validate_tool_name(tool)?;
}
compile(record)?;
Ok(())
}
fn compile(record: &RecipeRecord) -> Result<RecipeQueryDefinition, RecipeCatalogError> {
RecipeQueryDefinition::compile(
&record.name,
record.description.clone(),
record.cypher.clone(),
&record.parameters,
record.tool.clone(),
)
}
fn string_property(graph: &DirGraph, idx: petgraph::graph::NodeIndex, key: &str) -> String {
let Some(view) = graph.graph.node_view(idx) else {
return String::new();
};
match view.get_property_value(key) {
Some(Value::String(s)) => s,
Some(Value::Null) | None => String::new(),
Some(other) => crate::datatypes::values::raw_string(&other),
}
}
fn parameters_property(graph: &DirGraph, idx: petgraph::graph::NodeIndex) -> Json {
let Some(view) = graph.graph.node_view(idx) else {
return Json::Null;
};
match view.get_property_value("parameters") {
Some(value) => crate::param::kglite_value_to_json(&value),
None => Json::Null,
}
}
fn read_record(graph: &DirGraph, idx: petgraph::graph::NodeIndex) -> RecipeRecord {
RecipeRecord {
recipe: string_property(graph, idx, "recipe"),
name: string_property(graph, idx, "name"),
description: string_property(graph, idx, "description"),
parameters: parameters_property(graph, idx),
cypher: string_property(graph, idx, "cypher"),
recipe_description: string_property(graph, idx, "recipe_description"),
tool: Some(string_property(graph, idx, "tool")).filter(|name| !name.is_empty()),
}
}
pub fn list(graph: &DirGraph) -> Vec<RecipeRecord> {
let _arena_guard = graph.graph.begin_query();
let Some(members) = graph.type_indices.get(RECIPE_LABEL) else {
return Vec::new();
};
let mut out: Vec<RecipeRecord> = members.iter().map(|idx| read_record(graph, idx)).collect();
out.sort_by(|a, b| a.recipe.cmp(&b.recipe).then_with(|| a.name.cmp(&b.name)));
out
}
pub fn get(graph: &DirGraph, recipe: &str, name: &str) -> Result<RecipeRecord, KgError> {
let _arena_guard = graph.graph.begin_query();
let found = graph.type_indices.get(RECIPE_LABEL).and_then(|members| {
members.iter().find(|idx| {
string_property(graph, *idx, "recipe") == recipe
&& string_property(graph, *idx, "name") == name
})
});
match found {
Some(idx) => Ok(read_record(graph, idx)),
None => Err(KgError::NodeNotFound {
node_type: RECIPE_LABEL.to_string(),
id: format!("{recipe}/{name}"),
}),
}
}
fn recipe_opts(params: &HashMap<String, Value>) -> ExecuteOptions<'_> {
ExecuteOptions {
params,
deadline: None,
max_work_units: None,
row_limit: None,
lazy_eligible: false,
parallel: false,
disabled_passes: None,
embedder: None,
value_codecs: None,
cancel: None,
write_scope: None,
git_sha: None,
modified_by: None,
csv_import: CsvImportPolicy::Denied,
}
}
fn refuse_if_read_only(graph: &DirGraph) -> Result<(), KgError> {
if graph.read_only {
return Err(KgError::Argument(
"Graph is in read-only mode — recipes cannot be created, updated or \
deleted. Re-enable mutations before writing recipes."
.to_string(),
));
}
Ok(())
}
pub fn set(graph: &mut DirGraph, record: &RecipeRecord) -> Result<SetOutcome, KgError> {
validate(record)?;
refuse_if_read_only(graph)?;
let existed = get(graph, &record.recipe, &record.name).is_ok();
let props: Vec<(crate::datatypes::PropKey, Value)> = vec![
("cypher".into(), Value::String(record.cypher.clone())),
(
"description".into(),
Value::String(record.description.clone()),
),
("name".into(), Value::String(record.name.clone())),
(
"parameters".into(),
crate::param::json_value_to_kglite_value(&record.parameters),
),
("recipe".into(), Value::String(record.recipe.clone())),
(
"recipe_description".into(),
Value::String(record.recipe_description.clone()),
),
(
"tool".into(),
Value::String(record.tool.clone().unwrap_or_default()),
),
];
let mut params: HashMap<String, Value> = HashMap::new();
params.insert("recipe".to_string(), Value::String(record.recipe.clone()));
params.insert("name".to_string(), Value::String(record.name.clone()));
params.insert(
"props".to_string(),
Value::Map(crate::datatypes::PropMap::from_pairs(props)),
);
execute_mut(
graph,
&format!("MERGE (r:{RECIPE_LABEL} {{recipe: $recipe, name: $name}}) SET r += $props"),
&recipe_opts(¶ms),
)?;
Ok(if existed {
SetOutcome::Updated
} else {
SetOutcome::Created
})
}
pub fn delete(graph: &mut DirGraph, recipe: &str, name: &str) -> Result<bool, KgError> {
refuse_if_read_only(graph)?;
if get(graph, recipe, name).is_err() {
return Ok(false);
}
let mut params: HashMap<String, Value> = HashMap::new();
params.insert("recipe".to_string(), Value::String(recipe.to_string()));
params.insert("name".to_string(), Value::String(name.to_string()));
execute_mut(
graph,
&format!("MATCH (r:{RECIPE_LABEL} {{recipe: $recipe, name: $name}}) DETACH DELETE r"),
&recipe_opts(¶ms),
)?;
Ok(true)
}
pub fn catalogue_from_graph(graph: &DirGraph) -> (RecipeCatalog, Vec<RecipeWarning>) {
let mut catalogue = RecipeCatalog::default();
let mut warnings = Vec::new();
for record in list(graph) {
let compiled = validate(&record).and_then(|()| compile(&record).map_err(KgError::from));
match compiled {
Ok(query) => catalogue.insert_query(&record.recipe, &record.recipe_description, query),
Err(error) => warnings.push(RecipeWarning {
recipe: record.recipe,
name: record.name,
reason: error.to_string(),
}),
}
}
(catalogue, warnings)
}
pub fn export_value(graph: &DirGraph) -> Json {
let mut recipes: Map<String, Json> = Map::new();
for record in list(graph) {
let entry = recipes.entry(record.recipe.clone()).or_insert_with(|| {
serde_json::json!({
"description": record.recipe_description.clone(),
"queries": Json::Object(Map::new()),
})
});
let Some(queries) = entry.get_mut("queries").and_then(Json::as_object_mut) else {
continue;
};
let mut query = serde_json::json!({
"description": record.description,
"parameters": record.parameters,
"cypher": record.cypher,
});
if let (Some(tool), Some(map)) = (record.tool, query.as_object_mut()) {
map.insert("tool".to_string(), Json::String(tool));
}
queries.insert(record.name.clone(), query);
}
Json::Object(recipes)
}
#[cfg(feature = "okf")]
pub fn parse_markdown(text: &str) -> Result<RecipeRecord, KgError> {
let (yaml, body) = crate::okf::frontmatter::split(text);
let front = crate::okf::frontmatter::parse_yaml(yaml.as_deref().unwrap_or_default())
.map_err(KgError::Argument)?;
let front = match &front {
Value::Map(map) => map.clone(),
_ => {
return Err(KgError::Argument(
"a recipe file needs a YAML frontmatter block naming `recipe`, `name` and \
`description`"
.to_string(),
))
}
};
let scalar = |key: &str| -> String {
match front.get(key) {
Some(Value::String(s)) => s.clone(),
Some(Value::Null) | None => String::new(),
Some(other) => crate::datatypes::values::raw_string(other),
}
};
Ok(RecipeRecord {
recipe: scalar("recipe"),
name: scalar("name"),
description: scalar("description"),
recipe_description: scalar("recipe_description"),
parameters: match front.get("parameters") {
Some(Value::Null) | None => empty_schema(),
Some(value) => crate::param::kglite_value_to_json(value),
},
cypher: cypher_fence(&body)?,
tool: Some(scalar("tool")).filter(|name| !name.is_empty()),
})
}
#[cfg(feature = "okf")]
pub fn render_markdown(record: &RecipeRecord) -> String {
let quoted = |text: &str| -> String {
serde_json::to_string(text).unwrap_or_else(|_| format!("\"{}\"", text.replace('"', "'")))
};
let parameters = serde_json::to_string(&record.parameters).unwrap_or_else(|_| "{}".to_string());
let tool = record
.tool
.as_deref()
.map(|name| format!("tool: {}\n", quoted(name)))
.unwrap_or_default();
format!(
"---\nrecipe: {}\nname: {}\ndescription: {}\nrecipe_description: {}\nparameters: {}\n{tool}---\n\n```cypher\n{}\n```\n",
quoted(&record.recipe),
quoted(&record.name),
quoted(&record.description),
quoted(&record.recipe_description),
parameters,
record.cypher.trim(),
)
}
#[cfg(feature = "okf")]
fn cypher_fence(body: &str) -> Result<String, KgError> {
let mut blocks: Vec<String> = Vec::new();
let mut current: Option<Vec<&str>> = None;
for line in body.lines() {
let trimmed = line.trim();
match &mut current {
Some(lines) if trimmed.starts_with("```") => {
blocks.push(lines.join("\n"));
current = None;
}
Some(lines) => lines.push(line),
None => {
let tag = trimmed.strip_prefix("```").map(str::trim).unwrap_or("");
if trimmed.starts_with("```") && tag.eq_ignore_ascii_case("cypher") {
current = Some(Vec::new());
}
}
}
}
match blocks.len() {
1 => Ok(blocks.remove(0).trim().to_string()),
0 => Err(KgError::Argument(
"a recipe file's body must hold one ```cypher fenced block; found none".to_string(),
)),
n => Err(KgError::Argument(format!(
"a recipe file's body must hold exactly one ```cypher fenced block; found {n}"
))),
}
}
#[cfg(feature = "okf")]
fn empty_schema() -> Json {
let mut schema = Map::new();
schema.insert("type".to_string(), Json::String("object".to_string()));
schema.insert("properties".to_string(), Json::Object(Map::new()));
schema.insert("required".to_string(), Json::Array(Vec::new()));
schema.insert("additionalProperties".to_string(), Json::Bool(false));
Json::Object(schema)
}
#[cfg(feature = "okf")]
pub(crate) fn inherit_group_descriptions(
records: &mut [RecipeRecord],
graph: &DirGraph,
) -> Vec<String> {
let mut declared: HashMap<String, String> = HashMap::new();
for record in records.iter() {
if !record.recipe_description.trim().is_empty() {
declared
.entry(record.recipe.clone())
.or_insert_with(|| record.recipe_description.clone());
}
}
if records
.iter()
.any(|record| record.recipe_description.trim().is_empty())
{
for stored in list(graph) {
if !stored.recipe_description.trim().is_empty() {
declared
.entry(stored.recipe)
.or_insert(stored.recipe_description);
}
}
}
let mut undescribed: Vec<String> = Vec::new();
for record in records.iter_mut() {
if !record.recipe_description.trim().is_empty() {
continue;
}
match declared.get(&record.recipe) {
Some(description) => record.recipe_description = description.clone(),
None if !undescribed.contains(&record.recipe) => {
undescribed.push(record.recipe.clone())
}
None => {}
}
}
undescribed
}
#[cfg(feature = "okf")]
pub fn set_from_markdown(graph: &mut DirGraph, text: &str) -> Result<RecipeRecord, KgError> {
let mut records = [parse_markdown(text)?];
inherit_group_descriptions(&mut records, graph);
let [record] = records;
set(graph, &record)?;
Ok(record)
}
pub fn import_value(
graph: &mut DirGraph,
document: &Json,
) -> Result<Vec<(String, String)>, KgError> {
let raw = catalogue_section(document);
let catalogue = RecipeCatalog::from_manifest_value(raw).map_err(KgError::from)?;
let mut written = Vec::new();
for recipe in catalogue.recipes() {
for query in recipe.queries() {
let record = RecipeRecord {
recipe: recipe.name.clone(),
name: query.name.clone(),
description: query.description.clone(),
parameters: Json::Object(query.parameters.as_json().clone()),
cypher: query.cypher.clone(),
recipe_description: recipe.description.clone(),
tool: query.tool.clone(),
};
set(graph, &record)?;
written.push((record.recipe, record.name));
}
}
Ok(written)
}
fn catalogue_section(document: &Json) -> Option<&Json> {
match document.get("extensions") {
Some(extensions) => extensions.get("cypher_recipes"),
None => Some(document),
}
}
pub fn import_path(graph: &mut DirGraph, path: &Path) -> Result<Vec<(String, String)>, KgError> {
let meta = std::fs::metadata(path).map_err(|_| KgError::FileNotFound(path.to_path_buf()))?;
if meta.is_dir() {
return import_markdown_dir(graph, path);
}
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
match extension.as_str() {
"json" => {
let text = std::fs::read_to_string(path)
.map_err(|_| KgError::FileNotFound(path.to_path_buf()))?;
let document: Json =
serde_json::from_str(&text).map_err(|error| KgError::FileFormat {
path: path.to_path_buf(),
message: error.to_string(),
})?;
import_value(graph, &document)
}
"md" => import_markdown_dir(graph, path),
_ => Err(KgError::FileFormat {
path: path.to_path_buf(),
message: "recipe import reads a .json catalogue, a .md recipe file, or a \
directory of them; convert a YAML catalogue first, or pass the \
parsed document to import_value"
.to_string(),
}),
}
}
#[cfg(feature = "okf")]
fn import_markdown_dir(
graph: &mut DirGraph,
path: &Path,
) -> Result<Vec<(String, String)>, KgError> {
let files: Vec<std::path::PathBuf> = if path.is_dir() {
let mut found: Vec<std::path::PathBuf> = std::fs::read_dir(path)
.map_err(KgError::FileIo)?
.filter_map(|entry| entry.ok().map(|e| e.path()))
.filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "md"))
.collect();
found.sort();
found
} else {
vec![path.to_path_buf()]
};
let mut records: Vec<RecipeRecord> = Vec::with_capacity(files.len());
for file in &files {
let text = std::fs::read_to_string(file).map_err(KgError::FileIo)?;
records.push(parse_markdown(&text).map_err(|err| KgError::FileFormat {
path: file.clone(),
message: err.to_string(),
})?);
}
if let Some(group) = inherit_group_descriptions(&mut records, graph).first() {
return Err(KgError::Argument(format!(
"no file in recipe group `{group}` declares a `recipe_description`; \
one of them must carry it and the rest inherit it (VAULT.md §8)"
)));
}
for (file, record) in files.iter().zip(&records) {
validate(record).map_err(|err| KgError::FileFormat {
path: file.clone(),
message: err.to_string(),
})?;
}
let mut written = Vec::with_capacity(records.len());
for record in records {
set(graph, &record)?;
written.push((record.recipe, record.name));
}
Ok(written)
}
#[cfg(not(feature = "okf"))]
fn import_markdown_dir(
_graph: &mut DirGraph,
path: &Path,
) -> Result<Vec<(String, String)>, KgError> {
Err(KgError::FileFormat {
path: path.to_path_buf(),
message: "markdown recipe files need the `okf` feature; this build reads \
.json catalogues only"
.to_string(),
})
}
pub fn export_path(graph: &DirGraph, path: &Path) -> Result<(), KgError> {
let text = serde_json::to_string_pretty(&export_value(graph))
.map_err(|error| KgError::Argument(error.to_string()))?;
std::fs::write(path, text).map_err(KgError::FileIo)
}
#[cfg(test)]
#[path = "records_tests.rs"]
mod tests;