336 lines
11 KiB
Rust
336 lines
11 KiB
Rust
|
|
use std::path::{Path, PathBuf};
|
||
|
|
use std::process::Command;
|
||
|
|
|
||
|
|
use serde_json::Value;
|
||
|
|
|
||
|
|
use super::{
|
||
|
|
node_types::{
|
||
|
|
AdrRaw, ContentKind, GraphMeta, GraphNode, OntologyEdgeRaw, OntologyInstance,
|
||
|
|
OntologyNodeRaw,
|
||
|
|
},
|
||
|
|
BuildError,
|
||
|
|
};
|
||
|
|
|
||
|
|
// ── NCL export
|
||
|
|
// ────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// Run `nickel export --format json` with the given import paths.
|
||
|
|
/// Returns the parsed JSON value.
|
||
|
|
fn export_ncl(file: &Path, import_paths: &[&Path]) -> Result<Value, BuildError> {
|
||
|
|
let mut cmd = Command::new("nickel");
|
||
|
|
cmd.arg("export").arg("--format").arg("json");
|
||
|
|
|
||
|
|
for p in import_paths {
|
||
|
|
cmd.arg("--import-path").arg(p);
|
||
|
|
}
|
||
|
|
cmd.arg(file);
|
||
|
|
|
||
|
|
let out = cmd
|
||
|
|
.output()
|
||
|
|
.map_err(|e| BuildError::NiclCommand(e.to_string()))?;
|
||
|
|
|
||
|
|
if !out.status.success() {
|
||
|
|
return Err(BuildError::NclExport {
|
||
|
|
path: file.display().to_string(),
|
||
|
|
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
serde_json::from_slice(&out.stdout).map_err(|e| BuildError::NclParse(e.to_string()))
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Content NCL parsing
|
||
|
|
// ───────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// Configuration for parsing content NCL files.
|
||
|
|
pub struct ContentParserConfig {
|
||
|
|
/// Root of the site's nickel schema directory (contains content/metadata/).
|
||
|
|
/// Used as --import-path for nickel export on content files.
|
||
|
|
pub nickel_schema_dir: PathBuf,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Parse a single content `.ncl` file and extract graph metadata + node info.
|
||
|
|
/// Returns `None` if the file is not published or is a draft.
|
||
|
|
pub fn parse_content_ncl(
|
||
|
|
file: &Path,
|
||
|
|
config: &ContentParserConfig,
|
||
|
|
) -> Result<Option<(GraphNode, GraphMeta)>, BuildError> {
|
||
|
|
// Emit rerun directive for incremental builds
|
||
|
|
println!("cargo:rerun-if-changed={}", file.display());
|
||
|
|
|
||
|
|
let import_paths = [
|
||
|
|
config.nickel_schema_dir.as_path(),
|
||
|
|
file.parent().unwrap_or(Path::new(".")),
|
||
|
|
];
|
||
|
|
|
||
|
|
let value = export_ncl(file, &import_paths)?;
|
||
|
|
|
||
|
|
// Skip unpublished and draft content
|
||
|
|
if value.get("published").and_then(Value::as_bool) == Some(false) {
|
||
|
|
return Ok(None);
|
||
|
|
}
|
||
|
|
if value.get("draft").and_then(Value::as_bool) == Some(true) {
|
||
|
|
return Ok(None);
|
||
|
|
}
|
||
|
|
|
||
|
|
let graph_meta = extract_graph_meta(&value);
|
||
|
|
let node = build_content_node(file, &value)?;
|
||
|
|
|
||
|
|
Ok(Some((node, graph_meta)))
|
||
|
|
}
|
||
|
|
|
||
|
|
fn extract_graph_meta(value: &Value) -> GraphMeta {
|
||
|
|
let empty = Value::Object(Default::default());
|
||
|
|
let graph = value.get("graph").unwrap_or(&empty);
|
||
|
|
|
||
|
|
GraphMeta {
|
||
|
|
implements: str_array(graph, "implements"),
|
||
|
|
related_to: str_array(graph, "related_to"),
|
||
|
|
part_of: str_array(graph, "part_of"),
|
||
|
|
references: str_array(graph, "references"),
|
||
|
|
extends: str_array(graph, "extends"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn build_content_node(file: &Path, value: &Value) -> Result<GraphNode, BuildError> {
|
||
|
|
let (content_kind, lang, category) = infer_content_kind_and_lang(file);
|
||
|
|
|
||
|
|
let id = value
|
||
|
|
.get("id")
|
||
|
|
.and_then(Value::as_str)
|
||
|
|
.or_else(|| value.get("slug").and_then(Value::as_str))
|
||
|
|
.unwrap_or_else(|| {
|
||
|
|
file.file_stem()
|
||
|
|
.and_then(|s| s.to_str())
|
||
|
|
.unwrap_or("unknown")
|
||
|
|
})
|
||
|
|
.to_owned();
|
||
|
|
|
||
|
|
let title = value
|
||
|
|
.get("title")
|
||
|
|
.and_then(Value::as_str)
|
||
|
|
.unwrap_or("Untitled")
|
||
|
|
.to_owned();
|
||
|
|
|
||
|
|
let slug = value
|
||
|
|
.get("slug")
|
||
|
|
.and_then(Value::as_str)
|
||
|
|
.unwrap_or(&id)
|
||
|
|
.to_owned();
|
||
|
|
|
||
|
|
let page_route = value.get("page_route").and_then(Value::as_str);
|
||
|
|
|
||
|
|
let url = if let Some(route) = page_route {
|
||
|
|
route.to_owned()
|
||
|
|
} else {
|
||
|
|
build_content_url(&content_kind, &lang, category.as_deref(), &slug)
|
||
|
|
};
|
||
|
|
|
||
|
|
let tags = str_array(value, "tags");
|
||
|
|
|
||
|
|
let excerpt = value
|
||
|
|
.get("excerpt")
|
||
|
|
.and_then(Value::as_str)
|
||
|
|
.map(str::to_owned);
|
||
|
|
|
||
|
|
Ok(GraphNode::Content {
|
||
|
|
id,
|
||
|
|
title,
|
||
|
|
content_kind,
|
||
|
|
lang,
|
||
|
|
url,
|
||
|
|
tags,
|
||
|
|
excerpt,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Infer content kind, language, and optional category from the file path.
|
||
|
|
///
|
||
|
|
/// Expected path segments (relative to site/content/):
|
||
|
|
/// blog/en/category/post.ncl → Blog, "en", Some("category")
|
||
|
|
/// recipes/es/category/recipe.ncl → Recipe, "es", Some("category")
|
||
|
|
/// projects/en/slug/index.ncl → Project, "en", None
|
||
|
|
/// activities/en/category/item.ncl → Activity, "en", Some("category")
|
||
|
|
fn infer_content_kind_and_lang(file: &Path) -> (ContentKind, String, Option<String>) {
|
||
|
|
let parts: Vec<&str> = file
|
||
|
|
.components()
|
||
|
|
.filter_map(|c| c.as_os_str().to_str())
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
// Find "blog" | "recipes" | "projects" | "activities" in path
|
||
|
|
let kind_idx = parts
|
||
|
|
.iter()
|
||
|
|
.position(|&s| matches!(s, "blog" | "recipes" | "projects" | "activities"));
|
||
|
|
|
||
|
|
let Some(ki) = kind_idx else {
|
||
|
|
return (ContentKind::Blog, "en".into(), None);
|
||
|
|
};
|
||
|
|
|
||
|
|
let kind = match parts[ki] {
|
||
|
|
"recipes" => ContentKind::Recipe,
|
||
|
|
"projects" => ContentKind::Project,
|
||
|
|
"activities" => ContentKind::Activity,
|
||
|
|
_ => ContentKind::Blog,
|
||
|
|
};
|
||
|
|
|
||
|
|
let lang = parts.get(ki + 1).copied().unwrap_or("en").to_owned();
|
||
|
|
let category = if matches!(
|
||
|
|
kind,
|
||
|
|
ContentKind::Blog | ContentKind::Recipe | ContentKind::Activity
|
||
|
|
) {
|
||
|
|
parts.get(ki + 2).map(|s| (*s).to_owned())
|
||
|
|
} else {
|
||
|
|
None
|
||
|
|
};
|
||
|
|
|
||
|
|
(kind, lang, category)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn build_content_url(kind: &ContentKind, lang: &str, category: Option<&str>, slug: &str) -> String {
|
||
|
|
let segment = kind.url_segment_for_lang(lang);
|
||
|
|
match (kind, category) {
|
||
|
|
(ContentKind::Project, _) => format!("/{segment}/{slug}"),
|
||
|
|
(_, Some(cat)) => format!("/{segment}/{cat}/{slug}"),
|
||
|
|
(_, None) => format!("/{segment}/{slug}"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Ontology NCL parsing
|
||
|
|
// ──────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// Configuration for parsing ontology NCL files.
|
||
|
|
pub struct OntologyParserConfig {
|
||
|
|
/// Path to the ontoref ontology directory (contains defaults/core.ncl).
|
||
|
|
/// Provided via `ONTOREF_NICKEL_IMPORT_PATH` environment variable.
|
||
|
|
pub ontoref_import_path: PathBuf,
|
||
|
|
pub instance: OntologyInstance,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Parse a `core.ncl` file and return (nodes, edges).
|
||
|
|
pub fn parse_ontology_core(
|
||
|
|
file: &Path,
|
||
|
|
config: &OntologyParserConfig,
|
||
|
|
) -> Result<(Vec<OntologyNodeRaw>, Vec<OntologyEdgeRaw>), BuildError> {
|
||
|
|
println!("cargo:rerun-if-changed={}", file.display());
|
||
|
|
|
||
|
|
// AN ONTOLOGY RESOLVES ITS IMPORTS AGAINST ITS OWN SPINE.
|
||
|
|
//
|
||
|
|
// `core.ncl` opens with `import "defaults/core.ncl"`, and that path is relative
|
||
|
|
// to the SPINE (`.ontoref/`), not to `ontology/`. The only import paths
|
||
|
|
// passed were a single global `ontoref_import_path` and the file's own
|
||
|
|
// directory — so when two ontologies from DIFFERENT projects are parsed in
|
||
|
|
// one run (the framework's and the implementation's, which is the whole
|
||
|
|
// point of this parser), each was handed the OTHER project's spine and neither
|
||
|
|
// could find its own defaults. One global import path cannot serve two
|
||
|
|
// projects.
|
||
|
|
//
|
||
|
|
// The file's grandparent IS its spine: `<project>/.ontoref/ontology/core.ncl` →
|
||
|
|
// `<project>/.ontoref`.
|
||
|
|
let own_spine = file.parent().and_then(Path::parent);
|
||
|
|
let mut import_paths: Vec<&Path> = vec![config.ontoref_import_path.as_path()];
|
||
|
|
if let Some(sp) = own_spine {
|
||
|
|
import_paths.push(sp);
|
||
|
|
}
|
||
|
|
if let Some(dir) = file.parent() {
|
||
|
|
import_paths.push(dir);
|
||
|
|
}
|
||
|
|
|
||
|
|
let value = export_ncl(file, &import_paths)?;
|
||
|
|
|
||
|
|
let nodes = value
|
||
|
|
.get("nodes")
|
||
|
|
.and_then(Value::as_array)
|
||
|
|
.map(|arr| {
|
||
|
|
arr.iter()
|
||
|
|
.filter_map(|v| serde_json::from_value(v.clone()).ok())
|
||
|
|
.collect()
|
||
|
|
})
|
||
|
|
.unwrap_or_default();
|
||
|
|
|
||
|
|
let edges = value
|
||
|
|
.get("edges")
|
||
|
|
.and_then(Value::as_array)
|
||
|
|
.map(|arr| {
|
||
|
|
arr.iter()
|
||
|
|
.filter_map(|v| serde_json::from_value(v.clone()).ok())
|
||
|
|
.collect()
|
||
|
|
})
|
||
|
|
.unwrap_or_default();
|
||
|
|
|
||
|
|
Ok((nodes, edges))
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── ADR NCL parsing
|
||
|
|
// ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/// Parse a single ADR `.ncl` file.
|
||
|
|
pub fn parse_adr(file: &Path, ontoref_import_path: &Path) -> Result<Option<AdrRaw>, BuildError> {
|
||
|
|
println!("cargo:rerun-if-changed={}", file.display());
|
||
|
|
|
||
|
|
let dir = file.parent().unwrap_or(Path::new("."));
|
||
|
|
let import_paths = [ontoref_import_path, dir];
|
||
|
|
|
||
|
|
let value = export_ncl(file, &import_paths)?;
|
||
|
|
|
||
|
|
// Skip template files (id starts with "_" or is missing)
|
||
|
|
let id = match value.get("id").and_then(Value::as_str) {
|
||
|
|
Some(id) if !id.starts_with('_') => id.to_owned(),
|
||
|
|
_ => return Ok(None),
|
||
|
|
};
|
||
|
|
|
||
|
|
let adr = AdrRaw {
|
||
|
|
id,
|
||
|
|
title: value
|
||
|
|
.get("title")
|
||
|
|
.and_then(Value::as_str)
|
||
|
|
.unwrap_or("Untitled ADR")
|
||
|
|
.to_owned(),
|
||
|
|
status: value
|
||
|
|
.get("status")
|
||
|
|
.and_then(Value::as_str)
|
||
|
|
.unwrap_or("Unknown")
|
||
|
|
.to_owned(),
|
||
|
|
};
|
||
|
|
|
||
|
|
Ok(Some(adr))
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Conversion helpers (used by graph_generator)
|
||
|
|
// ──────────────────────────────
|
||
|
|
|
||
|
|
pub fn ontology_node_to_graph_node(raw: &OntologyNodeRaw, instance: OntologyInstance) -> GraphNode {
|
||
|
|
GraphNode::Ontology {
|
||
|
|
id: raw.id.clone(),
|
||
|
|
name: raw.name.clone(),
|
||
|
|
level: raw.level.clone(),
|
||
|
|
pole: raw.pole.clone(),
|
||
|
|
description: raw.description.clone(),
|
||
|
|
instance,
|
||
|
|
artifact_paths: raw.artifact_paths.clone(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn adr_to_graph_node(raw: &AdrRaw, instance: OntologyInstance) -> GraphNode {
|
||
|
|
GraphNode::Adr {
|
||
|
|
id: raw.id.clone(),
|
||
|
|
title: raw.title.clone(),
|
||
|
|
status: raw.status.clone(),
|
||
|
|
instance,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Helpers
|
||
|
|
// ───────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
fn str_array(value: &Value, key: &str) -> Vec<String> {
|
||
|
|
value
|
||
|
|
.get(key)
|
||
|
|
.and_then(Value::as_array)
|
||
|
|
.map(|arr| {
|
||
|
|
arr.iter()
|
||
|
|
.filter_map(|v| v.as_str().map(str::to_owned))
|
||
|
|
.collect()
|
||
|
|
})
|
||
|
|
.unwrap_or_default()
|
||
|
|
}
|