This commit is contained in:
2025-04-09 07:11:38 +02:00
parent b93894632a
commit 5e4dcbf77c
37 changed files with 2450 additions and 8 deletions

View File

@@ -4,3 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
walkdir = "2.3.3"
pulldown-cmark = "0.9.3"
thiserror = "1.0.40"
lazy_static = "1.4.0"

412
doctree/src/collection.rs Normal file
View File

@@ -0,0 +1,412 @@
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use std::fs;
use crate::error::{DocTreeError, Result};
use crate::storage::RedisStorage;
use crate::utils::{name_fix, markdown_to_html, ensure_md_extension};
use crate::include::process_includes;
/// Collection represents a collection of markdown pages and files
#[derive(Clone)]
pub struct Collection {
/// Base path of the collection
pub path: PathBuf,
/// Name of the collection (namefixed)
pub name: String,
/// Redis storage backend
pub storage: RedisStorage,
}
/// Builder for Collection
pub struct CollectionBuilder {
/// Base path of the collection
path: PathBuf,
/// Name of the collection (namefixed)
name: String,
/// Redis storage backend
storage: Option<RedisStorage>,
}
impl Collection {
/// Create a new CollectionBuilder
///
/// # Arguments
///
/// * `path` - Base path of the collection
/// * `name` - Name of the collection
///
/// # Returns
///
/// A new CollectionBuilder
pub fn builder<P: AsRef<Path>>(path: P, name: &str) -> CollectionBuilder {
CollectionBuilder {
path: path.as_ref().to_path_buf(),
name: name_fix(name),
storage: None,
}
}
/// Scan walks over the path and finds all files and .md files
/// It stores the relative positions in Redis
///
/// # Returns
///
/// Ok(()) on success or an error
pub fn scan(&self) -> Result<()> {
// Delete existing collection data if any
self.storage.delete_collection(&self.name)?;
// Walk through the directory
let walker = WalkDir::new(&self.path);
for entry_result in walker {
// Handle entry errors
let entry = match entry_result {
Ok(entry) => entry,
Err(e) => {
// Log the error and continue
eprintln!("Error walking directory: {}", e);
continue;
}
};
// Skip directories
if entry.file_type().is_dir() {
continue;
}
// Get the relative path from the base path
let rel_path = match entry.path().strip_prefix(&self.path) {
Ok(path) => path,
Err(_) => {
// Log the error and continue
eprintln!("Failed to get relative path for: {:?}", entry.path());
continue;
}
};
// Get the filename and apply namefix
let filename = entry.file_name().to_string_lossy().to_string();
let namefixed_filename = name_fix(&filename);
// Store in Redis using the namefixed filename as the key
// Store the original relative path to preserve case and special characters
self.storage.store_collection_entry(
&self.name,
&namefixed_filename,
&rel_path.to_string_lossy()
)?;
}
Ok(())
}
/// Get a page by name and return its markdown content
///
/// # Arguments
///
/// * `page_name` - Name of the page
///
/// # Returns
///
/// The page content or an error
pub fn page_get(&self, page_name: &str) -> Result<String> {
// Apply namefix to the page name
let namefixed_page_name = name_fix(page_name);
// Ensure it has .md extension
let namefixed_page_name = ensure_md_extension(&namefixed_page_name);
// Get the relative path from Redis
let rel_path = self.storage.get_collection_entry(&self.name, &namefixed_page_name)
.map_err(|_| DocTreeError::PageNotFound(page_name.to_string()))?;
// Read the file
let full_path = self.path.join(rel_path);
let content = fs::read_to_string(full_path)
.map_err(|e| DocTreeError::IoError(e))?;
// Skip include processing at this level to avoid infinite recursion
// Include processing will be done at the higher level
Ok(content)
}
/// Create or update a page in the collection
///
/// # Arguments
///
/// * `page_name` - Name of the page
/// * `content` - Content of the page
///
/// # Returns
///
/// Ok(()) on success or an error
pub fn page_set(&self, page_name: &str, content: &str) -> Result<()> {
// Apply namefix to the page name
let namefixed_page_name = name_fix(page_name);
// Ensure it has .md extension
let namefixed_page_name = ensure_md_extension(&namefixed_page_name);
// Create the full path
let full_path = self.path.join(&namefixed_page_name);
// Create directories if needed
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).map_err(DocTreeError::IoError)?;
}
// Write content to file
fs::write(&full_path, content).map_err(DocTreeError::IoError)?;
// Update Redis
self.storage.store_collection_entry(&self.name, &namefixed_page_name, &namefixed_page_name)?;
Ok(())
}
/// Delete a page from the collection
///
/// # Arguments
///
/// * `page_name` - Name of the page
///
/// # Returns
///
/// Ok(()) on success or an error
pub fn page_delete(&self, page_name: &str) -> Result<()> {
// Apply namefix to the page name
let namefixed_page_name = name_fix(page_name);
// Ensure it has .md extension
let namefixed_page_name = ensure_md_extension(&namefixed_page_name);
// Get the relative path from Redis
let rel_path = self.storage.get_collection_entry(&self.name, &namefixed_page_name)
.map_err(|_| DocTreeError::PageNotFound(page_name.to_string()))?;
// Delete the file
let full_path = self.path.join(rel_path);
fs::remove_file(full_path).map_err(DocTreeError::IoError)?;
// Remove from Redis
self.storage.delete_collection_entry(&self.name, &namefixed_page_name)?;
Ok(())
}
/// List all pages in the collection
///
/// # Returns
///
/// A vector of page names or an error
pub fn page_list(&self) -> Result<Vec<String>> {
// Get all keys from Redis
let keys = self.storage.list_collection_entries(&self.name)?;
// Filter to only include .md files
let pages = keys.into_iter()
.filter(|key| key.ends_with(".md"))
.collect();
Ok(pages)
}
/// Get the URL for a file
///
/// # Arguments
///
/// * `file_name` - Name of the file
///
/// # Returns
///
/// The URL for the file or an error
pub fn file_get_url(&self, file_name: &str) -> Result<String> {
// Apply namefix to the file name
let namefixed_file_name = name_fix(file_name);
// Get the relative path from Redis
let rel_path = self.storage.get_collection_entry(&self.name, &namefixed_file_name)
.map_err(|_| DocTreeError::FileNotFound(file_name.to_string()))?;
// Construct a URL for the file
let url = format!("/collections/{}/files/{}", self.name, rel_path);
Ok(url)
}
/// Add or update a file in the collection
///
/// # Arguments
///
/// * `file_name` - Name of the file
/// * `content` - Content of the file
///
/// # Returns
///
/// Ok(()) on success or an error
pub fn file_set(&self, file_name: &str, content: &[u8]) -> Result<()> {
// Apply namefix to the file name
let namefixed_file_name = name_fix(file_name);
// Create the full path
let full_path = self.path.join(&namefixed_file_name);
// Create directories if needed
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).map_err(DocTreeError::IoError)?;
}
// Write content to file
fs::write(&full_path, content).map_err(DocTreeError::IoError)?;
// Update Redis
self.storage.store_collection_entry(&self.name, &namefixed_file_name, &namefixed_file_name)?;
Ok(())
}
/// Delete a file from the collection
///
/// # Arguments
///
/// * `file_name` - Name of the file
///
/// # Returns
///
/// Ok(()) on success or an error
pub fn file_delete(&self, file_name: &str) -> Result<()> {
// Apply namefix to the file name
let namefixed_file_name = name_fix(file_name);
// Get the relative path from Redis
let rel_path = self.storage.get_collection_entry(&self.name, &namefixed_file_name)
.map_err(|_| DocTreeError::FileNotFound(file_name.to_string()))?;
// Delete the file
let full_path = self.path.join(rel_path);
fs::remove_file(full_path).map_err(DocTreeError::IoError)?;
// Remove from Redis
self.storage.delete_collection_entry(&self.name, &namefixed_file_name)?;
Ok(())
}
/// List all files (non-markdown) in the collection
///
/// # Returns
///
/// A vector of file names or an error
pub fn file_list(&self) -> Result<Vec<String>> {
// Get all keys from Redis
let keys = self.storage.list_collection_entries(&self.name)?;
// Filter to exclude .md files
let files = keys.into_iter()
.filter(|key| !key.ends_with(".md"))
.collect();
Ok(files)
}
/// Get the relative path of a page in the collection
///
/// # Arguments
///
/// * `page_name` - Name of the page
///
/// # Returns
///
/// The relative path of the page or an error
pub fn page_get_path(&self, page_name: &str) -> Result<String> {
// Apply namefix to the page name
let namefixed_page_name = name_fix(page_name);
// Ensure it has .md extension
let namefixed_page_name = ensure_md_extension(&namefixed_page_name);
// Get the relative path from Redis
self.storage.get_collection_entry(&self.name, &namefixed_page_name)
.map_err(|_| DocTreeError::PageNotFound(page_name.to_string()))
}
/// Get a page by name and return its HTML content
///
/// # Arguments
///
/// * `page_name` - Name of the page
/// * `doctree` - Optional DocTree instance for include processing
///
/// # Returns
///
/// The HTML content of the page or an error
pub fn page_get_html(&self, page_name: &str, doctree: Option<&crate::doctree::DocTree>) -> Result<String> {
// Get the markdown content
let markdown = self.page_get(page_name)?;
// Process includes if doctree is provided
let processed_markdown = if let Some(dt) = doctree {
process_includes(&markdown, &self.name, dt)?
} else {
markdown
};
// Convert markdown to HTML
let html = markdown_to_html(&processed_markdown);
Ok(html)
}
/// Get information about the Collection
///
/// # Returns
///
/// A map of information
pub fn info(&self) -> std::collections::HashMap<String, String> {
let mut info = std::collections::HashMap::new();
info.insert("name".to_string(), self.name.clone());
info.insert("path".to_string(), self.path.to_string_lossy().to_string());
info
}
}
impl CollectionBuilder {
/// Set the storage backend
///
/// # Arguments
///
/// * `storage` - Redis storage backend
///
/// # Returns
///
/// Self for method chaining
pub fn with_storage(mut self, storage: RedisStorage) -> Self {
self.storage = Some(storage);
self
}
/// Build the Collection
///
/// # Returns
///
/// A new Collection or an error
pub fn build(self) -> Result<Collection> {
let storage = self.storage.ok_or_else(|| {
DocTreeError::MissingParameter("storage".to_string())
})?;
let collection = Collection {
path: self.path,
name: self.name,
storage,
};
Ok(collection)
}
}

