use crate::datatypes::values::Value;
use crate::datatypes::PropMap;
use crate::graph::DirGraph;
use crate::okf::model::{
BuildReport, FolderNoteDirection, HubSpec, LabelFrom, Profile, TAGGED_CONN_TYPE, TAG_LABEL,
};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const CONFIG_DIR: &str = ".kglite";
pub const CONFIG_FILE: &str = "vault.yaml";
pub const SKILLS_DIR: &str = "skills";
pub const RECIPES_DIR: &str = "recipes";
pub const SUPPORTED_VERSION: i64 = 1;
const TYPE_KEYWORDS: [&str; 7] = ["string", "int", "float", "bool", "date", "datetime", "list"];
const TOP_LEVEL_KEYS: [&str; 13] = [
"kglite_vault",
"default_label",
"label_from",
"body",
"skip_dirs",
"folder_notes",
"hubs",
"heading_edges",
"types",
"indexes",
"text_indexes",
"ontology",
"embed",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IndexDecl {
Equality(String),
Range(String),
Composite(Vec<String>),
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct VaultConfig {
pub default_label: Option<String>,
pub label_from: Option<LabelFrom>,
pub body: Option<String>,
pub skip_dirs: Option<Vec<String>>,
pub folder_note_edge: Option<String>,
pub folder_note_direction: Option<FolderNoteDirection>,
pub hubs: BTreeMap<String, HubSpec>,
pub heading_edges: BTreeMap<String, String>,
pub types: BTreeMap<String, BTreeMap<String, String>>,
pub indexes: BTreeMap<String, Vec<IndexDecl>>,
pub text_indexes: BTreeMap<String, Vec<String>>,
pub ontology: Option<crate::graph::ontology::OntologyStore>,
pub embed: Vec<(String, String)>,
}
pub fn config_path(root: &Path) -> PathBuf {
config_dir(root).join(CONFIG_FILE)
}
pub fn config_dir(root: &Path) -> PathBuf {
root.join(CONFIG_DIR)
}
pub fn load(root: &Path) -> Result<Option<VaultConfig>, String> {
let path = config_path(root);
if !path.is_file() {
return Ok(None);
}
let text =
std::fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?;
parse(&text)
.map(Some)
.map_err(|e| format!("{}: {e}", path.display()))
}
pub fn parse(text: &str) -> Result<VaultConfig, String> {
let doc = crate::okf::frontmatter::parse_yaml(text)?;
let map = match &doc {
Value::Map(map) => map,
Value::Null => return Err("empty document; `kglite_vault: 1` is required".to_string()),
other => return Err(format!("must be a YAML mapping, not {}", kind_of(other))),
};
for (key, _) in map.iter() {
if !TOP_LEVEL_KEYS.contains(&key) {
return Err(format!(
"unknown key `{key}`; this format accepts {}",
TOP_LEVEL_KEYS.join(", ")
));
}
}
match map.get("kglite_vault") {
Some(Value::Int64(n)) if *n == SUPPORTED_VERSION => {}
Some(Value::Int64(n)) => {
return Err(format!(
"`kglite_vault: {n}` is not a format version this build reads; \
the supported version is {SUPPORTED_VERSION}"
))
}
Some(other) => {
return Err(format!(
"`kglite_vault` must be the integer {SUPPORTED_VERSION}, not {}",
kind_of(other)
))
}
None => return Err("`kglite_vault: 1` is required".to_string()),
}
let mut config = VaultConfig {
default_label: opt_string(map, "default_label")?,
body: opt_string(map, "body")?,
..VaultConfig::default()
};
if let Some(v) = map.get("label_from") {
config.label_from = Some(match string_of(v, "label_from")?.as_str() {
"type" => LabelFrom::Type,
"folder" => LabelFrom::Folder,
other => {
return Err(format!(
"`label_from: {other}` is not a rung; use `type` or `folder`"
))
}
});
}
if let Some(v) = map.get("skip_dirs") {
config.skip_dirs = Some(string_list(v, "skip_dirs")?);
}
if let Some(v) = map.get("folder_notes") {
parse_folder_notes(v, &mut config)?;
}
if let Some(v) = map.get("hubs") {
config.hubs = parse_hubs(v)?;
}
if let Some(v) = map.get("heading_edges") {
for (heading, edge) in map_of(v, "heading_edges")?.iter() {
config.heading_edges.insert(
heading.to_string(),
string_of(edge, &format!("heading_edges.{heading}"))?,
);
}
}
if let Some(v) = map.get("types") {
config.types = parse_types(v)?;
}
if let Some(v) = map.get("indexes") {
config.indexes = parse_indexes(v)?;
}
if let Some(v) = map.get("text_indexes") {
for (label, props) in map_of(v, "text_indexes")?.iter() {
config.text_indexes.insert(
label.to_string(),
string_list(props, &format!("text_indexes.{label}"))?,
);
}
}
if let Some(v) = map.get("ontology") {
config.ontology = Some(
crate::graph::ontology::ontology_from_value(v)
.map_err(|e| format!("`ontology`: {e}"))?,
);
}
if let Some(v) = map.get("embed") {
for (label, prop) in map_of(v, "embed")?.iter() {
config.embed.push((
label.to_string(),
string_of(prop, &format!("embed.{label}"))?,
));
}
}
Ok(config)
}
fn parse_folder_notes(v: &Value, config: &mut VaultConfig) -> Result<(), String> {
let map = map_of(v, "folder_notes")?;
for (key, _) in map.iter() {
if key != "edge" && key != "direction" {
return Err(format!(
"unknown key `folder_notes.{key}`; accepts `edge` and `direction`"
));
}
}
config.folder_note_edge = opt_string(map, "edge")?;
if let Some(d) = map.get("direction") {
config.folder_note_direction =
Some(match string_of(d, "folder_notes.direction")?.as_str() {
"child_to_parent" => FolderNoteDirection::ChildToParent,
"parent_to_child" => FolderNoteDirection::ParentToChild,
other => {
return Err(format!(
"`folder_notes.direction: {other}`; use `child_to_parent` or `parent_to_child`"
))
}
});
}
Ok(())
}
fn parse_hubs(v: &Value) -> Result<BTreeMap<String, HubSpec>, String> {
let mut out = BTreeMap::new();
for (key, decl) in map_of(v, "hubs")?.iter() {
let spec = map_of(decl, &format!("hubs.{key}"))?;
for (field, _) in spec.iter() {
if !["label", "edge", "case_insensitive"].contains(&field) {
return Err(format!(
"unknown key `hubs.{key}.{field}`; accepts `label`, `edge`, `case_insensitive`"
));
}
}
let label = opt_string(spec, "label")?.unwrap_or_else(|| TAG_LABEL.to_string());
let edge = opt_string(spec, "edge")?.unwrap_or_else(|| TAGGED_CONN_TYPE.to_string());
let case_insensitive = match spec.get("case_insensitive") {
Some(Value::Boolean(b)) => *b,
None | Some(Value::Null) => false,
Some(other) => {
return Err(format!(
"`hubs.{key}.case_insensitive` must be a boolean, not {}",
kind_of(other)
))
}
};
out.insert(
key.to_string(),
HubSpec {
label,
edge,
case_insensitive,
},
);
}
Ok(out)
}
fn parse_types(v: &Value) -> Result<BTreeMap<String, BTreeMap<String, String>>, String> {
let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for (label, props) in map_of(v, "types")?.iter() {
let mut declared = BTreeMap::new();
for (property, keyword) in map_of(props, &format!("types.{label}"))?.iter() {
let keyword = string_of(keyword, &format!("types.{label}.{property}"))?;
if !TYPE_KEYWORDS.contains(&keyword.as_str()) {
return Err(format!(
"`types.{label}.{property}: {keyword}` is not a declared type; \
use one of {}",
TYPE_KEYWORDS.join(", ")
));
}
declared.insert(property.to_string(), keyword);
}
out.insert(label.to_string(), declared);
}
Ok(out)
}
fn parse_indexes(v: &Value) -> Result<BTreeMap<String, Vec<IndexDecl>>, String> {
let mut out: BTreeMap<String, Vec<IndexDecl>> = BTreeMap::new();
for (label, entries) in map_of(v, "indexes")?.iter() {
let ctx = format!("indexes.{label}");
let Value::List(items) = entries else {
return Err(format!(
"`{ctx}` must be a list of index declarations, not {}",
kind_of(entries)
));
};
let mut decls = Vec::with_capacity(items.len());
for item in items.iter() {
decls.push(match item {
Value::String(property) => IndexDecl::Equality(property.clone()),
Value::Map(spec) => {
let mut keys = spec.iter();
let (Some((kind, value)), None) = (keys.next(), keys.next()) else {
return Err(format!(
"`{ctx}` entry must be a single-key map: \
`{{range: <prop>}}` or `{{composite: [<prop>, …]}}`"
));
};
match kind {
"range" => IndexDecl::Range(string_of(value, &format!("{ctx}.range"))?),
"composite" => {
let props = string_list(value, &format!("{ctx}.composite"))?;
if props.len() < 2 {
return Err(format!(
"`{ctx}.composite` needs at least two properties; \
one property is the plain equality index"
));
}
IndexDecl::Composite(props)
}
other => {
return Err(format!(
"unknown index kind `{other}` in `{ctx}`; \
use a bare property name, `range` or `composite`"
))
}
}
}
other => {
return Err(format!(
"`{ctx}` entry must be a property name or a single-key map, not {}",
kind_of(other)
))
}
});
}
out.insert(label.to_string(), decls);
}
Ok(out)
}
fn kind_of(v: &Value) -> &'static str {
match v {
Value::Null => "nothing",
Value::Boolean(_) => "a boolean",
Value::Int64(_) | Value::UniqueId(_) => "an integer",
Value::Float64(_) => "a float",
Value::String(_) => "a string",
Value::List(_) => "a list",
Value::Map(_) => "a mapping",
_ => "that value",
}
}
fn map_of<'a>(v: &'a Value, ctx: &str) -> Result<&'a PropMap, String> {
match v {
Value::Map(map) => Ok(map),
other => Err(format!("`{ctx}` must be a mapping, not {}", kind_of(other))),
}
}
fn string_of(v: &Value, ctx: &str) -> Result<String, String> {
match v {
Value::String(s) => Ok(s.clone()),
other => Err(format!("`{ctx}` must be a string, not {}", kind_of(other))),
}
}
fn opt_string(map: &PropMap, key: &str) -> Result<Option<String>, String> {
match map.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => Ok(Some(string_of(v, key)?)),
}
}
fn string_list(v: &Value, ctx: &str) -> Result<Vec<String>, String> {
match v {
Value::List(items) => items
.iter()
.map(|item| string_of(item, ctx))
.collect::<Result<Vec<_>, _>>(),
other => Err(format!(
"`{ctx}` must be a list of strings, not {}",
kind_of(other)
)),
}
}
impl VaultConfig {
pub fn apply_to_profile(&self, profile: &mut Profile) {
if let Some(label) = &self.default_label {
profile.default_label = Some(label.clone());
}
if let Some(from) = self.label_from {
profile.label_from = from;
}
if let Some(body) = &self.body {
profile.body_property = body.clone();
}
if let Some(dirs) = &self.skip_dirs {
profile.skip_dirs = dirs.clone();
}
if let Some(edge) = &self.folder_note_edge {
profile.folder_note_edge = edge.clone();
}
if let Some(direction) = self.folder_note_direction {
profile.folder_note_direction = direction;
}
for (key, spec) in &self.hubs {
profile.hubs.insert(key.clone(), spec.clone());
}
for (heading, edge) in &self.heading_edges {
profile.heading_edges.insert(heading.clone(), edge.clone());
}
}
pub(crate) fn apply_post_build(&self, graph: &mut DirGraph, report: &mut BuildReport) {
self.apply_indexes(graph, report);
self.apply_text_indexes(graph, report);
self.apply_ontology(graph, report);
report.embed_targets = self.embed.clone();
for (label, property) in &self.embed {
if !graph.has_node_type(label) {
report.warnings.push(format!(
"`vault.yaml` declares `embed: {label}.{property}`, but no note carries \
the label `{label}`"
));
}
}
}
fn apply_indexes(&self, graph: &mut DirGraph, report: &mut BuildReport) {
for (label, decls) in &self.indexes {
if !graph.has_node_type(label) {
report.warnings.push(format!(
"`vault.yaml` declares indexes on `{label}`, but no note carries that label"
));
continue;
}
for decl in decls {
let outcome = match decl {
IndexDecl::Equality(property) => graph
.create_property_index_routed(label, property)
.map(|(count, _persistent)| count),
IndexDecl::Range(property) => Ok(graph.declare_range_index(label, property)),
IndexDecl::Composite(properties) => {
let refs: Vec<&str> = properties.iter().map(String::as_str).collect();
Ok(graph.declare_composite_index(label, &refs))
}
};
match outcome {
Ok(count) => {
report.indexes_declared += 1;
if count == 0 {
report.warnings.push(format!(
"`vault.yaml` index {} indexed no value — no note of label \
`{label}` carries that property yet",
describe_index(label, decl)
));
}
}
Err(reason) => report.warnings.push(format!(
"`vault.yaml` index {} was not installed: {reason}",
describe_index(label, decl)
)),
}
}
}
}
fn apply_text_indexes(&self, graph: &mut DirGraph, report: &mut BuildReport) {
for (label, properties) in &self.text_indexes {
for property in properties {
match crate::graph::text_indexes::build_text_index(graph, label, property, None) {
Ok(_) => report.text_indexes_built += 1,
Err(reason) => report.warnings.push(format!(
"`vault.yaml` text index `{label}.{property}` was not built: {reason}"
)),
}
}
}
}
fn apply_ontology(&self, graph: &mut DirGraph, report: &mut BuildReport) {
let Some(store) = &self.ontology else { return };
match graph.define_ontology(store.clone()) {
Ok(warnings) => report.warnings.extend(
warnings
.into_iter()
.map(|w| format!("`vault.yaml` ontology: {w}")),
),
Err(reason) => report
.errors
.push(format!("`vault.yaml` ontology was not installed: {reason}")),
}
}
}
fn describe_index(label: &str, decl: &IndexDecl) -> String {
match decl {
IndexDecl::Equality(property) => format!("`{label}.{property}`"),
IndexDecl::Range(property) => format!("`{label}.{property}` (range)"),
IndexDecl::Composite(properties) => format!("`{label}.({})`", properties.join(",")),
}
}
pub(crate) fn coerce(value: &Value, declared: &str) -> Option<Value> {
use crate::graph::blueprint::typing::scalar;
if matches!(value, Value::Null) {
return Some(Value::Null);
}
match declared {
"string" => Some(Value::String(crate::datatypes::values::raw_string(value))),
"int" => match value {
Value::Int64(n) => Some(Value::Int64(*n)),
Value::UniqueId(n) => Some(Value::Int64(i64::from(*n))),
Value::Float64(f) if f.fract() == 0.0 && f.is_finite() => Some(Value::Int64(*f as i64)),
Value::String(s) => scalar::parse_integer(s).map(Value::Int64),
_ => None,
},
"float" => match value {
Value::Float64(f) => Some(Value::Float64(*f)),
Value::Int64(n) => Some(Value::Float64(*n as f64)),
Value::String(s) => scalar::parse_float(s).map(Value::Float64),
_ => None,
},
"bool" => match value {
Value::Boolean(b) => Some(Value::Boolean(*b)),
Value::String(s) => scalar::parse_boolean(s).map(Value::Boolean),
_ => None,
},
"date" => match value {
Value::DateTime(d) => Some(Value::DateTime(*d)),
Value::Timestamp(ts) => Some(Value::DateTime(ts.date())),
Value::String(s) => scalar::parse_date(s).map(Value::DateTime),
_ => None,
},
"datetime" => match value {
Value::Timestamp(ts) => Some(Value::Timestamp(*ts)),
Value::DateTime(d) => Some(Value::Timestamp(d.and_hms_opt(0, 0, 0)?)),
Value::String(s) => parse_datetime(s),
_ => None,
},
"list" => match value {
Value::List(items) => Some(Value::List(items.clone())),
_ => None,
},
_ => None,
}
}
fn parse_datetime(text: &str) -> Option<Value> {
let s = text.trim();
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
return Some(Value::Timestamp(dt.naive_utc()));
}
for format in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M"] {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, format) {
return Some(Value::Timestamp(dt));
}
}
crate::graph::blueprint::typing::scalar::parse_date(s)
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(Value::Timestamp)
}
pub(crate) fn import_carried(root: &Path, graph: &mut DirGraph, report: &mut BuildReport) {
let base = root.join(CONFIG_DIR);
for file in markdown_files(&base.join(SKILLS_DIR)) {
match read_and_set_skill(graph, &file) {
Ok(()) => report.skills_imported += 1,
Err(reason) => report.warnings.push(format!(
"`.kglite/skills/{}` was skipped: {reason}",
file_name(&file)
)),
}
}
import_carried_recipes(&base.join(RECIPES_DIR), graph, report);
}
fn import_carried_recipes(dir: &Path, graph: &mut DirGraph, report: &mut BuildReport) {
let mut parsed: Vec<(PathBuf, crate::graph::recipes::RecipeRecord)> = Vec::new();
for file in markdown_files(dir) {
match std::fs::read_to_string(&file)
.map_err(|e| e.to_string())
.and_then(|text| {
crate::graph::recipes::parse_markdown(&text).map_err(|e| e.to_string())
}) {
Ok(record) => parsed.push((file, record)),
Err(reason) => report.warnings.push(skipped_recipe(&file, &reason)),
}
}
let mut records: Vec<_> = parsed.iter().map(|(_, record)| record.clone()).collect();
crate::graph::recipes::inherit_group_descriptions(&mut records, graph);
for ((file, _), record) in parsed.iter().zip(records) {
match crate::graph::recipes::set(graph, &record) {
Ok(_) => report.recipes_imported += 1,
Err(error) => report
.warnings
.push(skipped_recipe(file, &error.to_string())),
}
}
}
fn skipped_recipe(file: &Path, reason: &str) -> String {
format!(
"`.kglite/recipes/{}` was skipped: {reason}",
file_name(file)
)
}
fn read_and_set_skill(graph: &mut DirGraph, file: &Path) -> Result<(), String> {
let text = std::fs::read_to_string(file).map_err(|e| e.to_string())?;
let record = crate::graph::skills::parse_markdown(&text).map_err(|e| e.to_string())?;
crate::graph::skills::set(graph, &record)
.map(|_| ())
.map_err(|e| e.to_string())
}
fn markdown_files(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut files: Vec<PathBuf> = entries
.filter_map(|entry| entry.ok().map(|e| e.path()))
.filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "md"))
.collect();
files.sort();
files
}
fn file_name(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}
#[cfg(test)]
#[path = "vault_config_tests.rs"]
mod tests;