use crate::{Edge, Node};
use fabryk_core::Result;
use std::path::Path;
pub trait GraphExtractor: Send + Sync {
type NodeData: Clone + Send + Sync;
type EdgeData: Clone + Send + Sync;
fn extract_node(
&self,
base_path: &Path,
file_path: &Path,
frontmatter: &serde_yaml::Value,
content: &str,
) -> Result<Self::NodeData>;
fn extract_edges(
&self,
frontmatter: &serde_yaml::Value,
content: &str,
) -> Result<Option<Self::EdgeData>>;
fn to_graph_node(&self, node_data: &Self::NodeData) -> Node;
fn to_graph_edges(&self, from_id: &str, edge_data: &Self::EdgeData) -> Vec<Edge>;
fn content_glob(&self) -> &str {
"**/*.md"
}
fn name(&self) -> &str {
"unnamed"
}
}
#[cfg(any(test, feature = "test-utils"))]
pub mod mock {
use super::*;
use crate::Relationship;
#[derive(Clone, Debug)]
pub struct MockNodeData {
pub id: String,
pub title: String,
pub category: Option<String>,
}
#[derive(Clone, Debug)]
pub struct MockEdgeData {
pub prerequisites: Vec<String>,
pub related: Vec<String>,
}
#[derive(Clone, Debug, Default)]
pub struct MockExtractor;
impl GraphExtractor for MockExtractor {
type NodeData = MockNodeData;
type EdgeData = MockEdgeData;
fn extract_node(
&self,
_base_path: &Path,
file_path: &Path,
frontmatter: &serde_yaml::Value,
_content: &str,
) -> Result<Self::NodeData> {
let id = fabryk_core::util::ids::id_from_path(file_path)
.ok_or_else(|| fabryk_core::Error::parse("no file stem"))?;
let title = frontmatter
.get("title")
.and_then(|v| v.as_str())
.unwrap_or(&id)
.to_string();
let category = frontmatter
.get("category")
.and_then(|v| v.as_str())
.map(String::from);
Ok(MockNodeData {
id,
title,
category,
})
}
fn extract_edges(
&self,
frontmatter: &serde_yaml::Value,
_content: &str,
) -> Result<Option<Self::EdgeData>> {
let prerequisites: Vec<String> = frontmatter
.get("prerequisites")
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str())
.map(String::from)
.collect()
})
.unwrap_or_default();
let related: Vec<String> = frontmatter
.get("related")
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|v| v.as_str())
.map(String::from)
.collect()
})
.unwrap_or_default();
if prerequisites.is_empty() && related.is_empty() {
Ok(None)
} else {
Ok(Some(MockEdgeData {
prerequisites,
related,
}))
}
}
fn to_graph_node(&self, node_data: &Self::NodeData) -> Node {
let mut node = Node::new(&node_data.id, &node_data.title);
if let Some(ref cat) = node_data.category {
node = node.with_category(cat);
}
node
}
fn to_graph_edges(&self, from_id: &str, edge_data: &Self::EdgeData) -> Vec<Edge> {
let mut edges = Vec::new();
for prereq in &edge_data.prerequisites {
edges.push(Edge::new(from_id, prereq, Relationship::Prerequisite));
}
for related in &edge_data.related {
edges.push(Edge::new(from_id, related, Relationship::RelatesTo));
}
edges
}
fn name(&self) -> &str {
"mock"
}
}
}
#[cfg(test)]
mod tests {
use super::mock::*;
use super::*;
use crate::Relationship;
use std::path::PathBuf;
fn sample_frontmatter() -> serde_yaml::Value {
serde_yaml::from_str(
r#"
title: "Test Concept"
category: "test-category"
prerequisites:
- prereq-a
- prereq-b
related:
- related-x
"#,
)
.unwrap()
}
#[test]
fn test_mock_extractor_extract_node() {
let extractor = MockExtractor;
let base_path = PathBuf::from("/data/concepts");
let file_path = PathBuf::from("/data/concepts/harmony/test-concept.md");
let frontmatter = sample_frontmatter();
let node_data = extractor
.extract_node(&base_path, &file_path, &frontmatter, "content")
.unwrap();
assert_eq!(node_data.id, "test-concept");
assert_eq!(node_data.title, "Test Concept");
assert_eq!(node_data.category, Some("test-category".to_string()));
}
#[test]
fn test_mock_extractor_extract_edges() {
let extractor = MockExtractor;
let frontmatter = sample_frontmatter();
let edge_data = extractor
.extract_edges(&frontmatter, "content")
.unwrap()
.unwrap();
assert_eq!(edge_data.prerequisites, vec!["prereq-a", "prereq-b"]);
assert_eq!(edge_data.related, vec!["related-x"]);
}
#[test]
fn test_mock_extractor_extract_edges_none() {
let extractor = MockExtractor;
let frontmatter = serde_yaml::from_str("title: Test").unwrap();
let edge_data = extractor.extract_edges(&frontmatter, "content").unwrap();
assert!(edge_data.is_none());
}
#[test]
fn test_mock_extractor_to_graph_node() {
let extractor = MockExtractor;
let node_data = MockNodeData {
id: "test-id".to_string(),
title: "Test Title".to_string(),
category: Some("test-cat".to_string()),
};
let node = extractor.to_graph_node(&node_data);
assert_eq!(node.id, "test-id");
assert_eq!(node.title, "Test Title");
assert_eq!(node.category, Some("test-cat".to_string()));
}
#[test]
fn test_mock_extractor_to_graph_node_no_category() {
let extractor = MockExtractor;
let node_data = MockNodeData {
id: "x".to_string(),
title: "X".to_string(),
category: None,
};
let node = extractor.to_graph_node(&node_data);
assert!(node.category.is_none());
}
#[test]
fn test_mock_extractor_to_graph_edges() {
let extractor = MockExtractor;
let edge_data = MockEdgeData {
prerequisites: vec!["a".to_string(), "b".to_string()],
related: vec!["x".to_string()],
};
let edges = extractor.to_graph_edges("from-node", &edge_data);
assert_eq!(edges.len(), 3);
assert!(
edges
.iter()
.any(|e| e.to == "a" && e.relationship == Relationship::Prerequisite)
);
assert!(
edges
.iter()
.any(|e| e.to == "b" && e.relationship == Relationship::Prerequisite)
);
assert!(
edges
.iter()
.any(|e| e.to == "x" && e.relationship == Relationship::RelatesTo)
);
assert!(edges.iter().all(|e| e.from == "from-node"));
}
#[test]
fn test_mock_extractor_to_graph_edges_empty() {
let extractor = MockExtractor;
let edge_data = MockEdgeData {
prerequisites: vec![],
related: vec![],
};
let edges = extractor.to_graph_edges("from-node", &edge_data);
assert!(edges.is_empty());
}
#[test]
fn test_extractor_default_methods() {
let extractor = MockExtractor;
assert_eq!(extractor.content_glob(), "**/*.md");
assert_eq!(extractor.name(), "mock");
}
}