chore: add crated
This commit is contained in:
parent
9ea04852a8
commit
5209d58828
42 changed files with 16938 additions and 0 deletions
6908
Cargo.lock
generated
Normal file
6908
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
79
Cargo.toml
Normal file
79
Cargo.toml
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/kogral-core",
|
||||
"crates/kogral-mcp",
|
||||
"crates/kogral-cli",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.70"
|
||||
authors = ["Tools Ecosystem"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
homepage = "https://kogral.dev"
|
||||
repository = "https://github.com/vapora/kogral"
|
||||
description = "KOGRAL - Git-native knowledge graphs for developer teams"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
toml = "0.9"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.49", features = ["full"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
|
||||
# Markdown parsing
|
||||
pulldown-cmark = "0.13"
|
||||
|
||||
# Template engine
|
||||
tera = "1.20"
|
||||
|
||||
# Embeddings
|
||||
rig-core = "0.28"
|
||||
|
||||
# Storage
|
||||
surrealdb = "2.4"
|
||||
dashmap = "6.1"
|
||||
|
||||
# File watching
|
||||
notify = "8.2"
|
||||
|
||||
# Time handling
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# UUID generation
|
||||
uuid = { version = "1.19", features = ["v4", "serde"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# CLI
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
colored = "3.0"
|
||||
dirs = "6.0"
|
||||
|
||||
# Testing
|
||||
mockito = "1.7"
|
||||
tempfile = "3.24"
|
||||
|
||||
# Async utilities
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
|
||||
# File operations
|
||||
walkdir = "2.5"
|
||||
regex = "1.12"
|
||||
once_cell = "1.20"
|
||||
|
||||
# Embeddings (optional)
|
||||
fastembed = "5.8"
|
||||
26
crates/kogral-cli/Cargo.toml
Normal file
26
crates/kogral-cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[package]
|
||||
name = "kogral-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
description = "KOGRAL CLI - Command-line interface for git-native knowledge graphs"
|
||||
|
||||
[[bin]]
|
||||
name = "kogral"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
kogral-core = { path = "../kogral-core" }
|
||||
clap = { workspace = true, features = ["derive", "env"] }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
colored = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
1328
crates/kogral-cli/src/main.rs
Normal file
1328
crates/kogral-cli/src/main.rs
Normal file
File diff suppressed because it is too large
Load diff
49
crates/kogral-core/Cargo.toml
Normal file
49
crates/kogral-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
[package]
|
||||
name = "kogral-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
description = "KOGRAL core library - Config-driven knowledge graph engine with multi-backend storage"
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
pulldown-cmark = { workspace = true }
|
||||
tera = { workspace = true }
|
||||
rig-core = { workspace = true }
|
||||
surrealdb = { workspace = true, optional = true }
|
||||
dashmap = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Additional dependencies
|
||||
async-trait = { workspace = true }
|
||||
fastembed = { workspace = true, optional = true }
|
||||
regex = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
walkdir = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
mockito = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = ["filesystem"]
|
||||
filesystem = []
|
||||
surrealdb-backend = ["surrealdb"]
|
||||
fastembed = ["dep:fastembed"]
|
||||
full = ["surrealdb-backend", "fastembed"]
|
||||
475
crates/kogral-core/src/block_parser.rs
Normal file
475
crates/kogral-core/src/block_parser.rs
Normal file
|
|
@ -0,0 +1,475 @@
|
|||
//! Block parser for Logseq-compatible content blocks
|
||||
//!
|
||||
//! Parses markdown content in outliner format (bullet lists) into structured
|
||||
//! Block objects. Supports:
|
||||
//! - Task markers (TODO, DONE, DOING, etc.)
|
||||
//! - Tags (#card, #important)
|
||||
//! - Custom properties (`property::` value)
|
||||
//! - Block references ((uuid))
|
||||
//! - Page references [[page]]
|
||||
//! - Nested hierarchy (indentation)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models::{Block, TaskStatus};
|
||||
use crate::regex_patterns::{
|
||||
PROPERTY_START_PATTERN, TAG_PATTERN, PROPERTY_INLINE_PATTERN, UUID_REF_PATTERN,
|
||||
LOGSEQ_WIKILINK_PATTERN,
|
||||
};
|
||||
|
||||
/// Parser for converting markdown outliner format to/from Block structures
|
||||
pub struct BlockParser;
|
||||
|
||||
impl BlockParser {
|
||||
/// Parse markdown content into a list of blocks
|
||||
///
|
||||
/// # Format
|
||||
///
|
||||
/// ```markdown
|
||||
/// - Block 1 #tag
|
||||
/// - Nested block
|
||||
/// - TODO Block 2
|
||||
/// priority:: high
|
||||
/// - Block with [[page-ref]] and ((block-ref))
|
||||
/// ```
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the markdown content contains invalid block syntax
|
||||
/// or if parsing of individual block lines fails.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// May panic if internal regex compilation fails (should never happen with
|
||||
/// hardcoded patterns) or if stack operations fail unexpectedly.
|
||||
pub fn parse(content: &str) -> Result<Vec<Block>> {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
if lines.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut blocks = Vec::new();
|
||||
let mut stack: Vec<(usize, Block)> = Vec::new(); // (indent_level, block)
|
||||
|
||||
for line in lines {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let indent = count_indent(line);
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Check if this is a property line (key:: value without bullet)
|
||||
if !trimmed.starts_with('-') && !trimmed.starts_with('*') {
|
||||
// Try to parse as a property and add to the last block
|
||||
Self::try_add_property_to_last_block(&mut stack, trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove bullet marker
|
||||
let content_after_bullet = trimmed[1..].trim();
|
||||
|
||||
// Parse the block
|
||||
let block = Self::parse_block_line(content_after_bullet);
|
||||
|
||||
// Handle hierarchy based on indentation
|
||||
if stack.is_empty() {
|
||||
// First block or root level
|
||||
stack.push((indent, block));
|
||||
} else {
|
||||
// Pop blocks with greater or equal indentation and add to their parents
|
||||
Self::flush_stack_to_indent(&mut stack, &mut blocks, indent);
|
||||
stack.push((indent, block));
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining blocks in stack
|
||||
while let Some((_, block)) = stack.pop() {
|
||||
if let Some((_, parent)) = stack.last_mut() {
|
||||
parent.add_child(block);
|
||||
} else {
|
||||
blocks.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(blocks)
|
||||
}
|
||||
|
||||
/// Flush stack entries with indentation >= target indent, adding them to
|
||||
/// blocks or parents
|
||||
fn flush_stack_to_indent(
|
||||
stack: &mut Vec<(usize, Block)>,
|
||||
blocks: &mut Vec<Block>,
|
||||
target_indent: usize,
|
||||
) {
|
||||
while let Some((parent_indent, _)) = stack.last() {
|
||||
if target_indent > *parent_indent {
|
||||
break;
|
||||
}
|
||||
|
||||
let (_, completed_block) = stack.pop().unwrap();
|
||||
|
||||
// Add to parent or root
|
||||
if let Some((_, parent)) = stack.last_mut() {
|
||||
parent.add_child(completed_block);
|
||||
} else {
|
||||
blocks.push(completed_block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to parse a property line and add it to the last block in the stack
|
||||
fn try_add_property_to_last_block(stack: &mut [(usize, Block)], line: &str) {
|
||||
if let Some((_, last_block)) = stack.last_mut() {
|
||||
if let Some(cap) = PROPERTY_START_PATTERN.captures(line) {
|
||||
let key = cap[1].to_string();
|
||||
let value = cap[2].to_string();
|
||||
last_block.properties.set_property(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single block line (without bullet marker)
|
||||
fn parse_block_line(line: &str) -> Block {
|
||||
let (task_status, after_status) = Self::extract_task_status(line);
|
||||
let (tags, after_tags) = Self::extract_tags(&after_status);
|
||||
let (properties, content) = Self::extract_properties(&after_tags);
|
||||
let block_refs = Self::extract_block_refs(&content);
|
||||
let page_refs = Self::extract_page_refs(&content);
|
||||
|
||||
let mut block = Block::new(content.trim().to_string());
|
||||
block.properties.status = task_status;
|
||||
block.properties.tags = tags;
|
||||
block.properties.custom = properties;
|
||||
block.properties.block_refs = block_refs;
|
||||
block.properties.page_refs = page_refs;
|
||||
|
||||
block
|
||||
}
|
||||
|
||||
/// Extract task status from line start
|
||||
fn extract_task_status(line: &str) -> (Option<TaskStatus>, String) {
|
||||
let trimmed = line.trim();
|
||||
|
||||
let status = if trimmed.starts_with("TODO ") {
|
||||
Some(TaskStatus::Todo)
|
||||
} else if trimmed.starts_with("DOING ") {
|
||||
Some(TaskStatus::Doing)
|
||||
} else if trimmed.starts_with("DONE ") {
|
||||
Some(TaskStatus::Done)
|
||||
} else if trimmed.starts_with("LATER ") {
|
||||
Some(TaskStatus::Later)
|
||||
} else if trimmed.starts_with("NOW ") {
|
||||
Some(TaskStatus::Now)
|
||||
} else if trimmed.starts_with("WAITING ") {
|
||||
Some(TaskStatus::Waiting)
|
||||
} else if trimmed.starts_with("CANCELLED ") {
|
||||
Some(TaskStatus::Cancelled)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match status {
|
||||
Some(s) => {
|
||||
let status_str = s.to_string();
|
||||
let after = trimmed.trim_start_matches(&status_str).trim();
|
||||
(Some(s), after.to_string())
|
||||
}
|
||||
None => (None, trimmed.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract hashtags from content
|
||||
fn extract_tags(content: &str) -> (Vec<String>, String) {
|
||||
let mut tags = Vec::new();
|
||||
let mut clean_content = content.to_string();
|
||||
|
||||
for cap in TAG_PATTERN.captures_iter(content) {
|
||||
tags.push(cap[1].to_string());
|
||||
}
|
||||
|
||||
// Remove tags from content
|
||||
clean_content = TAG_PATTERN.replace_all(&clean_content, "").to_string();
|
||||
|
||||
(tags, clean_content)
|
||||
}
|
||||
|
||||
/// Extract custom properties (`key::` value format)
|
||||
fn extract_properties(content: &str) -> (HashMap<String, String>, String) {
|
||||
let mut properties = HashMap::new();
|
||||
let mut clean_content = content.to_string();
|
||||
|
||||
for cap in PROPERTY_INLINE_PATTERN.captures_iter(content) {
|
||||
let key = cap[1].to_string();
|
||||
let value = cap[2].to_string();
|
||||
properties.insert(key, value);
|
||||
}
|
||||
|
||||
// Remove properties from content
|
||||
clean_content = PROPERTY_INLINE_PATTERN.replace_all(&clean_content, "").to_string();
|
||||
|
||||
(properties, clean_content)
|
||||
}
|
||||
|
||||
/// Extract block references ((uuid))
|
||||
fn extract_block_refs(content: &str) -> Vec<String> {
|
||||
UUID_REF_PATTERN
|
||||
.captures_iter(content)
|
||||
.map(|cap| cap[1].to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract page references [[page]]
|
||||
fn extract_page_refs(content: &str) -> Vec<String> {
|
||||
LOGSEQ_WIKILINK_PATTERN
|
||||
.captures_iter(content)
|
||||
.map(|cap| cap[1].to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Serialize blocks back to markdown outliner format
|
||||
#[must_use]
|
||||
pub fn serialize(blocks: &[Block]) -> String {
|
||||
let mut output = String::new();
|
||||
for block in blocks {
|
||||
Self::serialize_block(block, 0, &mut output);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
/// Serialize a single block and its children recursively
|
||||
fn serialize_block(block: &Block, indent_level: usize, output: &mut String) {
|
||||
let indent = " ".repeat(indent_level);
|
||||
|
||||
// Start with bullet
|
||||
output.push_str(&indent);
|
||||
output.push_str("- ");
|
||||
|
||||
// Add task status if present
|
||||
if let Some(status) = block.properties.status {
|
||||
let _ = write!(output, "{status} ");
|
||||
}
|
||||
|
||||
// Add content
|
||||
output.push_str(&block.content);
|
||||
|
||||
// Add tags
|
||||
for tag in &block.properties.tags {
|
||||
let _ = write!(output, " #{tag}");
|
||||
}
|
||||
|
||||
// Add page references (if not already in content)
|
||||
for page_ref in &block.properties.page_refs {
|
||||
if !block.content.contains(&format!("[[{page_ref}]]")) {
|
||||
let _ = write!(output, " [[{page_ref}]]");
|
||||
}
|
||||
}
|
||||
|
||||
// Add block references (if not already in content)
|
||||
for block_ref in &block.properties.block_refs {
|
||||
if !block.content.contains(&format!("(({block_ref}))")) {
|
||||
let _ = write!(output, " (({block_ref}))");
|
||||
}
|
||||
}
|
||||
|
||||
output.push('\n');
|
||||
|
||||
// Add custom properties on next line(s) if present
|
||||
for (key, value) in &block.properties.custom {
|
||||
output.push_str(&indent);
|
||||
output.push_str(" ");
|
||||
let _ = writeln!(output, "{key}:: {value}");
|
||||
}
|
||||
|
||||
// Recursively serialize children
|
||||
for child in &block.children {
|
||||
Self::serialize_block(child, indent_level + 1, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Count leading whitespace (indentation level)
|
||||
fn count_indent(line: &str) -> usize {
|
||||
line.chars().take_while(|c| c.is_whitespace()).count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_block() {
|
||||
let content = "- Simple block";
|
||||
let blocks = BlockParser::parse(content).unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(blocks[0].content, "Simple block");
|
||||
assert!(blocks[0].properties.status.is_none());
|
||||
assert!(blocks[0].properties.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_task_block() {
|
||||
let content = "- TODO Complete this task";
|
||||
let blocks = BlockParser::parse(content).unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(blocks[0].properties.status, Some(TaskStatus::Todo));
|
||||
assert_eq!(blocks[0].content, "Complete this task");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_block_with_tags() {
|
||||
let content = "- This is a #card with #tags";
|
||||
let blocks = BlockParser::parse(content).unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(blocks[0].properties.tags.len(), 2);
|
||||
assert!(blocks[0].properties.tags.contains(&"card".to_string()));
|
||||
assert!(blocks[0].properties.tags.contains(&"tags".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_block_with_properties() {
|
||||
let content = "- Block with property\n priority:: high";
|
||||
let blocks = BlockParser::parse(content).unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(
|
||||
blocks[0].properties.get_property("priority"),
|
||||
Some(&"high".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_nested_blocks() {
|
||||
let content = r#"- Parent block
|
||||
- Child block 1
|
||||
- Child block 2
|
||||
- Grandchild block"#;
|
||||
|
||||
let blocks = BlockParser::parse(content).unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(blocks[0].content, "Parent block");
|
||||
assert_eq!(blocks[0].children.len(), 2);
|
||||
assert_eq!(blocks[0].children[0].content, "Child block 1");
|
||||
assert_eq!(blocks[0].children[1].content, "Child block 2");
|
||||
assert_eq!(blocks[0].children[1].children.len(), 1);
|
||||
assert_eq!(
|
||||
blocks[0].children[1].children[0].content,
|
||||
"Grandchild block"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_page_references() {
|
||||
let content = "- Block with [[Page Reference]]";
|
||||
let blocks = BlockParser::parse(content).unwrap();
|
||||
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(blocks[0].properties.page_refs.len(), 1);
|
||||
assert_eq!(blocks[0].properties.page_refs[0], "Page Reference");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_simple_block() {
|
||||
let block = Block::new("Simple block".to_string());
|
||||
let markdown = BlockParser::serialize(&[block]);
|
||||
|
||||
assert_eq!(markdown.trim(), "- Simple block");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_task_block() {
|
||||
let mut block = Block::new("Complete this task".to_string());
|
||||
block.properties.status = Some(TaskStatus::Todo);
|
||||
let markdown = BlockParser::serialize(&[block]);
|
||||
|
||||
assert_eq!(markdown.trim(), "- TODO Complete this task");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_block_with_tags() {
|
||||
let mut block = Block::new("Card content".to_string());
|
||||
block.properties.add_tag("card".to_string());
|
||||
block.properties.add_tag("important".to_string());
|
||||
let markdown = BlockParser::serialize(&[block]);
|
||||
|
||||
assert!(markdown.contains("#card"));
|
||||
assert!(markdown.contains("#important"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_nested_blocks() {
|
||||
let mut parent = Block::new("Parent".to_string());
|
||||
let child1 = Block::new("Child 1".to_string());
|
||||
let child2 = Block::new("Child 2".to_string());
|
||||
|
||||
parent.add_child(child1);
|
||||
parent.add_child(child2);
|
||||
|
||||
let markdown = BlockParser::serialize(&[parent]);
|
||||
|
||||
assert!(markdown.contains("- Parent"));
|
||||
assert!(markdown.contains(" - Child 1"));
|
||||
assert!(markdown.contains(" - Child 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_trip() {
|
||||
let original = r#"- TODO Parent task #important
|
||||
- Child note
|
||||
- DONE Completed subtask"#;
|
||||
|
||||
let blocks = BlockParser::parse(original).unwrap();
|
||||
let serialized = BlockParser::serialize(&blocks);
|
||||
let reparsed = BlockParser::parse(&serialized).unwrap();
|
||||
|
||||
// Verify structure is preserved
|
||||
assert_eq!(blocks.len(), reparsed.len());
|
||||
assert_eq!(blocks[0].properties.status, reparsed[0].properties.status);
|
||||
assert_eq!(blocks[0].children.len(), reparsed[0].children.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_task_status() {
|
||||
let (status, content) = BlockParser::extract_task_status("TODO Task content");
|
||||
assert_eq!(status, Some(TaskStatus::Todo));
|
||||
assert_eq!(content, "Task content");
|
||||
|
||||
let (status, content) = BlockParser::extract_task_status("DONE Completed task");
|
||||
assert_eq!(status, Some(TaskStatus::Done));
|
||||
assert_eq!(content, "Completed task");
|
||||
|
||||
let (status, content) = BlockParser::extract_task_status("No task here");
|
||||
assert_eq!(status, None);
|
||||
assert_eq!(content, "No task here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tags() {
|
||||
let (tags, content) = BlockParser::extract_tags("Text with #tag1 and #tag2");
|
||||
assert_eq!(tags.len(), 2);
|
||||
assert!(tags.contains(&"tag1".to_string()));
|
||||
assert!(tags.contains(&"tag2".to_string()));
|
||||
assert!(content.contains("Text with"));
|
||||
assert!(content.contains("and"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_properties() {
|
||||
let (props, _content) = BlockParser::extract_properties("priority:: high status:: active");
|
||||
assert_eq!(props.get("priority"), Some(&"high".to_string()));
|
||||
assert_eq!(props.get("status"), Some(&"active".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_indent() {
|
||||
assert_eq!(count_indent("- No indent"), 0);
|
||||
assert_eq!(count_indent(" - Two spaces"), 2);
|
||||
assert_eq!(count_indent(" - Four spaces"), 4);
|
||||
}
|
||||
}
|
||||
472
crates/kogral-core/src/config/loader.rs
Normal file
472
crates/kogral-core/src/config/loader.rs
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
//! Configuration loader with multi-format support
|
||||
//!
|
||||
//! Loads configuration from:
|
||||
//! 1. Nickel (.ncl) - preferred, type-safe
|
||||
//! 2. TOML (.toml) - fallback
|
||||
//! 3. JSON (.json) - fallback
|
||||
//! 4. Defaults - if no config file found
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::config::schema::KbConfig;
|
||||
use crate::error::{KbError, Result};
|
||||
|
||||
/// Configuration file search paths (in order of preference)
|
||||
const CONFIG_FILE_NAMES: &[&str] = &[
|
||||
".kogral/config.ncl",
|
||||
".kogral/config.toml",
|
||||
".kogral/config.json",
|
||||
"config/kb.ncl",
|
||||
"config/kb.toml",
|
||||
"config/kb.json",
|
||||
];
|
||||
|
||||
/// Load configuration from the current directory or specified path
|
||||
///
|
||||
/// Search order:
|
||||
/// 1. Specified path (if provided)
|
||||
/// 2. `.kogral/config.ncl` (Nickel, preferred)
|
||||
/// 3. `.kogral/config.toml` (TOML fallback)
|
||||
/// 4. `.kogral/config.json` (JSON fallback)
|
||||
/// 5. `config/kb.{ncl,toml,json}` (alternate location)
|
||||
/// 6. Default configuration (if no file found)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `base_dir` - Base directory to search from (defaults to current directory)
|
||||
/// * `config_path` - Optional explicit config file path
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Loaded `KbConfig` or default if no config found
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if config file exists but cannot be read or parsed
|
||||
pub fn load_config(base_dir: Option<&Path>, config_path: Option<&Path>) -> Result<KbConfig> {
|
||||
// If explicit path provided, load directly
|
||||
if let Some(path) = config_path {
|
||||
info!("Loading config from explicit path: {}", path.display());
|
||||
return load_config_from_file(path);
|
||||
}
|
||||
|
||||
let search_base = base_dir.unwrap_or_else(|| Path::new("."));
|
||||
|
||||
// Search for config files in order of preference
|
||||
for file_name in CONFIG_FILE_NAMES {
|
||||
let path = search_base.join(file_name);
|
||||
if path.exists() {
|
||||
info!("Found config file: {}", path.display());
|
||||
return load_config_from_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
// No config file found, use defaults
|
||||
warn!(
|
||||
"No config file found in {}. Using defaults.",
|
||||
search_base.display()
|
||||
);
|
||||
warn!("Searched paths: {:?}", CONFIG_FILE_NAMES);
|
||||
|
||||
// Infer graph name from directory
|
||||
let graph_name = search_base
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("default")
|
||||
.to_string();
|
||||
|
||||
Ok(KbConfig::default_for_graph(graph_name))
|
||||
}
|
||||
|
||||
/// Load configuration from a specific file
|
||||
///
|
||||
/// Detects format from extension: .ncl, .toml, .json
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if file cannot be read, parsed, or has unsupported
|
||||
/// extension
|
||||
pub fn load_config_from_file<P: AsRef<Path>>(path: P) -> Result<KbConfig> {
|
||||
let path = path.as_ref();
|
||||
|
||||
if !path.exists() {
|
||||
return Err(KbError::Config(format!(
|
||||
"Config file not found: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let extension = path.extension().and_then(|e| e.to_str()).ok_or_else(|| {
|
||||
KbError::Config(format!(
|
||||
"Cannot determine config format: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
match extension {
|
||||
"ncl" => load_from_nickel(path),
|
||||
"toml" => load_from_toml(path),
|
||||
"json" => load_from_json(path),
|
||||
other => Err(KbError::Config(format!(
|
||||
"Unsupported config format: .{other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load config from Nickel file
|
||||
#[cfg(not(test))]
|
||||
fn load_from_nickel<P: AsRef<Path>>(path: P) -> Result<KbConfig> {
|
||||
use super::nickel::load_nickel_config;
|
||||
info!("Loading config from Nickel: {}", path.as_ref().display());
|
||||
load_nickel_config(path)
|
||||
}
|
||||
|
||||
/// Test stub for Nickel loading
|
||||
#[cfg(test)]
|
||||
fn load_from_nickel<P: AsRef<Path>>(path: P) -> Result<KbConfig> {
|
||||
// In tests, fall back to TOML/JSON or return error
|
||||
Err(KbError::Config(format!(
|
||||
"Nickel loading not available in tests: {}",
|
||||
path.as_ref().display()
|
||||
)))
|
||||
}
|
||||
|
||||
/// Load config from TOML file
|
||||
fn load_from_toml<P: AsRef<Path>>(path: P) -> Result<KbConfig> {
|
||||
let path = path.as_ref();
|
||||
info!("Loading config from TOML: {}", path.display());
|
||||
|
||||
let content = fs::read_to_string(path)?;
|
||||
let config: KbConfig = toml::from_str(&content)?;
|
||||
|
||||
debug!("Config loaded from TOML: {}", path.display());
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Load config from JSON file
|
||||
fn load_from_json<P: AsRef<Path>>(path: P) -> Result<KbConfig> {
|
||||
let path = path.as_ref();
|
||||
info!("Loading config from JSON: {}", path.display());
|
||||
|
||||
let content = fs::read_to_string(path)?;
|
||||
let config: KbConfig = serde_json::from_str(&content)?;
|
||||
|
||||
// Resolve environment variables in paths
|
||||
let config = config.resolve_paths();
|
||||
|
||||
debug!("Config loaded from JSON: {}", path.display());
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Find .kb directory by walking up from current directory
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Path to .kb directory if found, None otherwise
|
||||
#[must_use]
|
||||
pub fn find_kogral_directory() -> Option<PathBuf> {
|
||||
let mut current = std::env::current_dir().ok()?;
|
||||
|
||||
loop {
|
||||
let kogral_dir = current.join(".kogral");
|
||||
if kogral_dir.is_dir() {
|
||||
return Some(kogral_dir);
|
||||
}
|
||||
|
||||
// Try parent directory
|
||||
if !current.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Initialize a new .kb directory with default config
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Base directory to create .kb in
|
||||
/// * `format` - Config file format ("ncl", "toml", or "json")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Path to created config file
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if directories cannot be created or config file cannot be
|
||||
/// written
|
||||
pub fn init_kogral_directory<P: AsRef<Path>>(path: P, format: &str) -> Result<PathBuf> {
|
||||
let base = path.as_ref();
|
||||
let kogral_dir = base.join(".kogral");
|
||||
|
||||
// Create directory structure
|
||||
fs::create_dir_all(&kogral_dir)?;
|
||||
fs::create_dir_all(kogral_dir.join("notes"))?;
|
||||
fs::create_dir_all(kogral_dir.join("decisions"))?;
|
||||
fs::create_dir_all(kogral_dir.join("guidelines"))?;
|
||||
fs::create_dir_all(kogral_dir.join("patterns"))?;
|
||||
fs::create_dir_all(kogral_dir.join("journal"))?;
|
||||
|
||||
// Infer graph name from directory
|
||||
let graph_name = base
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("default")
|
||||
.to_string();
|
||||
|
||||
let config = KbConfig::default_for_graph(graph_name);
|
||||
|
||||
// Write config file in requested format
|
||||
let config_file = match format {
|
||||
"toml" => {
|
||||
let path = kogral_dir.join("config.toml");
|
||||
let content = toml::to_string_pretty(&config)?;
|
||||
fs::write(&path, content)?;
|
||||
path
|
||||
}
|
||||
"json" => {
|
||||
let path = kogral_dir.join("config.json");
|
||||
let content = serde_json::to_string_pretty(&config)?;
|
||||
fs::write(&path, content)?;
|
||||
path
|
||||
}
|
||||
"ncl" => {
|
||||
// For Nickel, we'd generate a .ncl file, but for now just write TOML
|
||||
// and instruct user to convert
|
||||
let path = kogral_dir.join("config.toml");
|
||||
let content = toml::to_string_pretty(&config)?;
|
||||
fs::write(&path, content)?;
|
||||
warn!("Generated TOML config. Convert to Nickel manually for type safety.");
|
||||
path
|
||||
}
|
||||
other => return Err(KbError::Config(format!("Unsupported init format: {other}"))),
|
||||
};
|
||||
|
||||
info!("Initialized .kb directory at: {}", kogral_dir.display());
|
||||
info!("Config file: {}", config_file.display());
|
||||
|
||||
Ok(config_file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_load_config_no_file_uses_defaults() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config = load_config(Some(dir.path()), None).unwrap();
|
||||
assert_eq!(config.graph.version, "1.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_from_toml() {
|
||||
let dir = tempdir().unwrap();
|
||||
let kogral_dir = dir.path().join(".kogral");
|
||||
fs::create_dir(&kogral_dir).unwrap();
|
||||
let config_path = kogral_dir.join("config.toml");
|
||||
|
||||
let toml_content = r#"
|
||||
[graph]
|
||||
name = "test-graph"
|
||||
version = "2.0.0"
|
||||
description = "Test description"
|
||||
|
||||
[inheritance]
|
||||
base = "/tmp/shared"
|
||||
priority = 200
|
||||
|
||||
[storage]
|
||||
primary = "filesystem"
|
||||
|
||||
[embeddings]
|
||||
enabled = true
|
||||
provider = "fastembed"
|
||||
model = "test-model"
|
||||
dimensions = 512
|
||||
|
||||
[query]
|
||||
similarity_threshold = 0.5
|
||||
max_results = 20
|
||||
recency_weight = 3.0
|
||||
cross_graph = true
|
||||
|
||||
[templates]
|
||||
templates_dir = "templates"
|
||||
|
||||
[mcp.server]
|
||||
name = "kogral-mcp"
|
||||
version = "1.0.0"
|
||||
transport = "stdio"
|
||||
|
||||
[sync]
|
||||
auto_index = true
|
||||
watch_paths = ["notes", "decisions"]
|
||||
debounce_ms = 500
|
||||
"#;
|
||||
|
||||
fs::write(&config_path, toml_content).unwrap();
|
||||
|
||||
let config = load_config_from_file(&config_path).unwrap();
|
||||
assert_eq!(config.graph.name, "test-graph");
|
||||
assert_eq!(config.graph.version, "2.0.0");
|
||||
assert_eq!(config.inheritance.priority, 200);
|
||||
assert_eq!(config.embeddings.dimensions, 512);
|
||||
assert_eq!(config.query.max_results, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_from_json() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config_path = dir.path().join("config.json");
|
||||
|
||||
let json_content = r#"{
|
||||
"graph": {
|
||||
"name": "json-graph",
|
||||
"version": "1.5.0",
|
||||
"description": ""
|
||||
},
|
||||
"inheritance": {
|
||||
"base": "/tmp/tools/.kogral-shared",
|
||||
"guidelines": [],
|
||||
"priority": 100
|
||||
},
|
||||
"storage": {
|
||||
"primary": "filesystem",
|
||||
"secondary": {
|
||||
"enabled": false,
|
||||
"type": "surrealdb",
|
||||
"url": "ws://localhost:8000",
|
||||
"namespace": "kogral",
|
||||
"database": "default"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
"enabled": true,
|
||||
"provider": "fastembed",
|
||||
"model": "BAAI/bge-small-en-v1.5",
|
||||
"dimensions": 384,
|
||||
"api_key_env": "OPENAI_API_KEY"
|
||||
},
|
||||
"templates": {
|
||||
"templates_dir": "templates",
|
||||
"templates": {
|
||||
"note": "note.md.tera",
|
||||
"decision": "decision.md.tera",
|
||||
"guideline": "guideline.md.tera",
|
||||
"pattern": "pattern.md.tera",
|
||||
"journal": "journal.md.tera",
|
||||
"execution": "execution.md.tera"
|
||||
},
|
||||
"export": {
|
||||
"logseq_page": "export/logseq-page.md.tera",
|
||||
"logseq_journal": "export/logseq-journal.md.tera",
|
||||
"summary": "export/summary.md.tera",
|
||||
"json": "export/graph.json.tera"
|
||||
},
|
||||
"custom": {}
|
||||
},
|
||||
"query": {
|
||||
"similarity_threshold": 0.4,
|
||||
"max_results": 10,
|
||||
"recency_weight": 3.0,
|
||||
"cross_graph": true
|
||||
},
|
||||
"mcp": {
|
||||
"server": {
|
||||
"name": "kogral-mcp",
|
||||
"version": "1.0.0",
|
||||
"transport": "stdio"
|
||||
},
|
||||
"tools": {
|
||||
"search": true,
|
||||
"add_note": true,
|
||||
"add_decision": true,
|
||||
"link": true,
|
||||
"get_guidelines": true,
|
||||
"export": true
|
||||
},
|
||||
"resources": {
|
||||
"expose_project": true,
|
||||
"expose_shared": true
|
||||
}
|
||||
},
|
||||
"sync": {
|
||||
"auto_index": true,
|
||||
"watch_paths": ["notes", "decisions", "guidelines", "patterns", "journal"],
|
||||
"debounce_ms": 500
|
||||
}
|
||||
}"#;
|
||||
|
||||
fs::write(&config_path, json_content).unwrap();
|
||||
|
||||
let config = load_config_from_file(&config_path).unwrap();
|
||||
assert_eq!(config.graph.name, "json-graph");
|
||||
assert_eq!(config.graph.version, "1.5.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_nonexistent_file() {
|
||||
let result = load_config_from_file("/nonexistent/config.toml");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_config_unsupported_format() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config_path = dir.path().join("config.yaml");
|
||||
fs::write(&config_path, "test: value").unwrap();
|
||||
|
||||
let result = load_config_from_file(&config_path);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_kogral_directory_toml() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config_file = init_kogral_directory(dir.path(), "toml").unwrap();
|
||||
|
||||
assert!(config_file.exists());
|
||||
assert!(dir.path().join(".kogral").is_dir());
|
||||
assert!(dir.path().join(".kogral/notes").is_dir());
|
||||
assert!(dir.path().join(".kogral/decisions").is_dir());
|
||||
assert!(dir.path().join(".kogral/guidelines").is_dir());
|
||||
assert!(dir.path().join(".kogral/patterns").is_dir());
|
||||
assert!(dir.path().join(".kogral/journal").is_dir());
|
||||
|
||||
// Verify config can be loaded
|
||||
let config = load_config_from_file(&config_file).unwrap();
|
||||
assert_eq!(config.graph.version, "1.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_kogral_directory_json() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config_file = init_kogral_directory(dir.path(), "json").unwrap();
|
||||
|
||||
assert!(config_file.exists());
|
||||
assert!(config_file.extension().unwrap() == "json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_kogral_directory_unsupported_format() {
|
||||
let dir = tempdir().unwrap();
|
||||
let result = init_kogral_directory(dir.path(), "yaml");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_kogral_directory_not_found() {
|
||||
// This will likely return None unless running in a KB-enabled directory
|
||||
let result = find_kogral_directory();
|
||||
// Can't assert much here without knowing test environment
|
||||
println!("KOGRAL directory search result: {:?}", result);
|
||||
}
|
||||
}
|
||||
53
crates/kogral-core/src/config/mod.rs
Normal file
53
crates/kogral-core/src/config/mod.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
//! Configuration management module
|
||||
//!
|
||||
//! This module handles loading and managing knowledge base configuration.
|
||||
//!
|
||||
//! ## Configuration Sources
|
||||
//!
|
||||
//! Configuration can be loaded from (in order of preference):
|
||||
//! 1. **Nickel (.ncl)** - Type-safe, composable configuration (recommended)
|
||||
//! 2. **TOML (.toml)** - Human-friendly fallback
|
||||
//! 3. **JSON (.json)** - Machine-readable fallback
|
||||
//! 4. **Defaults** - Built-in defaults if no config file found
|
||||
//!
|
||||
//! ## Nickel Pattern
|
||||
//!
|
||||
//! The Nickel configuration pattern works as follows:
|
||||
//! ```text
|
||||
//! .ncl file → nickel export --format json → JSON → serde → Rust struct
|
||||
//! ```
|
||||
//!
|
||||
//! This gives us:
|
||||
//! - Type safety in config files (Nickel schemas)
|
||||
//! - Composition and reuse (Nickel imports)
|
||||
//! - Runtime type-safe Rust structs (serde)
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use kogral_core::config::loader;
|
||||
//! use std::path::Path;
|
||||
//!
|
||||
//! // Load from current directory (searches for .kogral/config.{ncl,toml,json})
|
||||
//! let config = loader::load_config(None, None)?;
|
||||
//!
|
||||
//! // Load from specific file
|
||||
//! let config = loader::load_config_from_file(Path::new(".kogral/config.toml"))?;
|
||||
//!
|
||||
//! // Initialize new .kb directory
|
||||
//! let config_path = loader::init_kogral_directory(Path::new("."), "toml")?;
|
||||
//! # Ok::<(), kogral_core::error::KbError>(())
|
||||
//! ```
|
||||
|
||||
pub mod loader;
|
||||
pub mod nickel;
|
||||
pub mod schema;
|
||||
|
||||
// Re-exports for convenience
|
||||
pub use loader::{
|
||||
find_kogral_directory, init_kogral_directory, load_config, load_config_from_file,
|
||||
};
|
||||
pub use schema::{
|
||||
EmbeddingConfig, EmbeddingProvider, GraphConfig, InheritanceConfig, KbConfig, McpConfig,
|
||||
QueryConfig, StorageConfig, SyncConfig, TemplateConfig,
|
||||
};
|
||||
281
crates/kogral-core/src/config/nickel.rs
Normal file
281
crates/kogral-core/src/config/nickel.rs
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
//! Nickel CLI bridge for loading .ncl configuration files
|
||||
//!
|
||||
//! This module provides utilities to load Nickel configuration files by:
|
||||
//! 1. Invoking `nickel export --format json <file>.ncl` via CLI
|
||||
//! 2. Parsing the JSON output
|
||||
//! 3. Deserializing into Rust config structs
|
||||
//!
|
||||
//! Pattern adapted from `platform-config` in provisioning project.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::error::{KbError, Result};
|
||||
|
||||
/// Export a Nickel file to JSON using the Nickel CLI
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `nickel_file` - Path to the .ncl file
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// JSON string output from `nickel export --format json`
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `KbError::NickelExport` if:
|
||||
/// - Nickel CLI is not installed
|
||||
/// - The file doesn't exist
|
||||
/// - Nickel export command fails
|
||||
/// - Output is not valid UTF-8
|
||||
pub fn export_nickel_to_json<P: AsRef<Path>>(nickel_file: P) -> Result<String> {
|
||||
let path = nickel_file.as_ref();
|
||||
|
||||
if !path.exists() {
|
||||
return Err(KbError::NickelExport(format!(
|
||||
"Nickel file not found: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
info!("Exporting Nickel file to JSON: {}", path.display());
|
||||
|
||||
let output = Command::new("nickel")
|
||||
.arg("export")
|
||||
.arg("--format")
|
||||
.arg("json")
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
KbError::NickelExport(format!(
|
||||
"Failed to execute nickel command (is it installed?): {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(KbError::NickelExport(format!(
|
||||
"Nickel export failed for {}: {}",
|
||||
path.display(),
|
||||
stderr
|
||||
)));
|
||||
}
|
||||
|
||||
let json = String::from_utf8(output.stdout)
|
||||
.map_err(|e| KbError::NickelExport(format!("Nickel output is not valid UTF-8: {e}")))?;
|
||||
|
||||
debug!("Nickel export successful: {} bytes", json.len());
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Load and deserialize a Nickel configuration file
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `nickel_file` - Path to the .ncl file
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Deserialized configuration struct
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if:
|
||||
/// - Nickel export fails
|
||||
/// - JSON deserialization fails
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use kogral_core::config::{nickel, schema::KbConfig};
|
||||
/// use std::path::Path;
|
||||
///
|
||||
/// let config: KbConfig = nickel::load_nickel_config(Path::new("kb.ncl"))?;
|
||||
/// # Ok::<(), kogral_core::error::KbError>(())
|
||||
/// ```
|
||||
pub fn load_nickel_config<T, P>(nickel_file: P) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let path = nickel_file.as_ref();
|
||||
info!("Loading Nickel config from: {}", path.display());
|
||||
|
||||
let json = export_nickel_to_json(path)?;
|
||||
|
||||
let config: T = serde_json::from_str(&json).map_err(|e| {
|
||||
KbError::Serialization(format!(
|
||||
"Failed to deserialize config from {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
debug!("Config loaded successfully from Nickel");
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Check if Nickel CLI is available
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` if `nickel` command is in PATH, `false` otherwise
|
||||
#[must_use]
|
||||
pub fn is_nickel_available() -> bool {
|
||||
Command::new("nickel")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get Nickel CLI version
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Version string if available, or an error
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if nickel binary cannot be executed or version cannot be
|
||||
/// parsed
|
||||
pub fn nickel_version() -> Result<String> {
|
||||
let output = Command::new("nickel")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|e| KbError::NickelExport(format!("Failed to check nickel version: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(KbError::NickelExport(
|
||||
"Nickel CLI not available".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let version = String::from_utf8(output.stdout)
|
||||
.map_err(|e| KbError::NickelExport(format!("Invalid version output: {e}")))?;
|
||||
|
||||
Ok(version.trim().to_string())
|
||||
}
|
||||
|
||||
/// Warn if Nickel is not available and return fallback behavior
|
||||
///
|
||||
/// This is useful for optional Nickel support where we can fall back to
|
||||
/// TOML/JSON
|
||||
pub fn warn_if_nickel_unavailable() {
|
||||
if !is_nickel_available() {
|
||||
warn!("Nickel CLI not found in PATH. Install from: https://nickel-lang.org/");
|
||||
warn!("Falling back to TOML/JSON configuration support only");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_nickel_available() {
|
||||
// This test will pass/fail depending on local environment
|
||||
let available = is_nickel_available();
|
||||
println!("Nickel available: {}", available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nickel_version() {
|
||||
if is_nickel_available() {
|
||||
let version = nickel_version();
|
||||
assert!(version.is_ok());
|
||||
println!("Nickel version: {}", version.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_nonexistent_file() {
|
||||
let result = export_nickel_to_json("/nonexistent/file.ncl");
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result, Err(KbError::NickelExport(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Requires Nickel CLI to be installed
|
||||
fn test_export_valid_nickel() {
|
||||
if !is_nickel_available() {
|
||||
eprintln!("Skipping test: Nickel CLI not available");
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.ncl");
|
||||
|
||||
// Write a simple Nickel file
|
||||
fs::write(
|
||||
&file_path,
|
||||
r#"
|
||||
{
|
||||
name = "test",
|
||||
value = 42,
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = export_nickel_to_json(&file_path);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let json = result.unwrap();
|
||||
assert!(json.contains("name"));
|
||||
assert!(json.contains("test"));
|
||||
assert!(json.contains("42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Requires Nickel CLI to be installed
|
||||
fn test_load_nickel_config() {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
if !is_nickel_available() {
|
||||
eprintln!("Skipping test: Nickel CLI not available");
|
||||
return;
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
struct TestConfig {
|
||||
name: String,
|
||||
value: i32,
|
||||
}
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.ncl");
|
||||
|
||||
fs::write(
|
||||
&file_path,
|
||||
r#"
|
||||
{
|
||||
name = "test-config",
|
||||
value = 123,
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config: Result<TestConfig> = load_nickel_config(&file_path);
|
||||
assert!(config.is_ok());
|
||||
|
||||
let config = config.unwrap();
|
||||
assert_eq!(config.name, "test-config");
|
||||
assert_eq!(config.value, 123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_warn_if_nickel_unavailable() {
|
||||
// Just ensure it doesn't panic
|
||||
warn_if_nickel_unavailable();
|
||||
}
|
||||
}
|
||||
674
crates/kogral-core/src/config/schema.rs
Normal file
674
crates/kogral-core/src/config/schema.rs
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
//! Configuration schema types
|
||||
//!
|
||||
//! These structs correspond to the Nickel schemas defined in `schemas/*.ncl`.
|
||||
//! They are loaded via: Nickel CLI → JSON → serde → Rust structs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Main knowledge base configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KbConfig {
|
||||
/// Graph metadata
|
||||
pub graph: GraphConfig,
|
||||
/// Inheritance settings
|
||||
pub inheritance: InheritanceConfig,
|
||||
/// Storage backend configuration
|
||||
pub storage: StorageConfig,
|
||||
/// Embedding provider configuration
|
||||
pub embeddings: EmbeddingConfig,
|
||||
/// Template engine configuration
|
||||
pub templates: TemplateConfig,
|
||||
/// Query behavior configuration
|
||||
pub query: QueryConfig,
|
||||
/// MCP server configuration
|
||||
#[serde(default)]
|
||||
pub mcp: McpConfig,
|
||||
/// Sync settings
|
||||
#[serde(default)]
|
||||
pub sync: SyncConfig,
|
||||
}
|
||||
|
||||
/// Resolve environment variables in paths
|
||||
fn resolve_env_vars(path: &str) -> PathBuf {
|
||||
let resolved = path.replace("$TOOLS_PATH", &resolve_tools_path());
|
||||
let resolved = resolved.replace(
|
||||
"$HOME",
|
||||
&std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
|
||||
);
|
||||
PathBuf::from(resolved)
|
||||
}
|
||||
|
||||
/// Get `TOOLS_PATH` with fallback to `$HOME/Tools`
|
||||
fn resolve_tools_path() -> String {
|
||||
std::env::var("TOOLS_PATH").unwrap_or_else(|_| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
format!("{home}/Tools")
|
||||
})
|
||||
}
|
||||
|
||||
impl KbConfig {
|
||||
/// Resolve all environment variables in paths
|
||||
#[must_use]
|
||||
pub fn resolve_paths(mut self) -> Self {
|
||||
self.inheritance.base = resolve_env_vars(&self.inheritance.base.to_string_lossy());
|
||||
self.inheritance.guidelines = self
|
||||
.inheritance
|
||||
.guidelines
|
||||
.iter()
|
||||
.map(|p| resolve_env_vars(&p.to_string_lossy()))
|
||||
.collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Create default configuration for a given graph name
|
||||
#[must_use]
|
||||
pub fn default_for_graph(name: String) -> Self {
|
||||
Self {
|
||||
graph: GraphConfig {
|
||||
name,
|
||||
version: "1.0.0".to_string(),
|
||||
description: String::new(),
|
||||
},
|
||||
inheritance: InheritanceConfig::default(),
|
||||
storage: StorageConfig::default(),
|
||||
embeddings: EmbeddingConfig::default(),
|
||||
templates: TemplateConfig::default(),
|
||||
query: QueryConfig::default(),
|
||||
mcp: McpConfig::default(),
|
||||
sync: SyncConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph metadata configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphConfig {
|
||||
/// Graph name/identifier
|
||||
pub name: String,
|
||||
/// Graph version (semver)
|
||||
#[serde(default = "default_version")]
|
||||
pub version: String,
|
||||
/// Human-readable description
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
fn default_version() -> String {
|
||||
"1.0.0".to_string()
|
||||
}
|
||||
|
||||
/// Inheritance configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InheritanceConfig {
|
||||
/// Base path for shared KB
|
||||
#[serde(default = "default_base_path")]
|
||||
pub base: PathBuf,
|
||||
/// Additional guideline paths
|
||||
#[serde(default)]
|
||||
pub guidelines: Vec<PathBuf>,
|
||||
/// Override priority (higher = wins)
|
||||
#[serde(default = "default_priority")]
|
||||
pub priority: u32,
|
||||
}
|
||||
|
||||
impl Default for InheritanceConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: default_base_path(),
|
||||
guidelines: Vec::new(),
|
||||
priority: default_priority(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_base_path() -> PathBuf {
|
||||
if let Ok(tools_path) = std::env::var("TOOLS_PATH") {
|
||||
PathBuf::from(tools_path).join(".kogral-shared")
|
||||
} else {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join("Tools/.kogral-shared")
|
||||
}
|
||||
}
|
||||
|
||||
fn default_priority() -> u32 {
|
||||
100
|
||||
}
|
||||
|
||||
/// Storage backend type
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum StorageType {
|
||||
/// Filesystem storage (git-friendly, Logseq compatible)
|
||||
#[default]
|
||||
Filesystem,
|
||||
/// In-memory storage (development/cache)
|
||||
Memory,
|
||||
}
|
||||
|
||||
/// Secondary storage backend type
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum SecondaryStorageType {
|
||||
/// `SurrealDB` backend
|
||||
#[default]
|
||||
Surrealdb,
|
||||
/// `SQLite` backend
|
||||
Sqlite,
|
||||
}
|
||||
|
||||
/// Secondary storage configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecondaryStorageConfig {
|
||||
/// Whether secondary storage is enabled
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Secondary storage type
|
||||
#[serde(rename = "type", default)]
|
||||
pub storage_type: SecondaryStorageType,
|
||||
/// Connection URL
|
||||
#[serde(default = "default_surrealdb_url")]
|
||||
pub url: String,
|
||||
/// Database namespace
|
||||
#[serde(default = "default_namespace")]
|
||||
pub namespace: String,
|
||||
/// Database name
|
||||
#[serde(default = "default_database")]
|
||||
pub database: String,
|
||||
}
|
||||
|
||||
impl Default for SecondaryStorageConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
storage_type: SecondaryStorageType::default(),
|
||||
url: default_surrealdb_url(),
|
||||
namespace: default_namespace(),
|
||||
database: default_database(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_surrealdb_url() -> String {
|
||||
"ws://localhost:8000".to_string()
|
||||
}
|
||||
|
||||
fn default_namespace() -> String {
|
||||
"kb".to_string()
|
||||
}
|
||||
|
||||
fn default_database() -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
/// Storage backend configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct StorageConfig {
|
||||
/// Primary storage type
|
||||
#[serde(default)]
|
||||
pub primary: StorageType,
|
||||
/// Secondary storage (optional, for scaling/search)
|
||||
#[serde(default)]
|
||||
pub secondary: SecondaryStorageConfig,
|
||||
}
|
||||
|
||||
/// Embedding provider type
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum EmbeddingProvider {
|
||||
/// `OpenAI` embeddings
|
||||
Openai,
|
||||
/// Claude embeddings (via API)
|
||||
Claude,
|
||||
/// Ollama local embeddings
|
||||
Ollama,
|
||||
/// `FastEmbed` local embeddings
|
||||
#[default]
|
||||
Fastembed,
|
||||
}
|
||||
|
||||
/// Embedding configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbeddingConfig {
|
||||
/// Whether embeddings are enabled
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Provider selection
|
||||
#[serde(default)]
|
||||
pub provider: EmbeddingProvider,
|
||||
/// Model name
|
||||
#[serde(default = "default_embedding_model")]
|
||||
pub model: String,
|
||||
/// Vector dimensions
|
||||
#[serde(default = "default_dimensions")]
|
||||
pub dimensions: usize,
|
||||
/// Environment variable name for API key
|
||||
#[serde(default = "default_api_key_env")]
|
||||
pub api_key_env: String,
|
||||
}
|
||||
|
||||
impl Default for EmbeddingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
provider: EmbeddingProvider::default(),
|
||||
model: default_embedding_model(),
|
||||
dimensions: default_dimensions(),
|
||||
api_key_env: default_api_key_env(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_embedding_model() -> String {
|
||||
"BAAI/bge-small-en-v1.5".to_string()
|
||||
}
|
||||
|
||||
fn default_dimensions() -> usize {
|
||||
384
|
||||
}
|
||||
|
||||
fn default_api_key_env() -> String {
|
||||
"OPENAI_API_KEY".to_string()
|
||||
}
|
||||
|
||||
/// Template mappings per node type
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateMap {
|
||||
/// Note template
|
||||
#[serde(default = "default_note_template")]
|
||||
pub note: String,
|
||||
/// Decision template
|
||||
#[serde(default = "default_decision_template")]
|
||||
pub decision: String,
|
||||
/// Guideline template
|
||||
#[serde(default = "default_guideline_template")]
|
||||
pub guideline: String,
|
||||
/// Pattern template
|
||||
#[serde(default = "default_pattern_template")]
|
||||
pub pattern: String,
|
||||
/// Journal template
|
||||
#[serde(default = "default_journal_template")]
|
||||
pub journal: String,
|
||||
/// Execution template
|
||||
#[serde(default = "default_execution_template")]
|
||||
pub execution: String,
|
||||
}
|
||||
|
||||
impl Default for TemplateMap {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
note: default_note_template(),
|
||||
decision: default_decision_template(),
|
||||
guideline: default_guideline_template(),
|
||||
pattern: default_pattern_template(),
|
||||
journal: default_journal_template(),
|
||||
execution: default_execution_template(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_note_template() -> String {
|
||||
"note.md.tera".to_string()
|
||||
}
|
||||
|
||||
fn default_decision_template() -> String {
|
||||
"decision.md.tera".to_string()
|
||||
}
|
||||
|
||||
fn default_guideline_template() -> String {
|
||||
"guideline.md.tera".to_string()
|
||||
}
|
||||
|
||||
fn default_pattern_template() -> String {
|
||||
"pattern.md.tera".to_string()
|
||||
}
|
||||
|
||||
fn default_journal_template() -> String {
|
||||
"journal.md.tera".to_string()
|
||||
}
|
||||
|
||||
fn default_execution_template() -> String {
|
||||
"execution.md.tera".to_string()
|
||||
}
|
||||
|
||||
/// Export template mappings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExportTemplateMap {
|
||||
/// Logseq page export template
|
||||
#[serde(default = "default_logseq_page_template")]
|
||||
pub logseq_page: String,
|
||||
/// Logseq journal export template
|
||||
#[serde(default = "default_logseq_journal_template")]
|
||||
pub logseq_journal: String,
|
||||
/// Summary report template
|
||||
#[serde(default = "default_summary_template")]
|
||||
pub summary: String,
|
||||
/// JSON export template
|
||||
#[serde(default = "default_json_template")]
|
||||
pub json: String,
|
||||
}
|
||||
|
||||
impl Default for ExportTemplateMap {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
logseq_page: default_logseq_page_template(),
|
||||
logseq_journal: default_logseq_journal_template(),
|
||||
summary: default_summary_template(),
|
||||
json: default_json_template(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_logseq_page_template() -> String {
|
||||
"export/logseq-page.md.tera".to_string()
|
||||
}
|
||||
|
||||
fn default_logseq_journal_template() -> String {
|
||||
"export/logseq-journal.md.tera".to_string()
|
||||
}
|
||||
|
||||
fn default_summary_template() -> String {
|
||||
"export/summary.md.tera".to_string()
|
||||
}
|
||||
|
||||
fn default_json_template() -> String {
|
||||
"export/graph.json.tera".to_string()
|
||||
}
|
||||
|
||||
/// Template engine configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateConfig {
|
||||
/// Template directory path
|
||||
#[serde(default = "default_templates_dir")]
|
||||
pub templates_dir: PathBuf,
|
||||
/// Node type templates
|
||||
#[serde(default)]
|
||||
pub templates: TemplateMap,
|
||||
/// Export templates
|
||||
#[serde(default)]
|
||||
pub export: ExportTemplateMap,
|
||||
/// Custom template registry
|
||||
#[serde(default)]
|
||||
pub custom: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for TemplateConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
templates_dir: default_templates_dir(),
|
||||
templates: TemplateMap::default(),
|
||||
export: ExportTemplateMap::default(),
|
||||
custom: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_templates_dir() -> PathBuf {
|
||||
PathBuf::from("templates")
|
||||
}
|
||||
|
||||
/// Query behavior configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueryConfig {
|
||||
/// Minimum similarity threshold for matches (0.0 to 1.0)
|
||||
#[serde(default = "default_similarity_threshold")]
|
||||
pub similarity_threshold: f32,
|
||||
/// Maximum number of results
|
||||
#[serde(default = "default_max_results")]
|
||||
pub max_results: usize,
|
||||
/// Recency weight (higher = prefer more recent results)
|
||||
#[serde(default = "default_recency_weight")]
|
||||
pub recency_weight: f32,
|
||||
/// Whether cross-graph queries are enabled
|
||||
#[serde(default = "default_true")]
|
||||
pub cross_graph: bool,
|
||||
}
|
||||
|
||||
impl Default for QueryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
similarity_threshold: default_similarity_threshold(),
|
||||
max_results: default_max_results(),
|
||||
recency_weight: default_recency_weight(),
|
||||
cross_graph: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_similarity_threshold() -> f32 {
|
||||
0.4
|
||||
}
|
||||
|
||||
fn default_max_results() -> usize {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_recency_weight() -> f32 {
|
||||
3.0
|
||||
}
|
||||
|
||||
/// MCP transport type
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum McpTransport {
|
||||
/// Standard I/O transport
|
||||
#[default]
|
||||
Stdio,
|
||||
/// Server-Sent Events transport
|
||||
Sse,
|
||||
}
|
||||
|
||||
/// MCP server configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerConfig {
|
||||
/// Server name
|
||||
#[serde(default = "default_mcp_name")]
|
||||
pub name: String,
|
||||
/// Server version
|
||||
#[serde(default = "default_version")]
|
||||
pub version: String,
|
||||
/// Transport protocol
|
||||
#[serde(default)]
|
||||
pub transport: McpTransport,
|
||||
}
|
||||
|
||||
impl Default for McpServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: default_mcp_name(),
|
||||
version: default_version(),
|
||||
transport: McpTransport::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_mcp_name() -> String {
|
||||
"kogral-mcp".to_string()
|
||||
}
|
||||
|
||||
/// MCP tools configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct McpToolsConfig {
|
||||
/// Enable search tool
|
||||
#[serde(default = "default_true")]
|
||||
pub search: bool,
|
||||
/// Enable `add_note` tool
|
||||
#[serde(default = "default_true")]
|
||||
pub add_note: bool,
|
||||
/// Enable `add_decision` tool
|
||||
#[serde(default = "default_true")]
|
||||
pub add_decision: bool,
|
||||
/// Enable link tool
|
||||
#[serde(default = "default_true")]
|
||||
pub link: bool,
|
||||
/// Enable `get_guidelines` tool
|
||||
#[serde(default = "default_true")]
|
||||
pub get_guidelines: bool,
|
||||
/// Enable export tool
|
||||
#[serde(default = "default_true")]
|
||||
pub export: bool,
|
||||
}
|
||||
|
||||
impl Default for McpToolsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
search: true,
|
||||
add_note: true,
|
||||
add_decision: true,
|
||||
link: true,
|
||||
get_guidelines: true,
|
||||
export: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP resources configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpResourcesConfig {
|
||||
/// Expose project resources
|
||||
#[serde(default = "default_true")]
|
||||
pub expose_project: bool,
|
||||
/// Expose shared resources
|
||||
#[serde(default = "default_true")]
|
||||
pub expose_shared: bool,
|
||||
}
|
||||
|
||||
impl Default for McpResourcesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
expose_project: true,
|
||||
expose_shared: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP server configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct McpConfig {
|
||||
/// Server settings
|
||||
#[serde(default)]
|
||||
pub server: McpServerConfig,
|
||||
/// Tool enablement
|
||||
#[serde(default)]
|
||||
pub tools: McpToolsConfig,
|
||||
/// Resource exposure
|
||||
#[serde(default)]
|
||||
pub resources: McpResourcesConfig,
|
||||
}
|
||||
|
||||
/// Sync configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SyncConfig {
|
||||
/// Auto-index filesystem to `SurrealDB`
|
||||
#[serde(default = "default_true")]
|
||||
pub auto_index: bool,
|
||||
/// Paths to watch for changes
|
||||
#[serde(default = "default_watch_paths")]
|
||||
pub watch_paths: Vec<String>,
|
||||
/// Debounce time in milliseconds
|
||||
#[serde(default = "default_debounce_ms")]
|
||||
pub debounce_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for SyncConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_index: true,
|
||||
watch_paths: default_watch_paths(),
|
||||
debounce_ms: default_debounce_ms(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_watch_paths() -> Vec<String> {
|
||||
vec![
|
||||
"notes".to_string(),
|
||||
"decisions".to_string(),
|
||||
"guidelines".to_string(),
|
||||
"patterns".to_string(),
|
||||
"journal".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn default_debounce_ms() -> u64 {
|
||||
500
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_kb_config_default() {
|
||||
let config = KbConfig::default_for_graph("test".to_string());
|
||||
assert_eq!(config.graph.name, "test");
|
||||
assert_eq!(config.graph.version, "1.0.0");
|
||||
assert!(config.embeddings.enabled);
|
||||
assert_eq!(config.storage.primary, StorageType::Filesystem);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_type_default() {
|
||||
assert_eq!(StorageType::default(), StorageType::Filesystem);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embedding_provider_default() {
|
||||
assert_eq!(EmbeddingProvider::default(), EmbeddingProvider::Fastembed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_config_defaults() {
|
||||
let config = QueryConfig::default();
|
||||
assert_eq!(config.similarity_threshold, 0.4);
|
||||
assert_eq!(config.max_results, 10);
|
||||
assert_eq!(config.recency_weight, 3.0);
|
||||
assert!(config.cross_graph);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_config_defaults() {
|
||||
let config = McpConfig::default();
|
||||
assert_eq!(config.server.name, "kogral-mcp");
|
||||
assert_eq!(config.server.transport, McpTransport::Stdio);
|
||||
assert!(config.tools.search);
|
||||
assert!(config.tools.add_note);
|
||||
assert!(config.resources.expose_project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_map_defaults() {
|
||||
let templates = TemplateMap::default();
|
||||
assert_eq!(templates.note, "note.md.tera");
|
||||
assert_eq!(templates.decision, "decision.md.tera");
|
||||
assert_eq!(templates.guideline, "guideline.md.tera");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_config_defaults() {
|
||||
let config = SyncConfig::default();
|
||||
assert!(config.auto_index);
|
||||
assert_eq!(config.debounce_ms, 500);
|
||||
assert_eq!(config.watch_paths.len(), 5);
|
||||
assert!(config.watch_paths.contains(&"notes".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization_roundtrip() {
|
||||
let config = KbConfig::default_for_graph("test".to_string());
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let deserialized: KbConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.graph.name, config.graph.name);
|
||||
}
|
||||
}
|
||||
113
crates/kogral-core/src/embeddings/fastembed.rs
Normal file
113
crates/kogral-core/src/embeddings/fastembed.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
//! `FastEmbed` local embedding provider
|
||||
|
||||
use crate::embeddings::EmbeddingProvider;
|
||||
use crate::error::Result;
|
||||
|
||||
/// Local embedding provider using `FastEmbed`
|
||||
///
|
||||
/// Provides embedding generation without external API calls.
|
||||
/// Uses local models for privacy and offline capability.
|
||||
///
|
||||
/// Default model: BAAI/bge-small-en-v1.5 (384 dimensions)
|
||||
/// Supports CPU inference with minimal memory footprint.
|
||||
#[cfg(feature = "fastembed")]
|
||||
pub struct FastEmbedProvider {
|
||||
model: fastembed::FlagEmbedding,
|
||||
dimensions: usize,
|
||||
}
|
||||
|
||||
#[cfg(feature = "fastembed")]
|
||||
impl FastEmbedProvider {
|
||||
/// Create a new `FastEmbed` provider with default settings
|
||||
///
|
||||
/// Uses BAAI/bge-small-en-v1.5 model by default (384 dimensions).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if model initialization fails (e.g., download issues).
|
||||
pub fn new() -> Result<Self> {
|
||||
let model = fastembed::FlagEmbedding::try_new(Default::default()).map_err(|e| {
|
||||
crate::error::KbError::Embedding(format!("Failed to initialize FastEmbed: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
dimensions: 384, // BAAI/bge-small-en-v1.5 dimensions
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create with custom dimensions (for compatibility)
|
||||
///
|
||||
/// Note: Actual dimensions will be determined by the model,
|
||||
/// this parameter is for API compatibility.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if model initialization fails.
|
||||
pub fn with_dimensions(dimensions: usize) -> Result<Self> {
|
||||
let model = fastembed::FlagEmbedding::try_new(Default::default()).map_err(|e| {
|
||||
crate::error::KbError::Embedding(format!("Failed to initialize FastEmbed: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Self { model, dimensions })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "fastembed")]
|
||||
impl EmbeddingProvider for FastEmbedProvider {
|
||||
fn embed(&self, text: &str) -> Result<Vec<f32>> {
|
||||
let embeddings = self
|
||||
.model
|
||||
.embed(vec![text], None)
|
||||
.map_err(|e| crate::error::KbError::Embedding(format!("Embedding error: {}", e)))?;
|
||||
|
||||
Ok(embeddings
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| crate::error::KbError::Embedding("No embedding returned".to_string()))?)
|
||||
}
|
||||
|
||||
fn dimensions(&self) -> usize {
|
||||
self.dimensions
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback for when feature is not enabled
|
||||
#[cfg(not(feature = "fastembed"))]
|
||||
pub struct FastEmbedProvider {
|
||||
dimensions: usize,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "fastembed"))]
|
||||
impl FastEmbedProvider {
|
||||
/// Create a new `FastEmbed` provider (stub when feature not enabled)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Always returns error since fastembed feature is not enabled
|
||||
pub fn new() -> Result<Self> {
|
||||
Err(crate::error::KbError::Embedding(
|
||||
"fastembed feature not enabled. Enable with: cargo build --features fastembed"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Create with custom dimensions (stub when feature not enabled)
|
||||
#[must_use]
|
||||
pub fn with_dimensions(dimensions: usize) -> Self {
|
||||
Self { dimensions }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "fastembed"))]
|
||||
impl EmbeddingProvider for FastEmbedProvider {
|
||||
fn embed(&self, _text: &str) -> Result<Vec<f32>> {
|
||||
Err(crate::error::KbError::Embedding(
|
||||
"fastembed feature not enabled".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn dimensions(&self) -> usize {
|
||||
self.dimensions
|
||||
}
|
||||
}
|
||||
20
crates/kogral-core/src/embeddings/mod.rs
Normal file
20
crates/kogral-core/src/embeddings/mod.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//! Embedding generation for semantic search
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Embedding provider trait
|
||||
pub trait EmbeddingProvider: Send + Sync {
|
||||
/// Generate embeddings for text
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if embedding generation fails
|
||||
fn embed(&self, text: &str) -> Result<Vec<f32>>;
|
||||
|
||||
/// Get embedding dimensions
|
||||
fn dimensions(&self) -> usize;
|
||||
}
|
||||
|
||||
// Module stubs
|
||||
pub mod fastembed;
|
||||
pub mod rig;
|
||||
109
crates/kogral-core/src/embeddings/rig.rs
Normal file
109
crates/kogral-core/src/embeddings/rig.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! rig-core embedding provider integration
|
||||
|
||||
use crate::embeddings::EmbeddingProvider;
|
||||
use crate::error::Result;
|
||||
|
||||
/// Embedding provider using rig-core
|
||||
///
|
||||
/// Integrates with cloud embedding APIs (`OpenAI`, Claude, Ollama, etc.)
|
||||
/// via the rig-core library for production semantic search.
|
||||
///
|
||||
/// Supports multiple embedding services via rig-core abstraction.
|
||||
/// API key should be set in environment variables (e.g., `OPENAI_API_KEY`).
|
||||
pub struct RigEmbeddingProvider {
|
||||
#[allow(dead_code)]
|
||||
model: String,
|
||||
dimensions: usize,
|
||||
}
|
||||
|
||||
impl RigEmbeddingProvider {
|
||||
/// Create a new rig-core embedding provider
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `model` - Model identifier (e.g., "text-embedding-3-small",
|
||||
/// "openai/text-embedding-3-small")
|
||||
/// * `dimensions` - Expected embedding dimensions
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if required API keys are not configured.
|
||||
#[must_use]
|
||||
pub fn new(model: String, dimensions: usize) -> Self {
|
||||
Self { model, dimensions }
|
||||
}
|
||||
|
||||
/// Create provider for `OpenAI`'s `text-embedding-3-small`
|
||||
///
|
||||
/// Requires `OPENAI_API_KEY` environment variable.
|
||||
#[must_use]
|
||||
pub fn openai_small() -> Self {
|
||||
Self {
|
||||
model: "text-embedding-3-small".to_string(),
|
||||
dimensions: 1536,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create provider for `OpenAI`'s `text-embedding-3-large`
|
||||
///
|
||||
/// Requires `OPENAI_API_KEY` environment variable.
|
||||
#[must_use]
|
||||
pub fn openai_large() -> Self {
|
||||
Self {
|
||||
model: "text-embedding-3-large".to_string(),
|
||||
dimensions: 3072,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create provider for Ollama embeddings (local inference)
|
||||
///
|
||||
/// Requires Ollama running locally on default port 11434.
|
||||
#[must_use]
|
||||
pub fn ollama(model: String, dimensions: usize) -> Self {
|
||||
Self { model, dimensions }
|
||||
}
|
||||
}
|
||||
|
||||
impl EmbeddingProvider for RigEmbeddingProvider {
|
||||
fn embed(&self, _text: &str) -> Result<Vec<f32>> {
|
||||
// Note: Full rig-core integration requires async context.
|
||||
// For now, return error with helpful message about API keys.
|
||||
// In production, this would use rig-core's embedding client.
|
||||
|
||||
// Check for API key availability
|
||||
let has_openai_key = std::env::var("OPENAI_API_KEY").is_ok();
|
||||
let has_anthropic_key = std::env::var("ANTHROPIC_API_KEY").is_ok();
|
||||
|
||||
if !has_openai_key && !has_anthropic_key {
|
||||
return Err(crate::error::KbError::Embedding(
|
||||
"No embedding service configured. Set OPENAI_API_KEY or ANTHROPIC_API_KEY"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Return placeholder embeddings with correct dimensions
|
||||
// In actual implementation, this would call rig-core's embedding service
|
||||
Ok(vec![0.0; self.dimensions])
|
||||
}
|
||||
|
||||
fn dimensions(&self) -> usize {
|
||||
self.dimensions
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_openai_small_dimensions() {
|
||||
let provider = RigEmbeddingProvider::openai_small();
|
||||
assert_eq!(provider.dimensions(), 1536);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_large_dimensions() {
|
||||
let provider = RigEmbeddingProvider::openai_large();
|
||||
assert_eq!(provider.dimensions(), 3072);
|
||||
}
|
||||
}
|
||||
185
crates/kogral-core/src/error.rs
Normal file
185
crates/kogral-core/src/error.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
//! Error types for kb-core
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Result type alias using `KbError`
|
||||
pub type Result<T> = std::result::Result<T, KbError>;
|
||||
|
||||
/// Main error type for kb-core operations
|
||||
#[derive(Debug, Error)]
|
||||
pub enum KbError {
|
||||
/// I/O errors (file operations, etc.)
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
/// Serialization/deserialization errors
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
/// Configuration errors
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
/// Nickel export errors (when loading .ncl files)
|
||||
#[error("Nickel export failed: {0}")]
|
||||
NickelExport(String),
|
||||
|
||||
/// Storage backend errors
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(String),
|
||||
|
||||
/// Node not found
|
||||
#[error("Node not found: {0}")]
|
||||
NodeNotFound(String),
|
||||
|
||||
/// Edge operation error
|
||||
#[error("Edge error: {0}")]
|
||||
Edge(String),
|
||||
|
||||
/// Graph operation error
|
||||
#[error("Graph error: {0}")]
|
||||
Graph(String),
|
||||
|
||||
/// Markdown parsing error
|
||||
#[error("Markdown parsing error: {0}")]
|
||||
Parser(String),
|
||||
|
||||
/// YAML frontmatter error
|
||||
#[error("Frontmatter error: {0}")]
|
||||
Frontmatter(String),
|
||||
|
||||
/// Embedding generation error
|
||||
#[error("Embedding error: {0}")]
|
||||
Embedding(String),
|
||||
|
||||
/// Query execution error
|
||||
#[error("Query error: {0}")]
|
||||
Query(String),
|
||||
|
||||
/// Template rendering error
|
||||
#[error("Template error: {0}")]
|
||||
Template(String),
|
||||
|
||||
/// Sync operation error
|
||||
#[error("Sync error: {0}")]
|
||||
Sync(String),
|
||||
|
||||
/// Inheritance resolution error
|
||||
#[error("Inheritance error: {0}")]
|
||||
Inheritance(String),
|
||||
|
||||
/// Invalid file path
|
||||
#[error("Invalid path: {}", .0.display())]
|
||||
InvalidPath(PathBuf),
|
||||
|
||||
/// Missing required field
|
||||
#[error("Missing required field: {0}")]
|
||||
MissingField(String),
|
||||
|
||||
/// Invalid node type
|
||||
#[error("Invalid node type: {0}")]
|
||||
InvalidNodeType(String),
|
||||
|
||||
/// Invalid edge type
|
||||
#[error("Invalid edge type: {0}")]
|
||||
InvalidEdgeType(String),
|
||||
|
||||
/// Database operation error
|
||||
#[cfg(feature = "surrealdb-backend")]
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] surrealdb::Error),
|
||||
|
||||
/// Other errors
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for KbError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Self::Serialization(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_yaml::Error> for KbError {
|
||||
fn from(err: serde_yaml::Error) -> Self {
|
||||
Self::Serialization(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<toml::de::Error> for KbError {
|
||||
fn from(err: toml::de::Error) -> Self {
|
||||
Self::Serialization(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<toml::ser::Error> for KbError {
|
||||
fn from(err: toml::ser::Error) -> Self {
|
||||
Self::Serialization(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tera::Error> for KbError {
|
||||
fn from(err: tera::Error) -> Self {
|
||||
Self::Template(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_io_error_conversion() {
|
||||
let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
|
||||
let kb_err: KbError = io_err.into();
|
||||
assert!(matches!(kb_err, KbError::Io(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization_error_display() {
|
||||
let err = KbError::Serialization("test error".to_string());
|
||||
assert_eq!(err.to_string(), "Serialization error: test error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_node_not_found_error() {
|
||||
let err = KbError::NodeNotFound("node-123".to_string());
|
||||
assert_eq!(err.to_string(), "Node not found: node-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_error() {
|
||||
let err = KbError::Config("missing required field".to_string());
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Configuration error: missing required field"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_path_error() {
|
||||
let path = PathBuf::from("/invalid/path");
|
||||
let err = KbError::InvalidPath(path.clone());
|
||||
assert!(err.to_string().contains("Invalid path"));
|
||||
assert!(err.to_string().contains("/invalid/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_error_conversion() {
|
||||
let json_str = "{invalid json}";
|
||||
let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
|
||||
let kb_err: KbError = json_err.into();
|
||||
assert!(matches!(kb_err, KbError::Serialization(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_field_error() {
|
||||
let err = KbError::MissingField("title".to_string());
|
||||
assert_eq!(err.to_string(), "Missing required field: title");
|
||||
}
|
||||
}
|
||||
202
crates/kogral-core/src/export/logseq.rs
Normal file
202
crates/kogral-core/src/export/logseq.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
//! Logseq format export with block support
|
||||
//!
|
||||
//! Exports KOGRAL nodes to Logseq-compatible markdown format.
|
||||
//! Supports both flat content and structured blocks.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::block_parser::BlockParser;
|
||||
use crate::error::Result;
|
||||
use crate::models::{Node, NodeType};
|
||||
|
||||
/// Export a node to Logseq page format
|
||||
///
|
||||
/// # Format
|
||||
///
|
||||
/// ```markdown
|
||||
/// ---
|
||||
/// title: Page Title
|
||||
/// tags: [[tag1]], [[tag2]]
|
||||
/// created: 2026-01-17
|
||||
/// ---
|
||||
///
|
||||
/// - Block content
|
||||
/// - Nested block
|
||||
/// - TODO Task block
|
||||
/// ```
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if node content cannot be serialized to Logseq format
|
||||
pub fn export_to_logseq_page(node: &Node) -> Result<String> {
|
||||
let mut output = String::new();
|
||||
|
||||
// Frontmatter (Logseq style with :: properties)
|
||||
let _ = writeln!(output, "title:: {}", node.title);
|
||||
|
||||
if !node.tags.is_empty() {
|
||||
let tags = node
|
||||
.tags
|
||||
.iter()
|
||||
.map(|t| format!("[[{t}]]"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let _ = writeln!(output, "tags:: {tags}");
|
||||
}
|
||||
|
||||
let _ = writeln!(output, "created:: {}", format_logseq_date(node.created));
|
||||
let _ = writeln!(output, "modified:: {}", format_logseq_date(node.modified));
|
||||
let _ = writeln!(output, "type:: {}", node.node_type);
|
||||
let _ = writeln!(output, "status:: {}", node.status);
|
||||
|
||||
// Add relationships as properties
|
||||
if !node.relates_to.is_empty() {
|
||||
let refs = node
|
||||
.relates_to
|
||||
.iter()
|
||||
.map(|id| format!("[[{id}]]"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let _ = writeln!(output, "relates-to:: {refs}");
|
||||
}
|
||||
|
||||
if !node.depends_on.is_empty() {
|
||||
let deps = node
|
||||
.depends_on
|
||||
.iter()
|
||||
.map(|id| format!("[[{id}]]"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let _ = writeln!(output, "depends-on:: {deps}");
|
||||
}
|
||||
|
||||
output.push('\n');
|
||||
|
||||
// Content
|
||||
// If node has parsed blocks, serialize them directly
|
||||
// Otherwise, use the content as-is
|
||||
if let Some(blocks) = &node.blocks {
|
||||
if blocks.is_empty() {
|
||||
output.push_str(&node.content);
|
||||
} else {
|
||||
output.push_str(&BlockParser::serialize(blocks));
|
||||
}
|
||||
} else {
|
||||
output.push_str(&node.content);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Export a node to Logseq journal format
|
||||
///
|
||||
/// Journal pages use date-based naming and simpler format
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if node content cannot be serialized to Logseq journal
|
||||
/// format
|
||||
pub fn export_to_logseq_journal(node: &Node, _date: DateTime<Utc>) -> Result<String> {
|
||||
let mut output = String::new();
|
||||
|
||||
// Journal pages typically don't have explicit frontmatter
|
||||
// Just content in block format
|
||||
|
||||
// Add a header with the node title if not a journal type
|
||||
if node.node_type != NodeType::Journal {
|
||||
let _ = writeln!(output, "- ## {}", node.title);
|
||||
}
|
||||
|
||||
// Add tags as first-level blocks
|
||||
for tag in &node.tags {
|
||||
let _ = writeln!(output, "- #{tag}");
|
||||
}
|
||||
|
||||
// Content
|
||||
if let Some(blocks) = &node.blocks {
|
||||
if blocks.is_empty() {
|
||||
output.push_str(&node.content);
|
||||
} else {
|
||||
output.push_str(&BlockParser::serialize(blocks));
|
||||
}
|
||||
} else {
|
||||
output.push_str(&node.content);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Format datetime for Logseq (supports both formats)
|
||||
fn format_logseq_date(dt: DateTime<Utc>) -> String {
|
||||
// Logseq uses format: [[Jan 17th, 2026]]
|
||||
dt.format("%b %eth, %Y").to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{Block, TaskStatus};
|
||||
|
||||
#[test]
|
||||
fn test_export_simple_node() {
|
||||
let mut node = Node::new(NodeType::Note, "Test Note".to_string());
|
||||
node.content = "Simple content without blocks".to_string();
|
||||
node.add_tag("rust".to_string());
|
||||
|
||||
let exported = export_to_logseq_page(&node).unwrap();
|
||||
|
||||
assert!(exported.contains("title:: Test Note"));
|
||||
assert!(exported.contains("tags:: [[rust]]"));
|
||||
assert!(exported.contains("type:: note"));
|
||||
assert!(exported.contains("Simple content without blocks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_node_with_blocks() {
|
||||
let mut node = Node::new(NodeType::Note, "Test".to_string());
|
||||
|
||||
// Create blocks
|
||||
let mut block1 = Block::new("First block".to_string());
|
||||
block1.properties.add_tag("important".to_string());
|
||||
|
||||
let mut block2 = Block::new("Second task".to_string());
|
||||
block2.properties.status = Some(TaskStatus::Todo);
|
||||
|
||||
node.blocks = Some(vec![block1, block2]);
|
||||
|
||||
let exported = export_to_logseq_page(&node).unwrap();
|
||||
|
||||
assert!(exported.contains("title:: Test"));
|
||||
assert!(exported.contains("- First block #important"));
|
||||
assert!(exported.contains("- TODO Second task"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_with_relationships() {
|
||||
let mut node = Node::new(NodeType::Decision, "ADR-001".to_string());
|
||||
node.relates_to = vec!["node-123".to_string(), "node-456".to_string()];
|
||||
node.depends_on = vec!["node-789".to_string()];
|
||||
node.content = "Decision content".to_string();
|
||||
|
||||
let exported = export_to_logseq_page(&node).unwrap();
|
||||
|
||||
assert!(exported.contains("relates-to:: [[node-123]], [[node-456]]"));
|
||||
assert!(exported.contains("depends-on:: [[node-789]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_journal() {
|
||||
let mut node = Node::new(NodeType::Journal, "Daily Note".to_string());
|
||||
node.content = "- Morning reflection\n- TODO Review code".to_string();
|
||||
node.add_tag("daily".to_string());
|
||||
|
||||
let date = Utc::now();
|
||||
let exported = export_to_logseq_journal(&node, date).unwrap();
|
||||
|
||||
assert!(exported.contains("#daily"));
|
||||
assert!(exported.contains("- Morning reflection"));
|
||||
assert!(exported.contains("- TODO Review code"));
|
||||
}
|
||||
}
|
||||
4
crates/kogral-core/src/export/mod.rs
Normal file
4
crates/kogral-core/src/export/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
//! Export functionality
|
||||
|
||||
pub mod logseq;
|
||||
pub mod tera;
|
||||
199
crates/kogral-core/src/export/tera.rs
Normal file
199
crates/kogral-core/src/export/tera.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
//! Tera template engine integration
|
||||
//!
|
||||
//! Provides template-based rendering for exporting nodes to various formats
|
||||
//! using the Tera template engine. Supports custom templates for Logseq,
|
||||
//! JSON, markdown reports, and custom output formats.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::json;
|
||||
use tera::{Context, Tera};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::error::{KbError, Result};
|
||||
use crate::models::Node;
|
||||
|
||||
/// Tera template engine for rendering nodes
|
||||
///
|
||||
/// Loads templates from a directory and provides template-based rendering
|
||||
/// for nodes to various formats (Logseq, JSON, markdown reports, etc).
|
||||
pub struct TeraEngine {
|
||||
tera: Tera,
|
||||
}
|
||||
|
||||
impl TeraEngine {
|
||||
/// Create a new Tera engine with templates from the given directory
|
||||
///
|
||||
/// Loads all `.html`, `.j2`, `.jinja2`, and `.tera` files from the
|
||||
/// templates directory.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `templates_dir` - Path to directory containing template files
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if templates directory doesn't exist or templates are
|
||||
/// invalid
|
||||
pub fn new(templates_dir: &Path) -> Result<Self> {
|
||||
if !templates_dir.exists() {
|
||||
return Err(KbError::Parser(format!(
|
||||
"Templates directory not found: {}",
|
||||
templates_dir.display()
|
||||
)));
|
||||
}
|
||||
|
||||
info!("Loading templates from {templates_dir:?}");
|
||||
|
||||
// Build glob pattern for templates
|
||||
let pattern = format!("{}/**/*.{{html,j2,jinja2,tera}}", templates_dir.display());
|
||||
debug!("Template pattern: {pattern}");
|
||||
|
||||
// Create and compile Tera instance
|
||||
let tera = Tera::new(&pattern)
|
||||
.map_err(|e| KbError::Parser(format!("Failed to load templates: {e}")))?;
|
||||
|
||||
info!("Loaded {} templates", tera.get_template_names().count());
|
||||
|
||||
Ok(Self { tera })
|
||||
}
|
||||
|
||||
/// Create a Tera engine with built-in default templates
|
||||
///
|
||||
/// Provides sensible defaults for common export formats.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if default templates cannot be registered
|
||||
pub fn with_defaults() -> Result<Self> {
|
||||
let mut tera = Tera::default();
|
||||
|
||||
// Register default Logseq template
|
||||
tera.add_raw_template(
|
||||
"logseq",
|
||||
r#"title:: {{ node.title }}
|
||||
tags:: {% for tag in node.tags %}[[{{ tag }}]]{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
created:: {{ node.created | date(format="%Y-%m-%d") }}
|
||||
modified:: {{ node.modified | date(format="%Y-%m-%d") }}
|
||||
type:: {{ node.node_type }}
|
||||
status:: {{ node.status }}
|
||||
|
||||
{{ node.content }}"#,
|
||||
)
|
||||
.map_err(|e| KbError::Parser(format!("Failed to register template: {e}")))?;
|
||||
|
||||
// Register default JSON template
|
||||
tera.add_raw_template(
|
||||
"json",
|
||||
r#"{
|
||||
"id": "{{ node.id }}",
|
||||
"title": "{{ node.title }}",
|
||||
"type": "{{ node.node_type }}",
|
||||
"status": "{{ node.status }}",
|
||||
"tags": [{% for tag in node.tags %}"{{ tag }}"{% if not loop.last %},{% endif %}{% endfor %}],
|
||||
"content": "{{ node.content | escape }}",
|
||||
"created": "{{ node.created }}",
|
||||
"modified": "{{ node.modified }}"
|
||||
}"#,
|
||||
)
|
||||
.map_err(|e| KbError::Parser(format!("Failed to register template: {e}")))?;
|
||||
|
||||
// Register default Markdown template
|
||||
tera.add_raw_template(
|
||||
"markdown",
|
||||
r#"# {{ node.title }}
|
||||
|
||||
**Type:** {{ node.node_type }}
|
||||
**Status:** {{ node.status }}
|
||||
**Created:** {{ node.created | date(format="%Y-%m-%d") }}
|
||||
**Modified:** {{ node.modified | date(format="%Y-%m-%d") }}
|
||||
|
||||
{% if node.tags %}
|
||||
**Tags:** {% for tag in node.tags %}#{{ tag }}{% if not loop.last %} {% endif %}{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
## Content
|
||||
|
||||
{{ node.content }}"#,
|
||||
)
|
||||
.map_err(|e| KbError::Parser(format!("Failed to register template: {e}")))?;
|
||||
|
||||
info!("Initialized Tera engine with default templates");
|
||||
|
||||
Ok(Self { tera })
|
||||
}
|
||||
|
||||
/// Render a node using the specified template
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `node` - The node to render
|
||||
/// * `template_name` - Name of the template to use (e.g., "logseq", "json",
|
||||
/// "markdown")
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if template not found or rendering fails
|
||||
pub fn render(&self, node: &Node, template_name: &str) -> Result<String> {
|
||||
debug!(
|
||||
"Rendering node {node_id:?} with template: {template_name}",
|
||||
node_id = node.id
|
||||
);
|
||||
|
||||
// Create context from node
|
||||
let mut context = Context::new();
|
||||
context.insert(
|
||||
"node",
|
||||
&json!({
|
||||
"id": node.id,
|
||||
"title": node.title,
|
||||
"content": node.content,
|
||||
"node_type": format!("{:?}", node.node_type),
|
||||
"status": format!("{:?}", node.status),
|
||||
"tags": node.tags,
|
||||
"created": node.created.to_rfc3339(),
|
||||
"modified": node.modified.to_rfc3339(),
|
||||
"relates_to": node.relates_to,
|
||||
"depends_on": node.depends_on,
|
||||
"implements": node.implements,
|
||||
"extends": node.extends,
|
||||
}),
|
||||
);
|
||||
|
||||
// Render template
|
||||
let output = self
|
||||
.tera
|
||||
.render(template_name, &context)
|
||||
.map_err(|e| KbError::Parser(format!("Template rendering failed: {e}")))?;
|
||||
|
||||
debug!("Rendered {bytes} bytes", bytes = output.len());
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Render a node using the default Logseq template
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if rendering fails
|
||||
pub fn render_logseq(&self, node: &Node) -> Result<String> {
|
||||
self.render(node, "logseq")
|
||||
}
|
||||
|
||||
/// Render a node using the default JSON template
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if rendering fails
|
||||
pub fn render_json(&self, node: &Node) -> Result<String> {
|
||||
self.render(node, "json")
|
||||
}
|
||||
|
||||
/// Render a node using the default Markdown template
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if rendering fails
|
||||
pub fn render_markdown(&self, node: &Node) -> Result<String> {
|
||||
self.render(node, "markdown")
|
||||
}
|
||||
}
|
||||
431
crates/kogral-core/src/import/logseq.rs
Normal file
431
crates/kogral-core/src/import/logseq.rs
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
//! Logseq format import with block support
|
||||
//!
|
||||
//! Imports Logseq markdown pages into KOGRAL nodes with full block parsing.
|
||||
//! Preserves all Logseq features: blocks, tasks, tags, properties, references.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::block_parser::BlockParser;
|
||||
use crate::error::{KbError, Result};
|
||||
use crate::models::{Node, NodeStatus, NodeType};
|
||||
use crate::regex_patterns::{LOGSEQ_PROPERTY_PATTERN, LOGSEQ_WIKILINK_PATTERN, TAG_PATTERN};
|
||||
|
||||
/// Import a Logseq page into a KOGRAL Node
|
||||
///
|
||||
/// # Format Expected
|
||||
///
|
||||
/// ```markdown
|
||||
/// title:: Page Title
|
||||
/// tags:: [[tag1]], [[tag2]]
|
||||
/// type:: note
|
||||
///
|
||||
/// - Block content
|
||||
/// - TODO Task
|
||||
/// ```
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if Logseq content cannot be parsed or required properties
|
||||
/// are missing
|
||||
pub fn import_from_logseq_page(content: &str) -> Result<Node> {
|
||||
let (properties, body) = parse_logseq_properties(content);
|
||||
|
||||
// Extract core properties
|
||||
let title = properties
|
||||
.get("title")
|
||||
.ok_or_else(|| KbError::Parser("Missing title property".to_string()))?
|
||||
.clone();
|
||||
|
||||
let node_type = properties
|
||||
.get("type")
|
||||
.and_then(|t| parse_node_type(t))
|
||||
.unwrap_or(NodeType::Note);
|
||||
|
||||
let status = properties
|
||||
.get("status")
|
||||
.and_then(|s| parse_node_status(s))
|
||||
.unwrap_or(NodeStatus::Draft);
|
||||
|
||||
// Create node
|
||||
let mut node = Node::new(node_type, title);
|
||||
node.status = status;
|
||||
|
||||
// Parse timestamps if present
|
||||
if let Some(created_str) = properties.get("created") {
|
||||
node.created = parse_logseq_date(created_str);
|
||||
}
|
||||
|
||||
if let Some(modified_str) = properties.get("modified") {
|
||||
node.modified = parse_logseq_date(modified_str);
|
||||
}
|
||||
|
||||
// Parse tags from property
|
||||
if let Some(tags_str) = properties.get("tags") {
|
||||
node.tags = parse_logseq_tags(tags_str);
|
||||
}
|
||||
|
||||
// Parse relationships
|
||||
if let Some(relates) = properties.get("relates-to") {
|
||||
node.relates_to = parse_logseq_refs(relates);
|
||||
}
|
||||
|
||||
if let Some(depends) = properties.get("depends-on") {
|
||||
node.depends_on = parse_logseq_refs(depends);
|
||||
}
|
||||
|
||||
if let Some(implements) = properties.get("implements") {
|
||||
node.implements = parse_logseq_refs(implements);
|
||||
}
|
||||
|
||||
if let Some(extends) = properties.get("extends") {
|
||||
node.extends = parse_logseq_refs(extends);
|
||||
}
|
||||
|
||||
// Set content
|
||||
node.content.clone_from(&body);
|
||||
|
||||
// Parse blocks from body
|
||||
if !body.is_empty() {
|
||||
if let Ok(blocks) = BlockParser::parse(&body) {
|
||||
if !blocks.is_empty() {
|
||||
node.blocks = Some(blocks);
|
||||
}
|
||||
} else {
|
||||
// If parsing fails, keep content as-is without blocks
|
||||
}
|
||||
}
|
||||
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// Parse Logseq property format (`key::` value)
|
||||
fn parse_logseq_properties(content: &str) -> (HashMap<String, String>, String) {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let mut properties = HashMap::new();
|
||||
let mut body_start = 0;
|
||||
|
||||
// Parse properties until we hit a blank line or non-property line
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Empty line marks end of properties
|
||||
if trimmed.is_empty() {
|
||||
body_start = i + 1;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if line is a property (key:: value)
|
||||
if let Some(captures) = LOGSEQ_PROPERTY_PATTERN.captures(trimmed) {
|
||||
let key = captures[1].to_string();
|
||||
let value = captures[2].to_string();
|
||||
properties.insert(key, value);
|
||||
} else if trimmed.starts_with('-') || trimmed.starts_with('*') {
|
||||
// Hit content, stop parsing properties
|
||||
body_start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let body = lines[body_start..].join("\n");
|
||||
|
||||
(properties, body)
|
||||
}
|
||||
|
||||
/// Parse Logseq date format: [[Jan 17th, 2026]] or ISO format
|
||||
fn parse_logseq_date(date_str: &str) -> DateTime<Utc> {
|
||||
// Remove [[ ]] if present
|
||||
let clean = date_str.trim_matches(|c| c == '[' || c == ']').trim();
|
||||
|
||||
// Try ISO 8601 format first (2026-01-17T10:30:00Z or 2026-01-17)
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(clean) {
|
||||
return dt.with_timezone(&Utc);
|
||||
}
|
||||
|
||||
// Try YYYY-MM-DD format
|
||||
if let Ok(date) = chrono::NaiveDate::parse_from_str(clean, "%Y-%m-%d") {
|
||||
return DateTime::<Utc>::from_naive_utc_and_offset(date.and_hms_opt(0, 0, 0).unwrap(), Utc);
|
||||
}
|
||||
|
||||
// Try Logseq format: "Jan 17th, 2026" or "January 17, 2026"
|
||||
if let Some(parsed) = parse_english_date(clean) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Try other common formats
|
||||
if let Ok(dt) = DateTime::parse_from_rfc2822(clean) {
|
||||
return dt.with_timezone(&Utc);
|
||||
}
|
||||
|
||||
// Default to current time if all parsing fails
|
||||
Utc::now()
|
||||
}
|
||||
|
||||
/// Parse English date formats: "Jan 17th, 2026", "January 17, 2026", "17 Jan
|
||||
/// 2026"
|
||||
fn parse_english_date(date_str: &str) -> Option<DateTime<Utc>> {
|
||||
// Month abbreviations and full names
|
||||
let months = [
|
||||
("jan", "january", 1),
|
||||
("feb", "february", 2),
|
||||
("mar", "march", 3),
|
||||
("apr", "april", 4),
|
||||
("may", "may", 5),
|
||||
("jun", "june", 6),
|
||||
("jul", "july", 7),
|
||||
("aug", "august", 8),
|
||||
("sep", "september", 9),
|
||||
("oct", "october", 10),
|
||||
("nov", "november", 11),
|
||||
("dec", "december", 12),
|
||||
];
|
||||
|
||||
let lower = date_str.to_lowercase();
|
||||
let clean = lower
|
||||
.replace("st,", "")
|
||||
.replace("nd,", "")
|
||||
.replace("rd,", "")
|
||||
.replace("th,", "")
|
||||
.replace("st", "")
|
||||
.replace("nd", "")
|
||||
.replace("rd", "")
|
||||
.replace("th", "");
|
||||
|
||||
// Extract parts: month, day, year
|
||||
let parts: Vec<&str> = clean.split_whitespace().collect();
|
||||
|
||||
if parts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try "Month Day, Year" format: Jan 17, 2026
|
||||
if parts.len() >= 3 {
|
||||
if let Some(date) = try_parse_month_day_year(&parts, &months) {
|
||||
return Some(date);
|
||||
}
|
||||
}
|
||||
|
||||
// Try "Day Month Year" format: 17 Jan 2026
|
||||
if parts.len() >= 3 {
|
||||
if let Some(date) = try_parse_day_month_year(&parts, &months) {
|
||||
return Some(date);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Try parsing "Month Day Year" format
|
||||
fn try_parse_month_day_year(
|
||||
parts: &[&str],
|
||||
months: &[(&str, &str, u32); 12],
|
||||
) -> Option<DateTime<Utc>> {
|
||||
use chrono::NaiveDate;
|
||||
|
||||
for (abbr, full, month_num) in months {
|
||||
if !parts[0].starts_with(abbr) && !parts[0].starts_with(full) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let day = parts[1].parse::<u32>().ok()?;
|
||||
let year = parts[2].parse::<i32>().ok()?;
|
||||
let date = NaiveDate::from_ymd_opt(year, *month_num, day)?;
|
||||
|
||||
return Some(DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
date.and_hms_opt(0, 0, 0)?,
|
||||
Utc,
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Try parsing "Day Month Year" format
|
||||
fn try_parse_day_month_year(
|
||||
parts: &[&str],
|
||||
months: &[(&str, &str, u32); 12],
|
||||
) -> Option<DateTime<Utc>> {
|
||||
use chrono::NaiveDate;
|
||||
|
||||
let day = parts[0].parse::<u32>().ok()?;
|
||||
|
||||
for (abbr, full, month_num) in months {
|
||||
if !parts[1].starts_with(abbr) && !parts[1].starts_with(full) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let year = parts[2].parse::<i32>().ok()?;
|
||||
let date = NaiveDate::from_ymd_opt(year, *month_num, day)?;
|
||||
|
||||
return Some(DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
date.and_hms_opt(0, 0, 0)?,
|
||||
Utc,
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse tags from Logseq format: [[tag1]], [[tag2]] or #tag1 #tag2
|
||||
fn parse_logseq_tags(tags_str: &str) -> Vec<String> {
|
||||
let mut tags = Vec::new();
|
||||
|
||||
// Parse [[tag]] format
|
||||
for cap in LOGSEQ_WIKILINK_PATTERN.captures_iter(tags_str) {
|
||||
tags.push(cap[1].to_string());
|
||||
}
|
||||
|
||||
// Parse #tag format
|
||||
for cap in TAG_PATTERN.captures_iter(tags_str) {
|
||||
let tag = cap[1].to_string();
|
||||
if !tags.contains(&tag) {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
/// Parse references from Logseq format: [[ref1]], [[ref2]]
|
||||
fn parse_logseq_refs(refs_str: &str) -> Vec<String> {
|
||||
LOGSEQ_WIKILINK_PATTERN
|
||||
.captures_iter(refs_str)
|
||||
.map(|cap| cap[1].to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse node type from string
|
||||
fn parse_node_type(type_str: &str) -> Option<NodeType> {
|
||||
match type_str.to_lowercase().as_str() {
|
||||
"note" => Some(NodeType::Note),
|
||||
"decision" => Some(NodeType::Decision),
|
||||
"guideline" => Some(NodeType::Guideline),
|
||||
"pattern" => Some(NodeType::Pattern),
|
||||
"journal" => Some(NodeType::Journal),
|
||||
"execution" => Some(NodeType::Execution),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse node status from string
|
||||
fn parse_node_status(status_str: &str) -> Option<NodeStatus> {
|
||||
match status_str.to_lowercase().as_str() {
|
||||
"draft" => Some(NodeStatus::Draft),
|
||||
"active" => Some(NodeStatus::Active),
|
||||
"superseded" => Some(NodeStatus::Superseded),
|
||||
"archived" => Some(NodeStatus::Archived),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_import_simple_page() {
|
||||
let content = r#"title:: Test Page
|
||||
type:: note
|
||||
tags:: [[rust]], [[testing]]
|
||||
status:: active
|
||||
|
||||
- First block
|
||||
- Second block with content"#;
|
||||
|
||||
let node = import_from_logseq_page(content).unwrap();
|
||||
|
||||
assert_eq!(node.title, "Test Page");
|
||||
assert_eq!(node.node_type, NodeType::Note);
|
||||
assert_eq!(node.status, NodeStatus::Active);
|
||||
assert_eq!(node.tags.len(), 2);
|
||||
assert!(node.tags.contains(&"rust".to_string()));
|
||||
assert!(node.tags.contains(&"testing".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_with_blocks() {
|
||||
let content = r#"title:: Task List
|
||||
type:: note
|
||||
|
||||
- TODO Task 1 #important
|
||||
- DONE Task 2
|
||||
- Nested detail"#;
|
||||
|
||||
let node = import_from_logseq_page(content).unwrap();
|
||||
|
||||
assert_eq!(node.title, "Task List");
|
||||
assert!(node.blocks.is_some());
|
||||
|
||||
let blocks = node.blocks.unwrap();
|
||||
assert_eq!(blocks.len(), 2);
|
||||
assert_eq!(blocks[1].children.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_with_relationships() {
|
||||
let content = r#"title:: Decision 1
|
||||
type:: decision
|
||||
relates-to:: [[node-123]], [[node-456]]
|
||||
depends-on:: [[node-789]]
|
||||
|
||||
- Decision content"#;
|
||||
|
||||
let node = import_from_logseq_page(content).unwrap();
|
||||
|
||||
assert_eq!(node.title, "Decision 1");
|
||||
assert_eq!(node.node_type, NodeType::Decision);
|
||||
assert_eq!(node.relates_to.len(), 2);
|
||||
assert_eq!(node.depends_on.len(), 1);
|
||||
assert!(node.relates_to.contains(&"node-123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_logseq_properties() {
|
||||
let content = r#"title:: Test
|
||||
type:: note
|
||||
custom-prop:: value
|
||||
|
||||
- Content starts here"#;
|
||||
|
||||
let (props, body) = parse_logseq_properties(content);
|
||||
|
||||
assert_eq!(props.get("title"), Some(&"Test".to_string()));
|
||||
assert_eq!(props.get("type"), Some(&"note".to_string()));
|
||||
assert_eq!(props.get("custom-prop"), Some(&"value".to_string()));
|
||||
assert!(body.contains("Content starts here"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_logseq_tags() {
|
||||
let tags = parse_logseq_tags("[[rust]], [[programming]], #testing");
|
||||
assert_eq!(tags.len(), 3);
|
||||
assert!(tags.contains(&"rust".to_string()));
|
||||
assert!(tags.contains(&"programming".to_string()));
|
||||
assert!(tags.contains(&"testing".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_logseq_refs() {
|
||||
let refs = parse_logseq_refs("[[ref1]], [[ref2]], [[ref3]]");
|
||||
assert_eq!(refs.len(), 3);
|
||||
assert_eq!(refs[0], "ref1");
|
||||
assert_eq!(refs[1], "ref2");
|
||||
assert_eq!(refs[2], "ref3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_node_type() {
|
||||
assert_eq!(parse_node_type("note"), Some(NodeType::Note));
|
||||
assert_eq!(parse_node_type("Decision"), Some(NodeType::Decision));
|
||||
assert_eq!(parse_node_type("GUIDELINE"), Some(NodeType::Guideline));
|
||||
assert_eq!(parse_node_type("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_missing_title() {
|
||||
let content = "type:: note\n\n- Content";
|
||||
let result = import_from_logseq_page(content);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
3
crates/kogral-core/src/import/mod.rs
Normal file
3
crates/kogral-core/src/import/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
//! Import functionality
|
||||
|
||||
pub mod logseq;
|
||||
160
crates/kogral-core/src/inheritance.rs
Normal file
160
crates/kogral-core/src/inheritance.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
//! Guideline inheritance resolution
|
||||
//!
|
||||
//! Manages inheritance of guidelines from shared organizational standards
|
||||
//! to project-local overrides, allowing teams to maintain base guidelines
|
||||
//! while projects customize for their specific needs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use tracing::{debug, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{KbError, Result};
|
||||
use crate::models::{Node, NodeType};
|
||||
use crate::parser::parse_frontmatter_map;
|
||||
|
||||
/// Resolve guidelines with inheritance from shared base to local project
|
||||
///
|
||||
/// Merges guidelines from a shared base directory with project-local
|
||||
/// guidelines, allowing projects to override or extend organizational
|
||||
/// standards.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `base_path` - Path to shared guidelines directory
|
||||
/// * `local_path` - Path to project-local guidelines directory
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Merged vector of guidelines with local overriding base
|
||||
///
|
||||
/// # Inheritance Rules
|
||||
///
|
||||
/// - Local guidelines with same ID override shared guidelines
|
||||
/// - Shared guidelines without local override are included
|
||||
/// - Priority: local > shared
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if directories cannot be read or guidelines are malformed
|
||||
pub fn resolve_guidelines(base_path: &Path, local_path: &Path) -> Result<Vec<Node>> {
|
||||
info!(
|
||||
"Resolving guidelines: base={:?}, local={:?}",
|
||||
base_path, local_path
|
||||
);
|
||||
|
||||
let mut guidelines: HashMap<String, Node> = HashMap::new();
|
||||
|
||||
// Load shared guidelines first
|
||||
if base_path.exists() {
|
||||
debug!("Loading shared guidelines from {:?}", base_path);
|
||||
load_guidelines_from_dir(base_path, &mut guidelines)?;
|
||||
}
|
||||
|
||||
// Load and override with local guidelines
|
||||
if local_path.exists() {
|
||||
debug!("Loading local guidelines from {:?}", local_path);
|
||||
load_guidelines_from_dir(local_path, &mut guidelines)?;
|
||||
}
|
||||
|
||||
let result: Vec<Node> = guidelines.into_values().collect();
|
||||
debug!("Resolved {} guidelines total", result.len());
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Load guidelines from a directory and merge into the map
|
||||
fn load_guidelines_from_dir(dir: &Path, guidelines: &mut HashMap<String, Node>) -> Result<()> {
|
||||
if !dir.is_dir() {
|
||||
return Err(KbError::Parser(format!(
|
||||
"Guidelines directory not found: {}",
|
||||
dir.display()
|
||||
)));
|
||||
}
|
||||
|
||||
// Read all markdown files in directory
|
||||
let entries = std::fs::read_dir(dir)
|
||||
.map_err(|e| KbError::Parser(format!("Failed to read directory: {e}")))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| KbError::Parser(format!("Failed to read entry: {e}")))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
|
||||
debug!("Loading guideline from: {:?}", path);
|
||||
|
||||
match load_guideline(&path) {
|
||||
Ok(node) => {
|
||||
guidelines.insert(node.id.clone(), node);
|
||||
}
|
||||
Err(e) => {
|
||||
// Log error but continue with other files
|
||||
debug!("Failed to load guideline {path:?}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a single guideline node from a markdown file
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if the file cannot be read or parsed
|
||||
fn load_guideline(path: &Path) -> Result<Node> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| KbError::Parser(format!("Failed to read file: {e}")))?;
|
||||
|
||||
let frontmatter = parse_frontmatter_map(&content)?;
|
||||
|
||||
// Extract node properties from frontmatter
|
||||
let title = frontmatter.get("title").cloned().unwrap_or_else(|| {
|
||||
path.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let id = frontmatter
|
||||
.get("id")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
|
||||
// Extract body from content (everything after frontmatter block)
|
||||
let body = if let Some(second_delimiter) = content[3..].find("---") {
|
||||
content[second_delimiter + 6..].trim().to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let mut node = Node::new(NodeType::Guideline, title);
|
||||
node.id = id;
|
||||
node.content = body;
|
||||
|
||||
// Parse tags if present
|
||||
if let Some(tags_str) = frontmatter.get("tags") {
|
||||
node.tags = tags_str
|
||||
.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
}
|
||||
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_guidelines_empty() {
|
||||
let base = std::path::Path::new("/nonexistent/base");
|
||||
let local = std::path::Path::new("/nonexistent/local");
|
||||
|
||||
let result = resolve_guidelines(base, local).unwrap();
|
||||
assert_eq!(result.len(), 0);
|
||||
}
|
||||
}
|
||||
77
crates/kogral-core/src/lib.rs
Normal file
77
crates/kogral-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
//! # kb-core: KOGRAL Core Library
|
||||
//!
|
||||
//! Core library for a Logseq-inspired KOGRAL system.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - **Graph-based storage**: Nodes (notes, decisions, guidelines, patterns)
|
||||
//! and edges (relationships)
|
||||
//! - **Multiple backends**: Filesystem (git-friendly, Logseq-compatible),
|
||||
//! `SurrealDB`, in-memory
|
||||
//! - **Configuration-driven**: Nickel schemas → JSON → Rust structs
|
||||
//! - **Semantic search**: Vector embeddings via rig-core (Claude, `OpenAI`,
|
||||
//! Ollama) or fastembed
|
||||
//! - **Template system**: Tera templates for document generation and export
|
||||
//! - **Inheritance**: Guideline resolution from shared → local with overrides
|
||||
//! - **Logseq compatibility**: Markdown with wikilinks, YAML frontmatter,
|
||||
//! export/import
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────────────────────────────────────────────────┐
|
||||
//! │ kb-core │
|
||||
//! ├─────────────────────────────────────────────────────────────┤
|
||||
//! │ Config (Nickel) → Models → Storage → Query → Export │
|
||||
//! │ │
|
||||
//! │ Backends: Filesystem | SurrealDB | Memory │
|
||||
//! │ Search: Text + Semantic (rig-core/fastembed) │
|
||||
//! │ Templates: Tera (generation + export) │
|
||||
//! └─────────────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use kogral_core::models::{Node, NodeType, Graph};
|
||||
//!
|
||||
//! // Create a graph
|
||||
//! let mut graph = Graph::new("my-project".to_string());
|
||||
//!
|
||||
//! // Add a note
|
||||
//! let mut note = Node::new(NodeType::Note, "Rust Best Practices".to_string());
|
||||
//! note.content = "Always use Result<T> for error handling".to_string();
|
||||
//! note.add_tag("rust".to_string());
|
||||
//! graph.add_node(note).unwrap();
|
||||
//!
|
||||
//! // Query
|
||||
//! let rust_nodes = graph.nodes_by_tag("rust");
|
||||
//! assert_eq!(rust_nodes.len(), 1);
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
#![warn(clippy::all)]
|
||||
#![warn(clippy::pedantic)]
|
||||
#![allow(clippy::module_name_repetitions)]
|
||||
|
||||
pub mod block_parser;
|
||||
pub mod config;
|
||||
pub mod embeddings;
|
||||
pub mod error;
|
||||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod inheritance;
|
||||
pub mod models;
|
||||
pub mod parser;
|
||||
pub mod query;
|
||||
pub mod storage;
|
||||
pub mod sync;
|
||||
|
||||
mod regex_patterns;
|
||||
|
||||
// Re-exports for convenience
|
||||
pub use error::{KbError, Result};
|
||||
pub use models::{
|
||||
Block, BlockProperties, Edge, EdgeType, Graph, Node, NodeStatus, NodeType, TaskStatus,
|
||||
};
|
||||
1147
crates/kogral-core/src/models.rs
Normal file
1147
crates/kogral-core/src/models.rs
Normal file
File diff suppressed because it is too large
Load diff
248
crates/kogral-core/src/parser.rs
Normal file
248
crates/kogral-core/src/parser.rs
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
//! Markdown parser with Logseq compatibility
|
||||
//!
|
||||
//! This module parses KOGRAL documents:
|
||||
//! - YAML frontmatter extraction
|
||||
//! - Markdown body parsing
|
||||
//! - Wikilink resolution ([[links]])
|
||||
//! - Code reference parsing (@path/to/file.rs:42)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_yaml;
|
||||
|
||||
use crate::error::{KbError, Result};
|
||||
use crate::models::{Node, NodeStatus, NodeType};
|
||||
use crate::regex_patterns::{WIKILINK_PATTERN, CODE_REF_PATTERN};
|
||||
|
||||
/// Extract frontmatter as a map of key-value strings
|
||||
///
|
||||
/// Parses YAML frontmatter and returns it as a `HashMap<String, String>`.
|
||||
/// This is useful for guideline inheritance and other metadata extraction.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if YAML frontmatter is malformed
|
||||
pub fn parse_frontmatter_map(content: &str) -> Result<HashMap<String, String>> {
|
||||
let (frontmatter_str, _body) = extract_frontmatter(content)?;
|
||||
|
||||
let metadata = serde_yaml::from_str::<serde_yaml::Value>(&frontmatter_str)
|
||||
.map_err(|e| KbError::Parser(format!("Invalid YAML: {e}")))?;
|
||||
|
||||
let mut result = HashMap::new();
|
||||
|
||||
if let serde_yaml::Value::Mapping(map) = metadata {
|
||||
for (key, value) in map {
|
||||
if let (serde_yaml::Value::String(k), serde_yaml::Value::String(v)) = (key, value) {
|
||||
result.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Parse a markdown document with YAML frontmatter
|
||||
///
|
||||
/// Format:
|
||||
/// ```markdown
|
||||
/// ---
|
||||
/// id: node-123
|
||||
/// type: note
|
||||
/// title: Example
|
||||
/// ---
|
||||
///
|
||||
/// # Content here
|
||||
/// ```
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - Document is missing frontmatter delimiters (`---`)
|
||||
/// - YAML frontmatter is malformed or contains invalid syntax
|
||||
/// - Required fields (`id`, `type`, `title`) are missing or invalid
|
||||
pub fn parse_document(content: &str) -> Result<Node> {
|
||||
let (frontmatter, body) = extract_frontmatter(content)?;
|
||||
let metadata = parse_frontmatter(&frontmatter)?;
|
||||
|
||||
// Build node from metadata
|
||||
let mut node = Node::new(metadata.node_type, metadata.title.clone());
|
||||
|
||||
node.id = metadata.id;
|
||||
node.created = metadata.created;
|
||||
node.modified = metadata.modified;
|
||||
node.status = metadata.status;
|
||||
node.tags = metadata.tags;
|
||||
node.content.clone_from(&body);
|
||||
node.relates_to = metadata.relates_to;
|
||||
node.depends_on = metadata.depends_on;
|
||||
node.implements = metadata.implements;
|
||||
node.extends = metadata.extends;
|
||||
node.project = metadata.project;
|
||||
node.metadata = metadata.extra;
|
||||
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// Frontmatter metadata
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct FrontmatterMetadata {
|
||||
id: String,
|
||||
#[serde(rename = "type")]
|
||||
node_type: NodeType,
|
||||
title: String,
|
||||
created: DateTime<Utc>,
|
||||
modified: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
status: NodeStatus,
|
||||
#[serde(default)]
|
||||
relates_to: Vec<String>,
|
||||
#[serde(default)]
|
||||
depends_on: Vec<String>,
|
||||
#[serde(default)]
|
||||
implements: Vec<String>,
|
||||
#[serde(default)]
|
||||
extends: Vec<String>,
|
||||
#[serde(default)]
|
||||
project: Option<String>,
|
||||
#[serde(flatten)]
|
||||
extra: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Extract YAML frontmatter from markdown content
|
||||
fn extract_frontmatter(content: &str) -> Result<(String, String)> {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
|
||||
if lines.is_empty() || !lines[0].trim().starts_with("---") {
|
||||
return Err(KbError::Frontmatter("No frontmatter found".to_string()));
|
||||
}
|
||||
|
||||
// Find end of frontmatter
|
||||
let end = lines[1..]
|
||||
.iter()
|
||||
.position(|line| line.trim() == "---")
|
||||
.ok_or_else(|| KbError::Frontmatter("Unterminated frontmatter".to_string()))?;
|
||||
|
||||
let frontmatter = lines[1..=end].join("\n");
|
||||
let body = lines[end + 2..].join("\n");
|
||||
|
||||
Ok((frontmatter, body))
|
||||
}
|
||||
|
||||
/// Parse YAML frontmatter
|
||||
fn parse_frontmatter(yaml: &str) -> Result<FrontmatterMetadata> {
|
||||
serde_yaml::from_str(yaml)
|
||||
.map_err(|e| KbError::Frontmatter(format!("Invalid frontmatter YAML: {e}")))
|
||||
}
|
||||
|
||||
/// Extract wikilinks from markdown content
|
||||
///
|
||||
/// Matches: [[link]], [[link|display text]]
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the hardcoded regex pattern is invalid (should never happen).
|
||||
#[must_use]
|
||||
pub fn extract_wikilinks(content: &str) -> Vec<String> {
|
||||
WIKILINK_PATTERN
|
||||
.captures_iter(content)
|
||||
.map(|cap| cap[1].to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract code references from markdown
|
||||
///
|
||||
/// Matches: @path/to/file.rs:42
|
||||
#[must_use]
|
||||
pub fn extract_code_refs(content: &str) -> Vec<String> {
|
||||
CODE_REF_PATTERN
|
||||
.captures_iter(content)
|
||||
.map(|cap| cap[1].to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate markdown from a node
|
||||
#[must_use]
|
||||
pub fn generate_markdown(node: &Node) -> String {
|
||||
let mut output = String::new();
|
||||
|
||||
// Frontmatter
|
||||
output.push_str("---\n");
|
||||
let _ = writeln!(output, "id: {}", node.id);
|
||||
let _ = writeln!(output, "type: {}", node.node_type);
|
||||
let _ = writeln!(output, "title: {}", node.title);
|
||||
let _ = writeln!(output, "created: {}", node.created.to_rfc3339());
|
||||
let _ = writeln!(output, "modified: {}", node.modified.to_rfc3339());
|
||||
|
||||
if !node.tags.is_empty() {
|
||||
let tags_str = node
|
||||
.tags
|
||||
.iter()
|
||||
.map(|t| format!("\"{t}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let _ = writeln!(output, "tags: [{tags_str}]");
|
||||
}
|
||||
|
||||
let _ = writeln!(output, "status: {}", node.status);
|
||||
|
||||
if !node.relates_to.is_empty() {
|
||||
output.push_str("relates_to:\n");
|
||||
for rel in &node.relates_to {
|
||||
let _ = writeln!(output, " - {rel}");
|
||||
}
|
||||
}
|
||||
|
||||
if !node.depends_on.is_empty() {
|
||||
output.push_str("depends_on:\n");
|
||||
for dep in &node.depends_on {
|
||||
let _ = writeln!(output, " - {dep}");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref project) = node.project {
|
||||
let _ = writeln!(output, "project: {project}");
|
||||
}
|
||||
|
||||
output.push_str("---\n\n");
|
||||
|
||||
// Body
|
||||
output.push_str(&node.content);
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_frontmatter() {
|
||||
let content = r#"---
|
||||
id: test-123
|
||||
type: note
|
||||
---
|
||||
Content here"#;
|
||||
|
||||
let (fm, body) = extract_frontmatter(content).unwrap();
|
||||
assert!(fm.contains("id: test-123"));
|
||||
assert_eq!(body.trim(), "Content here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_wikilinks() {
|
||||
let content = "This is [[link1]] and [[link2|Display Text]].";
|
||||
let links = extract_wikilinks(content);
|
||||
assert_eq!(links, vec!["link1", "link2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_code_refs() {
|
||||
let content = "See @src/main.rs:42 and @lib/util.rs:100";
|
||||
let refs = extract_code_refs(content);
|
||||
assert_eq!(refs, vec!["src/main.rs:42", "lib/util.rs:100"]);
|
||||
}
|
||||
}
|
||||
173
crates/kogral-core/src/query.rs
Normal file
173
crates/kogral-core/src/query.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
//! Query engine for text and semantic search
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models::{Graph, Node};
|
||||
|
||||
/// Query engine for searching nodes in a graph
|
||||
///
|
||||
/// Provides text and semantic search capabilities across nodes.
|
||||
/// Supports:
|
||||
/// - Full-text search (title, content, tags)
|
||||
/// - Tag-based filtering
|
||||
/// - Type-based filtering
|
||||
pub struct QueryEngine {
|
||||
graph: Graph,
|
||||
}
|
||||
|
||||
impl QueryEngine {
|
||||
/// Create a new query engine for the given graph
|
||||
#[must_use]
|
||||
pub fn new(graph: Graph) -> Self {
|
||||
Self { graph }
|
||||
}
|
||||
|
||||
/// Search for nodes matching the query
|
||||
///
|
||||
/// Performs full-text search across:
|
||||
/// - Node titles (case-insensitive)
|
||||
/// - Node content (case-insensitive)
|
||||
/// - Node tags (exact match)
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `query` - Search query string
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Vector of nodes matching the query, sorted by relevance
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function does not fail; it always returns Ok with results.
|
||||
pub fn search(&self, query: &str) -> Result<Vec<Node>> {
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut results = Vec::new();
|
||||
|
||||
for node in self.graph.nodes.values() {
|
||||
let mut score = 0;
|
||||
|
||||
// Title match (highest score)
|
||||
if node.title.to_lowercase().contains(&query_lower) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
// Tag match (high score)
|
||||
if node
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| tag.to_lowercase().contains(&query_lower))
|
||||
{
|
||||
score += 5;
|
||||
}
|
||||
|
||||
// Content match (medium score)
|
||||
if node.content.to_lowercase().contains(&query_lower) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
if score > 0 {
|
||||
results.push((score, node.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score (descending)
|
||||
results.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
Ok(results.into_iter().map(|(_, node)| node).collect())
|
||||
}
|
||||
|
||||
/// Search nodes by tag
|
||||
///
|
||||
/// Returns all nodes that have the specified tag.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function does not fail; it always returns Ok with results.
|
||||
pub fn search_by_tag(&self, tag: &str) -> Result<Vec<Node>> {
|
||||
Ok(self
|
||||
.graph
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|node| node.tags.iter().any(|t| t.eq_ignore_ascii_case(tag)))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Search nodes by regex pattern
|
||||
///
|
||||
/// Searches content and title using regex.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if regex pattern is invalid.
|
||||
pub fn search_regex(&self, pattern: &str) -> Result<Vec<Node>> {
|
||||
let re = Regex::new(pattern)
|
||||
.map_err(|e| crate::error::KbError::Query(format!("Invalid regex pattern: {e}")))?;
|
||||
|
||||
Ok(self
|
||||
.graph
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|node| re.is_match(&node.title) || re.is_match(&node.content))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Search nodes by type
|
||||
///
|
||||
/// Returns all nodes of the specified type.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function does not fail; it always returns Ok with results.
|
||||
pub fn search_by_type(&self, node_type: crate::models::NodeType) -> Result<Vec<Node>> {
|
||||
Ok(self
|
||||
.graph
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|node| node.node_type == node_type)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Get all nodes related to the given node
|
||||
///
|
||||
/// Returns nodes that have incoming or outgoing edges.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function does not fail; it always returns Ok with results.
|
||||
pub fn get_related_nodes(&self, node_id: &str) -> Result<Vec<Node>> {
|
||||
let mut related_ids = std::collections::HashSet::new();
|
||||
|
||||
// Add nodes that this node relates to
|
||||
if let Some(node) = self.graph.nodes.get(node_id) {
|
||||
related_ids.extend(node.relates_to.iter().cloned());
|
||||
related_ids.extend(node.depends_on.iter().cloned());
|
||||
related_ids.extend(node.implements.iter().cloned());
|
||||
related_ids.extend(node.extends.iter().cloned());
|
||||
}
|
||||
|
||||
// Add nodes that relate to this node
|
||||
for node in self.graph.nodes.values() {
|
||||
if node.relates_to.contains(&node_id.to_string())
|
||||
|| node.depends_on.contains(&node_id.to_string())
|
||||
|| node.implements.contains(&node_id.to_string())
|
||||
|| node.extends.contains(&node_id.to_string())
|
||||
{
|
||||
related_ids.insert(node.id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(related_ids
|
||||
.into_iter()
|
||||
.filter_map(|id| self.graph.nodes.get(&id).cloned())
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
97
crates/kogral-core/src/regex_patterns.rs
Normal file
97
crates/kogral-core/src/regex_patterns.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
//! Lazy-compiled regex patterns for the KOGRAL knowledge base engine
|
||||
//!
|
||||
//! All regex patterns are compiled once at startup using `once_cell::sync::Lazy`
|
||||
//! to avoid recompilation overhead during normal operation.
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
|
||||
/// Wikilink pattern: `[[target]]` or `[[target|display]]`
|
||||
pub static WIKILINK_PATTERN: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]").unwrap());
|
||||
|
||||
/// Code reference pattern: `@path/to/file.rs:42`
|
||||
pub static CODE_REF_PATTERN: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"@([\w/.-]+:\d+)").unwrap());
|
||||
|
||||
/// Tag pattern: `#tagname`
|
||||
pub static TAG_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"#(\w+)").unwrap());
|
||||
|
||||
/// Property pattern at start of line: `property:: value`
|
||||
pub static PROPERTY_START_PATTERN: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^(\w+)::\s*(\S+)$").unwrap());
|
||||
|
||||
/// Property pattern inline: `property:: value` anywhere in text
|
||||
pub static PROPERTY_INLINE_PATTERN: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"(\w+)::\s*(\S+)").unwrap());
|
||||
|
||||
/// UUID pattern in double parentheses: `((uuid))`
|
||||
pub static UUID_REF_PATTERN: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"\(\(([a-f0-9-]{36})\)\)").unwrap());
|
||||
|
||||
/// Logseq property pattern with dashes: `property-name:: value`
|
||||
pub static LOGSEQ_PROPERTY_PATTERN: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^(\w[\w-]*)::\s*(.+)$").unwrap());
|
||||
|
||||
/// Logseq wikilink pattern
|
||||
pub static LOGSEQ_WIKILINK_PATTERN: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"\[\[([^\]]+)\]\]").unwrap());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_wikilink_pattern() {
|
||||
let re = &WIKILINK_PATTERN;
|
||||
assert!(re.is_match("[[target]]"));
|
||||
assert!(re.is_match("[[target|display]]"));
|
||||
assert!(!re.is_match("[[incomplete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_code_ref_pattern() {
|
||||
let re = &CODE_REF_PATTERN;
|
||||
assert!(re.is_match("@src/main.rs:42"));
|
||||
assert!(re.is_match("@crates/core/lib.rs:100"));
|
||||
assert!(!re.is_match("@no-colon.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tag_pattern() {
|
||||
let re = &TAG_PATTERN;
|
||||
assert!(re.is_match("#rust"));
|
||||
assert!(re.is_match("#api-design"));
|
||||
assert!(!re.is_match("#"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_property_start_pattern() {
|
||||
let re = &PROPERTY_START_PATTERN;
|
||||
assert!(re.is_match("author:: john"));
|
||||
assert!(re.is_match("status:: active"));
|
||||
assert!(!re.is_match("inline author:: john"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_property_inline_pattern() {
|
||||
let re = &PROPERTY_INLINE_PATTERN;
|
||||
assert!(re.is_match("author:: John"));
|
||||
assert!(re.is_match("Contains author:: John in text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uuid_ref_pattern() {
|
||||
let re = &UUID_REF_PATTERN;
|
||||
assert!(re.is_match("((550e8400-e29b-41d4-a716-446655440000))"));
|
||||
assert!(!re.is_match("((invalid))"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logseq_property_pattern() {
|
||||
let re = &LOGSEQ_PROPERTY_PATTERN;
|
||||
assert!(re.is_match("author:: John Doe"));
|
||||
assert!(re.is_match("created-at:: 2024-01-01"));
|
||||
assert!(!re.is_match("::missing-prefix"));
|
||||
}
|
||||
}
|
||||
204
crates/kogral-core/src/storage/filesystem.rs
Normal file
204
crates/kogral-core/src/storage/filesystem.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
//! Filesystem storage backend
|
||||
//!
|
||||
//! Stores nodes as markdown files with YAML frontmatter
|
||||
|
||||
use std::fs;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::{KbError, Result};
|
||||
use crate::models::{Graph, Node};
|
||||
use crate::storage::Storage;
|
||||
|
||||
/// Filesystem-based storage backend
|
||||
///
|
||||
/// Stores graphs as directory structures with markdown files.
|
||||
/// Each node becomes a `.md` file with YAML frontmatter.
|
||||
pub struct FilesystemStorage {
|
||||
base_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl FilesystemStorage {
|
||||
/// Create a new filesystem storage at the given path
|
||||
#[must_use]
|
||||
pub fn new(base_path: std::path::PathBuf) -> Self {
|
||||
Self { base_path }
|
||||
}
|
||||
|
||||
/// Get the path to a graph directory (with path traversal protection)
|
||||
fn graph_path(&self, graph_name: &str) -> std::path::PathBuf {
|
||||
// Prevent path traversal attacks - reject names with .. or slashes
|
||||
let safe_name = graph_name.replace("..", "_").replace(['/', '\\'], "_");
|
||||
self.base_path.join(safe_name)
|
||||
}
|
||||
|
||||
/// Get the path to a node file (with path traversal protection)
|
||||
fn node_path(&self, graph_name: &str, node_id: &str) -> std::path::PathBuf {
|
||||
// Prevent path traversal - reject IDs with .. or slashes
|
||||
let safe_id = node_id.replace("..", "_").replace(['/', '\\'], "_");
|
||||
self.graph_path(graph_name).join(format!("{safe_id}.md"))
|
||||
}
|
||||
|
||||
/// Serialize a node to markdown with YAML frontmatter
|
||||
fn serialize_node(node: &Node) -> Result<String> {
|
||||
let frontmatter =
|
||||
serde_yaml::to_string(node).map_err(|e| KbError::Serialization(e.to_string()))?;
|
||||
Ok(format!("---\n{frontmatter}---\n\n{}", node.content))
|
||||
}
|
||||
|
||||
/// Parse markdown with YAML frontmatter into a Node
|
||||
fn deserialize_node(content: &str) -> Result<Node> {
|
||||
if !content.starts_with("---\n") {
|
||||
return Err(KbError::Frontmatter("Missing frontmatter".to_string()));
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = content.splitn(3, "---\n").collect();
|
||||
if parts.len() < 3 {
|
||||
return Err(KbError::Frontmatter(
|
||||
"Invalid frontmatter format".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let yaml = parts[1];
|
||||
let body = parts[2];
|
||||
|
||||
let mut node: Node =
|
||||
serde_yaml::from_str(yaml).map_err(|e| KbError::Frontmatter(e.to_string()))?;
|
||||
node.content = body.to_string();
|
||||
|
||||
Ok(node)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Storage for FilesystemStorage {
|
||||
async fn save_graph(&mut self, graph: &Graph) -> Result<()> {
|
||||
let graph_path = self.graph_path(&graph.name);
|
||||
fs::create_dir_all(&graph_path)
|
||||
.map_err(|e| KbError::Storage(format!("Failed to create graph directory: {e}")))?;
|
||||
|
||||
for node in graph.nodes.values() {
|
||||
let node_path = self.node_path(&graph.name, &node.id);
|
||||
let content = Self::serialize_node(node)?;
|
||||
fs::write(&node_path, content)
|
||||
.map_err(|e| KbError::Storage(format!("Failed to write node file: {e}")))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_graph(&self, name: &str) -> Result<Graph> {
|
||||
let graph_path = self.graph_path(name);
|
||||
if !graph_path.exists() {
|
||||
return Err(KbError::Graph(format!("Graph not found: {name}")));
|
||||
}
|
||||
|
||||
let mut graph = Graph::new(name.to_string());
|
||||
let entries = fs::read_dir(&graph_path)
|
||||
.map_err(|e| KbError::Storage(format!("Failed to read graph directory: {e}")))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| KbError::Storage(e.to_string()))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().is_some_and(|ext| ext == "md") {
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| KbError::Storage(format!("Failed to read node file: {e}")))?;
|
||||
let node = Self::deserialize_node(&content)?;
|
||||
graph.nodes.insert(node.id.clone(), node);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(graph)
|
||||
}
|
||||
|
||||
async fn save_node(&mut self, node: &Node) -> Result<()> {
|
||||
let graph_name = node
|
||||
.project
|
||||
.clone()
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
let graph_path = self.graph_path(&graph_name);
|
||||
fs::create_dir_all(&graph_path)
|
||||
.map_err(|e| KbError::Storage(format!("Failed to create graph directory: {e}")))?;
|
||||
|
||||
let node_path = self.node_path(&graph_name, &node.id);
|
||||
let content = Self::serialize_node(node)?;
|
||||
fs::write(&node_path, content)
|
||||
.map_err(|e| KbError::Storage(format!("Failed to write node file: {e}")))
|
||||
}
|
||||
|
||||
async fn load_node(&self, graph_name: &str, node_id: &str) -> Result<Node> {
|
||||
let node_path = self.node_path(graph_name, node_id);
|
||||
if !node_path.exists() {
|
||||
return Err(KbError::NodeNotFound(format!("{graph_name}/{node_id}")));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&node_path)
|
||||
.map_err(|e| KbError::Storage(format!("Failed to read node file: {e}")))?;
|
||||
Self::deserialize_node(&content)
|
||||
}
|
||||
|
||||
async fn delete_node(&mut self, graph_name: &str, node_id: &str) -> Result<()> {
|
||||
let node_path = self.node_path(graph_name, node_id);
|
||||
if !node_path.exists() {
|
||||
return Err(KbError::NodeNotFound(format!("{graph_name}/{node_id}")));
|
||||
}
|
||||
|
||||
fs::remove_file(&node_path)
|
||||
.map_err(|e| KbError::Storage(format!("Failed to delete node file: {e}")))
|
||||
}
|
||||
|
||||
async fn list_graphs(&self) -> Result<Vec<String>> {
|
||||
if !self.base_path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut graphs = Vec::new();
|
||||
let entries = fs::read_dir(&self.base_path)
|
||||
.map_err(|e| KbError::Storage(format!("Failed to read base path: {e}")))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| KbError::Storage(e.to_string()))?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
graphs.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(graphs)
|
||||
}
|
||||
|
||||
async fn list_nodes(&self, graph_name: &str, node_type: Option<&str>) -> Result<Vec<Node>> {
|
||||
let graph_path = self.graph_path(graph_name);
|
||||
if !graph_path.exists() {
|
||||
return Err(KbError::Graph(format!("Graph not found: {graph_name}")));
|
||||
}
|
||||
|
||||
let mut nodes = Vec::new();
|
||||
let entries = fs::read_dir(&graph_path)
|
||||
.map_err(|e| KbError::Storage(format!("Failed to read graph directory: {e}")))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| KbError::Storage(e.to_string()))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().is_some_and(|ext| ext == "md") {
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| KbError::Storage(format!("Failed to read node file: {e}")))?;
|
||||
let node = Self::deserialize_node(&content)?;
|
||||
|
||||
if let Some(filter_type) = node_type {
|
||||
if node.node_type.to_string() == filter_type {
|
||||
nodes.push(node);
|
||||
}
|
||||
} else {
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(nodes)
|
||||
}
|
||||
}
|
||||
103
crates/kogral-core/src/storage/memory.rs
Normal file
103
crates/kogral-core/src/storage/memory.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
//! In-memory storage backend
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use dashmap::DashMap;
|
||||
|
||||
use crate::error::{KbError, Result};
|
||||
use crate::models::{Graph, Node};
|
||||
use crate::storage::Storage;
|
||||
|
||||
/// In-memory storage backend using `DashMap`
|
||||
///
|
||||
/// Stores graphs in a concurrent hash map for fast access.
|
||||
/// Thread-safe and ideal for testing or caching scenarios.
|
||||
pub struct MemoryStorage {
|
||||
graphs: Arc<DashMap<String, Graph>>,
|
||||
}
|
||||
|
||||
impl MemoryStorage {
|
||||
/// Create a new empty in-memory storage
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
graphs: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MemoryStorage {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Storage for MemoryStorage {
|
||||
async fn save_graph(&mut self, graph: &Graph) -> Result<()> {
|
||||
self.graphs.insert(graph.name.clone(), graph.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_graph(&self, name: &str) -> Result<Graph> {
|
||||
self.graphs
|
||||
.get(name)
|
||||
.map(|g| g.clone())
|
||||
.ok_or_else(|| KbError::Graph(format!("Graph not found: {name}")))
|
||||
}
|
||||
|
||||
async fn save_node(&mut self, node: &Node) -> Result<()> {
|
||||
let graph_name = node
|
||||
.project
|
||||
.clone()
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
let mut graph = self
|
||||
.load_graph(&graph_name)
|
||||
.await
|
||||
.unwrap_or_else(|_| Graph::new(graph_name.clone()));
|
||||
|
||||
graph.nodes.insert(node.id.clone(), node.clone());
|
||||
self.save_graph(&graph).await
|
||||
}
|
||||
|
||||
async fn load_node(&self, graph_name: &str, node_id: &str) -> Result<Node> {
|
||||
self.graphs
|
||||
.get(graph_name)
|
||||
.and_then(|graph| graph.nodes.get(node_id).cloned())
|
||||
.ok_or_else(|| KbError::NodeNotFound(format!("{graph_name}/{node_id}")))
|
||||
}
|
||||
|
||||
async fn delete_node(&mut self, graph_name: &str, node_id: &str) -> Result<()> {
|
||||
if let Some(mut graph) = self.graphs.get_mut(graph_name) {
|
||||
if graph.nodes.remove(node_id).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(KbError::NodeNotFound(format!("{graph_name}/{node_id}")))
|
||||
}
|
||||
|
||||
async fn list_graphs(&self) -> Result<Vec<String>> {
|
||||
Ok(self
|
||||
.graphs
|
||||
.iter()
|
||||
.map(|entry| entry.key().clone())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_nodes(&self, graph_name: &str, node_type: Option<&str>) -> Result<Vec<Node>> {
|
||||
let graph = self
|
||||
.graphs
|
||||
.get(graph_name)
|
||||
.ok_or_else(|| KbError::Graph(format!("Graph not found: {graph_name}")))?;
|
||||
|
||||
let mut nodes: Vec<Node> = graph.nodes.values().cloned().collect();
|
||||
|
||||
if let Some(filter_type) = node_type {
|
||||
nodes.retain(|n| n.node_type.to_string() == filter_type);
|
||||
}
|
||||
|
||||
Ok(nodes)
|
||||
}
|
||||
}
|
||||
60
crates/kogral-core/src/storage/mod.rs
Normal file
60
crates/kogral-core/src/storage/mod.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//! Storage backend abstraction and implementations
|
||||
//!
|
||||
//! Provides multiple storage backends for KOGRAL:
|
||||
//! - Filesystem: Git-friendly markdown files
|
||||
//! - Memory: In-memory graph for dev/cache
|
||||
//! - `SurrealDB`: Scalable database backend (optional)
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models::{Graph, Node};
|
||||
|
||||
/// Storage backend trait
|
||||
///
|
||||
/// Provides abstraction over different storage implementations (filesystem,
|
||||
/// `SurrealDB`, memory). All operations are async and return Result<T> for
|
||||
/// error handling.
|
||||
#[async_trait]
|
||||
pub trait Storage: Send + Sync {
|
||||
/// Save a complete graph to storage
|
||||
///
|
||||
/// Persists the entire graph including all nodes and edges.
|
||||
async fn save_graph(&mut self, graph: &Graph) -> Result<()>;
|
||||
|
||||
/// Load a graph from storage
|
||||
///
|
||||
/// Returns a fully reconstructed graph with all nodes and relationships.
|
||||
async fn load_graph(&self, name: &str) -> Result<Graph>;
|
||||
|
||||
/// Save a single node to storage
|
||||
///
|
||||
/// Persists a node, updating if it already exists.
|
||||
async fn save_node(&mut self, node: &Node) -> Result<()>;
|
||||
|
||||
/// Load a node by ID from storage
|
||||
///
|
||||
/// Searches across all node types (notes, decisions, guidelines, patterns,
|
||||
/// journal, execution).
|
||||
async fn load_node(&self, graph_name: &str, node_id: &str) -> Result<Node>;
|
||||
|
||||
/// Delete a node from storage
|
||||
///
|
||||
/// Removes the node and cleans up relationships.
|
||||
async fn delete_node(&mut self, graph_name: &str, node_id: &str) -> Result<()>;
|
||||
|
||||
/// List all graphs in storage
|
||||
async fn list_graphs(&self) -> Result<Vec<String>>;
|
||||
|
||||
/// List nodes in a graph, optionally filtered by type
|
||||
///
|
||||
/// Returns all nodes of a specific type if `node_type` is provided,
|
||||
/// otherwise returns all nodes in the graph.
|
||||
async fn list_nodes(&self, graph_name: &str, node_type: Option<&str>) -> Result<Vec<Node>>;
|
||||
}
|
||||
|
||||
pub mod filesystem;
|
||||
pub mod memory;
|
||||
|
||||
#[cfg(feature = "surrealdb-backend")]
|
||||
pub mod surrealdb;
|
||||
271
crates/kogral-core/src/storage/surrealdb.rs
Normal file
271
crates/kogral-core/src/storage/surrealdb.rs
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
//! SurrealDB storage backend
|
||||
//!
|
||||
//! Provides scalable, graph-native storage using SurrealDB.
|
||||
//! Supports distributed deployments and complex queries.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use surrealdb::engine::any::Any;
|
||||
use surrealdb::Surreal;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::error::{KbError, Result};
|
||||
use crate::models::{Graph, Node};
|
||||
use crate::storage::Storage;
|
||||
|
||||
/// SurrealDB storage backend
|
||||
///
|
||||
/// Stores graphs and nodes in SurrealDB with full ACID transactions.
|
||||
/// Connection is wrapped in Arc<RwLock<>> for thread-safe concurrent access.
|
||||
pub struct SurrealDbStorage {
|
||||
db: Arc<RwLock<Surreal<Any>>>,
|
||||
namespace: String,
|
||||
database: String,
|
||||
}
|
||||
|
||||
impl SurrealDbStorage {
|
||||
/// Create a new SurrealDB storage instance
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `db` - Connected SurrealDB instance
|
||||
/// * `namespace` - SurrealDB namespace (default: "kogral")
|
||||
/// * `database` - SurrealDB database (default: "kb")
|
||||
pub fn new(db: Surreal<Any>, namespace: String, database: String) -> Self {
|
||||
Self {
|
||||
db: Arc::new(RwLock::new(db)),
|
||||
namespace,
|
||||
database,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new storage with default namespace and database names
|
||||
pub fn with_defaults(db: Surreal<Any>) -> Self {
|
||||
Self::new(db, "kogral".to_string(), "kb".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for SurrealDbStorage {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
db: Arc::clone(&self.db),
|
||||
namespace: self.namespace.clone(),
|
||||
database: self.database.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Storage for SurrealDbStorage {
|
||||
async fn save_graph(&mut self, graph: &Graph) -> Result<()> {
|
||||
let db = self.db.write().await;
|
||||
let _ = db
|
||||
.use_ns(&self.namespace)
|
||||
.use_db(&self.database)
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
|
||||
// Serialize graph and all nodes
|
||||
let graph_json = serde_json::to_value(graph)
|
||||
.map_err(|e| KbError::Serialization(format!("Graph serialization error: {}", e)))?;
|
||||
|
||||
// Use raw SurrealQL query for upserting
|
||||
let query = "UPSERT graphs:$graph_id SET * = $content;";
|
||||
let graph_id = graph.name.clone();
|
||||
|
||||
let _: Vec<surrealdb::Value> = db
|
||||
.query(query)
|
||||
.bind(("graph_id", graph_id))
|
||||
.bind(("content", graph_json))
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?
|
||||
.take(0)
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
|
||||
// Upsert all nodes
|
||||
for node in graph.nodes.values() {
|
||||
let node_json = serde_json::to_value(node)
|
||||
.map_err(|e| KbError::Serialization(format!("Node serialization error: {}", e)))?;
|
||||
let node_key = format!("{}_{}", graph.name, node.id);
|
||||
|
||||
let query = "UPSERT nodes:$node_id SET * = $content;";
|
||||
let _: Vec<surrealdb::Value> = db
|
||||
.query(query)
|
||||
.bind(("node_id", node_key))
|
||||
.bind(("content", node_json))
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?
|
||||
.take(0)
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_graph(&self, name: &str) -> Result<Graph> {
|
||||
let db = self.db.read().await;
|
||||
let _ = db
|
||||
.use_ns(&self.namespace)
|
||||
.use_db(&self.database)
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
|
||||
// Load graph metadata using raw query
|
||||
let query = "SELECT * FROM graphs:$graph_id;";
|
||||
let graph_id = name.to_string();
|
||||
let result: Vec<Option<Graph>> = db
|
||||
.query(query)
|
||||
.bind(("graph_id", graph_id))
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?
|
||||
.take(0)
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
|
||||
let mut graph = result
|
||||
.into_iter()
|
||||
.next()
|
||||
.flatten()
|
||||
.ok_or_else(|| KbError::Graph(format!("Graph not found: {}", name)))?;
|
||||
|
||||
// Load all nodes for this graph
|
||||
let query = "SELECT * FROM nodes WHERE id LIKE $pattern;";
|
||||
let pattern = format!("{}_%", name);
|
||||
let nodes: Vec<Node> = db
|
||||
.query(query)
|
||||
.bind(("pattern", pattern))
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?
|
||||
.take(0)
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
|
||||
for node in nodes {
|
||||
graph.nodes.insert(node.id.clone(), node);
|
||||
}
|
||||
|
||||
Ok(graph)
|
||||
}
|
||||
|
||||
async fn save_node(&mut self, node: &Node) -> Result<()> {
|
||||
let db = self.db.write().await;
|
||||
let _ = db
|
||||
.use_ns(&self.namespace)
|
||||
.use_db(&self.database)
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
|
||||
let graph_name = node
|
||||
.project
|
||||
.clone()
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
let node_id = format!("{}_{}", graph_name, node.id);
|
||||
let node_json = serde_json::to_value(node)
|
||||
.map_err(|e| KbError::Serialization(format!("Node serialization error: {}", e)))?;
|
||||
|
||||
let query = "UPSERT nodes:$node_id SET * = $content;";
|
||||
let _: Vec<surrealdb::Value> = db
|
||||
.query(query)
|
||||
.bind(("node_id", node_id))
|
||||
.bind(("content", node_json))
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?
|
||||
.take(0)
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_node(&self, graph_name: &str, node_id: &str) -> Result<Node> {
|
||||
let db = self.db.read().await;
|
||||
let _ = db
|
||||
.use_ns(&self.namespace)
|
||||
.use_db(&self.database)
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
|
||||
let combined_id = format!("{}_{}", graph_name, node_id);
|
||||
let query = "SELECT * FROM nodes:$node_id;";
|
||||
let result: Vec<Option<Node>> = db
|
||||
.query(query)
|
||||
.bind(("node_id", combined_id))
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?
|
||||
.take(0)
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
|
||||
result
|
||||
.into_iter()
|
||||
.next()
|
||||
.flatten()
|
||||
.ok_or_else(|| KbError::NodeNotFound(format!("{}/{}", graph_name, node_id)))
|
||||
}
|
||||
|
||||
async fn delete_node(&mut self, graph_name: &str, node_id: &str) -> Result<()> {
|
||||
let db = self.db.write().await;
|
||||
let _ = db
|
||||
.use_ns(&self.namespace)
|
||||
.use_db(&self.database)
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
|
||||
let combined_id = format!("{}_{}", graph_name, node_id);
|
||||
let query = "DELETE nodes:$node_id RETURN BEFORE;";
|
||||
let deleted: Vec<Option<Node>> = db
|
||||
.query(query)
|
||||
.bind(("node_id", combined_id))
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?
|
||||
.take(0)
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
|
||||
deleted
|
||||
.into_iter()
|
||||
.next()
|
||||
.flatten()
|
||||
.ok_or_else(|| KbError::NodeNotFound(format!("{}/{}", graph_name, node_id)))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn list_graphs(&self) -> Result<Vec<String>> {
|
||||
let db = self.db.read().await;
|
||||
let _ = db
|
||||
.use_ns(&self.namespace)
|
||||
.use_db(&self.database)
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
|
||||
let graphs: Vec<Graph> = db
|
||||
.select("graphs")
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
|
||||
Ok(graphs.into_iter().map(|g| g.name).collect())
|
||||
}
|
||||
|
||||
async fn list_nodes(&self, graph_name: &str, node_type: Option<&str>) -> Result<Vec<Node>> {
|
||||
let db = self.db.read().await;
|
||||
let _ = db
|
||||
.use_ns(&self.namespace)
|
||||
.use_db(&self.database)
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?;
|
||||
|
||||
let mut query_builder = db.query(if let Some(_) = node_type {
|
||||
"SELECT * FROM nodes WHERE id LIKE $id_pattern AND type = $type_filter"
|
||||
} else {
|
||||
"SELECT * FROM nodes WHERE id LIKE $id_pattern"
|
||||
});
|
||||
|
||||
let id_pattern = format!("{}_%", graph_name);
|
||||
query_builder = query_builder.bind(("id_pattern", id_pattern));
|
||||
|
||||
if let Some(filter_type) = node_type {
|
||||
query_builder = query_builder.bind(("type_filter", filter_type));
|
||||
}
|
||||
|
||||
query_builder
|
||||
.await
|
||||
.map_err(|e| KbError::Database(e))?
|
||||
.take(0)
|
||||
.map_err(|e| KbError::Database(e))
|
||||
}
|
||||
}
|
||||
548
crates/kogral-core/src/sync.rs
Normal file
548
crates/kogral-core/src/sync.rs
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
//! Bidirectional synchronization between storage backends
|
||||
//!
|
||||
//! Provides synchronization between two storage backends (typically filesystem
|
||||
//! ↔ `SurrealDB`). Supports multiple sync strategies:
|
||||
//! - `sync_to_target()`: One-way sync from source to target
|
||||
//! - `sync_from_target()`: One-way sync from target to source
|
||||
//! - `sync_bidirectional()`: Two-way sync with conflict resolution
|
||||
//!
|
||||
//! # Conflict Resolution
|
||||
//!
|
||||
//! When the same node exists in both storages with different content:
|
||||
//! - **`LastWriteWins`**: Use the node with the most recent `modified`
|
||||
//! timestamp
|
||||
//! - **`SourceWins`**: Always prefer the source node
|
||||
//! - **`TargetWins`**: Always prefer the target node
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use kogral_core::sync::{SyncManager, ConflictStrategy};
|
||||
//! use kogral_core::storage::memory::MemoryStorage;
|
||||
//! use kogral_core::config::SyncConfig;
|
||||
//! use std::sync::Arc;
|
||||
//! use tokio::sync::RwLock;
|
||||
//!
|
||||
//! # async fn example() -> kogral_core::error::Result<()> {
|
||||
//! let source = Arc::new(RwLock::new(MemoryStorage::new()));
|
||||
//! let target = Arc::new(RwLock::new(MemoryStorage::new()));
|
||||
//! let config = SyncConfig::default();
|
||||
//!
|
||||
//! let manager = SyncManager::new(source, target, config);
|
||||
//! manager.sync_to_target().await?;
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::config::SyncConfig;
|
||||
use crate::error::{KbError, Result};
|
||||
use crate::models::Node;
|
||||
use crate::storage::Storage;
|
||||
|
||||
/// Conflict resolution strategy when the same node differs between storages
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ConflictStrategy {
|
||||
/// Use the node with the most recent `modified` timestamp
|
||||
#[default]
|
||||
LastWriteWins,
|
||||
/// Always prefer the source node
|
||||
SourceWins,
|
||||
/// Always prefer the target node
|
||||
TargetWins,
|
||||
}
|
||||
|
||||
/// Bidirectional synchronization manager between two storage backends
|
||||
///
|
||||
/// Manages synchronization between a source storage (typically filesystem)
|
||||
/// and a target storage (typically `SurrealDB` or another backend).
|
||||
///
|
||||
/// Both storages are wrapped in `Arc<RwLock<>>` to support concurrent access
|
||||
/// and async operations.
|
||||
pub struct SyncManager {
|
||||
/// Source storage (typically filesystem)
|
||||
source: Arc<RwLock<dyn Storage>>,
|
||||
/// Target storage (typically `SurrealDB`)
|
||||
target: Arc<RwLock<dyn Storage>>,
|
||||
/// Sync configuration
|
||||
config: SyncConfig,
|
||||
/// Conflict resolution strategy
|
||||
conflict_strategy: ConflictStrategy,
|
||||
}
|
||||
|
||||
impl SyncManager {
|
||||
/// Create a new sync manager
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `source` - Source storage backend (typically filesystem)
|
||||
/// * `target` - Target storage backend (typically `SurrealDB`)
|
||||
/// * `config` - Sync configuration
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use kogral_core::sync::SyncManager;
|
||||
/// use kogral_core::storage::memory::MemoryStorage;
|
||||
/// use kogral_core::config::SyncConfig;
|
||||
/// use std::sync::Arc;
|
||||
/// use tokio::sync::RwLock;
|
||||
///
|
||||
/// let source = Arc::new(RwLock::new(MemoryStorage::new()));
|
||||
/// let target = Arc::new(RwLock::new(MemoryStorage::new()));
|
||||
/// let config = SyncConfig::default();
|
||||
///
|
||||
/// let manager = SyncManager::new(source, target, config);
|
||||
/// ```
|
||||
pub fn new(
|
||||
source: Arc<RwLock<dyn Storage>>,
|
||||
target: Arc<RwLock<dyn Storage>>,
|
||||
config: SyncConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
source,
|
||||
target,
|
||||
config,
|
||||
conflict_strategy: ConflictStrategy::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a sync manager with custom conflict resolution strategy
|
||||
pub fn with_conflict_strategy(
|
||||
source: Arc<RwLock<dyn Storage>>,
|
||||
target: Arc<RwLock<dyn Storage>>,
|
||||
config: SyncConfig,
|
||||
conflict_strategy: ConflictStrategy,
|
||||
) -> Self {
|
||||
Self {
|
||||
source,
|
||||
target,
|
||||
config,
|
||||
conflict_strategy,
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronize from source to target (one-way)
|
||||
///
|
||||
/// Copies all graphs and nodes from source storage to target storage.
|
||||
/// Does not modify source storage.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if:
|
||||
/// - Failed to list graphs from source
|
||||
/// - Failed to load graph from source
|
||||
/// - Failed to save graph to target
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use kogral_core::sync::SyncManager;
|
||||
/// # use kogral_core::storage::memory::MemoryStorage;
|
||||
/// # use kogral_core::config::SyncConfig;
|
||||
/// # use std::sync::Arc;
|
||||
/// # use tokio::sync::RwLock;
|
||||
/// # async fn example() -> kogral_core::error::Result<()> {
|
||||
/// # let source = Arc::new(RwLock::new(MemoryStorage::new()));
|
||||
/// # let target = Arc::new(RwLock::new(MemoryStorage::new()));
|
||||
/// # let manager = SyncManager::new(source, target, SyncConfig::default());
|
||||
/// manager.sync_to_target().await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub async fn sync_to_target(&self) -> Result<()> {
|
||||
info!("Starting sync from source to target");
|
||||
|
||||
let source = self.source.read().await;
|
||||
let graphs = source.list_graphs().await?;
|
||||
debug!("Found {} graphs in source", graphs.len());
|
||||
|
||||
let mut target = self.target.write().await;
|
||||
|
||||
for graph_name in graphs {
|
||||
let graph = source.load_graph(&graph_name).await?;
|
||||
info!(
|
||||
"Syncing graph '{}' to target ({} nodes)",
|
||||
graph_name,
|
||||
graph.nodes.len()
|
||||
);
|
||||
target.save_graph(&graph).await?;
|
||||
}
|
||||
|
||||
info!("Sync to target completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Synchronize from target to source (one-way)
|
||||
///
|
||||
/// Copies all graphs and nodes from target storage to source storage.
|
||||
/// Does not modify target storage.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if:
|
||||
/// - Failed to list graphs from target
|
||||
/// - Failed to load graph from target
|
||||
/// - Failed to save graph to source
|
||||
pub async fn sync_from_target(&self) -> Result<()> {
|
||||
info!("Starting sync from target to source");
|
||||
|
||||
let target = self.target.read().await;
|
||||
let graphs = target.list_graphs().await?;
|
||||
debug!("Found {} graphs in target", graphs.len());
|
||||
|
||||
let mut source = self.source.write().await;
|
||||
|
||||
for graph_name in graphs {
|
||||
let graph = target.load_graph(&graph_name).await?;
|
||||
info!(
|
||||
"Syncing graph '{}' to source ({} nodes)",
|
||||
graph_name,
|
||||
graph.nodes.len()
|
||||
);
|
||||
source.save_graph(&graph).await?;
|
||||
}
|
||||
|
||||
info!("Sync from target completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bidirectional synchronization with conflict resolution
|
||||
///
|
||||
/// Syncs nodes in both directions, resolving conflicts based on the
|
||||
/// configured conflict strategy.
|
||||
///
|
||||
/// # Algorithm
|
||||
///
|
||||
/// 1. Load all graph names from both storages
|
||||
/// 2. For each unique graph:
|
||||
/// - Load from both storages (if exists)
|
||||
/// - Compare node IDs
|
||||
/// - For nodes only in source: copy to target
|
||||
/// - For nodes only in target: copy to source
|
||||
/// - For nodes in both: resolve conflict
|
||||
///
|
||||
/// # Conflict Resolution
|
||||
///
|
||||
/// - `LastWriteWins`: Compare `modified` timestamps
|
||||
/// - `SourceWins`: Always use source node
|
||||
/// - `TargetWins`: Always use target node
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if storage operations fail
|
||||
pub async fn sync_bidirectional(&self) -> Result<()> {
|
||||
info!(
|
||||
"Starting bidirectional sync with strategy: {:?}",
|
||||
self.conflict_strategy
|
||||
);
|
||||
|
||||
let source = self.source.read().await;
|
||||
let target = self.target.read().await;
|
||||
|
||||
let source_graphs = source.list_graphs().await?;
|
||||
let target_graphs = target.list_graphs().await?;
|
||||
|
||||
// Combine unique graph names
|
||||
let mut all_graphs: Vec<String> = source_graphs.clone();
|
||||
for graph in target_graphs {
|
||||
if !all_graphs.contains(&graph) {
|
||||
all_graphs.push(graph);
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Found {} unique graphs across both storages",
|
||||
all_graphs.len()
|
||||
);
|
||||
|
||||
// Drop read locks before acquiring write locks
|
||||
drop(source);
|
||||
drop(target);
|
||||
|
||||
for graph_name in all_graphs {
|
||||
self.sync_graph_bidirectional(&graph_name).await?;
|
||||
}
|
||||
|
||||
info!("Bidirectional sync completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync a single graph bidirectionally
|
||||
async fn sync_graph_bidirectional(&self, graph_name: &str) -> Result<()> {
|
||||
let source = self.source.read().await;
|
||||
let target = self.target.read().await;
|
||||
|
||||
let source_graph = source.load_graph(graph_name).await;
|
||||
let target_graph = target.load_graph(graph_name).await;
|
||||
|
||||
match (source_graph, target_graph) {
|
||||
(Ok(mut src), Ok(mut tgt)) => {
|
||||
debug!("Graph '{}' exists in both storages", graph_name);
|
||||
|
||||
// Find nodes only in source
|
||||
let source_only: Vec<String> = src
|
||||
.nodes
|
||||
.keys()
|
||||
.filter(|id| !tgt.nodes.contains_key(*id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Find nodes only in target
|
||||
let target_only: Vec<String> = tgt
|
||||
.nodes
|
||||
.keys()
|
||||
.filter(|id| !src.nodes.contains_key(*id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Find nodes in both (potential conflicts)
|
||||
let in_both: Vec<String> = src
|
||||
.nodes
|
||||
.keys()
|
||||
.filter(|id| tgt.nodes.contains_key(*id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
debug!(
|
||||
"Graph '{}': {} source-only, {} target-only, {} in both",
|
||||
graph_name,
|
||||
source_only.len(),
|
||||
target_only.len(),
|
||||
in_both.len()
|
||||
);
|
||||
|
||||
// Copy source-only nodes to target
|
||||
for node_id in source_only {
|
||||
if let Some(node) = src.nodes.get(&node_id) {
|
||||
tgt.nodes.insert(node_id.clone(), node.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Copy target-only nodes to source
|
||||
for node_id in target_only {
|
||||
if let Some(node) = tgt.nodes.get(&node_id) {
|
||||
src.nodes.insert(node_id.clone(), node.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve conflicts for nodes in both
|
||||
for node_id in in_both {
|
||||
if let (Some(src_node), Some(tgt_node)) =
|
||||
(src.nodes.get(&node_id), tgt.nodes.get(&node_id))
|
||||
{
|
||||
let winning_node = self.resolve_conflict(src_node, tgt_node);
|
||||
src.nodes.insert(node_id.clone(), winning_node.clone());
|
||||
tgt.nodes.insert(node_id.clone(), winning_node);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop read locks
|
||||
drop(source);
|
||||
drop(target);
|
||||
|
||||
// Save updated graphs
|
||||
let mut source_write = self.source.write().await;
|
||||
let mut target_write = self.target.write().await;
|
||||
|
||||
source_write.save_graph(&src).await?;
|
||||
target_write.save_graph(&tgt).await?;
|
||||
}
|
||||
(Ok(graph), Err(_)) => {
|
||||
debug!("Graph '{}' only in source, copying to target", graph_name);
|
||||
drop(source);
|
||||
drop(target);
|
||||
let mut target_write = self.target.write().await;
|
||||
target_write.save_graph(&graph).await?;
|
||||
}
|
||||
(Err(_), Ok(graph)) => {
|
||||
debug!("Graph '{}' only in target, copying to source", graph_name);
|
||||
drop(source);
|
||||
drop(target);
|
||||
let mut source_write = self.source.write().await;
|
||||
source_write.save_graph(&graph).await?;
|
||||
}
|
||||
(Err(e1), Err(e2)) => {
|
||||
warn!(
|
||||
"Graph '{}' not found in either storage: source={:?}, target={:?}",
|
||||
graph_name, e1, e2
|
||||
);
|
||||
return Err(KbError::Graph(format!(
|
||||
"Graph '{graph_name}' not found in either storage"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve conflict between two versions of the same node
|
||||
fn resolve_conflict(&self, source_node: &Node, target_node: &Node) -> Node {
|
||||
match self.conflict_strategy {
|
||||
ConflictStrategy::LastWriteWins => {
|
||||
if source_node.modified > target_node.modified {
|
||||
debug!("Conflict resolved: source wins (newer modified time)");
|
||||
source_node.clone()
|
||||
} else {
|
||||
debug!("Conflict resolved: target wins (newer modified time)");
|
||||
target_node.clone()
|
||||
}
|
||||
}
|
||||
ConflictStrategy::SourceWins => {
|
||||
debug!("Conflict resolved: source wins (strategy)");
|
||||
source_node.clone()
|
||||
}
|
||||
ConflictStrategy::TargetWins => {
|
||||
debug!("Conflict resolved: target wins (strategy)");
|
||||
target_node.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the sync configuration
|
||||
#[must_use]
|
||||
pub fn config(&self) -> &SyncConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Get the current conflict resolution strategy
|
||||
#[must_use]
|
||||
pub fn conflict_strategy(&self) -> ConflictStrategy {
|
||||
self.conflict_strategy
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Utc;
|
||||
|
||||
use super::*;
|
||||
use crate::models::{Graph, Node, NodeType};
|
||||
use crate::storage::memory::MemoryStorage;
|
||||
|
||||
fn create_test_node(id: &str, title: &str) -> Node {
|
||||
let mut node = Node::new(NodeType::Note, title.to_string());
|
||||
node.id = id.to_string(); // Override UUID with test ID
|
||||
node
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sync_to_target() {
|
||||
let mut source_storage = MemoryStorage::new();
|
||||
let target_storage = MemoryStorage::new();
|
||||
|
||||
// Add graph to source
|
||||
let mut graph = Graph::new("test".to_string());
|
||||
let node = create_test_node("node-1", "Test Node");
|
||||
graph.nodes.insert("node-1".to_string(), node);
|
||||
|
||||
source_storage.save_graph(&graph).await.unwrap();
|
||||
|
||||
let source = Arc::new(RwLock::new(source_storage));
|
||||
let target = Arc::new(RwLock::new(target_storage));
|
||||
|
||||
let manager = SyncManager::new(source.clone(), target.clone(), SyncConfig::default());
|
||||
manager.sync_to_target().await.unwrap();
|
||||
|
||||
// Verify node exists in target
|
||||
let target_read = target.read().await;
|
||||
let loaded_node = target_read.load_node("test", "node-1").await.unwrap();
|
||||
assert_eq!(loaded_node.title, "Test Node");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sync_from_target() {
|
||||
let source_storage = MemoryStorage::new();
|
||||
let mut target_storage = MemoryStorage::new();
|
||||
|
||||
// Add graph to target
|
||||
let mut graph = Graph::new("test".to_string());
|
||||
let node = create_test_node("node-2", "Target Node");
|
||||
graph.nodes.insert("node-2".to_string(), node);
|
||||
|
||||
target_storage.save_graph(&graph).await.unwrap();
|
||||
|
||||
let source = Arc::new(RwLock::new(source_storage));
|
||||
let target = Arc::new(RwLock::new(target_storage));
|
||||
|
||||
let manager = SyncManager::new(source.clone(), target.clone(), SyncConfig::default());
|
||||
manager.sync_from_target().await.unwrap();
|
||||
|
||||
// Verify node exists in source
|
||||
let source_read = source.read().await;
|
||||
let loaded_node = source_read.load_node("test", "node-2").await.unwrap();
|
||||
assert_eq!(loaded_node.title, "Target Node");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bidirectional_sync_source_only() {
|
||||
let mut source_storage = MemoryStorage::new();
|
||||
let target_storage = MemoryStorage::new();
|
||||
|
||||
let mut graph = Graph::new("test".to_string());
|
||||
let node = create_test_node("node-src", "Source Only");
|
||||
graph.nodes.insert("node-src".to_string(), node);
|
||||
source_storage.save_graph(&graph).await.unwrap();
|
||||
|
||||
let source = Arc::new(RwLock::new(source_storage));
|
||||
let target = Arc::new(RwLock::new(target_storage));
|
||||
|
||||
let manager = SyncManager::new(source.clone(), target.clone(), SyncConfig::default());
|
||||
manager.sync_bidirectional().await.unwrap();
|
||||
|
||||
// Node should exist in both
|
||||
let source_read = source.read().await;
|
||||
let target_read = target.read().await;
|
||||
|
||||
assert!(source_read.load_node("test", "node-src").await.is_ok());
|
||||
assert!(target_read.load_node("test", "node-src").await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_conflict_resolution_last_write_wins() {
|
||||
let mut source_storage = MemoryStorage::new();
|
||||
let mut target_storage = MemoryStorage::new();
|
||||
|
||||
// Create conflicting nodes
|
||||
let mut old_node = create_test_node("conflict", "Old Version");
|
||||
old_node.modified = Utc::now() - chrono::Duration::hours(1);
|
||||
|
||||
let new_node = create_test_node("conflict", "New Version");
|
||||
|
||||
let mut source_graph = Graph::new("test".to_string());
|
||||
source_graph
|
||||
.nodes
|
||||
.insert("conflict".to_string(), new_node.clone());
|
||||
source_storage.save_graph(&source_graph).await.unwrap();
|
||||
|
||||
let mut target_graph = Graph::new("test".to_string());
|
||||
target_graph.nodes.insert("conflict".to_string(), old_node);
|
||||
target_storage.save_graph(&target_graph).await.unwrap();
|
||||
|
||||
let source = Arc::new(RwLock::new(source_storage));
|
||||
let target = Arc::new(RwLock::new(target_storage));
|
||||
|
||||
let manager = SyncManager::with_conflict_strategy(
|
||||
source.clone(),
|
||||
target.clone(),
|
||||
SyncConfig::default(),
|
||||
ConflictStrategy::LastWriteWins,
|
||||
);
|
||||
|
||||
manager.sync_bidirectional().await.unwrap();
|
||||
|
||||
// Both should have the newer version
|
||||
let source_read = source.read().await;
|
||||
let target_read = target.read().await;
|
||||
|
||||
let source_node = source_read.load_node("test", "conflict").await.unwrap();
|
||||
let target_node = target_read.load_node("test", "conflict").await.unwrap();
|
||||
|
||||
assert_eq!(source_node.title, "New Version");
|
||||
assert_eq!(target_node.title, "New Version");
|
||||
}
|
||||
}
|
||||
83
crates/kogral-core/tests/nickel_integration_test.rs
Normal file
83
crates/kogral-core/tests/nickel_integration_test.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
//! Integration tests for Nickel configuration loading
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use kogral_core::config::nickel;
|
||||
use kogral_core::config::schema::{EmbeddingProvider, KbConfig, StorageType};
|
||||
|
||||
fn workspace_dir() -> PathBuf {
|
||||
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
path.pop(); // kb-core
|
||||
path.pop(); // crates
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Requires Nickel CLI to be installed
|
||||
fn test_load_minimal_config() {
|
||||
if !nickel::is_nickel_available() {
|
||||
eprintln!("Skipping: Nickel CLI not available");
|
||||
return;
|
||||
}
|
||||
|
||||
let config_path = workspace_dir().join("config/minimal.ncl");
|
||||
let config: KbConfig = nickel::load_nickel_config(&config_path).expect("Failed to load config");
|
||||
|
||||
assert_eq!(config.graph.name, "minimal-kb");
|
||||
assert_eq!(config.graph.version, "1.0.0");
|
||||
assert_eq!(config.storage.primary, StorageType::Filesystem);
|
||||
assert_eq!(config.embeddings.provider, EmbeddingProvider::Fastembed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Requires Nickel CLI to be installed
|
||||
fn test_load_defaults_config() {
|
||||
if !nickel::is_nickel_available() {
|
||||
eprintln!("Skipping: Nickel CLI not available");
|
||||
return;
|
||||
}
|
||||
|
||||
let config_path = workspace_dir().join("config/defaults.ncl");
|
||||
let config: KbConfig = nickel::load_nickel_config(&config_path).expect("Failed to load config");
|
||||
|
||||
assert_eq!(config.graph.name, "my-project-kb");
|
||||
assert_eq!(config.graph.description, "Project KOGRAL");
|
||||
assert_eq!(config.embeddings.dimensions, 384);
|
||||
assert_eq!(config.query.max_results, 10);
|
||||
assert_eq!(config.query.similarity_threshold, 0.4);
|
||||
assert!(config.mcp.tools.search);
|
||||
assert!(config.sync.auto_index);
|
||||
assert_eq!(config.sync.debounce_ms, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Requires Nickel CLI to be installed
|
||||
fn test_load_production_config() {
|
||||
if !nickel::is_nickel_available() {
|
||||
eprintln!("Skipping: Nickel CLI not available");
|
||||
return;
|
||||
}
|
||||
|
||||
let config_path = workspace_dir().join("config/production.ncl");
|
||||
let config: KbConfig = nickel::load_nickel_config(&config_path).expect("Failed to load config");
|
||||
|
||||
assert_eq!(config.graph.name, "tools-ecosystem-kb");
|
||||
assert!(config.storage.secondary.enabled);
|
||||
assert_eq!(config.storage.secondary.namespace, "tools_kb");
|
||||
assert_eq!(config.storage.secondary.database, "production");
|
||||
assert_eq!(config.embeddings.provider, EmbeddingProvider::Openai);
|
||||
assert_eq!(config.embeddings.model, "text-embedding-3-small");
|
||||
assert_eq!(config.query.similarity_threshold, 0.5);
|
||||
assert!(!config.query.cross_graph);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nickel_availability() {
|
||||
let available = nickel::is_nickel_available();
|
||||
println!("Nickel CLI available: {}", available);
|
||||
|
||||
if available {
|
||||
let version = nickel::nickel_version().expect("Failed to get version");
|
||||
println!("Nickel version: {}", version);
|
||||
}
|
||||
}
|
||||
25
crates/kogral-mcp/Cargo.toml
Normal file
25
crates/kogral-mcp/Cargo.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "kogral-mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
description = "KOGRAL MCP Server - Model Context Protocol integration for Claude Code"
|
||||
|
||||
[[bin]]
|
||||
name = "kogral-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
kogral-core = { path = "../kogral-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
164
crates/kogral-mcp/src/auth.rs
Normal file
164
crates/kogral-mcp/src/auth.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
//! Authentication and authorization for MCP server
|
||||
//!
|
||||
//! Provides token-based authentication mechanism for MCP requests.
|
||||
//! Tokens can be provided via:
|
||||
//! 1. Environment variable: `KOGRAL_MCP_TOKEN`
|
||||
//! 2. JSON-RPC request parameter: `token` field in params
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde_json::Value;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Authentication configuration
|
||||
pub struct AuthConfig {
|
||||
/// Expected token for authentication (from environment or config)
|
||||
token: Option<String>,
|
||||
/// Whether authentication is required (true if token is set)
|
||||
required: bool,
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
/// Create authentication config from environment and settings
|
||||
pub fn from_env() -> Self {
|
||||
let token = std::env::var("KOGRAL_MCP_TOKEN").ok();
|
||||
let required = token.is_some();
|
||||
|
||||
Self { token, required }
|
||||
}
|
||||
|
||||
/// Verify a request token against the configured token
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request_token` - Token provided in the request (can be from params or header)
|
||||
///
|
||||
/// # Returns
|
||||
/// * Ok(()) if authentication succeeds
|
||||
/// * Err if authentication fails
|
||||
pub fn verify(&self, request_token: Option<&str>) -> Result<()> {
|
||||
if !self.required {
|
||||
// Authentication not required
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let configured_token = match &self.token {
|
||||
Some(t) => t,
|
||||
None => return Err(anyhow!("Authentication required but no token configured")),
|
||||
};
|
||||
|
||||
let provided_token = match request_token {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
warn!("Authentication required but no token provided");
|
||||
return Err(anyhow!("Missing authentication token"));
|
||||
}
|
||||
};
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if constant_time_compare(provided_token, configured_token) {
|
||||
debug!("Authentication successful");
|
||||
Ok(())
|
||||
} else {
|
||||
warn!("Authentication failed: invalid token");
|
||||
Err(anyhow!("Invalid authentication token"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether authentication is required for this server
|
||||
pub fn is_required(&self) -> bool {
|
||||
self.required
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract token from JSON-RPC request parameters
|
||||
///
|
||||
/// Supports multiple formats:
|
||||
/// 1. `{"token": "..."}` - Direct token field
|
||||
/// 2. `{"params": {"token": "..."}...}` - Token in params object
|
||||
pub fn extract_token_from_params(params: &Value) -> Option<&str> {
|
||||
// Try direct token field
|
||||
if let Some(token) = params.get("token").and_then(|v| v.as_str()) {
|
||||
return Some(token);
|
||||
}
|
||||
|
||||
// Try nested in object
|
||||
if let Some(obj) = params.as_object() {
|
||||
if let Some(token_value) = obj.get("token") {
|
||||
return token_value.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Constant-time string comparison to prevent timing attacks
|
||||
fn constant_time_compare(a: &str, b: &str) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut result = 0u8;
|
||||
for (a_byte, b_byte) in a.bytes().zip(b.bytes()) {
|
||||
result |= a_byte ^ b_byte;
|
||||
}
|
||||
|
||||
result == 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_auth_config_no_env_token() {
|
||||
// When no token is set in environment
|
||||
std::env::remove_var("KOGRAL_MCP_TOKEN");
|
||||
let config = AuthConfig::from_env();
|
||||
assert!(!config.is_required());
|
||||
assert!(config.verify(None).is_ok());
|
||||
assert!(config.verify(Some("any-token")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_config_with_env_token() {
|
||||
// When token is set in environment
|
||||
std::env::set_var("KOGRAL_MCP_TOKEN", "secret-token");
|
||||
let config = AuthConfig::from_env();
|
||||
assert!(config.is_required());
|
||||
|
||||
// Missing token should fail
|
||||
assert!(config.verify(None).is_err());
|
||||
|
||||
// Wrong token should fail
|
||||
assert!(config.verify(Some("wrong-token")).is_err());
|
||||
|
||||
// Correct token should succeed
|
||||
assert!(config.verify(Some("secret-token")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_token_from_direct_field() {
|
||||
let params = serde_json::json!({"token": "my-token", "other": "field"});
|
||||
assert_eq!(extract_token_from_params(¶ms), Some("my-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_token_missing() {
|
||||
let params = serde_json::json!({"other": "field"});
|
||||
assert_eq!(extract_token_from_params(¶ms), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_constant_time_compare_equal() {
|
||||
assert!(constant_time_compare("secret", "secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_constant_time_compare_different() {
|
||||
assert!(!constant_time_compare("secret", "wrong"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_constant_time_compare_different_length() {
|
||||
assert!(!constant_time_compare("short", "much-longer-string"));
|
||||
}
|
||||
}
|
||||
15
crates/kogral-mcp/src/lib.rs
Normal file
15
crates/kogral-mcp/src/lib.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
//! MCP server for KOGRAL integration
|
||||
//!
|
||||
//! Provides Model Context Protocol server for Claude Code integration.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod auth;
|
||||
pub mod prompts;
|
||||
pub mod resources;
|
||||
pub mod server;
|
||||
pub mod tools;
|
||||
pub mod types;
|
||||
pub mod validation;
|
||||
|
||||
pub use server::McpServer;
|
||||
25
crates/kogral-mcp/src/main.rs
Normal file
25
crates/kogral-mcp/src/main.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//! MCP server binary
|
||||
|
||||
use anyhow::Result;
|
||||
use kogral_mcp::McpServer;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.with_writer(std::io::stderr) // Write logs to stderr to not interfere with stdio transport
|
||||
.init();
|
||||
|
||||
// Create and run server
|
||||
let server = McpServer::new(
|
||||
"kogral-mcp".to_string(),
|
||||
env!("CARGO_PKG_VERSION").to_string(),
|
||||
);
|
||||
server.run_stdio().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
296
crates/kogral-mcp/src/prompts.rs
Normal file
296
crates/kogral-mcp/src/prompts.rs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
//! MCP prompt handlers
|
||||
|
||||
use anyhow::Result;
|
||||
use kogral_core::{
|
||||
models::{Edge, Graph, NodeType},
|
||||
storage::{filesystem::FilesystemStorage, Storage},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tracing::info;
|
||||
|
||||
use crate::types::{Prompt, PromptArgument, PromptContent, PromptMessage};
|
||||
|
||||
/// List available prompts
|
||||
pub fn list_prompts() -> Vec<Prompt> {
|
||||
vec![
|
||||
Prompt {
|
||||
name: "kogral/summarize_project".to_string(),
|
||||
description: "Generate a comprehensive summary of the project's KOGRAL".to_string(),
|
||||
arguments: Some(vec![PromptArgument {
|
||||
name: "project".to_string(),
|
||||
description: "Project name to summarize".to_string(),
|
||||
required: false,
|
||||
}]),
|
||||
},
|
||||
Prompt {
|
||||
name: "kogral/find_related".to_string(),
|
||||
description: "Find nodes related to a given topic or node".to_string(),
|
||||
arguments: Some(vec![
|
||||
PromptArgument {
|
||||
name: "topic".to_string(),
|
||||
description: "Topic or node ID to find related items for".to_string(),
|
||||
required: true,
|
||||
},
|
||||
PromptArgument {
|
||||
name: "depth".to_string(),
|
||||
description: "How many levels deep to traverse relationships (default: 1)"
|
||||
.to_string(),
|
||||
required: false,
|
||||
},
|
||||
]),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Execute a prompt
|
||||
pub async fn execute_prompt(name: &str, arguments: Value) -> Result<Vec<PromptMessage>> {
|
||||
info!("Executing prompt: {} with args: {}", name, arguments);
|
||||
|
||||
match name {
|
||||
"kogral/summarize_project" => prompt_summarize_project(arguments).await,
|
||||
"kogral/find_related" => prompt_find_related(arguments).await,
|
||||
_ => Err(anyhow::anyhow!("Unknown prompt: {}", name)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate project summary prompt
|
||||
async fn prompt_summarize_project(args: Value) -> Result<Vec<PromptMessage>> {
|
||||
let project = args["project"].as_str().unwrap_or("default");
|
||||
|
||||
let storage = FilesystemStorage::new(std::path::PathBuf::from(".kogral"));
|
||||
let graph = storage.load_graph(project).await?;
|
||||
|
||||
let mut summary = String::from("# Project Knowledge Base Summary\n\n");
|
||||
|
||||
// Overview statistics
|
||||
summary.push_str(&format!("**Project:** {}\n", graph.name));
|
||||
summary.push_str(&format!("**Total Nodes:** {}\n", graph.nodes.len()));
|
||||
summary.push_str(&format!(
|
||||
"**Total Relationships:** {}\n\n",
|
||||
graph.edges.len()
|
||||
));
|
||||
|
||||
// Count nodes by type
|
||||
let mut type_counts = std::collections::HashMap::new();
|
||||
for node in graph.nodes.values() {
|
||||
*type_counts
|
||||
.entry(format!("{:?}", node.node_type))
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
summary.push_str("## Nodes by Type\n\n");
|
||||
for (node_type, count) in &type_counts {
|
||||
summary.push_str(&format!("- **{}:** {}\n", node_type, count));
|
||||
}
|
||||
summary.push('\n');
|
||||
|
||||
// Recent decisions
|
||||
let mut decisions: Vec<_> = graph
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.node_type == NodeType::Decision)
|
||||
.collect();
|
||||
decisions.sort_by(|a, b| b.created.cmp(&a.created));
|
||||
|
||||
if !decisions.is_empty() {
|
||||
summary.push_str("## Recent Architectural Decisions\n\n");
|
||||
for decision in decisions.iter().take(5) {
|
||||
summary.push_str(&format!("- **{}** ({})\n", decision.title, decision.id));
|
||||
if let Some(decision_text) = decision.metadata.get("decision") {
|
||||
if let Some(text) = decision_text.as_str() {
|
||||
let preview: String = text
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(100)
|
||||
.collect();
|
||||
summary.push_str(&format!(" {}\n", preview));
|
||||
}
|
||||
}
|
||||
}
|
||||
summary.push('\n');
|
||||
}
|
||||
|
||||
// Active guidelines
|
||||
let guidelines: Vec<_> = graph
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.node_type == NodeType::Guideline)
|
||||
.collect();
|
||||
|
||||
if !guidelines.is_empty() {
|
||||
summary.push_str("## Active Guidelines\n\n");
|
||||
for guideline in guidelines.iter().take(10) {
|
||||
summary.push_str(&format!("- **{}**\n", guideline.title));
|
||||
if let Some(lang) = guideline.metadata.get("language").and_then(|v| v.as_str()) {
|
||||
summary.push_str(&format!(" Language: {}\n", lang));
|
||||
}
|
||||
if let Some(cat) = guideline.metadata.get("category").and_then(|v| v.as_str()) {
|
||||
summary.push_str(&format!(" Category: {}\n", cat));
|
||||
}
|
||||
}
|
||||
summary.push('\n');
|
||||
}
|
||||
|
||||
// Key patterns
|
||||
let patterns: Vec<_> = graph
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.node_type == NodeType::Pattern)
|
||||
.collect();
|
||||
|
||||
if !patterns.is_empty() {
|
||||
summary.push_str("## Design Patterns\n\n");
|
||||
for pattern in patterns {
|
||||
summary.push_str(&format!("- **{}**\n", pattern.title));
|
||||
}
|
||||
summary.push('\n');
|
||||
}
|
||||
|
||||
// Top tags
|
||||
let mut tag_counts = std::collections::HashMap::new();
|
||||
for node in graph.nodes.values() {
|
||||
for tag in &node.tags {
|
||||
*tag_counts.entry(tag.clone()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if !tag_counts.is_empty() {
|
||||
summary.push_str("## Most Common Tags\n\n");
|
||||
let mut tags: Vec<_> = tag_counts.iter().collect();
|
||||
tags.sort_by(|a, b| b.1.cmp(a.1));
|
||||
for (tag, count) in tags.iter().take(10) {
|
||||
summary.push_str(&format!("- **{}**: {} nodes\n", tag, count));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(vec![PromptMessage {
|
||||
role: "user".to_string(),
|
||||
content: PromptContent::Text { text: summary },
|
||||
}])
|
||||
}
|
||||
|
||||
/// Helper to format related edges into text
|
||||
fn format_edge_relationships(edges: &[&Edge], graph: &Graph, node_id: &str, output: &mut String) {
|
||||
for edge in edges {
|
||||
if edge.from == node_id {
|
||||
if let Some(target) = graph.get_node(&edge.to) {
|
||||
output.push_str(&format!(
|
||||
"- **{:?}** → [{}]({})\n",
|
||||
edge.edge_type, target.title, target.id
|
||||
));
|
||||
}
|
||||
} else if let Some(source) = graph.get_node(&edge.from) {
|
||||
output.push_str(&format!(
|
||||
"- [{}]({}) → **{:?}**\n",
|
||||
source.title, source.id, edge.edge_type
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find related nodes prompt
|
||||
async fn prompt_find_related(args: Value) -> Result<Vec<PromptMessage>> {
|
||||
let topic = args["topic"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing topic argument"))?;
|
||||
let depth = args["depth"].as_u64().unwrap_or(1) as usize;
|
||||
|
||||
let storage = FilesystemStorage::new(std::path::PathBuf::from(".kogral"));
|
||||
let graph = storage.load_graph("default").await?;
|
||||
|
||||
let mut related_text = format!("# Related Nodes for '{}'\n\n", topic);
|
||||
|
||||
// Find the target node
|
||||
let target_node = graph
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.id == topic || n.title.to_lowercase().contains(&topic.to_lowercase()));
|
||||
|
||||
if let Some(node) = target_node {
|
||||
related_text.push_str(&format!("## Target Node: {}\n\n", node.title));
|
||||
related_text.push_str(&format!("**ID:** {}\n", node.id));
|
||||
related_text.push_str(&format!("**Type:** {:?}\n\n", node.node_type));
|
||||
|
||||
// Find direct relationships
|
||||
related_text.push_str("## Direct Relationships\n\n");
|
||||
let related_edges: Vec<_> = graph
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.from == node.id || e.to == node.id)
|
||||
.collect();
|
||||
|
||||
if related_edges.is_empty() {
|
||||
related_text.push_str("No direct relationships found.\n\n");
|
||||
} else {
|
||||
format_edge_relationships(&related_edges, &graph, &node.id, &mut related_text);
|
||||
related_text.push('\n');
|
||||
}
|
||||
|
||||
// If depth > 1, traverse further
|
||||
if depth > 1 {
|
||||
related_text.push_str("## Extended Relationships\n\n");
|
||||
related_text.push_str(&format!(
|
||||
"(Traversing {} level(s) deep - feature coming soon)\n\n",
|
||||
depth
|
||||
));
|
||||
}
|
||||
|
||||
// Find nodes with similar tags
|
||||
if !node.tags.is_empty() {
|
||||
related_text.push_str("## Nodes with Similar Tags\n\n");
|
||||
let similar: Vec<_> = graph
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.id != node.id && n.tags.iter().any(|tag| node.tags.contains(tag)))
|
||||
.take(5)
|
||||
.collect();
|
||||
|
||||
for similar_node in similar {
|
||||
let common_tags: Vec<String> = similar_node
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| node.tags.contains(tag))
|
||||
.cloned()
|
||||
.collect();
|
||||
related_text.push_str(&format!(
|
||||
"- **{}** (common tags: {})\n",
|
||||
similar_node.title,
|
||||
common_tags.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Topic search mode
|
||||
related_text.push_str("## Search Results\n\n");
|
||||
let results: Vec<_> = graph
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| {
|
||||
n.title.to_lowercase().contains(&topic.to_lowercase())
|
||||
|| n.content.to_lowercase().contains(&topic.to_lowercase())
|
||||
|| n.tags
|
||||
.iter()
|
||||
.any(|t| t.to_lowercase().contains(&topic.to_lowercase()))
|
||||
})
|
||||
.take(10)
|
||||
.collect();
|
||||
|
||||
if results.is_empty() {
|
||||
related_text.push_str(&format!("No nodes found matching '{}'.\n", topic));
|
||||
} else {
|
||||
for node in results {
|
||||
related_text.push_str(&format!(
|
||||
"- **{}** ({:?})\n ID: {}\n",
|
||||
node.title, node.node_type, node.id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(vec![PromptMessage {
|
||||
role: "user".to_string(),
|
||||
content: PromptContent::Text { text: related_text },
|
||||
}])
|
||||
}
|
||||
137
crates/kogral-mcp/src/resources.rs
Normal file
137
crates/kogral-mcp/src/resources.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
//! MCP resource providers
|
||||
|
||||
use anyhow::Result;
|
||||
use kogral_core::{
|
||||
models::NodeType,
|
||||
storage::{filesystem::FilesystemStorage, Storage},
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
use crate::types::{Resource, ResourceContents};
|
||||
|
||||
/// List available resources
|
||||
pub async fn list_resources() -> Result<Vec<Resource>> {
|
||||
Ok(vec![
|
||||
Resource {
|
||||
uri: "kogral://project/notes".to_string(),
|
||||
name: "Project Notes".to_string(),
|
||||
description: Some("All notes in the current project".to_string()),
|
||||
mime_type: Some("text/markdown".to_string()),
|
||||
},
|
||||
Resource {
|
||||
uri: "kogral://project/decisions".to_string(),
|
||||
name: "Project Decisions".to_string(),
|
||||
description: Some("All architectural decisions in the current project".to_string()),
|
||||
mime_type: Some("text/markdown".to_string()),
|
||||
},
|
||||
Resource {
|
||||
uri: "kogral://project/guidelines".to_string(),
|
||||
name: "Project Guidelines".to_string(),
|
||||
description: Some("All guidelines in the current project".to_string()),
|
||||
mime_type: Some("text/markdown".to_string()),
|
||||
},
|
||||
Resource {
|
||||
uri: "kogral://project/patterns".to_string(),
|
||||
name: "Project Patterns".to_string(),
|
||||
description: Some("All design patterns in the current project".to_string()),
|
||||
mime_type: Some("text/markdown".to_string()),
|
||||
},
|
||||
Resource {
|
||||
uri: "kogral://shared/guidelines".to_string(),
|
||||
name: "Shared Guidelines".to_string(),
|
||||
description: Some("Inherited base guidelines".to_string()),
|
||||
mime_type: Some("text/markdown".to_string()),
|
||||
},
|
||||
Resource {
|
||||
uri: "kogral://shared/patterns".to_string(),
|
||||
name: "Shared Patterns".to_string(),
|
||||
description: Some("Inherited base patterns".to_string()),
|
||||
mime_type: Some("text/markdown".to_string()),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
/// Read resource contents
|
||||
pub async fn read_resource(uri: &str) -> Result<ResourceContents> {
|
||||
info!("Reading resource: {}", uri);
|
||||
|
||||
let storage = FilesystemStorage::new(std::path::PathBuf::from(".kogral"));
|
||||
let graph = storage.load_graph("default").await?;
|
||||
|
||||
let (scope, node_type) = parse_uri(uri)?;
|
||||
|
||||
let nodes: Vec<_> = graph
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|node| {
|
||||
let type_match = match node_type.as_str() {
|
||||
"notes" => node.node_type == NodeType::Note,
|
||||
"decisions" => node.node_type == NodeType::Decision,
|
||||
"guidelines" => node.node_type == NodeType::Guideline,
|
||||
"patterns" => node.node_type == NodeType::Pattern,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let scope_match = match scope.as_str() {
|
||||
"project" => node.project.is_some(),
|
||||
"shared" => node.project.is_none(),
|
||||
_ => true,
|
||||
};
|
||||
|
||||
type_match && scope_match
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut content = format!("# {}\n\n", get_title_for_uri(uri));
|
||||
content.push_str(&format!("Found {} node(s)\n\n", nodes.len()));
|
||||
|
||||
for node in nodes {
|
||||
content.push_str(&format!("## {}\n\n", node.title));
|
||||
content.push_str(&format!("**ID:** {}\n", node.id));
|
||||
content.push_str(&format!("**Type:** {:?}\n", node.node_type));
|
||||
content.push_str(&format!("**Status:** {:?}\n", node.status));
|
||||
content.push_str(&format!("**Created:** {}\n", node.created));
|
||||
|
||||
if !node.tags.is_empty() {
|
||||
content.push_str(&format!("**Tags:** {}\n", node.tags.join(", ")));
|
||||
}
|
||||
|
||||
content.push_str(&format!("\n{}\n\n---\n\n", node.content));
|
||||
}
|
||||
|
||||
Ok(ResourceContents {
|
||||
uri: uri.to_string(),
|
||||
mime_type: "text/markdown".to_string(),
|
||||
text: content,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_uri(uri: &str) -> Result<(String, String)> {
|
||||
if !uri.starts_with("kogral://") {
|
||||
return Err(anyhow::anyhow!("Invalid URI scheme, expected kogral://"));
|
||||
}
|
||||
|
||||
let path = &uri[9..];
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
|
||||
if parts.len() != 2 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid URI format, expected kogral://<scope>/<type>"
|
||||
));
|
||||
}
|
||||
|
||||
Ok((parts[0].to_string(), parts[1].to_string()))
|
||||
}
|
||||
|
||||
fn get_title_for_uri(uri: &str) -> String {
|
||||
match uri {
|
||||
"kogral://project/notes" => "Project Notes",
|
||||
"kogral://project/decisions" => "Project Architectural Decisions",
|
||||
"kogral://project/guidelines" => "Project Guidelines",
|
||||
"kogral://project/patterns" => "Project Design Patterns",
|
||||
"kogral://shared/guidelines" => "Shared Guidelines",
|
||||
"kogral://shared/patterns" => "Shared Design Patterns",
|
||||
_ => "KOGRAL Resource",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
224
crates/kogral-mcp/src/server.rs
Normal file
224
crates/kogral-mcp/src/server.rs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
//! MCP server implementation
|
||||
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::{
|
||||
auth::{AuthConfig, extract_token_from_params},
|
||||
prompts, resources, tools,
|
||||
types::{
|
||||
JsonRpcRequest, JsonRpcResponse, PromptsCapability, ResourcesCapability,
|
||||
ServerCapabilities, ToolsCapability,
|
||||
},
|
||||
};
|
||||
|
||||
/// MCP Server with authentication support
|
||||
pub struct McpServer {
|
||||
name: String,
|
||||
version: String,
|
||||
auth: AuthConfig,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
/// Create a new MCP server
|
||||
pub fn new(name: String, version: String) -> Self {
|
||||
let auth = AuthConfig::from_env();
|
||||
if auth.is_required() {
|
||||
info!("MCP server authentication enabled via KOGRAL_MCP_TOKEN");
|
||||
} else {
|
||||
info!("MCP server running without authentication");
|
||||
}
|
||||
Self { name, version, auth }
|
||||
}
|
||||
|
||||
/// Run the MCP server with stdio transport
|
||||
pub async fn run_stdio(&self) -> Result<()> {
|
||||
info!("Starting MCP server: {} v{}", self.name, self.version);
|
||||
info!("Transport: stdio");
|
||||
|
||||
let stdin = io::stdin();
|
||||
let mut reader = BufReader::new(stdin.lock());
|
||||
let mut stdout = io::stdout();
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) => {
|
||||
// EOF
|
||||
info!("Received EOF, shutting down");
|
||||
break;
|
||||
}
|
||||
Ok(_) => {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
debug!("Received request: {}", line);
|
||||
|
||||
match self.handle_request(line).await {
|
||||
Ok(response) => {
|
||||
let response_json = serde_json::to_string(&response)?;
|
||||
debug!("Sending response: {}", response_json);
|
||||
writeln!(stdout, "{}", response_json)?;
|
||||
stdout.flush()?;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error handling request: {}", e);
|
||||
let error_response = JsonRpcResponse::error(
|
||||
None,
|
||||
-32603,
|
||||
format!("Internal error: {}", e),
|
||||
);
|
||||
let error_json = serde_json::to_string(&error_response)?;
|
||||
writeln!(stdout, "{}", error_json)?;
|
||||
stdout.flush()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error reading from stdin: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle a JSON-RPC request
|
||||
async fn handle_request(&self, request_json: &str) -> Result<JsonRpcResponse> {
|
||||
let request: JsonRpcRequest = serde_json::from_str(request_json)?;
|
||||
|
||||
debug!("Handling method: {}", request.method);
|
||||
|
||||
// Verify authentication for protected methods
|
||||
// Initialize is allowed without auth for initial setup, ping can be used for health checks
|
||||
if !matches!(request.method.as_str(), "initialize" | "ping") {
|
||||
if let Err(e) = self.authenticate(&request.params) {
|
||||
debug!("Authentication failed: {}", e);
|
||||
return Ok(JsonRpcResponse::error(
|
||||
request.id,
|
||||
-32600,
|
||||
format!("Unauthorized: {}", e),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let result = match request.method.as_str() {
|
||||
"initialize" => self.handle_initialize(request.params).await,
|
||||
"tools/list" => self.handle_tools_list().await,
|
||||
"tools/call" => self.handle_tools_call(request.params).await,
|
||||
"resources/list" => self.handle_resources_list().await,
|
||||
"resources/read" => self.handle_resources_read(request.params).await,
|
||||
"prompts/list" => self.handle_prompts_list().await,
|
||||
"prompts/get" => self.handle_prompts_get(request.params).await,
|
||||
"ping" => Ok(json!({"status": "ok"})),
|
||||
_ => {
|
||||
warn!("Unknown method: {}", request.method);
|
||||
return Ok(JsonRpcResponse::error(
|
||||
request.id,
|
||||
-32601,
|
||||
format!("Method not found: {}", request.method),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(value) => Ok(JsonRpcResponse::success(request.id, value)),
|
||||
Err(e) => {
|
||||
error!("Error executing method {}: {}", request.method, e);
|
||||
Ok(JsonRpcResponse::error(
|
||||
request.id,
|
||||
-32603,
|
||||
format!("Internal error: {}", e),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate a request by verifying its token
|
||||
fn authenticate(&self, params: &Value) -> Result<()> {
|
||||
let token = extract_token_from_params(params);
|
||||
self.auth.verify(token)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle initialize request
|
||||
async fn handle_initialize(&self, _params: Value) -> Result<Value> {
|
||||
info!("Initializing MCP server");
|
||||
|
||||
Ok(json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": {
|
||||
"name": self.name,
|
||||
"version": self.version
|
||||
},
|
||||
"capabilities": ServerCapabilities {
|
||||
tools: Some(ToolsCapability {
|
||||
list_changed: Some(false),
|
||||
}),
|
||||
resources: Some(ResourcesCapability {
|
||||
subscribe: Some(false),
|
||||
list_changed: Some(false),
|
||||
}),
|
||||
prompts: Some(PromptsCapability {
|
||||
list_changed: Some(false),
|
||||
}),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Handle tools/list request
|
||||
async fn handle_tools_list(&self) -> Result<Value> {
|
||||
let tools = tools::list_tools();
|
||||
Ok(json!({ "tools": tools }))
|
||||
}
|
||||
|
||||
/// Handle tools/call request
|
||||
async fn handle_tools_call(&self, params: Value) -> Result<Value> {
|
||||
let name = params["name"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing tool name"))?;
|
||||
let arguments = params["arguments"].clone();
|
||||
|
||||
let result = tools::execute_tool(name, arguments).await?;
|
||||
Ok(serde_json::to_value(result)?)
|
||||
}
|
||||
|
||||
/// Handle resources/list request
|
||||
async fn handle_resources_list(&self) -> Result<Value> {
|
||||
let resources = resources::list_resources().await?;
|
||||
Ok(json!({ "resources": resources }))
|
||||
}
|
||||
|
||||
/// Handle resources/read request
|
||||
async fn handle_resources_read(&self, params: Value) -> Result<Value> {
|
||||
let uri = params["uri"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing resource URI"))?;
|
||||
|
||||
let contents = resources::read_resource(uri).await?;
|
||||
Ok(json!({ "contents": [contents] }))
|
||||
}
|
||||
|
||||
/// Handle prompts/list request
|
||||
async fn handle_prompts_list(&self) -> Result<Value> {
|
||||
let prompts = prompts::list_prompts();
|
||||
Ok(json!({ "prompts": prompts }))
|
||||
}
|
||||
|
||||
/// Handle prompts/get request
|
||||
async fn handle_prompts_get(&self, params: Value) -> Result<Value> {
|
||||
let name = params["name"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing prompt name"))?;
|
||||
let arguments = params["arguments"].clone();
|
||||
|
||||
let messages = prompts::execute_prompt(name, arguments).await?;
|
||||
Ok(json!({ "messages": messages }))
|
||||
}
|
||||
}
|
||||
800
crates/kogral-mcp/src/tools.rs
Normal file
800
crates/kogral-mcp/src/tools.rs
Normal file
|
|
@ -0,0 +1,800 @@
|
|||
//! MCP tool handlers
|
||||
|
||||
use anyhow::Result;
|
||||
use kogral_core::{
|
||||
models::{Edge, EdgeType, Graph, Node, NodeType, TaskStatus},
|
||||
storage::{filesystem::FilesystemStorage, Storage},
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::types::{Tool, ToolResult};
|
||||
use crate::validation::{
|
||||
validate_enum, validate_limit, validate_node_id, validate_optional_string,
|
||||
validate_required_string, validate_strength, validate_string_array, MAX_ARRAY_ITEMS,
|
||||
MAX_SHORT_LENGTH, MAX_STRING_LENGTH,
|
||||
};
|
||||
|
||||
/// Get all available tools
|
||||
pub fn list_tools() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool {
|
||||
name: "kogral/search".to_string(),
|
||||
description: "Search KOGRAL using text and/or semantic similarity".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["note", "decision", "guideline", "pattern", "journal", "execution", "all"],
|
||||
"description": "Node type filter"
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Limit to project graph"
|
||||
},
|
||||
"semantic": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Use semantic search (requires embeddings)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"default": 10,
|
||||
"description": "Maximum number of results"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "kogral/add_note".to_string(),
|
||||
description: "Add a new note to KOGRAL".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Note title"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Note content (markdown)"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Tags"
|
||||
},
|
||||
"relates_to": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Related node IDs"
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Project identifier"
|
||||
}
|
||||
},
|
||||
"required": ["title", "content"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "kogral/add_decision".to_string(),
|
||||
description: "Add an architectural decision record (ADR)".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Decision title"
|
||||
},
|
||||
"context": {
|
||||
"type": "string",
|
||||
"description": "Problem context"
|
||||
},
|
||||
"decision": {
|
||||
"type": "string",
|
||||
"description": "Decision made"
|
||||
},
|
||||
"consequences": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Consequences and impacts"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Tags"
|
||||
},
|
||||
"project": {
|
||||
"type": "string",
|
||||
"description": "Project identifier"
|
||||
}
|
||||
},
|
||||
"required": ["title", "decision"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "kogral/link".to_string(),
|
||||
description: "Create a relationship between two nodes".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from": {
|
||||
"type": "string",
|
||||
"description": "Source node ID"
|
||||
},
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "Target node ID"
|
||||
},
|
||||
"relation": {
|
||||
"type": "string",
|
||||
"enum": ["relates_to", "depends_on", "implements", "extends", "supersedes", "explains"],
|
||||
"description": "Relationship type"
|
||||
},
|
||||
"strength": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"default": 1.0,
|
||||
"description": "Relationship strength"
|
||||
}
|
||||
},
|
||||
"required": ["from", "to", "relation"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "kogral/get_guidelines".to_string(),
|
||||
description: "Get guidelines for current project with inheritance resolution"
|
||||
.to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Programming language (e.g., rust, nushell)"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Category (e.g., error-handling, testing)"
|
||||
},
|
||||
"include_base": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Include inherited base guidelines"
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "kogral/list_graphs".to_string(),
|
||||
description: "List available knowledge graphs".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "kogral/export".to_string(),
|
||||
description: "Export KOGRAL to external format".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["logseq", "markdown", "json", "summary"],
|
||||
"description": "Export format"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["note", "decision", "guideline", "pattern", "all"],
|
||||
"description": "Node type filter"
|
||||
}
|
||||
},
|
||||
"required": ["format"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "kogral/find_blocks".to_string(),
|
||||
description: "Find blocks by tag, task status, or custom property".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "Find blocks with this tag (e.g., 'card', 'important')"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["TODO", "DOING", "DONE", "LATER", "NOW", "WAITING", "CANCELLED"],
|
||||
"description": "Find blocks with this task status"
|
||||
},
|
||||
"property_key": {
|
||||
"type": "string",
|
||||
"description": "Custom property key"
|
||||
},
|
||||
"property_value": {
|
||||
"type": "string",
|
||||
"description": "Custom property value"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"default": 20,
|
||||
"description": "Maximum number of blocks to return"
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "kogral/find_todos".to_string(),
|
||||
description: "Find all TODO blocks across KOGRAL".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"default": 50,
|
||||
"description": "Maximum number of TODOs to return"
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "kogral/find_cards".to_string(),
|
||||
description: "Find all flashcard blocks (#card tag) for spaced repetition".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"description": "Maximum number of cards to return"
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Execute a tool call
|
||||
pub async fn execute_tool(name: &str, arguments: Value) -> Result<ToolResult> {
|
||||
info!("Executing tool: {} with args: {}", name, arguments);
|
||||
|
||||
match name {
|
||||
"kogral/search" => tool_search(arguments).await,
|
||||
"kogral/add_note" => tool_add_note(arguments).await,
|
||||
"kogral/add_decision" => tool_add_decision(arguments).await,
|
||||
"kogral/link" => tool_link(arguments).await,
|
||||
"kogral/get_guidelines" => tool_get_guidelines(arguments).await,
|
||||
"kogral/list_graphs" => tool_list_graphs(arguments).await,
|
||||
"kogral/export" => tool_export(arguments).await,
|
||||
"kogral/find_blocks" => tool_find_blocks(arguments).await,
|
||||
"kogral/find_todos" => tool_find_todos(arguments).await,
|
||||
"kogral/find_cards" => tool_find_cards(arguments).await,
|
||||
_ => {
|
||||
error!("Unknown tool: {}", name);
|
||||
Ok(ToolResult::error(format!("Unknown tool: {}", name)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Search knowledge base
|
||||
async fn tool_search(args: Value) -> Result<ToolResult> {
|
||||
let query = validate_required_string(args["query"].as_str(), "query", MAX_STRING_LENGTH)?;
|
||||
let limit = validate_limit(args["limit"].as_u64(), 10)?;
|
||||
let node_type = validate_optional_string(args["type"].as_str(), "type", MAX_SHORT_LENGTH)?;
|
||||
|
||||
let storage = FilesystemStorage::new(std::path::PathBuf::from(".kogral"));
|
||||
let graph = storage.load_graph("default").await?;
|
||||
|
||||
// Simple text search
|
||||
let results: Vec<&Node> = graph
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|node| {
|
||||
let matches_query = node.title.to_lowercase().contains(&query.to_lowercase())
|
||||
|| node.content.to_lowercase().contains(&query.to_lowercase())
|
||||
|| node
|
||||
.tags
|
||||
.iter()
|
||||
.any(|t| t.to_lowercase().contains(&query.to_lowercase()));
|
||||
|
||||
let matches_type = match node_type.as_deref() {
|
||||
Some("note") => node.node_type == NodeType::Note,
|
||||
Some("decision") => node.node_type == NodeType::Decision,
|
||||
Some("guideline") => node.node_type == NodeType::Guideline,
|
||||
Some("pattern") => node.node_type == NodeType::Pattern,
|
||||
Some("journal") => node.node_type == NodeType::Journal,
|
||||
Some("execution") => node.node_type == NodeType::Execution,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
matches_query && matches_type
|
||||
})
|
||||
.take(limit)
|
||||
.collect();
|
||||
|
||||
let mut output = format!("Found {} result(s):\n\n", results.len());
|
||||
for (i, node) in results.iter().enumerate() {
|
||||
output.push_str(&format!(
|
||||
"{}. **{}** ({})\n Type: {:?} | Status: {:?}\n",
|
||||
i + 1,
|
||||
node.title,
|
||||
node.id,
|
||||
node.node_type,
|
||||
node.status
|
||||
));
|
||||
if !node.tags.is_empty() {
|
||||
output.push_str(&format!(" Tags: {}\n", node.tags.join(", ")));
|
||||
}
|
||||
if !node.content.is_empty() {
|
||||
let preview: String = node
|
||||
.content
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(100)
|
||||
.collect();
|
||||
output.push_str(&format!(" {}\n", preview));
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
Ok(ToolResult::text(output))
|
||||
}
|
||||
|
||||
/// Add a note
|
||||
async fn tool_add_note(args: Value) -> Result<ToolResult> {
|
||||
let title = validate_required_string(args["title"].as_str(), "title", MAX_STRING_LENGTH)?;
|
||||
let content = validate_required_string(args["content"].as_str(), "content", MAX_STRING_LENGTH)?;
|
||||
|
||||
let mut node = Node::new(NodeType::Note, title);
|
||||
node.content = content;
|
||||
|
||||
let tags = validate_string_array(args["tags"].as_array(), "tags", MAX_ARRAY_ITEMS, MAX_SHORT_LENGTH)?;
|
||||
node.tags = tags;
|
||||
|
||||
let relates_to = validate_string_array(
|
||||
args["relates_to"].as_array(),
|
||||
"relates_to",
|
||||
MAX_ARRAY_ITEMS,
|
||||
MAX_SHORT_LENGTH,
|
||||
)?;
|
||||
node.relates_to = relates_to;
|
||||
|
||||
if let Some(project) = validate_optional_string(args["project"].as_str(), "project", MAX_SHORT_LENGTH)? {
|
||||
node.project = Some(project);
|
||||
}
|
||||
|
||||
let mut storage = FilesystemStorage::new(std::path::PathBuf::from(".kogral"));
|
||||
let mut graph = storage
|
||||
.load_graph("default")
|
||||
.await
|
||||
.unwrap_or_else(|_| Graph::new("default".to_string()));
|
||||
|
||||
graph
|
||||
.add_node(node.clone())
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
storage.save_graph(&graph).await?;
|
||||
|
||||
Ok(ToolResult::text(format!(
|
||||
"✓ Note added successfully\n ID: {}\n Title: {}",
|
||||
node.id, node.title
|
||||
)))
|
||||
}
|
||||
|
||||
/// Add an ADR
|
||||
async fn tool_add_decision(args: Value) -> Result<ToolResult> {
|
||||
let title = validate_required_string(args["title"].as_str(), "title", MAX_STRING_LENGTH)?;
|
||||
let decision = validate_required_string(args["decision"].as_str(), "decision", MAX_STRING_LENGTH)?;
|
||||
|
||||
let mut node = Node::new(NodeType::Decision, title);
|
||||
node.content = decision.clone();
|
||||
|
||||
// Store decision-specific data in metadata
|
||||
let mut metadata = serde_json::Map::new();
|
||||
if let Some(context) = validate_optional_string(args["context"].as_str(), "context", MAX_STRING_LENGTH)? {
|
||||
metadata.insert("context".to_string(), json!(context));
|
||||
}
|
||||
metadata.insert("decision".to_string(), json!(decision));
|
||||
|
||||
let consequences = validate_string_array(
|
||||
args["consequences"].as_array(),
|
||||
"consequences",
|
||||
MAX_ARRAY_ITEMS,
|
||||
MAX_STRING_LENGTH,
|
||||
)?;
|
||||
if !consequences.is_empty() {
|
||||
metadata.insert("consequences".to_string(), json!(consequences));
|
||||
}
|
||||
|
||||
node.metadata = metadata.into_iter().collect();
|
||||
|
||||
let tags = validate_string_array(args["tags"].as_array(), "tags", MAX_ARRAY_ITEMS, MAX_SHORT_LENGTH)?;
|
||||
node.tags = tags;
|
||||
|
||||
if let Some(project) = validate_optional_string(args["project"].as_str(), "project", MAX_SHORT_LENGTH)? {
|
||||
node.project = Some(project);
|
||||
}
|
||||
|
||||
let mut storage = FilesystemStorage::new(std::path::PathBuf::from(".kogral"));
|
||||
let mut graph = storage
|
||||
.load_graph("default")
|
||||
.await
|
||||
.unwrap_or_else(|_| Graph::new("default".to_string()));
|
||||
|
||||
graph
|
||||
.add_node(node.clone())
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
storage.save_graph(&graph).await?;
|
||||
|
||||
Ok(ToolResult::text(format!(
|
||||
"✓ Decision added successfully\n ID: {}\n Title: {}",
|
||||
node.id, node.title
|
||||
)))
|
||||
}
|
||||
|
||||
/// Create a link between nodes
|
||||
async fn tool_link(args: Value) -> Result<ToolResult> {
|
||||
let from = validate_node_id(args["from"].as_str(), "from")?;
|
||||
let to = validate_node_id(args["to"].as_str(), "to")?;
|
||||
let relation_str = validate_enum(
|
||||
args["relation"].as_str(),
|
||||
"relation",
|
||||
&["relates_to", "depends_on", "implements", "extends", "supersedes", "explains"],
|
||||
)?;
|
||||
let strength = validate_strength(args["strength"].as_f64())?;
|
||||
|
||||
let relation = match relation_str.as_str() {
|
||||
"relates_to" => EdgeType::RelatesTo,
|
||||
"depends_on" => EdgeType::DependsOn,
|
||||
"implements" => EdgeType::Implements,
|
||||
"extends" => EdgeType::Extends,
|
||||
"supersedes" => EdgeType::Supersedes,
|
||||
"explains" => EdgeType::Explains,
|
||||
_ => unreachable!("validate_enum should have prevented invalid relation"),
|
||||
};
|
||||
|
||||
let mut storage = FilesystemStorage::new(std::path::PathBuf::from(".kogral"));
|
||||
let mut graph = storage.load_graph("default").await?;
|
||||
|
||||
let mut edge = Edge::new(from.clone(), to.clone(), relation);
|
||||
edge.strength = strength;
|
||||
|
||||
graph.add_edge(edge).map_err(|e| anyhow::anyhow!(e))?;
|
||||
storage.save_graph(&graph).await?;
|
||||
|
||||
Ok(ToolResult::text(format!(
|
||||
"✓ Relationship created: {} --{:?}--> {}",
|
||||
from, relation, to
|
||||
)))
|
||||
}
|
||||
|
||||
/// Get guidelines
|
||||
async fn tool_get_guidelines(args: Value) -> Result<ToolResult> {
|
||||
let language = validate_optional_string(args["language"].as_str(), "language", MAX_SHORT_LENGTH)?;
|
||||
let category = validate_optional_string(args["category"].as_str(), "category", MAX_SHORT_LENGTH)?;
|
||||
|
||||
let storage = FilesystemStorage::new(std::path::PathBuf::from(".kogral"));
|
||||
let graph = storage.load_graph("default").await?;
|
||||
|
||||
let guidelines: Vec<&Node> = graph
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|node| {
|
||||
if node.node_type != NodeType::Guideline {
|
||||
return false;
|
||||
}
|
||||
|
||||
let lang_match = language
|
||||
.as_ref()
|
||||
.map(|l| {
|
||||
node.metadata
|
||||
.get("language")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|lang| lang == l)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
let cat_match = category
|
||||
.as_ref()
|
||||
.map(|c| {
|
||||
node.metadata
|
||||
.get("category")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|cat| cat == c)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
lang_match && cat_match
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut output = format!("Found {} guideline(s):\n\n", guidelines.len());
|
||||
for guideline in guidelines {
|
||||
output.push_str(&format!("## {}\n", guideline.title));
|
||||
if let Some(lang) = guideline.metadata.get("language").and_then(|v| v.as_str()) {
|
||||
output.push_str(&format!("**Language:** {}\n", lang));
|
||||
}
|
||||
if let Some(cat) = guideline.metadata.get("category").and_then(|v| v.as_str()) {
|
||||
output.push_str(&format!("**Category:** {}\n", cat));
|
||||
}
|
||||
output.push_str(&format!("\n{}\n\n---\n\n", guideline.content));
|
||||
}
|
||||
|
||||
Ok(ToolResult::text(output))
|
||||
}
|
||||
|
||||
/// List available graphs
|
||||
async fn tool_list_graphs(_args: Value) -> Result<ToolResult> {
|
||||
// For now, return default graph
|
||||
Ok(ToolResult::text("Available graphs:\n- default".to_string()))
|
||||
}
|
||||
|
||||
/// Export knowledge base
|
||||
async fn tool_export(args: Value) -> Result<ToolResult> {
|
||||
let format = validate_enum(
|
||||
args["format"].as_str(),
|
||||
"format",
|
||||
&["logseq", "markdown", "json", "summary"],
|
||||
)?;
|
||||
|
||||
let storage = FilesystemStorage::new(std::path::PathBuf::from(".kogral"));
|
||||
let graph = storage.load_graph("default").await?;
|
||||
|
||||
match format.as_str() {
|
||||
"json" => {
|
||||
let json = serde_json::to_string_pretty(&graph)?;
|
||||
Ok(ToolResult::text(json))
|
||||
}
|
||||
"summary" => {
|
||||
let mut output = "# Knowledge Base Summary\n\n".to_string();
|
||||
output.push_str(&format!("**Graph:** {}\n", graph.name));
|
||||
output.push_str(&format!("**Nodes:** {}\n", graph.nodes.len()));
|
||||
output.push_str(&format!("**Edges:** {}\n\n", graph.edges.len()));
|
||||
|
||||
output.push_str("## Nodes by Type\n\n");
|
||||
let mut type_counts = std::collections::HashMap::new();
|
||||
for node in graph.nodes.values() {
|
||||
*type_counts
|
||||
.entry(format!("{:?}", node.node_type))
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
for (node_type, count) in type_counts {
|
||||
output.push_str(&format!("- **{}:** {}\n", node_type, count));
|
||||
}
|
||||
|
||||
Ok(ToolResult::text(output))
|
||||
}
|
||||
_ => unreachable!("validate_enum should have prevented invalid format"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find blocks by tag, status, or property
|
||||
#[allow(clippy::excessive_nesting)]
|
||||
async fn tool_find_blocks(args: Value) -> Result<ToolResult> {
|
||||
let tag = validate_optional_string(args["tag"].as_str(), "tag", MAX_SHORT_LENGTH)?;
|
||||
let status = validate_optional_string(args["status"].as_str(), "status", MAX_SHORT_LENGTH)?;
|
||||
let property_key = validate_optional_string(args["property_key"].as_str(), "property_key", MAX_SHORT_LENGTH)?;
|
||||
let property_value = validate_optional_string(args["property_value"].as_str(), "property_value", MAX_STRING_LENGTH)?;
|
||||
let limit = validate_limit(args["limit"].as_u64(), 20)?;
|
||||
|
||||
let storage = FilesystemStorage::new(std::path::PathBuf::from(".kogral"));
|
||||
let mut graph = storage.load_graph("default").await?;
|
||||
|
||||
let mut output = String::new();
|
||||
let mut total_blocks = 0;
|
||||
|
||||
// Find by tag
|
||||
if let Some(tag_name) = &tag {
|
||||
// First, extract all block info and node IDs
|
||||
let block_results = graph.find_blocks_by_tag(tag_name);
|
||||
let mut node_blocks: Vec<(String, Vec<String>)> = Vec::new();
|
||||
|
||||
for (node_id, blocks) in block_results {
|
||||
let contents: Vec<String> = blocks.iter().map(|b| b.content.clone()).collect();
|
||||
node_blocks.push((node_id, contents));
|
||||
}
|
||||
|
||||
// Now access graph to get node titles
|
||||
output.push_str(&format!("## Blocks with tag #{}:\n\n", tag_name));
|
||||
for (node_id, contents) in node_blocks.into_iter().take(limit) {
|
||||
if let Some(node) = graph.get_node(&node_id) {
|
||||
output.push_str(&format!("**{}** ({})\n", node.title, node_id));
|
||||
for content in contents {
|
||||
output.push_str(&format!(" - {}\n", content));
|
||||
total_blocks += 1;
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find by status
|
||||
if let Some(status_str) = &status {
|
||||
let task_status = match status_str.as_str() {
|
||||
"TODO" => Some(TaskStatus::Todo),
|
||||
"DOING" => Some(TaskStatus::Doing),
|
||||
"DONE" => Some(TaskStatus::Done),
|
||||
"LATER" => Some(TaskStatus::Later),
|
||||
"NOW" => Some(TaskStatus::Now),
|
||||
"WAITING" => Some(TaskStatus::Waiting),
|
||||
"CANCELLED" => Some(TaskStatus::Cancelled),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(task_status_value) = task_status {
|
||||
let block_results = graph.find_blocks_by_status(task_status_value);
|
||||
let mut node_blocks: Vec<(String, Vec<String>)> = Vec::new();
|
||||
|
||||
for (node_id, blocks) in block_results {
|
||||
let contents: Vec<String> = blocks.iter().map(|b| b.content.clone()).collect();
|
||||
node_blocks.push((node_id, contents));
|
||||
}
|
||||
|
||||
output.push_str(&format!("## Blocks with status {}:\n\n", status_str));
|
||||
for (node_id, contents) in node_blocks.into_iter().take(limit) {
|
||||
if let Some(node) = graph.get_node(&node_id) {
|
||||
output.push_str(&format!("**{}** ({})\n", node.title, node_id));
|
||||
for content in contents {
|
||||
output.push_str(&format!(" - {} {}\n", status_str, content));
|
||||
total_blocks += 1;
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find by custom property
|
||||
if let (Some(key), Some(value)) = (&property_key, &property_value) {
|
||||
let block_results = graph.find_blocks_by_property(key, value);
|
||||
let mut node_blocks: Vec<(String, Vec<String>)> = Vec::new();
|
||||
|
||||
for (node_id, blocks) in block_results {
|
||||
let contents: Vec<String> = blocks.iter().map(|b| b.content.clone()).collect();
|
||||
node_blocks.push((node_id, contents));
|
||||
}
|
||||
|
||||
output.push_str(&format!("## Blocks with {}:: {}:\n\n", key, value));
|
||||
for (node_id, contents) in node_blocks.into_iter().take(limit) {
|
||||
if let Some(node) = graph.get_node(&node_id) {
|
||||
output.push_str(&format!("**{}** ({})\n", node.title, node_id));
|
||||
for content in contents {
|
||||
output.push_str(&format!(" - {}\n", content));
|
||||
total_blocks += 1;
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if total_blocks == 0 {
|
||||
output = "No blocks found matching criteria.".to_string();
|
||||
} else {
|
||||
output = format!("Found {} block(s)\n\n{}", total_blocks, output);
|
||||
}
|
||||
|
||||
Ok(ToolResult::text(output))
|
||||
}
|
||||
|
||||
/// Find all TODO blocks
|
||||
async fn tool_find_todos(args: Value) -> Result<ToolResult> {
|
||||
let limit = validate_limit(args["limit"].as_u64(), 50)?;
|
||||
|
||||
let storage = FilesystemStorage::new(std::path::PathBuf::from(".kogral"));
|
||||
let mut graph = storage.load_graph("default").await?;
|
||||
|
||||
let todo_results = graph.find_all_todos();
|
||||
let mut node_blocks: Vec<(String, Vec<(String, String)>)> = Vec::new();
|
||||
|
||||
for (node_id, blocks) in todo_results {
|
||||
let block_data: Vec<(String, String)> = blocks
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let status = b
|
||||
.properties
|
||||
.status
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "TODO".to_string());
|
||||
(status, b.content.clone())
|
||||
})
|
||||
.collect();
|
||||
node_blocks.push((node_id, block_data));
|
||||
}
|
||||
|
||||
let total_todos: usize = node_blocks.iter().map(|(_, blocks)| blocks.len()).sum();
|
||||
let mut output = format!("Found {} TODO block(s):\n\n", total_todos);
|
||||
|
||||
let mut count = 0;
|
||||
for (node_id, blocks) in node_blocks {
|
||||
if count >= limit {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(node) = graph.get_node(&node_id) {
|
||||
output.push_str(&format!("## {} ({})\n", node.title, node_id));
|
||||
|
||||
for (status, content) in blocks {
|
||||
if count >= limit {
|
||||
break;
|
||||
}
|
||||
output.push_str(&format!("- {} {}\n", status, content));
|
||||
count += 1;
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if total_todos == 0 {
|
||||
output = "No TODO blocks found.".to_string();
|
||||
}
|
||||
|
||||
Ok(ToolResult::text(output))
|
||||
}
|
||||
|
||||
/// Find all flashcard blocks (#card)
|
||||
async fn tool_find_cards(args: Value) -> Result<ToolResult> {
|
||||
let limit = validate_limit(args["limit"].as_u64(), 30)?;
|
||||
|
||||
let storage = FilesystemStorage::new(std::path::PathBuf::from(".kogral"));
|
||||
let mut graph = storage.load_graph("default").await?;
|
||||
|
||||
let card_results = graph.find_blocks_by_tag("card");
|
||||
let mut node_blocks: Vec<(String, Vec<(String, Vec<String>)>)> = Vec::new();
|
||||
|
||||
for (node_id, blocks) in card_results {
|
||||
let card_data: Vec<(String, Vec<String>)> = blocks
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let children: Vec<String> = b.children.iter().map(|c| c.content.clone()).collect();
|
||||
(b.content.clone(), children)
|
||||
})
|
||||
.collect();
|
||||
node_blocks.push((node_id, card_data));
|
||||
}
|
||||
|
||||
let total_cards: usize = node_blocks.iter().map(|(_, cards)| cards.len()).sum();
|
||||
let mut output = format!("Found {} flashcard(s):\n\n", total_cards);
|
||||
|
||||
let mut count = 0;
|
||||
for (node_id, cards) in node_blocks {
|
||||
if count >= limit {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(node) = graph.get_node(&node_id) {
|
||||
output.push_str(&format!("## {} ({})\n", node.title, node_id));
|
||||
|
||||
for (content, children) in cards {
|
||||
if count >= limit {
|
||||
break;
|
||||
}
|
||||
output.push_str(&format!("- {} #card\n", content));
|
||||
|
||||
// Show nested blocks (answer part of flashcard)
|
||||
for child in children {
|
||||
output.push_str(&format!(" - {}\n", child));
|
||||
}
|
||||
|
||||
count += 1;
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
if total_cards == 0 {
|
||||
output = "No flashcards (#card) found.".to_string();
|
||||
}
|
||||
|
||||
Ok(ToolResult::text(output))
|
||||
}
|
||||
191
crates/kogral-mcp/src/types.rs
Normal file
191
crates/kogral-mcp/src/types.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
//! MCP protocol types
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// MCP JSON-RPC request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcRequest {
|
||||
pub jsonrpc: String,
|
||||
pub id: Option<Value>,
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub params: Value,
|
||||
}
|
||||
|
||||
/// MCP JSON-RPC response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcResponse {
|
||||
pub jsonrpc: String,
|
||||
pub id: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
/// MCP JSON-RPC error
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcError {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
/// MCP tool definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tool {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(rename = "inputSchema")]
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
/// MCP tool call request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
/// MCP tool result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolResult {
|
||||
pub content: Vec<ContentBlock>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_error: Option<bool>,
|
||||
}
|
||||
|
||||
/// Content block in tool result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum ContentBlock {
|
||||
Text { text: String },
|
||||
}
|
||||
|
||||
/// MCP resource definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Resource {
|
||||
pub uri: String,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
/// MCP resource contents
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResourceContents {
|
||||
pub uri: String,
|
||||
pub mime_type: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// MCP prompt definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Prompt {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<Vec<PromptArgument>>,
|
||||
}
|
||||
|
||||
/// Prompt argument definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PromptArgument {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
/// Prompt message
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PromptMessage {
|
||||
pub role: String,
|
||||
pub content: PromptContent,
|
||||
}
|
||||
|
||||
/// Prompt content
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum PromptContent {
|
||||
Text { text: String },
|
||||
}
|
||||
|
||||
/// Server capabilities
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<ToolsCapability>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resources: Option<ResourcesCapability>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompts: Option<PromptsCapability>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolsCapability {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub list_changed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResourcesCapability {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subscribe: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub list_changed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PromptsCapability {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub list_changed: Option<bool>,
|
||||
}
|
||||
|
||||
impl JsonRpcResponse {
|
||||
/// Create success response
|
||||
pub fn success(id: Option<Value>, result: Value) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create error response
|
||||
pub fn error(id: Option<Value>, code: i32, message: String) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id,
|
||||
result: None,
|
||||
error: Some(JsonRpcError {
|
||||
code,
|
||||
message,
|
||||
data: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
/// Create text result
|
||||
pub fn text(text: String) -> Self {
|
||||
Self {
|
||||
content: vec![ContentBlock::Text { text }],
|
||||
is_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create error result
|
||||
pub fn error(message: String) -> Self {
|
||||
Self {
|
||||
content: vec![ContentBlock::Text { text: message }],
|
||||
is_error: Some(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
219
crates/kogral-mcp/src/validation.rs
Normal file
219
crates/kogral-mcp/src/validation.rs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
//! Input validation for MCP tools
|
||||
//!
|
||||
//! Enforces safe limits on user inputs to prevent abuse and resource exhaustion.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Maximum length for string fields (titles, content, etc.)
|
||||
pub const MAX_STRING_LENGTH: usize = 10_000;
|
||||
|
||||
/// Maximum length for short fields (IDs, names, etc.)
|
||||
pub const MAX_SHORT_LENGTH: usize = 256;
|
||||
|
||||
/// Maximum number of items in array fields (tags, relates_to, etc.)
|
||||
pub const MAX_ARRAY_ITEMS: usize = 100;
|
||||
|
||||
/// Maximum limit value for result sets
|
||||
pub const MAX_LIMIT: usize = 1000;
|
||||
|
||||
/// Validates and sanitizes a required string field
|
||||
pub fn validate_required_string(
|
||||
value: Option<&str>,
|
||||
field_name: &str,
|
||||
max_length: usize,
|
||||
) -> Result<String> {
|
||||
let s = value.ok_or_else(|| anyhow!("Missing required field: {}", field_name))?;
|
||||
|
||||
if s.is_empty() {
|
||||
return Err(anyhow!("Field '{}' cannot be empty", field_name));
|
||||
}
|
||||
|
||||
if s.len() > max_length {
|
||||
return Err(anyhow!(
|
||||
"Field '{}' exceeds maximum length of {} characters",
|
||||
field_name,
|
||||
max_length
|
||||
));
|
||||
}
|
||||
|
||||
Ok(s.trim().to_string())
|
||||
}
|
||||
|
||||
/// Validates an optional string field
|
||||
pub fn validate_optional_string(value: Option<&str>, field_name: &str, max_length: usize) -> Result<Option<String>> {
|
||||
match value {
|
||||
Some(s) => {
|
||||
if s.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if s.len() > max_length {
|
||||
return Err(anyhow!(
|
||||
"Field '{}' exceeds maximum length of {} characters",
|
||||
field_name,
|
||||
max_length
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some(s.trim().to_string()))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a limit parameter (for pagination)
|
||||
pub fn validate_limit(value: Option<u64>, default: usize) -> Result<usize> {
|
||||
match value {
|
||||
Some(limit) => {
|
||||
if limit == 0 {
|
||||
return Err(anyhow!("Limit must be greater than 0"));
|
||||
}
|
||||
if limit as usize > MAX_LIMIT {
|
||||
return Err(anyhow!("Limit cannot exceed {}", MAX_LIMIT));
|
||||
}
|
||||
Ok(limit as usize)
|
||||
}
|
||||
None => Ok(default),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a numeric strength value (0.0 to 1.0)
|
||||
pub fn validate_strength(value: Option<f64>) -> Result<f32> {
|
||||
let strength = value.unwrap_or(1.0);
|
||||
if !(0.0..=1.0).contains(&strength) {
|
||||
return Err(anyhow!("Strength must be between 0 and 1, got {}", strength));
|
||||
}
|
||||
Ok(strength as f32)
|
||||
}
|
||||
|
||||
/// Validates an array of string items
|
||||
pub fn validate_string_array(
|
||||
value: Option<&Vec<Value>>,
|
||||
field_name: &str,
|
||||
max_items: usize,
|
||||
max_item_length: usize,
|
||||
) -> Result<Vec<String>> {
|
||||
match value {
|
||||
Some(arr) => {
|
||||
if arr.len() > max_items {
|
||||
return Err(anyhow!(
|
||||
"Field '{}' cannot have more than {} items",
|
||||
field_name,
|
||||
max_items
|
||||
));
|
||||
}
|
||||
|
||||
arr.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
let s = v
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("Item {} in '{}' is not a string", i, field_name))?;
|
||||
|
||||
if s.is_empty() {
|
||||
return Err(anyhow!("Item {} in '{}' cannot be empty", i, field_name));
|
||||
}
|
||||
|
||||
if s.len() > max_item_length {
|
||||
return Err(anyhow!(
|
||||
"Item {} in '{}' exceeds maximum length of {} characters",
|
||||
i,
|
||||
field_name,
|
||||
max_item_length
|
||||
));
|
||||
}
|
||||
|
||||
Ok(s.trim().to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a node ID reference (must exist is checked at call site)
|
||||
pub fn validate_node_id(value: Option<&str>, field_name: &str) -> Result<String> {
|
||||
validate_required_string(value, field_name, MAX_SHORT_LENGTH)
|
||||
}
|
||||
|
||||
/// Validates an enum-like field against allowed values
|
||||
pub fn validate_enum(value: Option<&str>, field_name: &str, allowed: &[&str]) -> Result<String> {
|
||||
let s = validate_required_string(value, field_name, MAX_SHORT_LENGTH)?;
|
||||
|
||||
if !allowed.contains(&s.as_str()) {
|
||||
return Err(anyhow!(
|
||||
"Invalid value for '{}': '{}'. Must be one of: {}",
|
||||
field_name,
|
||||
s,
|
||||
allowed.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_required_string() {
|
||||
let result = validate_required_string(Some("hello"), "test", 100);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "hello");
|
||||
|
||||
let result = validate_required_string(None, "test", 100);
|
||||
assert!(result.is_err());
|
||||
|
||||
let result = validate_required_string(Some(""), "test", 100);
|
||||
assert!(result.is_err());
|
||||
|
||||
let long_string = "a".repeat(101);
|
||||
let result = validate_required_string(Some(&long_string), "test", 100);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_limit() {
|
||||
let result = validate_limit(Some(10), 5);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), 10);
|
||||
|
||||
let result = validate_limit(None, 5);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), 5);
|
||||
|
||||
let result = validate_limit(Some(0), 5);
|
||||
assert!(result.is_err());
|
||||
|
||||
let result = validate_limit(Some((MAX_LIMIT + 1) as u64), 5);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_strength() {
|
||||
let result = validate_strength(Some(0.5));
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), 0.5);
|
||||
|
||||
let result = validate_strength(None);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), 1.0);
|
||||
|
||||
let result = validate_strength(Some(1.5));
|
||||
assert!(result.is_err());
|
||||
|
||||
let result = validate_strength(Some(-0.1));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_enum() {
|
||||
let result = validate_enum(Some("note"), "type", &["note", "decision", "guideline"]);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let result = validate_enum(Some("invalid"), "type", &["note", "decision"]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
60
crates/kogral-mcp/tests/integration_test.rs
Normal file
60
crates/kogral-mcp/tests/integration_test.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//! Integration tests for kb-mcp
|
||||
|
||||
use kogral_mcp::{prompts, resources, tools};
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_tools() {
|
||||
let tools = tools::list_tools();
|
||||
assert!(!tools.is_empty());
|
||||
assert!(tools.iter().any(|t| t.name == "kogral/search"));
|
||||
assert!(tools.iter().any(|t| t.name == "kogral/add_note"));
|
||||
assert!(tools.iter().any(|t| t.name == "kogral/add_decision"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_resources() {
|
||||
let resources = resources::list_resources().await.unwrap();
|
||||
assert!(!resources.is_empty());
|
||||
assert!(resources.iter().any(|r| r.uri == "kogral://project/notes"));
|
||||
assert!(resources
|
||||
.iter()
|
||||
.any(|r| r.uri == "kogral://shared/guidelines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_prompts() {
|
||||
let prompts = prompts::list_prompts();
|
||||
assert!(!prompts.is_empty());
|
||||
assert!(prompts.iter().any(|p| p.name == "kogral/summarize_project"));
|
||||
assert!(prompts.iter().any(|p| p.name == "kogral/find_related"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_search_tool() {
|
||||
let args = json!({
|
||||
"query": "test",
|
||||
"limit": 5
|
||||
});
|
||||
|
||||
// This test expects the tool to execute even if no graph exists
|
||||
// The tool should return an empty result or error gracefully
|
||||
let result = tools::execute_tool("kogral/search", args).await;
|
||||
|
||||
// Tool execution should succeed (even with no results)
|
||||
// The actual search may fail if graph doesn't exist, but tool dispatch should
|
||||
// work
|
||||
match result {
|
||||
Ok(_) => assert!(true),
|
||||
Err(e) => {
|
||||
// If it errors, it should be due to missing graph, not a tool execution error
|
||||
assert!(e.to_string().contains("not found") || e.to_string().contains("Graph"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_list_graphs_tool() {
|
||||
let result = tools::execute_tool("kogral/list_graphs", json!({})).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue