Rustelo/features/content-graph/src/build/ncl_parser.rs

336 lines
11 KiB
Rust
Raw Normal View History

2026-07-18 20:14:32 +01:00
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()
}