433
doctree/src/doctree.rs Normal file
View File

@@ -0,0 +1,433 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use crate::collection::{Collection, CollectionBuilder};
use crate::error::{DocTreeError, Result};
use crate::storage::RedisStorage;
use crate::include::process_includes;
use crate::utils::{name_fix, ensure_md_extension};
// Global variable to track the current collection name
// This is for compatibility with the Go implementation
lazy_static::lazy_static! {
static ref CURRENT_COLLECTION_NAME: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
}
// Global variable to track the current Collection
// This is for compatibility with the Go implementation
/// DocTree represents a manager for multiple collections
pub struct DocTree {
/// Map of collections by name
pub collections: HashMap<String, Collection>,
/// Default collection name
pub default_collection: Option<String>,
/// Redis storage backend
storage: RedisStorage,
/// For backward compatibility
pub name: String,
/// For backward compatibility
pub path: PathBuf,
}
/// Builder for DocTree
pub struct DocTreeBuilder {
/// Map of collections by name
collections: HashMap<String, Collection>,
/// Default collection name
default_collection: Option<String>,
/// Redis storage backend
storage: Option<RedisStorage>,
/// For backward compatibility
name: Option<String>,
/// For backward compatibility
path: Option<PathBuf>,
}
impl DocTree {
/// Create a new DocTreeBuilder
///
/// # Returns
///
/// A new DocTreeBuilder
pub fn builder() -> DocTreeBuilder {
DocTreeBuilder {
collections: HashMap::new(),
default_collection: None,
storage: None,
name: None,
path: None,
}
}
/// Add a collection to the DocTree
///
/// # Arguments
///
/// * `path` - Base path of the collection
/// * `name` - Name of the collection
///
/// # Returns
///
/// The added collection or an error
pub fn add_collection<P: AsRef<Path>>(&mut self, path: P, name: &str) -> Result<&Collection> {
// Create a new collection
let namefixed = name_fix(name);
let collection = Collection::builder(path, &namefixed)
.with_storage(self.storage.clone())
.build()?;
// Scan the collection
collection.scan()?;
// Add to the collections map
self.collections.insert(collection.name.clone(), collection);
// Return a reference to the added collection
self.collections.get(&namefixed).ok_or_else(|| {
DocTreeError::CollectionNotFound(namefixed.clone())
})
}
/// Get a collection by name
///
/// # Arguments
///
/// * `name` - Name of the collection
///
/// # Returns
///
/// The collection or an error
pub fn get_collection(&self, name: &str) -> Result<&Collection> {
// For compatibility with tests, apply namefix
let namefixed = name_fix(name);
// Check if the collection exists
self.collections.get(&namefixed).ok_or_else(|| {
DocTreeError::CollectionNotFound(name.to_string())
})
}
/// Delete a collection from the DocTree
///
/// # Arguments
///
/// * `name` - Name of the collection
///
/// # Returns
///
/// Ok(()) on success or an error
pub fn delete_collection(&mut self, name: &str) -> Result<()> {
// For compatibility with tests, apply namefix
let namefixed = name_fix(name);
// Check if the collection exists
if !self.collections.contains_key(&namefixed) {
return Err(DocTreeError::CollectionNotFound(name.to_string()));
}
// Delete from Redis
self.storage.delete_collection(&namefixed)?;
// Remove from the collections map
self.collections.remove(&namefixed);
Ok(())
}
/// List all collections
///
/// # Returns
///
/// A vector of collection names
pub fn list_collections(&self) -> Vec<String> {
self.collections.keys().cloned().collect()
}
/// Get a page by name from a specific collection
///
/// # Arguments
///
/// * `collection_name` - Name of the collection (optional)
/// * `page_name` - Name of the page
///
/// # Returns
///
/// The page content or an error
pub fn page_get(&self, collection_name: Option<&str>, page_name: &str) -> Result<String> {
let (collection_name, page_name) = self.resolve_collection_and_page(collection_name, page_name)?;
// Get the collection
let collection = self.get_collection(&collection_name)?;
// Get the page content
let content = collection.page_get(page_name)?;
// Process includes
let processed_content = process_includes(&content, &collection_name, self)?;
Ok(processed_content)
}
/// Get a page by name from a specific collection and return its HTML content
///
/// # Arguments
///
/// * `collection_name` - Name of the collection (optional)
/// * `page_name` - Name of the page
///
/// # Returns
///
/// The HTML content or an error
pub fn page_get_html(&self, collection_name: Option<&str>, page_name: &str) -> Result<String> {
let (collection_name, page_name) = self.resolve_collection_and_page(collection_name, page_name)?;
// Get the collection
let collection = self.get_collection(&collection_name)?;
// Get the HTML
collection.page_get_html(page_name, Some(self))
}
/// Get the URL for a file in a specific collection
///
/// # Arguments
///
/// * `collection_name` - Name of the collection (optional)
/// * `file_name` - Name of the file
///
/// # Returns
///
/// The URL for the file or an error
pub fn file_get_url(&self, collection_name: Option<&str>, file_name: &str) -> Result<String> {
let (collection_name, file_name) = self.resolve_collection_and_page(collection_name, file_name)?;
// Get the collection
let collection = self.get_collection(&collection_name)?;
// Get the URL
collection.file_get_url(file_name)
}
/// Get the path to a page in the default collection
///
/// # Arguments
///
/// * `page_name` - Name of the page
///
/// # Returns
///
/// The path to the page or an error
pub fn page_get_path(&self, page_name: &str) -> Result<String> {
// Check if a default collection is set
let default_collection = self.default_collection.as_ref().ok_or_else(|| {
DocTreeError::NoDefaultCollection
})?;
// Get the collection
let collection = self.get_collection(default_collection)?;
// Get the path
collection.page_get_path(page_name)
}
/// Get information about the DocTree
///
/// # Returns
///
/// A map of information
pub fn info(&self) -> HashMap<String, String> {
let mut info = HashMap::new();
info.insert("name".to_string(), self.name.clone());
info.insert("path".to_string(), self.path.to_string_lossy().to_string());
info.insert("collections".to_string(), self.collections.len().to_string());
info
}
/// Scan the default collection
///
/// # Returns
///
/// Ok(()) on success or an error
pub fn scan(&self) -> Result<()> {
// Check if a default collection is set
let default_collection = self.default_collection.as_ref().ok_or_else(|| {
DocTreeError::NoDefaultCollection
})?;
// Get the collection
let collection = self.get_collection(default_collection)?;
// Scan the collection
collection.scan()
}
/// Resolve collection and page names
///
/// # Arguments
///
/// * `collection_name` - Name of the collection (optional)
/// * `page_name` - Name of the page
///
/// # Returns
///
/// A tuple of (collection_name, page_name) or an error
fn resolve_collection_and_page<'a>(&self, collection_name: Option<&'a str>, page_name: &'a str) -> Result<(String, &'a str)> {
match collection_name {
Some(name) => Ok((name_fix(name), page_name)),
None => {
// Use the default collection
let default_collection = self.default_collection.as_ref().ok_or_else(|| {
DocTreeError::NoDefaultCollection
})?;
Ok((default_collection.clone(), page_name))
}
}
}
}
impl DocTreeBuilder {
/// Set the storage backend
///
/// # Arguments
///
/// * `storage` - Redis storage backend
///
/// # Returns
///
/// Self for method chaining
pub fn with_storage(mut self, storage: RedisStorage) -> Self {
self.storage = Some(storage);
self
}
/// Add a collection
///
/// # Arguments
///
/// * `path` - Base path of the collection
/// * `name` - Name of the collection
///
/// # Returns
///
/// Self for method chaining or an error
pub fn with_collection<P: AsRef<Path>>(mut self, path: P, name: &str) -> Result<Self> {
// Ensure storage is set
let storage = self.storage.as_ref().ok_or_else(|| {
DocTreeError::MissingParameter("storage".to_string())
})?;
// Create a new collection
let namefixed = name_fix(name);
let collection = Collection::builder(path.as_ref(), &namefixed)
.with_storage(storage.clone())
.build()?;
// Scan the collection
collection.scan()?;
// Add to the collections map
self.collections.insert(collection.name.clone(), collection);
// For backward compatibility
if self.name.is_none() {
self.name = Some(namefixed.clone());
}
if self.path.is_none() {
self.path = Some(path.as_ref().to_path_buf());
}
Ok(self)
}
/// Set the default collection
///
/// # Arguments
///
/// * `name` - Name of the default collection
///
/// # Returns
///
/// Self for method chaining
pub fn with_default_collection(mut self, name: &str) -> Self {
self.default_collection = Some(name_fix(name));
self
}
/// Build the DocTree
///
/// # Returns
///
/// A new DocTree or an error
pub fn build(self) -> Result<DocTree> {
// Ensure storage is set
let storage = self.storage.ok_or_else(|| {
DocTreeError::MissingParameter("storage".to_string())
})?;
// Create the DocTree
let doctree = DocTree {
collections: self.collections,
default_collection: self.default_collection,
storage: storage.clone(),
name: self.name.unwrap_or_default(),
path: self.path.unwrap_or_else(|| PathBuf::from("")),
};
// Set the global current collection name if a default collection is set
if let Some(default_collection) = &doctree.default_collection {
let mut current_collection_name = CURRENT_COLLECTION_NAME.lock().unwrap();
*current_collection_name = Some(default_collection.clone());
}
Ok(doctree)
}
}
/// Create a new DocTree instance
///
/// For backward compatibility, it also accepts path and name parameters
/// to create a DocTree with a single collection
///
/// # Arguments
///
/// * `args` - Optional path and name for backward compatibility
///
/// # Returns
///
/// A new DocTree or an error
pub fn new<P: AsRef<Path>>(args: &[&str]) -> Result<DocTree> {
let storage = RedisStorage::new("redis://localhost:6379")?;
let mut builder = DocTree::builder().with_storage(storage);
// For backward compatibility with existing code
if args.len() == 2 {
let path = args[0];
let name = args[1];
// Apply namefix for compatibility with tests
let namefixed = name_fix(name);
// Add the collection
builder = builder.with_collection(path, &namefixed)?;
// Set the default collection
builder = builder.with_default_collection(&namefixed);
}
builder.build()
}

44
doctree/src/error.rs Normal file
View File

@@ -0,0 +1,44 @@
use thiserror::Error;
/// Custom error type for the doctree library
#[derive(Error, Debug)]
pub enum DocTreeError {
/// IO error
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
/// WalkDir error
#[error("WalkDir error: {0}")]
WalkDirError(String),
/// Collection not found
#[error("Collection not found: {0}")]
CollectionNotFound(String),
/// Page not found
#[error("Page not found: {0}")]
PageNotFound(String),
/// File not found
#[error("File not found: {0}")]
FileNotFound(String),
/// Invalid include directive
#[error("Invalid include directive: {0}")]
InvalidIncludeDirective(String),
/// No default collection set
#[error("No default collection set")]
NoDefaultCollection,
/// Invalid number of arguments
#[error("Invalid number of arguments")]
InvalidArgumentCount,
/// Missing required parameter
#[error("Missing required parameter: {0}")]
MissingParameter(String),
}
/// Result type alias for doctree operations
pub type Result<T> = std::result::Result<T, DocTreeError>;

178
doctree/src/include.rs Normal file
View File

@@ -0,0 +1,178 @@
use crate::doctree::DocTree;
use crate::error::{DocTreeError, Result};
use crate::utils::trim_spaces_and_quotes;
/// Process includes in markdown content
///
/// # Arguments
///
/// * `content` - The markdown content to process
/// * `current_collection_name` - The name of the current collection
/// * `doctree` - The DocTree instance
///
/// # Returns
///
/// The processed content or an error
pub fn process_includes(content: &str, current_collection_name: &str, doctree: &DocTree) -> Result<String> {
// Find all include directives
let lines: Vec<&str> = content.split('\n').collect();
let mut result = Vec::with_capacity(lines.len());
for line in lines {
match parse_include_line(line) {
Ok((Some(c), Some(p))) => {
// Both collection and page specified
match handle_include(&p, &c, doctree) {
Ok(include_content) => {
// Process any nested includes in the included content
match process_includes(&include_content, &c, doctree) {
Ok(processed_include_content) => {
result.push(processed_include_content);
},
Err(e) => {
result.push(format!(">>ERROR: Failed to process nested includes: {}", e));
}
}
},
Err(e) => {
result.push(format!(">>ERROR: {}", e));
}
}
},
Ok((Some(_), None)) => {
// Invalid case: collection specified but no page
result.push(format!(">>ERROR: Invalid include directive: collection specified but no page name"));
},
Ok((None, Some(p))) => {
// Only page specified, use current collection
match handle_include(&p, current_collection_name, doctree) {
Ok(include_content) => {
// Process any nested includes in the included content
match process_includes(&include_content, current_collection_name, doctree) {
Ok(processed_include_content) => {
result.push(processed_include_content);
},
Err(e) => {
result.push(format!(">>ERROR: Failed to process nested includes: {}", e));
}
}
},
Err(e) => {
result.push(format!(">>ERROR: {}", e));
}
}
},
Ok((None, None)) => {
// Not an include directive, keep the line
result.push(line.to_string());
},
Err(e) => {
// Error parsing include directive
result.push(format!(">>ERROR: Failed to process include directive: {}", e));
}
}
}
Ok(result.join("\n"))
}
/// Parse an include directive line
///
/// # Arguments
///
/// * `line` - The line to parse
///
/// # Returns
///
/// A tuple of (collection_name, page_name) or an error
///
/// Supports:
/// - !!include collectionname:'pagename'
/// - !!include collectionname:'pagename.md'
/// - !!include 'pagename'
/// - !!include collectionname:pagename
/// - !!include collectionname:pagename.md
/// - !!include name:'pagename'
/// - !!include pagename
fn parse_include_line(line: &str) -> Result<(Option<String>, Option<String>)> {
// Check if the line contains an include directive
if !line.contains("!!include") {
return Ok((None, None));
}
// Extract the part after !!include
let parts: Vec<&str> = line.splitn(2, "!!include").collect();
if parts.len() != 2 {
return Err(DocTreeError::InvalidIncludeDirective(line.to_string()));
}
// Trim spaces and check if the include part is empty
let include_text = trim_spaces_and_quotes(parts[1]);
if include_text.is_empty() {
return Err(DocTreeError::InvalidIncludeDirective(line.to_string()));
}
// Remove name: prefix if present
let include_text = if include_text.starts_with("name:") {
let text = include_text.trim_start_matches("name:").trim();
if text.is_empty() {
return Err(DocTreeError::InvalidIncludeDirective(
format!("empty page name after 'name:' prefix: {}", line)
));
}
text.to_string()
} else {
include_text
};
// Check if it contains a collection reference (has a colon)
if include_text.contains(':') {
let parts: Vec<&str> = include_text.splitn(2, ':').collect();
if parts.len() != 2 {
return Err(DocTreeError::InvalidIncludeDirective(
format!("malformed collection reference: {}", include_text)
));
}
let collection_name = parts[0].trim();
let page_name = trim_spaces_and_quotes(parts[1]);
if collection_name.is_empty() {
return Err(DocTreeError::InvalidIncludeDirective(
format!("empty collection name in include directive: {}", line)
));
}
if page_name.is_empty() {
return Err(DocTreeError::InvalidIncludeDirective(
format!("empty page name in include directive: {}", line)
));
}
Ok((Some(collection_name.to_string()), Some(page_name)))
} else {
// No collection specified, just a page name
Ok((None, Some(include_text)))
}
}
/// Handle an include directive
///
/// # Arguments
///
/// * `page_name` - The name of the page to include
/// * `collection_name` - The name of the collection
/// * `doctree` - The DocTree instance
///
/// # Returns
///
/// The included content or an error
fn handle_include(page_name: &str, collection_name: &str, doctree: &DocTree) -> Result<String> {
// Get the collection
let collection = doctree.get_collection(collection_name)?;
// Get the page content
let content = collection.page_get(page_name)?;
Ok(content)
}

View File

@@ -1,14 +1,41 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
//! DocTree is a library for managing collections of markdown documents.
//!
//! It provides functionality for scanning directories, managing collections,
//! and processing includes between documents.
// Import lazy_static
#[macro_use]
extern crate lazy_static;
mod error;
mod storage;
mod utils;
mod collection;
mod doctree;
mod include;
pub use error::{DocTreeError, Result};
pub use storage::RedisStorage;
pub use collection::{Collection, CollectionBuilder};
pub use doctree::{DocTree, DocTreeBuilder, new};
pub use include::process_includes;
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
fn test_doctree_builder() {
// Create a storage instance
let storage = RedisStorage::new("dummy_url").unwrap();
let doctree = DocTree::builder()
.with_storage(storage)
.build()
.unwrap();
assert_eq!(doctree.collections.len(), 0);
assert_eq!(doctree.default_collection, None);
}
}

169
doctree/src/storage.rs Normal file
View File

@@ -0,0 +1,169 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::error::{DocTreeError, Result};
/// Storage backend for doctree
pub struct RedisStorage {
// Using a simple in-memory storage for demonstration
// In a real implementation, this would be a Redis client
collections: Arc<Mutex<HashMap<String, HashMap<String, String>>>>,
}
impl RedisStorage {
/// Create a new RedisStorage instance
///
/// # Arguments
///
/// * `url` - Redis connection URL (e.g., "redis://localhost:6379")
/// This is ignored in the in-memory implementation
///
/// # Returns
///
/// A new RedisStorage instance or an error
pub fn new(_url: &str) -> Result<Self> {
Ok(Self {
collections: Arc::new(Mutex::new(HashMap::new())),
})
}
/// Store a collection entry
///
/// # Arguments
///
/// * `collection` - Collection name
/// * `key` - Entry key
/// * `value` - Entry value
///
/// # Returns
///
/// Ok(()) on success or an error
pub fn store_collection_entry(&self, collection: &str, key: &str, value: &str) -> Result<()> {
let mut collections = self.collections.lock().unwrap();
// Get or create the collection
let collection_entries = collections
.entry(format!("collections:{}", collection))
.or_insert_with(HashMap::new);
// Store the entry
collection_entries.insert(key.to_string(), value.to_string());
Ok(())
}
/// Get a collection entry
///
/// # Arguments
///
/// * `collection` - Collection name
/// * `key` - Entry key
///
/// # Returns
///
/// The entry value or an error
pub fn get_collection_entry(&self, collection: &str, key: &str) -> Result<String> {
let collections = self.collections.lock().unwrap();
// Get the collection
let collection_key = format!("collections:{}", collection);
let collection_entries = collections.get(&collection_key)
.ok_or_else(|| DocTreeError::CollectionNotFound(collection.to_string()))?;
// Get the entry
collection_entries.get(key)
.cloned()
.ok_or_else(|| DocTreeError::FileNotFound(key.to_string()))
}
/// Delete a collection entry
///
/// # Arguments
///
/// * `collection` - Collection name
/// * `key` - Entry key
///
/// # Returns
///
/// Ok(()) on success or an error
pub fn delete_collection_entry(&self, collection: &str, key: &str) -> Result<()> {
let mut collections = self.collections.lock().unwrap();
// Get the collection
let collection_key = format!("collections:{}", collection);
let collection_entries = collections.get_mut(&collection_key)
.ok_or_else(|| DocTreeError::CollectionNotFound(collection.to_string()))?;
// Remove the entry
collection_entries.remove(key);
Ok(())
}
/// List all entries in a collection
///
/// # Arguments
///
/// * `collection` - Collection name
///
/// # Returns
///
/// A vector of entry keys or an error
pub fn list_collection_entries(&self, collection: &str) -> Result<Vec<String>> {
let collections = self.collections.lock().unwrap();
// Get the collection
let collection_key = format!("collections:{}", collection);
let collection_entries = collections.get(&collection_key)
.ok_or_else(|| DocTreeError::CollectionNotFound(collection.to_string()))?;
// Get the keys
let keys = collection_entries.keys().cloned().collect();
Ok(keys)
}
/// Delete a collection
///
/// # Arguments
///
/// * `collection` - Collection name
///
/// # Returns
///
/// Ok(()) on success or an error
pub fn delete_collection(&self, collection: &str) -> Result<()> {
let mut collections = self.collections.lock().unwrap();
// Remove the collection
collections.remove(&format!("collections:{}", collection));
Ok(())
}
/// Check if a collection exists
///
/// # Arguments
///
/// * `collection` - Collection name
///
/// # Returns
///
/// true if the collection exists, false otherwise
pub fn collection_exists(&self, collection: &str) -> Result<bool> {
let collections = self.collections.lock().unwrap();
// Check if the collection exists
let exists = collections.contains_key(&format!("collections:{}", collection));
Ok(exists)
}
}
// Implement Clone for RedisStorage
impl Clone for RedisStorage {
fn clone(&self) -> Self {
Self {
collections: Arc::clone(&self.collections),
}
}
}

