mod attachments;
mod edges;
mod folders;
mod hubs;
mod nodes;
mod resolver;
mod structure;
use crate::datatypes::values::{DataFrame, Value};
use crate::graph::mutation::maintain;
use crate::graph::DirGraph;
use crate::okf::model::{BuildOptions, BuildReport, ConceptDoc};
use attachments::build_attachments;
use edges::build_edges;
use folders::build_folders;
use hubs::{build_aux_nodes, build_hubs};
use nodes::{build_nodes, declared_pairs, report_unmatched};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::Path;
use std::sync::Arc;
use structure::build_structure;
type EdgeRow = (String, String, Vec<(String, Value)>);
type EdgeGroups = BTreeMap<(String, String, String), Vec<EdgeRow>>;
#[derive(Clone)]
pub struct BuildOutput {
pub graph: Arc<DirGraph>,
pub report: BuildReport,
}
pub fn build(root: &Path, opts: &BuildOptions) -> Result<BuildOutput, String> {
let mut config_warnings: Vec<String> = Vec::new();
let (effective, config) = effective_options(root, opts, &mut config_warnings)?;
let opts = &effective;
let walked = super::walk::discover(root, opts)?;
let (docs, findings) = super::parse_concepts_reported(&walked.concepts, opts);
let mut report = BuildReport {
files_scanned: walked.concepts.len(),
concepts: docs.len(),
errors: findings.errors,
warnings: findings.warnings,
..BuildReport::default()
};
report.warnings.extend(config_warnings);
let mut graph = DirGraph::new();
if docs.is_empty() {
finish_vault(root, opts, config.as_ref(), &mut graph, &mut report);
stamp_provenance(&mut graph, root, &walked, opts);
return Ok(BuildOutput {
graph: Arc::new(graph),
report,
});
}
let declared_types = config.as_ref().map(|c| &c.types);
let mut unmatched = declared_pairs(declared_types);
build_nodes(
&mut graph,
&docs,
opts,
declared_types,
&mut unmatched,
&mut report,
)?;
build_aux_nodes(&mut graph, &docs, &mut report)?;
let mut groups = build_hubs(&mut graph, &docs, &opts.profile, &mut report)?;
merge_groups(
&mut groups,
build_folders(&mut graph, &docs, &walked.index_files, opts, &mut report)?,
);
merge_groups(
&mut groups,
build_attachments(
&mut graph,
&docs,
&walked.attachments,
&opts.profile,
&mut report,
)?,
);
let (derived_groups, derived) = build_structure(
&mut graph,
&docs,
opts,
declared_types,
&mut unmatched,
&mut report,
)?;
merge_groups(&mut groups, derived_groups);
report_unmatched(unmatched, &mut report);
build_edges(&mut graph, &docs, opts, groups, &derived, &mut report)?;
finish_vault(root, opts, config.as_ref(), &mut graph, &mut report);
stamp_provenance(&mut graph, root, &walked, opts);
Ok(BuildOutput {
graph: Arc::new(graph),
report,
})
}
fn stamp_provenance(
graph: &mut DirGraph,
root: &Path,
walked: &crate::okf::walk::WalkResult,
opts: &BuildOptions,
) {
let absolute = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
graph.source_root = Some(absolute.to_string_lossy().into_owned());
graph.source_fingerprint = Some(crate::okf::fingerprint::fingerprint_of(root, walked, opts));
}
pub(crate) fn effective_options(
root: &Path,
opts: &BuildOptions,
warnings: &mut Vec<String>,
) -> Result<(BuildOptions, Option<crate::okf::vault_config::VaultConfig>), String> {
let config = load_vault_config(root, opts, warnings)?;
let mut effective = opts.clone();
if let Some(cfg) = &config {
cfg.apply_to_profile(&mut effective.profile);
}
Ok((effective, config))
}
fn load_vault_config(
root: &Path,
opts: &BuildOptions,
warnings: &mut Vec<String>,
) -> Result<Option<crate::okf::vault_config::VaultConfig>, String> {
if opts.dialect == crate::okf::Dialect::Obsidian {
return crate::okf::vault_config::load(root);
}
if crate::okf::vault_config::config_path(root).is_file() {
warnings.push(format!(
"`.kglite/vault.yaml` is a vault declaration and is ignored under the `{}` \
dialect; build with dialect=\"obsidian\" to apply it",
match opts.dialect {
crate::okf::Dialect::Loose => "loose",
_ => "okf",
}
));
}
Ok(None)
}
fn finish_vault(
root: &Path,
opts: &BuildOptions,
config: Option<&crate::okf::vault_config::VaultConfig>,
graph: &mut DirGraph,
report: &mut BuildReport,
) {
if let Some(cfg) = config {
cfg.apply_post_build(graph, report);
}
if opts.dialect == crate::okf::Dialect::Obsidian {
crate::okf::vault_config::import_carried(root, graph, report);
}
}
fn doc_path(d: &ConceptDoc) -> &str {
d.file_path.strip_suffix(".md").unwrap_or(&d.file_path)
}
fn count_nodes(report: &mut BuildReport, label: &str, count: usize) {
if count > 0 {
*report.nodes_by_label.entry(label.to_string()).or_default() += count;
}
}
pub(crate) fn column_value(v: &Value, native: bool) -> Value {
match v {
Value::List(_) | Value::Map(_) if !native => Value::String(
serde_json::to_string(&crate::param::kglite_value_to_json(v)).unwrap_or_default(),
),
other => other.clone(),
}
}
fn emit_groups(
graph: &mut DirGraph,
groups: EdgeGroups,
edge_defaults: &BTreeMap<String, Vec<(String, Value)>>,
report: &mut BuildReport,
) -> Result<(), String> {
let fresh: BTreeSet<&str> = groups
.keys()
.map(|(conn, _, _)| conn.as_str())
.filter(|conn| !graph.connection_type_metadata.contains_key(*conn))
.collect();
let fresh: BTreeSet<String> = fresh.into_iter().map(str::to_string).collect();
let present: BTreeSet<String> = groups.keys().map(|(conn, _, _)| conn.clone()).collect();
for conn in edge_defaults.keys() {
if !present.contains(conn) {
report.warnings.push(format!(
"`edge_defaults:` declares `{conn}`, but the vault has no edge of that type"
));
}
}
for ((conn, src_label, tgt_label), edges) in groups {
let mut seen: HashSet<EdgeRow> = HashSet::new();
let mut edges: Vec<EdgeRow> = edges
.into_iter()
.filter(|r| seen.insert(r.clone()))
.collect();
if let Some(defaults) = edge_defaults.get(&conn) {
apply_edge_defaults(&conn, defaults, &mut edges, report);
}
*report.edges_by_type.entry(conn.clone()).or_default() += edges.len();
let prop_keys: Vec<String> = edges
.iter()
.flat_map(|(_, _, props)| props.iter().map(|(k, _)| k.clone()))
.collect::<BTreeSet<String>>()
.into_iter()
.collect();
let rows: Vec<Vec<Value>> = edges
.into_iter()
.map(|(s, t, props)| {
let mut row = Vec::with_capacity(2 + prop_keys.len());
row.push(Value::String(s));
row.push(Value::String(t));
for key in &prop_keys {
row.push(
props
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.clone())
.unwrap_or(Value::Null),
);
}
row
})
.collect();
let mut columns = vec!["source_id".to_string(), "target_id".to_string()];
columns.extend(prop_keys);
let df = DataFrame::from_cypher_rows(columns, rows)?;
let initial = maintain::InitialLoad::Preset(fresh.contains(&conn));
maintain::add_connections_with_initial_load(
graph,
df,
conn,
src_label,
"source_id".to_string(),
tgt_label,
"target_id".to_string(),
None,
None,
Some("update".to_string()),
initial,
)?;
}
Ok(())
}
fn apply_edge_defaults(
conn: &str,
defaults: &[(String, Value)],
edges: &mut [EdgeRow],
report: &mut BuildReport,
) {
for (name, value) in defaults {
let mut clashed = false;
for (_, _, props) in edges.iter_mut() {
if props.iter().any(|(key, _)| key == name) {
clashed = true;
continue;
}
props.push((name.clone(), value.clone()));
}
if clashed {
report.warnings.push(format!(
"`edge_defaults.{conn}.{name}` names a property a `{conn}` edge \
already carries; the edge's own value is kept"
));
}
}
}
fn merge_groups(into: &mut EdgeGroups, from: EdgeGroups) {
for (key, rows) in from {
into.entry(key).or_default().extend(rows);
}
}
#[cfg(test)]
mod build_tests;
#[cfg(test)]
pub(crate) mod tests_support;