"""
HiveMind MCP Server - Utilities
Helper functions for path manipulation, formatting, and common operations.
"""
import re
from pathlib import Path
from typing import List, Optional, Any
from datetime import datetime
from config import ANCHOR_PREFIX, IGNORE_PATTERNS
def sanitize_filename(name: str) -> str:
"""
Clean filename for use in Mermaid IDs.
Removes special characters and replaces spaces/dots with underscores.
Args:
name: Original filename or identifier
Returns:
Sanitized string safe for Mermaid node IDs
"""
# Remove file extension if present
name = Path(name).stem if '.' in name else name
# Replace special characters with underscores
sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', name)
# Remove consecutive underscores
sanitized = re.sub(r'_+', '_', sanitized)
# Remove leading/trailing underscores
sanitized = sanitized.strip('_')
# Ensure it starts with a letter (Mermaid requirement)
if sanitized and not sanitized[0].isalpha():
sanitized = 'n_' + sanitized
return sanitized or 'unnamed'
def normalize_path(path: str) -> Path:
"""
Normalize file path to absolute Path object.
Args:
path: Relative or absolute path string
Returns:
Normalized absolute Path object
"""
p = Path(path)
if not p.is_absolute():
p = Path.cwd() / p
return p.resolve()
def parse_anchor(anchor: str) -> Path:
"""
Convert anchor string to file path.
Args:
anchor: Anchor in format "anchor://path/to/directory"
Returns:
Path object pointing to the directory
Raises:
ValueError: If anchor format is invalid
"""
if not anchor.startswith(ANCHOR_PREFIX):
raise ValueError(f"Invalid anchor format. Expected '{ANCHOR_PREFIX}...'")
path_str = anchor[len(ANCHOR_PREFIX):]
return normalize_path(path_str)
def create_anchor(path: Path, root: Optional[Path] = None) -> str:
"""
Create anchor string from path.
Args:
path: Path to create anchor for
root: Optional root path to make anchor relative to
Returns:
Anchor string in format "anchor://path/to/directory"
"""
if root:
try:
relative = path.relative_to(root)
return f"{ANCHOR_PREFIX}{relative.as_posix()}"
except ValueError:
pass
return f"{ANCHOR_PREFIX}{path.as_posix()}"
def format_list(items: List[Any], prefix: str = "- ") -> str:
"""
Format list items for markdown.
Args:
items: List of items to format
prefix: Prefix for each line (default "- ")
Returns:
Formatted markdown list string
"""
if not items:
return "_None_"
return '\n'.join(f"{prefix}{item}" for item in items)
def format_code_block(code: str, language: str = "") -> str:
"""
Format code as markdown code block.
Args:
code: Code content
language: Language for syntax highlighting
Returns:
Formatted code block string
"""
return f"```{language}\n{code}\n```"
def get_relative_path(from_path: Path, to_path: Path) -> str:
"""
Get relative path from one path to another.
Args:
from_path: Starting path
to_path: Target path
Returns:
Relative path string
"""
try:
# Find common ancestor
from_parts = from_path.resolve().parts
to_parts = to_path.resolve().parts
# Find where paths diverge
common_length = 0
for i, (f, t) in enumerate(zip(from_parts, to_parts)):
if f != t:
break
common_length = i + 1
# Build relative path
ups = len(from_parts) - common_length
downs = to_parts[common_length:]
if ups == 0 and not downs:
return '.'
parts = ['..'] * ups + list(downs)
return '/'.join(parts)
except Exception:
return str(to_path)
def should_ignore(path: Path) -> bool:
"""
Check if path should be ignored.
Args:
path: Path to check
Returns:
True if path should be ignored
"""
for pattern in IGNORE_PATTERNS:
if pattern in path.parts:
return True
# Check for glob patterns
if '*' in pattern:
if path.match(pattern):
return True
return False
def calculate_complexity(node_count: int, branch_count: int = 0, loop_count: int = 0) -> int:
"""
Calculate simplified cyclomatic complexity.
Cyclomatic complexity = E - N + 2P where:
- E = edges
- N = nodes
- P = connected components (usually 1)
Simplified: 1 + number of decision points (branches, loops)
Args:
node_count: Number of nodes/statements
branch_count: Number of if/switch/ternary
loop_count: Number of for/while/do-while
Returns:
Complexity score
"""
return 1 + branch_count + loop_count
def get_file_info(file_path: Path) -> dict:
"""
Get basic file information.
Args:
file_path: Path to file
Returns:
Dict with name, path, lines, size, modified
"""
try:
content = file_path.read_text(encoding='utf-8', errors='ignore')
lines = len(content.splitlines())
size = file_path.stat().st_size
modified = datetime.fromtimestamp(file_path.stat().st_mtime)
return {
'name': file_path.name,
'path': str(file_path),
'lines': lines,
'size': size,
'modified': modified.isoformat(),
}
except Exception as e:
return {
'name': file_path.name,
'path': str(file_path),
'lines': 0,
'size': 0,
'error': str(e),
}
def truncate_string(s: str, max_length: int = 100, suffix: str = "...") -> str:
"""
Truncate string to maximum length.
Args:
s: String to truncate
max_length: Maximum length
suffix: Suffix to add if truncated
Returns:
Truncated string
"""
if len(s) <= max_length:
return s
return s[:max_length - len(suffix)] + suffix
def format_size(size_bytes: int) -> str:
"""
Format file size in human-readable format.
Args:
size_bytes: Size in bytes
Returns:
Formatted size string (e.g., "1.5 KB")
"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024:
return f"{size_bytes:.1f} {unit}" if unit != 'B' else f"{size_bytes} {unit}"
size_bytes /= 1024
return f"{size_bytes:.1f} TB"
def extract_first_paragraph(markdown: str) -> str:
"""
Extract the first paragraph from markdown content.
Useful for generating summaries.
Args:
markdown: Markdown content
Returns:
First paragraph text
"""
lines = markdown.strip().split('\n')
paragraph = []
started = False
for line in lines:
# Skip headers and empty lines at start
if not started:
if line.startswith('#') or not line.strip():
continue
started = True
# End at empty line or new header
if started:
if not line.strip() or line.startswith('#'):
break
paragraph.append(line.strip())
return ' '.join(paragraph)