106
doctree/src/utils.rs Normal file
View File

@@ -0,0 +1,106 @@
use pulldown_cmark::{Parser, Options, html};
use std::path::Path;
/// Fix a name to be used as a key
///
/// This is equivalent to the tools.NameFix function in the Go implementation.
/// It normalizes the name by converting to lowercase, replacing spaces with hyphens, etc.
///
/// # Arguments
///
/// * `name` - The name to fix
///
/// # Returns
///
/// The fixed name
pub fn name_fix(name: &str) -> String {
// Convert to lowercase
let mut result = name.to_lowercase();
// Replace spaces with hyphens
result = result.replace(' ', "-");
// Remove special characters
result = result.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '.')
.collect();
result
}
/// Convert markdown to HTML
///
/// # Arguments
///
/// * `markdown` - The markdown content to convert
///
/// # Returns
///
/// The HTML content
pub fn markdown_to_html(markdown: &str) -> String {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(markdown, options);
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
html_output
}
/// Trim spaces and quotes from a string
///
/// # Arguments
///
/// * `s` - The string to trim
///
/// # Returns
///
/// The trimmed string
pub fn trim_spaces_and_quotes(s: &str) -> String {
let mut result = s.trim().to_string();
// Remove surrounding quotes
if (result.starts_with('\'') && result.ends_with('\'')) ||
(result.starts_with('"') && result.ends_with('"')) {
result = result[1..result.len()-1].to_string();
}
result
}
/// Ensure a string has a .md extension
///
/// # Arguments
///
/// * `name` - The name to check
///
/// # Returns
///
/// The name with a .md extension
pub fn ensure_md_extension(name: &str) -> String {
if !name.ends_with(".md") {
format!("{}.md", name)
} else {
name.to_string()
}
}
/// Get the file extension from a path
///
/// # Arguments
///
/// * `path` - The path to check
///
/// # Returns
///
/// The file extension or an empty string
pub fn get_extension(path: &str) -> String {
Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_string()
}