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 { 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, 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 { 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) { 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, Vec), 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: `/.ontoref/ontology/core.ncl` → // `/.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, 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 { 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() }