#![allow(clippy::result_large_err)]
use std::collections::HashMap;
use std::path::Path;
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;
pub const SKILL_LABEL: &str = "KgliteSkill";
pub const MAX_BODY_BYTES: usize = 16 * 1024;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Delivery {
Eager,
#[default]
Lazy,
}
impl Delivery {
pub fn as_str(self) -> &'static str {
match self {
Delivery::Eager => "eager",
Delivery::Lazy => "lazy",
}
}
pub fn parse(text: &str) -> Result<Self, KgError> {
match text {
"eager" => Ok(Delivery::Eager),
"lazy" => Ok(Delivery::Lazy),
other => Err(KgError::InvalidArgument {
argument: "delivery".to_string(),
expected: "'eager' or 'lazy'".to_string(),
found: other.to_string(),
}),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SkillRecord {
pub name: String,
pub description: String,
pub body: String,
pub references_tools: Vec<String>,
pub delivery: Delivery,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SetOutcome {
Created,
Updated,
}
pub fn validate(record: &SkillRecord) -> Result<(), KgError> {
if record.name.trim().is_empty() {
return Err(KgError::InvalidArgument {
argument: "name".to_string(),
expected: "a non-empty skill name".to_string(),
found: "empty".to_string(),
});
}
let unsafe_name = record
.name
.chars()
.any(|c| c.is_whitespace() || c == '/' || c == '\\' || c == ':' || c.is_control())
|| record.name == "."
|| record.name == "..";
if unsafe_name {
return Err(KgError::InvalidArgument {
argument: "name".to_string(),
expected: "a single path-safe token (no whitespace, '/', '\\' or ':')".to_string(),
found: record.name.clone(),
});
}
if record.description.trim().is_empty() {
return Err(KgError::InvalidArgument {
argument: "description".to_string(),
expected:
"a non-empty description — it is all an agent sees before asking for the body"
.to_string(),
found: "empty".to_string(),
});
}
if record.body.len() > MAX_BODY_BYTES {
return Err(KgError::InvalidArgument {
argument: "body".to_string(),
expected: format!("at most {MAX_BODY_BYTES} bytes"),
found: format!("{} bytes", record.body.len()),
});
}
Ok(())
}
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 tools_property(graph: &DirGraph, idx: petgraph::graph::NodeIndex) -> Vec<String> {
let Some(view) = graph.graph.node_view(idx) else {
return Vec::new();
};
match view.get_property_value("references_tools") {
Some(Value::List(items)) => items
.iter()
.map(crate::datatypes::values::raw_string)
.collect(),
Some(Value::String(one)) => vec![one],
_ => Vec::new(),
}
}
fn read_record(graph: &DirGraph, idx: petgraph::graph::NodeIndex, with_body: bool) -> SkillRecord {
SkillRecord {
name: string_property(graph, idx, "name"),
description: string_property(graph, idx, "description"),
body: if with_body {
string_property(graph, idx, "body")
} else {
String::new()
},
references_tools: tools_property(graph, idx),
delivery: Delivery::parse(&string_property(graph, idx, "delivery")).unwrap_or_default(),
}
}
pub fn list(graph: &DirGraph) -> Vec<SkillRecord> {
let _arena_guard = graph.graph.begin_query();
let Some(members) = graph.type_indices.get(SKILL_LABEL) else {
return Vec::new();
};
let mut out: Vec<SkillRecord> = members
.iter()
.map(|idx| read_record(graph, idx, false))
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn get(graph: &DirGraph, name: &str) -> Result<SkillRecord, KgError> {
let _arena_guard = graph.graph.begin_query();
let found = graph.type_indices.get(SKILL_LABEL).and_then(|members| {
members
.iter()
.find(|idx| string_property(graph, *idx, "name") == name)
});
match found {
Some(idx) => Ok(read_record(graph, idx, true)),
None => Err(KgError::NodeNotFound {
node_type: SKILL_LABEL.to_string(),
id: name.to_string(),
}),
}
}
fn skill_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 — skills cannot be created, updated or \
deleted. Re-enable mutations before writing skills."
.to_string(),
));
}
Ok(())
}
pub fn set(graph: &mut DirGraph, record: &SkillRecord) -> Result<SetOutcome, KgError> {
validate(record)?;
refuse_if_read_only(graph)?;
let existed = get(graph, &record.name).is_ok();
let props: Vec<(crate::datatypes::PropKey, Value)> = vec![
("body".into(), Value::String(record.body.clone())),
(
"delivery".into(),
Value::String(record.delivery.as_str().to_string()),
),
(
"description".into(),
Value::String(record.description.clone()),
),
(
"references_tools".into(),
Value::List(
record
.references_tools
.iter()
.map(|t| Value::String(t.clone()))
.collect(),
),
),
];
let mut params: HashMap<String, Value> = HashMap::new();
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 (s:{SKILL_LABEL} {{name: $name}}) SET s += $props"),
&skill_opts(¶ms),
)?;
Ok(if existed {
SetOutcome::Updated
} else {
SetOutcome::Created
})
}
pub fn delete(graph: &mut DirGraph, name: &str) -> Result<bool, KgError> {
refuse_if_read_only(graph)?;
if get(graph, name).is_err() {
return Ok(false);
}
let mut params: HashMap<String, Value> = HashMap::new();
params.insert("name".to_string(), Value::String(name.to_string()));
execute_mut(
graph,
&format!("MATCH (s:{SKILL_LABEL} {{name: $name}}) DETACH DELETE s"),
&skill_opts(¶ms),
)?;
Ok(true)
}
fn yaml_quoted(text: &str) -> String {
serde_json::to_string(text).unwrap_or_else(|_| format!("\"{}\"", text.replace('"', "'")))
}
pub fn render_markdown(record: &SkillRecord) -> String {
let mut out = String::with_capacity(record.body.len() + 256);
out.push_str("---\n");
out.push_str(&format!("name: {}\n", yaml_quoted(&record.name)));
out.push_str(&format!(
"description: {}\n",
yaml_quoted(&record.description)
));
out.push_str("references_tools:\n");
for tool in &record.references_tools {
out.push_str(&format!(" - {}\n", yaml_quoted(tool)));
}
out.push_str(&format!(
"delivery: {}\n",
yaml_quoted(record.delivery.as_str())
));
out.push_str("---\n\n");
out.push_str(&record.body);
out
}
#[cfg(feature = "okf")]
pub fn parse_markdown(text: &str) -> Result<SkillRecord, KgError> {
let (_, body) = crate::okf::frontmatter::split(text);
let front = crate::okf::frontmatter::parse(text).map_err(KgError::Argument)?;
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),
}
};
let delivery_text = scalar("delivery");
let record = SkillRecord {
name: scalar("name"),
description: scalar("description"),
body: body.trim_start_matches('\n').to_string(),
references_tools: match front.get("references_tools") {
Some(Value::List(items)) => items
.iter()
.map(crate::datatypes::values::raw_string)
.collect(),
Some(Value::String(one)) => vec![one.clone()],
_ => Vec::new(),
},
delivery: if delivery_text.is_empty() {
Delivery::default()
} else {
Delivery::parse(&delivery_text)?
},
};
validate(&record)?;
Ok(record)
}
#[cfg(feature = "okf")]
pub fn import_path(graph: &mut DirGraph, path: &Path) -> Result<Vec<String>, KgError> {
let meta = std::fs::metadata(path).map_err(|_| KgError::FileNotFound(path.to_path_buf()))?;
let mut files: Vec<std::path::PathBuf> = if meta.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()]
};
files.dedup();
let mut names = Vec::with_capacity(files.len());
for file in files {
let text = std::fs::read_to_string(&file).map_err(KgError::FileIo)?;
let record = parse_markdown(&text).map_err(|err| KgError::FileFormat {
path: file.clone(),
message: err.to_string(),
})?;
set(graph, &record)?;
names.push(record.name);
}
Ok(names)
}
pub fn export_dir(graph: &DirGraph, dir: &Path) -> Result<Vec<String>, KgError> {
std::fs::create_dir_all(dir).map_err(KgError::FileIo)?;
let mut names = Vec::new();
for summary in list(graph) {
let record = get(graph, &summary.name)?;
std::fs::write(
dir.join(format!("{}.md", record.name)),
render_markdown(&record),
)
.map_err(KgError::FileIo)?;
names.push(record.name);
}
Ok(names)
}
#[cfg(test)]
#[path = "skills_tests.rs"]
mod tests;