Initial Commit

This commit is contained in:
Andy
2025-08-11 14:31:51 +08:00
commit 39e94c1b13
36 changed files with 12517 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(chmod:*)"
],
"deny": []
}
}
+434
View File
@@ -0,0 +1,434 @@
name: Continuous Integration
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
schedule:
# Run tests daily at 2 AM UTC
- cron: '0 2 * * *'
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
test:
name: Test Suite
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
rust: [stable, beta, nightly]
exclude:
# Reduce matrix size by excluding some combinations
- os: windows-latest
rust: beta
- os: windows-latest
rust: nightly
- os: macos-latest
rust: beta
include:
# Add minimum supported Rust version
- os: ubuntu-latest
rust: 1.70.0
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.rust }}
components: rustfmt, clippy
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-${{ matrix.rust }}-
${{ runner.os }}-cargo-
- name: Install system dependencies (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Install system dependencies (macOS)
if: matrix.os == 'macos-latest'
run: |
brew update
brew install pkg-config freetype jpeg libpng
- name: Check code formatting
if: matrix.rust == 'stable'
run: cargo fmt --all -- --check
- name: Run Clippy lints
if: matrix.rust == 'stable'
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Build project
run: cargo build --verbose --all-features
- name: Run unit tests
run: cargo test --verbose --lib
- name: Run integration tests
run: cargo test --verbose --test '*'
- name: Run doc tests
run: cargo test --verbose --doc
- name: Test with minimal features
run: cargo test --verbose --no-default-features
- name: Test with all features
run: cargo test --verbose --all-features
security:
name: Security Audit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ubuntu-cargo-audit-${{ hashFiles('**/Cargo.lock') }}
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Run security audit
run: cargo audit
- name: Install cargo-deny
run: cargo install cargo-deny
- name: Check licenses and dependencies
run: cargo deny check
coverage:
name: Code Coverage
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Generate coverage report
run: |
cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: lcov.info
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
benchmarks:
name: Performance Benchmarks
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ubuntu-cargo-bench-${{ hashFiles('**/Cargo.lock') }}
- name: Run benchmarks
run: cargo bench --all-features
- name: Store benchmark results
uses: benchmark-action/github-action-benchmark@v1
with:
tool: 'cargo'
output-file-path: target/criterion/reports/index.html
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
comment-on-alert: true
alert-threshold: '200%'
fail-on-alert: true
memory-safety:
name: Memory Safety Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly
with:
components: rust-src
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Install Miri
run: rustup component add miri
- name: Run Miri tests
run: |
cargo miri setup
# Run a subset of tests with Miri (full test suite might be too slow)
cargo miri test --lib -- --test-threads=1
env:
MIRIFLAGS: -Zmiri-strict-provenance
docker:
name: Docker Build Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
run: |
cat > Dockerfile << 'EOF'
FROM rust:1.75 as builder
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
RUN cargo build --release --all-features
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
libssl3 \
libfontconfig1 \
libfreetype6 \
libjpeg62-turbo \
libpng16-16 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/docx-mcp /usr/local/bin/
EXPOSE 8080
CMD ["docx-mcp"]
EOF
docker buildx build --tag docx-mcp:test .
- name: Test Docker container
run: |
# Start container in background
docker run -d --name docx-mcp-test -p 8080:8080 docx-mcp:test
sleep 10
# Basic health check (adapt based on your server's health endpoint)
docker logs docx-mcp-test
docker stop docx-mcp-test
docker rm docx-mcp-test
release-check:
name: Release Readiness
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Check that release builds
run: cargo build --release --all-features
- name: Verify package can be published
run: cargo package --dry-run
- name: Generate documentation
run: cargo doc --all-features --no-deps
- name: Check documentation links
run: cargo doc --all-features --no-deps --open || true
integration:
name: Integration Tests
runs-on: ubuntu-latest
services:
# Add any services your integration tests might need
# For example, if you need a test database or cache
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Run integration tests
run: |
# Run integration tests with proper environment setup
export TEST_INTEGRATION=1
export REDIS_URL="redis://localhost:6379"
cargo test --test integration -- --test-threads=1
env:
RUST_LOG: debug
stress-test:
name: Stress Testing
runs-on: ubuntu-latest
if: github.event_name == 'schedule' || contains(github.event.head_commit.message, '[stress-test]')
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libssl-dev \
libfontconfig1-dev \
libfreetype6-dev \
libjpeg-dev \
libpng-dev
- name: Build in release mode
run: cargo build --release --all-features
- name: Run stress tests
run: |
export STRESS_TEST=1
export RUST_LOG=info
cargo test --release --test performance_tests -- --ignored --test-threads=1
cargo test --release --test e2e_workflow_tests -- --ignored --test-threads=1
notify:
name: Notify Results
runs-on: ubuntu-latest
needs: [test, security, coverage, benchmarks]
if: always() && (github.event_name == 'push' && github.ref == 'refs/heads/main')
steps:
- name: Notify on success
if: ${{ needs.test.result == 'success' && needs.security.result == 'success' && needs.coverage.result == 'success' }}
run: |
echo "✅ All CI checks passed for main branch!"
# Add webhook notification here if needed
- name: Notify on failure
if: ${{ needs.test.result == 'failure' || needs.security.result == 'failure' || needs.coverage.result == 'failure' }}
run: |
echo "❌ CI checks failed for main branch!"
# Add failure notification here if needed
exit 1
+67
View File
@@ -0,0 +1,67 @@
/example/MCP-Doc
# Rust
target/
Cargo.lock
**/*.rs.bk
*.pdb
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Test outputs
*.docx
*.pdf
*.png
*.jpg
*.jpeg
images/
thumbnails/
output/
# Temporary files
/tmp/
*.tmp
*.temp
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv
# Logs
*.log
logs/
# Documentation build
/docs/_build/
/docs/.doctrees/
# Coverage
*.gcov
*.gcda
*.gcno
coverage/
lcov.info
# Profiling
*.prof
perf.data
perf.data.old
# Dependencies
node_modules/
vendor/
+106
View File
@@ -0,0 +1,106 @@
[package]
name = "docx-mcp"
version = "0.1.0"
edition = "2021"
[dependencies]
# Official MCP SDK
mcp-server = "0.3"
mcp-core = "0.3"
# Async runtime
tokio = { version = "1.40", features = ["full"] }
async-trait = "0.1"
# DOCX manipulation (pure Rust)
docx-rs = "0.4"
zip = "0.6"
quick-xml = "0.36"
# Pure Rust text extraction from DOCX
roxmltree = "0.20" # XML parsing without external deps
# PDF generation (pure Rust)
printpdf = "0.7"
lopdf = "0.34"
rusttype = "0.9" # Font rendering in pure Rust
# Embedded fonts for PDF
include_bytes_plus = "1.0"
# Image processing (pure Rust)
image = { version = "0.25", features = ["png", "jpeg", "webp", "bmp", "gif"] }
imageproc = "0.25"
resvg = "0.44" # SVG rendering in pure Rust
tiny-skia = "0.11" # 2D graphics in pure Rust
usvg = "0.44" # SVG parsing
# HTML/Markdown to PDF (pure Rust alternatives)
pulldown-cmark = "0.12" # Markdown parsing
html5ever = "0.29" # HTML parsing
comrak = "0.28" # CommonMark parsing
# Template rendering (pure Rust)
handlebars = "6.0" # Template engine
tera = { version = "1.20", optional = true }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
# Error handling and logging
anyhow = "1.0"
thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# File handling
tempfile = "3.10"
walkdir = "2.5"
# Additional utilities
uuid = { version = "1.10", features = ["v4", "serde"] }
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
regex = "1.10"
once_cell = "1.20"
# Optional external tool support
headless_chrome = { version = "1.0", optional = true }
wkhtmltopdf = { version = "0.4", optional = true }
[features]
default = ["embedded-fonts", "pure-rust-pdf"]
embedded-fonts = []
pure-rust-pdf = []
external-tools = ["headless_chrome", "wkhtmltopdf"]
full = ["embedded-fonts", "pure-rust-pdf", "external-tools", "tera"]
[build-dependencies]
anyhow = "1.0"
[[bin]]
name = "docx-mcp"
path = "src/main.rs"
[dev-dependencies]
# Testing framework
tokio-test = "0.4"
assert_matches = "1.5"
pretty_assertions = "1.4"
rstest = "0.18"
test-log = "0.2"
# Test utilities
tempfile = "3.10"
uuid = { version = "1.10", features = ["v4"] }
criterion = { version = "0.5", features = ["html_reports"] }
# Mock and fixtures
mockito = "1.4"
serde_yaml = "0.9"
[[bench]]
name = "docx_benchmarks"
harness = false
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 DOCX MCP Server Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+855
View File
@@ -0,0 +1,855 @@
# DOCX MCP Server
A comprehensive Model Context Protocol (MCP) server for Microsoft Word DOCX file manipulation, built with Rust. This server provides AI systems with powerful tools to create, edit, convert, and manage Word documents programmatically.
## 📖 Table of Contents
- [Quick Start](#-quick-start)
- [AI Tool Integration](#-ai-tool-integration)
- [Claude Desktop](#claude-desktop)
- [Cursor](#cursor)
- [Windsurf](#windsurf-codeium)
- [Continue.dev](#continuedev)
- [VS Code](#vs-code-with-mcp-extension)
- [Features](#-features)
- [Real-World Usage Examples](#-real-world-usage-examples-with-ai-assistants)
- [Prerequisites](#-prerequisites)
- [Installation](#-installation)
- [Common Use Cases](#-common-use-cases)
- [Available Tools](#available-tools)
- [Example Workflows](#example-workflows)
- [Architecture](#architecture)
- [Development](#development)
- [Troubleshooting](#-troubleshooting)
- [Examples Directory](#-examples-directory)
- [Contributing](#contributing)
- [License](#license)
## 🚀 Quick Start
```bash
# Clone the repository
git clone https://github.com/yourusername/docx-mcp.git
cd docx-mcp
# Download embedded fonts for standalone operation (optional but recommended)
./download_fonts.sh
# Build the server (creates a fully standalone binary)
./build.sh
# The server is now ready - no external dependencies required!
```
### 🎯 Standalone Operation
This MCP server is designed to work **completely standalone** without requiring LibreOffice, unoconv, or any external tools:
-**Pure Rust DOCX parsing** - No external libraries needed
-**Built-in PDF generation** - Creates PDFs without LibreOffice
-**Embedded fonts** - Professional typography included in the binary
-**Native image processing** - PNG/JPG generation without ImageMagick
-**Zero external dependencies** - Single binary deployment
The server will automatically use external tools if available for enhanced quality, but they are **completely optional**.
## 🔒 Security Features
The server includes comprehensive security features for enterprise and restricted environments:
### Readonly Mode
```bash
# Enable readonly mode - only allows document viewing and analysis
export DOCX_MCP_READONLY=true
./target/release/docx-mcp
```
In readonly mode, only these operations are allowed:
- Open and view documents
- Extract text and analyze structure
- Export to other formats (Markdown, PDF)
- Search and word count analysis
- Get document metadata and statistics
### Command Filtering
```bash
# Whitelist specific commands only
export DOCX_MCP_WHITELIST="open_document,extract_text,get_metadata,export_to_markdown"
# Or blacklist dangerous commands
export DOCX_MCP_BLACKLIST="save_document,convert_to_pdf,merge_documents"
```
### Sandbox Mode
```bash
# Restrict all file operations to temp directory only
export DOCX_MCP_SANDBOX=true
./target/release/docx-mcp
```
### Resource Limits
```bash
# Set maximum document size (100MB default)
export DOCX_MCP_MAX_SIZE=52428800 # 50MB
# Set maximum number of open documents
export DOCX_MCP_MAX_DOCS=20
# Disable external tools
export DOCX_MCP_NO_EXTERNAL_TOOLS=true
# Disable network operations
export DOCX_MCP_NO_NETWORK=true
```
## 🤖 AI Tool Integration
### Claude Desktop
Add to your Claude Desktop configuration file:
**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
```json
{
"mcpServers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
```
After adding, restart Claude Desktop. You can then ask Claude to:
- "Create a new Word document with our Q4 report"
- "Convert this DOCX file to PDF"
- "Extract all text from my Word documents"
- "Add a table with sales data to the document"
### Cursor
Add to your Cursor settings (`~/.cursor/config.json` or through Settings UI):
```json
{
"mcp": {
"servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
}
```
### Windsurf (Codeium)
Add to your Windsurf configuration (`~/.windsurf/config.json`):
```json
{
"mcp": {
"servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
}
```
### Continue.dev
Add to your Continue configuration (`~/.continue/config.json`):
```json
{
"models": [
{
"title": "Your Model",
"provider": "your-provider",
"mcp_servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": []
}
}
}
]
}
```
### VS Code with MCP Extension
If using the MCP extension for VS Code, add to your workspace settings (`.vscode/settings.json`):
```json
{
"mcp.servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
```
## 📚 Features
### Document Operations
- **Create & Open**: Create new documents or open existing DOCX files
- **Text Manipulation**: Add paragraphs, headings, lists with full styling support
- **Tables**: Create and format tables with custom layouts
- **Page Layout**: Add page breaks, set headers/footers
- **Find & Replace**: Search and replace text throughout documents
- **Text Extraction**: Extract plain text content from documents
### Conversion Capabilities
- **DOCX to PDF**: Convert Word documents to PDF format
- Uses LibreOffice/unoconv for high-fidelity conversion
- Fallback to basic PDF generation if external tools unavailable
- **DOCX to Images**: Convert document pages to PNG/JPG images
- Configurable DPI for quality control
- Support for multiple image formats
- **PDF Operations**: Split, merge, and manipulate PDF files
### Advanced Features
- **Document Metadata**: Track creation time, size, author, etc.
- **Styling Support**: Font family, size, bold, italic, underline, colors, alignment
- **Multiple Documents**: Handle multiple documents simultaneously
- **Temp File Management**: Automatic cleanup of temporary files
### Professional Templates
- **Business Letters**: Professional correspondence with proper formatting
- **Resumes**: Modern resume layouts with sections for experience, education, skills
- **Reports**: Technical and business reports with table of contents
- **Invoices**: Professional invoice templates with itemized billing
- **Contracts**: Legal document templates with signature blocks
- **Memos**: Corporate memorandum format
- **Newsletters**: Multi-column layouts for publications
### Advanced Document Features
- **Table of Contents**: Automatic TOC generation with heading links
- **Images & Charts**: Embed images and create data visualizations
- **Hyperlinks & Bookmarks**: Internal and external linking with navigation
- **Footnotes & Endnotes**: Academic and professional citation support
- **Comments & Track Changes**: Collaboration features for document review
- **Watermarks**: Confidential, draft, and custom watermarks
- **Mail Merge**: Automated personalized document generation
- **Custom Styles**: Create and apply consistent formatting themes
### Analysis & Review Tools
- **Document Structure Analysis**: Outline view of headings and sections
- **Formatting Analysis**: Detect fonts, styles, and formatting inconsistencies
- **Advanced Search**: Pattern matching with context and positioning
- **Word Count Statistics**: Detailed metrics including reading time
- **Export Options**: Convert to Markdown, HTML, and other formats
## 💬 Real-World Usage Examples with AI Assistants
### With Claude Desktop
Once configured, you can have natural conversations with Claude:
```
You: "Create a professional invoice template for my consulting business"
Claude will:
1. Create a new DOCX document
2. Add your company header
3. Insert a table for line items
4. Add payment terms and footer
5. Save it as invoice_template.docx
```
```
You: "Convert all the Word documents in my reports folder to PDF"
Claude will:
1. List all DOCX files
2. Open each document
3. Convert to PDF with the same name
4. Report completion status
```
### With Cursor/Windsurf
While coding, you can generate documentation:
```
You: "Generate API documentation from these TypeScript interfaces and save as Word"
The AI will:
1. Parse your code
2. Create a formatted DOCX with:
- Title and table of contents
- Endpoint descriptions
- Request/response examples
- Error codes table
3. Convert to PDF for distribution
```
### Automation Examples
```python
# Ask your AI: "Create a script to generate monthly reports"
# The AI can use the DOCX server to:
async def generate_monthly_report(month, year):
# Create document
doc = await mcp.call("create_document")
# Add dynamic content
await mcp.call("add_heading", {
"document_id": doc.id,
"text": f"Monthly Report - {month} {year}",
"level": 1
})
# Add data from your database
sales_data = fetch_sales_data(month, year)
await mcp.call("add_table", {
"document_id": doc.id,
"rows": format_sales_table(sales_data)
})
# Convert to PDF and email
await mcp.call("convert_to_pdf", {
"document_id": doc.id,
"output_path": f"reports/{year}_{month}_report.pdf"
})
```
## 📋 Prerequisites
### Required
- Rust 1.70+ and Cargo (for building from source)
- MCP-compatible AI client (Claude Desktop, Cursor, Windsurf, etc.)
### Completely Optional (for enhanced features)
The server works standalone, but can optionally use these tools if available:
- **LibreOffice** (recommended): For high-quality DOCX to PDF conversion
```bash
# macOS
brew install libreoffice
# Ubuntu/Debian
sudo apt-get install libreoffice
# Windows
# Download from https://www.libreoffice.org/
```
- **PDF to Image Tools** (any one of these):
- pdftoppm (part of poppler-utils)
- ImageMagick
- Ghostscript
```bash
# macOS
brew install poppler imagemagick ghostscript
# Ubuntu/Debian
sudo apt-get install poppler-utils imagemagick ghostscript
```
## 🔧 Installation
### Method 1: Build from Source
```bash
# Clone the repository
git clone https://github.com/yourusername/docx-mcp.git
cd docx-mcp
# Build the server (uses the build script)
./build.sh
# Or manually with cargo
cargo build --release
# Optional: Enable Chrome-based PDF conversion
cargo build --release --features chrome-pdf
```
### Method 2: Download Pre-built Binary (Coming Soon)
```bash
# Download the latest release
curl -L https://github.com/yourusername/docx-mcp/releases/latest/download/docx-mcp-linux-x64 -o docx-mcp
chmod +x docx-mcp
```
### Verify Installation
```bash
# Test the server
./target/release/docx-mcp --version
# Check for optional dependencies
./build.sh
```
## 🎯 Common Use Cases
### 1. Document Automation
- Generate contracts, invoices, and reports
- Mail merge operations
- Batch document processing
- Template-based document creation
### 2. Data Export
- Export database reports to Word/PDF
- Create formatted documentation from JSON/CSV
- Generate test reports with charts and tables
### 3. Document Conversion Pipeline
- DOCX → PDF for archival
- DOCX → Images for previews
- Batch conversion of legacy documents
### 4. Content Management
- Extract text for indexing
- Find and replace across multiple documents
- Document metadata management
### 5. Integration Scenarios
- CI/CD documentation generation
- API documentation from code
- Automated report generation from monitoring tools
## Available Tools
### Document Management
#### `create_document`
Creates a new empty DOCX document.
```json
{
"tool": "create_document",
"arguments": {}
}
```
#### `open_document`
Opens an existing DOCX file.
```json
{
"tool": "open_document",
"arguments": {
"path": "/path/to/document.docx"
}
}
```
#### `save_document`
Saves the document to a specified path.
```json
{
"tool": "save_document",
"arguments": {
"document_id": "doc_123",
"output_path": "/path/to/output.docx"
}
}
```
### Content Addition
#### `add_paragraph`
Adds a styled paragraph to the document.
```json
{
"tool": "add_paragraph",
"arguments": {
"document_id": "doc_123",
"text": "This is a paragraph",
"style": {
"font_size": 12,
"bold": true,
"color": "#FF0000",
"alignment": "center"
}
}
}
```
#### `add_heading`
Adds a heading (levels 1-6).
```json
{
"tool": "add_heading",
"arguments": {
"document_id": "doc_123",
"text": "Chapter 1",
"level": 1
}
}
```
#### `add_table`
Creates a table with specified data.
```json
{
"tool": "add_table",
"arguments": {
"document_id": "doc_123",
"rows": [
["Name", "Age", "City"],
["Alice", "30", "New York"],
["Bob", "25", "Los Angeles"]
],
"headers": ["Name", "Age", "City"]
}
}
```
#### `add_list`
Adds a bulleted or numbered list.
```json
{
"tool": "add_list",
"arguments": {
"document_id": "doc_123",
"items": ["First item", "Second item", "Third item"],
"ordered": true
}
}
```
### Document Conversion
#### `convert_to_pdf`
Converts the document to PDF format.
```json
{
"tool": "convert_to_pdf",
"arguments": {
"document_id": "doc_123",
"output_path": "/path/to/output.pdf"
}
}
```
#### `convert_to_images`
Converts document pages to images.
```json
{
"tool": "convert_to_images",
"arguments": {
"document_id": "doc_123",
"output_dir": "/path/to/images/",
"format": "png",
"dpi": 300
}
}
```
### Text Operations
#### `extract_text`
Extracts all text content from the document.
```json
{
"tool": "extract_text",
"arguments": {
"document_id": "doc_123"
}
}
```
#### `find_and_replace`
Finds and replaces text in the document.
```json
{
"tool": "find_and_replace",
"arguments": {
"document_id": "doc_123",
"find_text": "old text",
"replace_text": "new text"
}
}
```
## Example Workflows
### Creating a Report
```javascript
// 1. Create a new document
const doc = await mcp.call("create_document", {});
// 2. Add title
await mcp.call("add_heading", {
document_id: doc.document_id,
text: "Annual Report 2024",
level: 1
});
// 3. Add executive summary
await mcp.call("add_paragraph", {
document_id: doc.document_id,
text: "This report provides a comprehensive overview...",
style: { font_size: 12, alignment: "justify" }
});
// 4. Add data table
await mcp.call("add_table", {
document_id: doc.document_id,
rows: [
["Quarter", "Revenue", "Growth"],
["Q1", "$1.2M", "15%"],
["Q2", "$1.5M", "25%"]
]
});
// 5. Convert to PDF
await mcp.call("convert_to_pdf", {
document_id: doc.document_id,
output_path: "./annual_report_2024.pdf"
});
```
### Batch Processing Documents
```javascript
// Open and convert multiple documents
const documents = ["doc1.docx", "doc2.docx", "doc3.docx"];
for (const docPath of documents) {
const doc = await mcp.call("open_document", { path: docPath });
// Extract text for analysis
const text = await mcp.call("extract_text", {
document_id: doc.document_id
});
// Convert to PDF
await mcp.call("convert_to_pdf", {
document_id: doc.document_id,
output_path: docPath.replace(".docx", ".pdf")
});
// Generate thumbnails
await mcp.call("convert_to_images", {
document_id: doc.document_id,
output_dir: "./thumbnails/",
format: "jpg",
dpi: 72
});
await mcp.call("close_document", { document_id: doc.document_id });
}
```
## Architecture
The server is built with a modular architecture:
- **`main.rs`**: MCP server setup and initialization
- **`docx_handler.rs`**: Core DOCX manipulation logic
- **`converter.rs`**: PDF and image conversion functionality
- **`docx_tools.rs`**: MCP tool definitions and handlers
## Development
### Building from Source
```bash
cargo build
```
### Running Tests
```bash
cargo test
```
### Debug Mode
```bash
RUST_LOG=debug cargo run
```
## 🐛 Troubleshooting
### AI Tool Specific Issues
#### Claude Desktop Not Recognizing the Server
1. Ensure the path in config is absolute, not relative
2. Restart Claude Desktop after config changes
3. Check logs: `tail -f ~/Library/Logs/Claude/mcp.log` (macOS)
4. Verify the binary is executable: `chmod +x /path/to/docx-mcp`
#### Cursor/Windsurf Connection Issues
1. Check the MCP server is running: `ps aux | grep docx-mcp`
2. Verify port availability: `lsof -i :3000`
3. Try reloading the window: `Cmd/Ctrl + R`
4. Check developer console for errors: `Cmd/Ctrl + Shift + I`
#### "Tool not found" Errors
1. Ensure the server is properly configured in your AI tool
2. Check the server is running with: `RUST_LOG=debug /path/to/docx-mcp`
3. Verify tool names match exactly (case-sensitive)
### Conversion Issues
#### LibreOffice Not Found
```bash
# Check if installed
which libreoffice
# Install if missing
# macOS
brew install libreoffice
# Ubuntu/Debian
sudo apt-get install libreoffice
# Fedora
sudo dnf install libreoffice
```
#### PDF to Image Conversion Fails
```bash
# Install at least one converter
# Option 1: pdftoppm (fastest)
sudo apt-get install poppler-utils # Linux
brew install poppler # macOS
# Option 2: ImageMagick
sudo apt-get install imagemagick # Linux
brew install imagemagick # macOS
# Option 3: Ghostscript
sudo apt-get install ghostscript # Linux
brew install ghostscript # macOS
```
### Permission Errors
```bash
# Check temp directory permissions
ls -la /tmp/docx-mcp/
# Fix permissions if needed
mkdir -p /tmp/docx-mcp
chmod 755 /tmp/docx-mcp
# For system-wide installation
sudo chown $USER:$USER /tmp/docx-mcp
```
### Memory Issues with Large Documents
```bash
# Increase Rust stack size if needed
export RUST_MIN_STACK=8388608 # 8MB
./target/release/docx-mcp
```
### Debugging Tips
```bash
# Run with verbose logging
RUST_LOG=trace ./target/release/docx-mcp
# Test with the example client
python3 example/test_client.py
# Check MCP communication
RUST_LOG=mcp_server=debug ./target/release/docx-mcp
```
## 📁 Examples Directory
The `example/` directory contains comprehensive examples and templates:
### Files Included
- **`test_client.py`** - Python client to test all MCP server functions
- **`claude_examples.md`** - Real-world examples for Claude Desktop users
- **`config_examples.json`** - Configuration templates for all supported AI tools
- **`automation_example.py`** - Advanced automation workflows including:
- Monthly report generation
- Mail merge operations
- Document processing pipelines
- Contract generation
### Running Examples
```bash
# Test the server functionality
python3 example/test_client.py
# Run automation examples
python3 example/automation_example.py
# View Claude Desktop usage examples
cat example/claude_examples.md
```
### Example Categories
1. **Basic Operations**: Create, edit, save documents
2. **Formatting**: Styles, tables, lists, headers/footers
3. **Conversion**: DOCX to PDF, DOCX to images
4. **Automation**: Batch processing, mail merge, report generation
5. **Integration**: Working with CSV data, template processing
## 🤝 Contributing
We welcome contributions! Here's how you can help:
### Areas for Contribution
- Additional document manipulation features
- Support for more conversion formats
- Performance optimizations
- Documentation improvements
- Bug fixes and testing
### How to Contribute
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
### Development Setup
```bash
# Clone your fork
git clone https://github.com/yourusername/docx-mcp.git
cd docx-mcp
# Install development dependencies
cargo install cargo-watch cargo-expand
# Run tests
cargo test
# Run with watch mode for development
cargo watch -x run
```
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- Built with the official [MCP Rust SDK](https://github.com/modelcontextprotocol/rust-sdk)
- Uses [docx-rs](https://github.com/bokuweb/docx-rs) for DOCX manipulation
- PDF generation with [printpdf](https://github.com/fschutt/printpdf)
- Image processing with [image-rs](https://github.com/image-rs/image)
+456
View File
@@ -0,0 +1,456 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
use docx_mcp::pure_converter::PureRustConverter;
use tempfile::TempDir;
use std::time::Duration;
fn setup_handler() -> (DocxHandler, TempDir) {
let temp_dir = TempDir::new().unwrap();
let handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
(handler, temp_dir)
}
fn bench_document_creation(c: &mut Criterion) {
c.bench_function("create_document", |b| {
b.iter_batched(
|| setup_handler(),
|(mut handler, _temp_dir)| {
black_box(handler.create_document().unwrap())
},
criterion::BatchSize::LargeInput,
)
});
}
fn bench_paragraph_addition(c: &mut Criterion) {
let mut group = c.benchmark_group("add_paragraph");
for paragraph_count in [1, 10, 100, 1000].iter() {
group.bench_with_input(
BenchmarkId::new("count", paragraph_count),
paragraph_count,
|b, &count| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
(handler, doc_id, temp_dir)
},
|(mut handler, doc_id, _temp_dir)| {
for i in 0..count {
let text = format!("This is paragraph number {} with some content", i);
handler.add_paragraph(&doc_id, &text, None).unwrap();
}
black_box(doc_id)
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_styled_paragraph_addition(c: &mut Criterion) {
c.bench_function("add_styled_paragraph", |b| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
let style = DocxStyle {
font_family: Some("Arial".to_string()),
font_size: Some(12),
bold: Some(true),
italic: Some(false),
underline: Some(false),
color: Some("#000000".to_string()),
alignment: Some("left".to_string()),
line_spacing: Some(1.0),
};
(handler, doc_id, temp_dir, style)
},
|(mut handler, doc_id, _temp_dir, style)| {
black_box(handler.add_paragraph(&doc_id, "Styled paragraph", Some(style)).unwrap())
},
criterion::BatchSize::LargeInput,
)
});
}
fn bench_heading_addition(c: &mut Criterion) {
let mut group = c.benchmark_group("add_heading");
for level in 1..=6 {
group.bench_with_input(
BenchmarkId::new("level", level),
&level,
|b, &level| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
(handler, doc_id, temp_dir)
},
|(mut handler, doc_id, _temp_dir)| {
black_box(handler.add_heading(&doc_id, &format!("Heading Level {}", level), level).unwrap())
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_table_addition(c: &mut Criterion) {
let mut group = c.benchmark_group("add_table");
for size in [(2, 2), (5, 5), (10, 10), (20, 10)].iter() {
group.bench_with_input(
BenchmarkId::new("size", format!("{}x{}", size.0, size.1)),
size,
|b, &(rows, cols)| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
let mut table_rows = Vec::new();
for i in 0..rows {
let mut row = Vec::new();
for j in 0..cols {
row.push(format!("Cell {}x{}", i, j));
}
table_rows.push(row);
}
let table_data = TableData {
rows: table_rows,
headers: None,
border_style: Some("single".to_string()),
};
(handler, doc_id, temp_dir, table_data)
},
|(mut handler, doc_id, _temp_dir, table_data)| {
black_box(handler.add_table(&doc_id, table_data).unwrap())
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_list_addition(c: &mut Criterion) {
let mut group = c.benchmark_group("add_list");
for item_count in [5, 20, 50, 100].iter() {
group.bench_with_input(
BenchmarkId::new("items", item_count),
item_count,
|b, &count| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
let items: Vec<String> = (0..count)
.map(|i| format!("List item number {}", i))
.collect();
(handler, doc_id, temp_dir, items)
},
|(mut handler, doc_id, _temp_dir, items)| {
black_box(handler.add_list(&doc_id, items, false).unwrap())
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_text_extraction(c: &mut Criterion) {
let mut group = c.benchmark_group("extract_text");
for paragraph_count in [10, 100, 500, 1000].iter() {
group.bench_with_input(
BenchmarkId::new("paragraphs", paragraph_count),
paragraph_count,
|b, &count| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
// Create document with many paragraphs
for i in 0..count {
let text = format!("This is paragraph {} with substantial content to test text extraction performance. It includes various words and punctuation to make it realistic.", i);
handler.add_paragraph(&doc_id, &text, None).unwrap();
}
(handler, doc_id, temp_dir)
},
|(handler, doc_id, _temp_dir)| {
black_box(handler.extract_text(&doc_id).unwrap())
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_pdf_conversion(c: &mut Criterion) {
let mut group = c.benchmark_group("pdf_conversion");
group.measurement_time(Duration::from_secs(30)); // Longer measurement for PDF conversion
for paragraph_count in [10, 50, 200].iter() {
group.bench_with_input(
BenchmarkId::new("paragraphs", paragraph_count),
paragraph_count,
|b, &count| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
// Create substantial document content
handler.add_heading(&doc_id, "Performance Test Document", 1).unwrap();
for i in 0..count {
if i % 20 == 0 {
handler.add_heading(&doc_id, &format!("Section {}", i / 20 + 1), 2).unwrap();
}
let text = format!("This is paragraph {} designed to test PDF conversion performance. It contains enough text to make the conversion meaningful and test the system under realistic load conditions.", i);
handler.add_paragraph(&doc_id, &text, None).unwrap();
}
let metadata = handler.get_metadata(&doc_id).unwrap();
let converter = PureRustConverter::new();
let output_path = temp_dir.path().join("benchmark.pdf");
(metadata, converter, output_path, temp_dir)
},
|(metadata, converter, output_path, _temp_dir)| {
black_box(converter.convert_docx_to_pdf(&metadata.path, &output_path).unwrap())
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_image_conversion(c: &mut Criterion) {
let mut group = c.benchmark_group("image_conversion");
group.measurement_time(Duration::from_secs(45)); // Even longer for image conversion
for paragraph_count in [5, 20, 50].iter() {
group.bench_with_input(
BenchmarkId::new("paragraphs", paragraph_count),
paragraph_count,
|b, &count| {
b.iter_batched(
|| {
let (mut handler, temp_dir) = setup_handler();
let doc_id = handler.create_document().unwrap();
handler.add_heading(&doc_id, "Image Conversion Test", 1).unwrap();
for i in 0..count {
let text = format!("Paragraph {} for image conversion testing.", i);
handler.add_paragraph(&doc_id, &text, None).unwrap();
}
let metadata = handler.get_metadata(&doc_id).unwrap();
let converter = PureRustConverter::new();
let output_dir = temp_dir.path().join("images");
std::fs::create_dir_all(&output_dir).unwrap();
(metadata, converter, output_dir, temp_dir)
},
|(metadata, converter, output_dir, _temp_dir)| {
black_box(converter.convert_docx_to_images(&metadata.path, &output_dir).unwrap())
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_concurrent_operations(c: &mut Criterion) {
let mut group = c.benchmark_group("concurrent_operations");
for thread_count in [2, 4, 8].iter() {
group.bench_with_input(
BenchmarkId::new("threads", thread_count),
thread_count,
|b, &threads| {
b.iter_batched(
|| {
let temp_dir = TempDir::new().unwrap();
(temp_dir, threads)
},
|(temp_dir, thread_count)| {
use std::sync::Arc;
use std::thread;
let temp_path = Arc::new(temp_dir.path().to_path_buf());
let handles: Vec<_> = (0..thread_count).map(|i| {
let temp_path = Arc::clone(&temp_path);
thread::spawn(move || {
let mut handler = DocxHandler::new_with_temp_dir(&temp_path).unwrap();
let doc_id = handler.create_document().unwrap();
for j in 0..10 {
let text = format!("Thread {} paragraph {}", i, j);
handler.add_paragraph(&doc_id, &text, None).unwrap();
}
handler.extract_text(&doc_id).unwrap()
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
black_box(())
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_memory_usage(c: &mut Criterion) {
let mut group = c.benchmark_group("memory_usage");
for doc_count in [5, 20, 50].iter() {
group.bench_with_input(
BenchmarkId::new("documents", doc_count),
doc_count,
|b, &count| {
b.iter_batched(
|| setup_handler(),
|(mut handler, _temp_dir)| {
let mut doc_ids = Vec::new();
// Create multiple documents
for i in 0..count {
let doc_id = handler.create_document().unwrap();
// Add content to each document
handler.add_heading(&doc_id, &format!("Document {}", i), 1).unwrap();
for j in 0..20 {
let text = format!("Content paragraph {} in document {}", j, i);
handler.add_paragraph(&doc_id, &text, None).unwrap();
}
doc_ids.push(doc_id);
}
// Extract text from all documents
for doc_id in &doc_ids {
handler.extract_text(doc_id).unwrap();
}
black_box(doc_ids)
},
criterion::BatchSize::LargeInput,
)
},
);
}
group.finish();
}
fn bench_complex_document_operations(c: &mut Criterion) {
c.bench_function("complex_document", |b| {
b.iter_batched(
|| setup_handler(),
|(mut handler, _temp_dir)| {
let doc_id = handler.create_document().unwrap();
// Create a complex document with all features
handler.add_heading(&doc_id, "Complex Document Test", 1).unwrap();
handler.add_paragraph(&doc_id, "This is a comprehensive test document.", None).unwrap();
// Add styled paragraph
let style = DocxStyle {
font_size: Some(14),
bold: Some(true),
color: Some("#FF0000".to_string()),
alignment: Some("center".to_string()),
..Default::default()
};
handler.add_paragraph(&doc_id, "Styled paragraph", Some(style)).unwrap();
// Add table
let table_data = TableData {
rows: vec![
vec!["Header 1".to_string(), "Header 2".to_string(), "Header 3".to_string()],
vec!["Row 1 Col 1".to_string(), "Row 1 Col 2".to_string(), "Row 1 Col 3".to_string()],
vec!["Row 2 Col 1".to_string(), "Row 2 Col 2".to_string(), "Row 2 Col 3".to_string()],
],
headers: Some(vec!["Header 1".to_string(), "Header 2".to_string(), "Header 3".to_string()]),
border_style: Some("single".to_string()),
};
handler.add_table(&doc_id, table_data).unwrap();
// Add list
let items = vec![
"First item".to_string(),
"Second item".to_string(),
"Third item".to_string(),
];
handler.add_list(&doc_id, items, true).unwrap();
// Add page break and more content
handler.add_page_break(&doc_id).unwrap();
handler.add_heading(&doc_id, "Second Page", 1).unwrap();
handler.add_paragraph(&doc_id, "Content on second page", None).unwrap();
// Set header and footer
handler.set_header(&doc_id, "Document Header").unwrap();
handler.set_footer(&doc_id, "Document Footer").unwrap();
// Extract all text
let text = handler.extract_text(&doc_id).unwrap();
black_box(text)
},
criterion::BatchSize::LargeInput,
)
});
}
criterion_group!(
benches,
bench_document_creation,
bench_paragraph_addition,
bench_styled_paragraph_addition,
bench_heading_addition,
bench_table_addition,
bench_list_addition,
bench_text_extraction,
bench_pdf_conversion,
bench_image_conversion,
bench_concurrent_operations,
bench_memory_usage,
bench_complex_document_operations
);
criterion_main!(benches);
+37
View File
@@ -0,0 +1,37 @@
use anyhow::Result;
use std::fs;
use std::path::Path;
fn main() -> Result<()> {
println!("cargo:rerun-if-changed=build.rs");
// Create assets directory if it doesn't exist
let fonts_dir = Path::new("assets/fonts");
fs::create_dir_all(fonts_dir)?;
// Check if fonts exist, if not, create placeholder files
// In production, you would download actual font files here
let font_files = vec![
"LiberationSans-Regular.ttf",
"LiberationSans-Bold.ttf",
"LiberationSans-Italic.ttf",
"LiberationMono-Regular.ttf",
"NotoSans-Regular.ttf",
"NotoSans-Bold.ttf",
];
for font_file in font_files {
let font_path = fonts_dir.join(font_file);
if !font_path.exists() {
// For now, we'll create empty placeholder files
// In production, download actual Liberation or Noto fonts (which are open source)
println!("cargo:warning=Font file {} not found. Please download Liberation fonts from https://github.com/liberationfonts/liberation-fonts", font_file);
// Create a minimal placeholder TTF file (this won't work for actual rendering)
// You should download the actual fonts
fs::write(&font_path, &[0u8; 100])?;
}
}
Ok(())
}
Executable
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
# Build script for DOCX MCP Server
set -e
echo "🔨 Building DOCX MCP Server (Standalone Edition)..."
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Check for Rust
if ! command -v cargo &> /dev/null; then
echo -e "${RED}❌ Cargo not found. Please install Rust.${NC}"
echo "Visit: https://www.rust-lang.org/tools/install"
exit 1
fi
# Check if fonts are downloaded
if [ ! -f "assets/fonts/LiberationSans-Regular.ttf" ]; then
echo -e "${YELLOW}📥 Fonts not found. Downloading open-source fonts...${NC}"
if [ -f "./download_fonts.sh" ]; then
./download_fonts.sh
else
echo -e "${YELLOW}⚠️ Font files not found. The server will still work but with basic fonts.${NC}"
echo -e "${YELLOW} Run ./download_fonts.sh to download professional fonts.${NC}"
mkdir -p assets/fonts
# Create placeholder files so build doesn't fail
touch assets/fonts/LiberationSans-Regular.ttf
touch assets/fonts/LiberationSans-Bold.ttf
touch assets/fonts/LiberationSans-Italic.ttf
touch assets/fonts/LiberationMono-Regular.ttf
touch assets/fonts/NotoSans-Regular.ttf
touch assets/fonts/NotoSans-Bold.ttf
fi
fi
# Build mode selection
BUILD_MODE=${1:-release}
FEATURES=${2:-}
if [ "$BUILD_MODE" = "debug" ]; then
echo -e "${YELLOW}📦 Building in debug mode...${NC}"
if [ -n "$FEATURES" ]; then
cargo build --features "$FEATURES"
else
cargo build
fi
BINARY_PATH="target/debug/docx-mcp"
else
echo -e "${YELLOW}📦 Building in release mode...${NC}"
if [ -n "$FEATURES" ]; then
cargo build --release --features "$FEATURES"
else
cargo build --release
fi
BINARY_PATH="target/release/docx-mcp"
fi
# Check if build succeeded
if [ -f "$BINARY_PATH" ]; then
echo -e "${GREEN}✅ Build successful!${NC}"
echo -e "Binary location: ${GREEN}$BINARY_PATH${NC}"
# Display standalone features
echo -e "\n${BLUE}🎯 Standalone Features Enabled:${NC}"
echo -e "${GREEN}${NC} Pure Rust DOCX parsing"
echo -e "${GREEN}${NC} Built-in PDF generation"
echo -e "${GREEN}${NC} Embedded fonts"
echo -e "${GREEN}${NC} Native image processing"
echo -e "${GREEN}${NC} Zero external dependencies required"
# Check for optional enhancements
echo -e "\n${YELLOW}Optional enhancements (not required):${NC}"
if command -v libreoffice &> /dev/null; then
echo -e "${GREEN}${NC} LibreOffice found (enhanced PDF conversion available)"
else
echo -e "${YELLOW}${NC} LibreOffice not found (using built-in PDF converter)"
echo " Optional: brew install libreoffice (macOS) or apt-get install libreoffice (Linux)"
fi
if command -v pdftoppm &> /dev/null; then
echo -e "${GREEN}${NC} pdftoppm found (PDF to image conversion available)"
elif command -v convert &> /dev/null; then
echo -e "${GREEN}${NC} ImageMagick found (PDF to image conversion available)"
elif command -v gs &> /dev/null; then
echo -e "${GREEN}${NC} Ghostscript found (PDF to image conversion available)"
else
echo -e "${YELLOW}${NC} No PDF to image converter found"
echo " Install one of: poppler-utils, imagemagick, or ghostscript"
fi
# Create example output directories
mkdir -p example/output example/images example/thumbnails
echo -e "\n${GREEN}🚀 Ready to run!${NC}"
echo -e "Start the server with: ${GREEN}$BINARY_PATH${NC}"
echo -e "Or with logging: ${GREEN}RUST_LOG=info $BINARY_PATH${NC}"
else
echo -e "${RED}❌ Build failed!${NC}"
exit 1
fi
+107
View File
@@ -0,0 +1,107 @@
# cargo-deny configuration for dependency management and security
[graph]
targets = [
{ triple = "x86_64-unknown-linux-gnu" },
{ triple = "x86_64-pc-windows-msvc" },
{ triple = "x86_64-apple-darwin" },
{ triple = "aarch64-apple-darwin" },
]
[advisories]
# The path where the advisory database is cloned/fetched into
db-path = "~/.cargo/advisory-db"
# The url(s) of the advisory databases to use
db-urls = ["https://github.com/rustsec/advisory-db"]
# The lint level for security vulnerabilities
vulnerability = "deny"
# The lint level for unmaintained crates
unmaintained = "warn"
# The lint level for crates that have been yanked from their source registry
yanked = "warn"
# The lint level for crates with security notices
notice = "warn"
# A list of advisory IDs to ignore. Note that ignored advisories will still
# output a note when they are encountered.
ignore = [
#"RUSTSEC-0000-0000",
]
[licenses]
# The confidence threshold for detecting a license from a license text.
confidence-threshold = 0.8
# List of explicitly allowed licenses
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-DFS-2016",
]
# List of explicitly disallowed licenses
deny = [
"GPL-2.0",
"GPL-3.0",
"AGPL-3.0",
]
# Lint level for when multiple versions of the same license are detected
copyleft = "warn"
# Some crates don't have license files and we allow them specifically
exceptions = [
# Allow ring which has some complex licensing
{ allow = ["MIT", "ISC", "OpenSSL"], name = "ring" },
# webpki has Mozilla's license
{ allow = ["ISC", "MIT", "MPL-2.0"], name = "webpki" },
]
[[licenses.clarify]]
name = "ring"
# SPDX identifier
expression = "MIT AND ISC AND OpenSSL"
# License file paths
license-files = [
{ path = "LICENSE", hash = 0xbd0eed23 }
]
[bans]
# Lint level for when multiple versions of the same crate are detected
multiple-versions = "warn"
# Lint level for when a crate version requirement is `*`
wildcards = "allow"
# The graph highlighting used when creating dotgraphs for crates
highlight = "all"
# List of crates that are allowed. Use with care!
allow = [
#{ name = "ansi_term", version = "=0.11.0" },
]
# List of crates to deny
deny = [
# Insecure random number generation
{ name = "openssl", version = "*", use-instead = "rustls" },
# Unmaintained and insecure
{ name = "chrono", version = "<0.4.20" },
]
# Certain crates/versions that will be skipped when doing duplicate detection.
skip = [
#{ name = "ansi_term", version = "=0.11.0" },
]
# Similarly to `skip` allows you to skip certain crates from being checked. Unlike
# `skip`, a skipped crate is removed from the crate graph entirely.
skip-tree = [
#{ name = "ansi_term", version = "=0.11.0", depth = 20 },
]
[sources]
# Lint level for what to happen when a crate from a crate registry that is
# not in the allow list is encountered
unknown-registry = "warn"
# Lint level for what to happen when a crate from a git repository that is not
# in the allow list is encountered
unknown-git = "warn"
# List of URLs for allowed crate registries. Defaults to the crates.io index
# if not specified. If it is specified but empty, no registries are allowed.
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
# List of URLs for allowed Git repositories
allow-git = []
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Script to download open-source fonts for embedded PDF generation
# These fonts are used when creating PDFs without external dependencies
set -e
FONTS_DIR="assets/fonts"
mkdir -p "$FONTS_DIR"
echo "📥 Downloading open-source fonts for standalone operation..."
# Liberation Fonts (Red Hat) - Open source replacements for Arial, Times New Roman, Courier
LIBERATION_VERSION="2.1.5"
LIBERATION_URL="https://github.com/liberationfonts/liberation-fonts/files/7261482/liberation-fonts-ttf-${LIBERATION_VERSION}.tar.gz"
# Download Liberation fonts
echo "Downloading Liberation fonts..."
curl -L "$LIBERATION_URL" -o /tmp/liberation-fonts.tar.gz
tar -xzf /tmp/liberation-fonts.tar.gz -C /tmp/
# Copy the fonts we need
cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationSans-Regular.ttf" "$FONTS_DIR/"
cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationSans-Bold.ttf" "$FONTS_DIR/"
cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationSans-Italic.ttf" "$FONTS_DIR/"
cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationMono-Regular.ttf" "$FONTS_DIR/"
# Noto Sans (Google) - Fallback font with wide Unicode coverage
echo "Downloading Noto Sans fonts..."
NOTO_BASE_URL="https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSans"
curl -L "${NOTO_BASE_URL}/NotoSans-Regular.ttf" -o "$FONTS_DIR/NotoSans-Regular.ttf"
curl -L "${NOTO_BASE_URL}/NotoSans-Bold.ttf" -o "$FONTS_DIR/NotoSans-Bold.ttf"
# Clean up
rm -f /tmp/liberation-fonts.tar.gz
rm -rf /tmp/liberation-fonts-ttf-${LIBERATION_VERSION}
echo "✅ Fonts downloaded successfully!"
echo ""
echo "Fonts installed in $FONTS_DIR:"
ls -la "$FONTS_DIR"/*.ttf
echo ""
echo "The application can now run completely standalone without external dependencies!"
+1
Submodule example/MCP-Doc added at 377d05f0a9
+492
View File
@@ -0,0 +1,492 @@
# Advanced DOCX MCP Server Usage Examples
This document demonstrates the advanced capabilities of the DOCX MCP server with real-world examples.
## Professional Document Templates
### Creating a Business Report
```javascript
// Ask your AI: "Create a professional quarterly report with our sales data"
// 1. Create from report template
const doc = await mcp.call("create_from_template", {
template: "Report"
});
// 2. Set document properties
await mcp.call("set_document_properties", {
document_id: doc.document_id,
properties: {
title: "Q3 2024 Sales Report",
subject: "Quarterly Business Review",
author: "Sales Team",
company: "TechCorp Inc",
keywords: ["sales", "quarterly", "2024", "revenue"]
}
});
// 3. Add custom sections with advanced formatting
await mcp.call("add_section", {
document_id: doc.document_id,
section_config: {
page_size: "Letter",
landscape: false,
margins: {
top: 25.4,
bottom: 25.4,
left: 31.8,
right: 25.4
},
columns: 1
}
});
// 4. Add charts and data visualization
await mcp.call("add_chart", {
document_id: doc.document_id,
chart_type: "Column",
data: {
title: "Quarterly Revenue Growth",
categories: ["Q1", "Q2", "Q3"],
series: [{
name: "Revenue ($M)",
values: [1.2, 1.5, 1.8]
}]
}
});
```
### Advanced Mail Merge Campaign
```javascript
// Ask your AI: "Create personalized letters for our client list with custom fields"
// 1. Create template with merge fields
const template = await mcp.call("create_from_template", {
template: "BusinessLetter"
});
await mcp.call("prepare_mail_merge_template", {
document_id: template.document_id,
fields: ["ClientName", "Company", "LastOrderDate", "AccountManager", "SpecialOffer"]
});
// 2. Process each recipient
const recipients = [
{
ClientName: "John Smith",
Company: "ABC Corp",
LastOrderDate: "2024-02-15",
AccountManager: "Sarah Johnson",
SpecialOffer: "20% off next order"
}
// ... more recipients
];
for (const recipient of recipients) {
// Create personalized document
const personalDoc = await mcp.call("merge_template", {
template_id: template.document_id,
data: recipient
});
// Add watermark for draft review
await mcp.call("add_watermark", {
document_id: personalDoc.document_id,
text: "CONFIDENTIAL",
style: "Diagonal"
});
}
```
## Document Analysis & Review
### Comprehensive Document Analysis
```javascript
// Ask your AI: "Analyze this contract for structure, formatting, and key terms"
const doc = await mcp.call("open_document", {
path: "./contracts/service_agreement.docx"
});
// 1. Get document structure
const structure = await mcp.call("get_document_structure", {
document_id: doc.document_id
});
// 2. Analyze formatting consistency
const formatting = await mcp.call("analyze_formatting", {
document_id: doc.document_id
});
// 3. Get detailed statistics
const stats = await mcp.call("get_word_count", {
document_id: doc.document_id
});
// 4. Search for key legal terms
const terms = ["liability", "indemnification", "termination", "confidential"];
for (const term of terms) {
const results = await mcp.call("search_text", {
document_id: doc.document_id,
search_term: term,
case_sensitive: false,
whole_word: true
});
console.log(`Found "${term}" ${results.total_matches} times`);
}
// 5. Export analysis to Markdown
await mcp.call("export_to_markdown", {
document_id: doc.document_id,
output_path: "./analysis/contract_analysis.md"
});
```
### Collaborative Review Process
```javascript
// Ask your AI: "Set up this document for review with comments and track changes"
// 1. Enable track changes
await mcp.call("enable_track_changes", {
document_id: doc.document_id,
author: "Legal Review Team"
});
// 2. Add review comments
await mcp.call("add_comment", {
document_id: doc.document_id,
text: "Payment terms in section 3.2",
comment: "Consider reducing payment terms from 60 to 30 days",
author: "Finance Team"
});
// 3. Add footnotes for clarification
await mcp.call("add_footnote", {
document_id: doc.document_id,
reference_text: "governing law",
footnote_text: "This clause should specify the state jurisdiction for legal disputes"
});
// 4. Create bookmarks for easy navigation
await mcp.call("add_bookmark", {
document_id: doc.document_id,
bookmark_name: "payment_terms",
text: "3.2 Payment Terms"
});
// 5. Add cross-references
await mcp.call("add_cross_reference", {
document_id: doc.document_id,
bookmark_name: "payment_terms",
display_text: "See Payment Terms section"
});
```
## Security & Compliance Examples
### Readonly Document Review
```bash
# Start server in readonly mode for document review only
export DOCX_MCP_READONLY=true
./target/release/docx-mcp
```
```javascript
// In readonly mode, these operations are available:
const doc = await mcp.call("open_document", {
path: "./confidential/annual_report.docx"
});
// ✅ Allowed: Extract and analyze content
const text = await mcp.call("extract_text", {
document_id: doc.document_id
});
const structure = await mcp.call("get_document_structure", {
document_id: doc.document_id
});
// ✅ Allowed: Export for analysis
await mcp.call("export_to_markdown", {
document_id: doc.document_id,
output_path: "./analysis/report_content.md"
});
// ❌ Blocked: Any modification attempts
// These would return security errors:
// - add_paragraph
// - save_document
// - find_and_replace
```
### Sandboxed Environment
```bash
# Run in sandbox mode - restricts file operations to temp directory
export DOCX_MCP_SANDBOX=true
export DOCX_MCP_NO_EXTERNAL_TOOLS=true
./target/release/docx-mcp
```
```javascript
// All file operations restricted to temporary directory
// Perfect for untrusted document processing
const doc = await mcp.call("create_document", {});
// ✅ Allowed: Operations in temp directory
await mcp.call("save_document", {
document_id: doc.document_id,
output_path: "/tmp/docx-mcp/safe_output.docx"
});
// ❌ Blocked: Operations outside temp directory
// This would return a security error:
await mcp.call("save_document", {
document_id: doc.document_id,
output_path: "/home/user/documents/output.docx" // BLOCKED
});
```
## Advanced Automation Workflows
### Automated Report Generation Pipeline
```javascript
// Ask your AI: "Create an automated monthly report generation system"
class ReportGenerator {
async generateMonthlyReport(month, year, data) {
// 1. Create from template
const doc = await mcp.call("create_from_template", {
template: "Report"
});
// 2. Set up custom styles
await mcp.call("add_custom_style", {
document_id: doc.document_id,
style: {
id: "CompanyHeading",
name: "Company Heading",
font: "Arial",
size: 18,
bold: true,
color: "#2E86C1",
spacing: {
before: 12,
after: 6,
line: 1.15
}
}
});
// 3. Add dynamic content with bookmarks
await mcp.call("add_bookmark", {
document_id: doc.document_id,
bookmark_name: "executive_summary",
text: "Executive Summary"
});
// 4. Insert data charts
for (const metric of data.metrics) {
await mcp.call("add_chart", {
document_id: doc.document_id,
chart_type: metric.type,
data: {
title: metric.title,
categories: metric.categories,
series: metric.series
}
});
}
// 5. Add table of contents
await mcp.call("add_table_of_contents", {
document_id: doc.document_id
});
// 6. Apply watermark
await mcp.call("add_watermark", {
document_id: doc.document_id,
text: "INTERNAL USE ONLY",
style: "Horizontal"
});
// 7. Generate multiple formats
const filename = `monthly_report_${year}_${month}`;
// Save DOCX
await mcp.call("save_document", {
document_id: doc.document_id,
output_path: `./reports/${filename}.docx`
});
// Convert to PDF
await mcp.call("convert_to_pdf", {
document_id: doc.document_id,
output_path: `./reports/${filename}.pdf`
});
// Generate preview images
await mcp.call("convert_to_images", {
document_id: doc.document_id,
output_dir: `./reports/previews/`,
format: "png",
dpi: 150
});
return {
docx: `./reports/${filename}.docx`,
pdf: `./reports/${filename}.pdf`,
preview: `./reports/previews/`
};
}
}
```
### Document Quality Assurance
```javascript
// Ask your AI: "Create a document QA system that checks formatting and compliance"
class DocumentQA {
async auditDocument(documentPath) {
const doc = await mcp.call("open_document", {
path: documentPath
});
const audit = {
document: documentPath,
timestamp: new Date().toISOString(),
issues: [],
recommendations: []
};
// 1. Check document structure
const structure = await mcp.call("get_document_structure", {
document_id: doc.document_id
});
if (structure.structure.filter(s => s.type === "heading").length < 2) {
audit.issues.push("Document lacks proper heading structure");
}
// 2. Analyze formatting consistency
const formatting = await mcp.call("analyze_formatting", {
document_id: doc.document_id
});
if (formatting.formatting_analysis.fonts_detected.length > 3) {
audit.issues.push("Too many fonts used - limit to 2-3 for consistency");
}
// 3. Check for required content
const requiredTerms = ["confidential", "copyright", "contact"];
for (const term of requiredTerms) {
const search = await mcp.call("search_text", {
document_id: doc.document_id,
search_term: term,
case_sensitive: false
});
if (search.total_matches === 0) {
audit.recommendations.push(`Consider adding ${term} information`);
}
}
// 4. Check document statistics
const stats = await mcp.call("get_word_count", {
document_id: doc.document_id
});
if (stats.statistics.words < 500) {
audit.issues.push("Document may be too short for professional standards");
}
// 5. Generate audit report
const auditDoc = await mcp.call("create_document", {});
await mcp.call("add_heading", {
document_id: auditDoc.document_id,
text: "Document Quality Audit Report",
level: 1
});
await mcp.call("add_paragraph", {
document_id: auditDoc.document_id,
text: `Audit completed for: ${documentPath}`
});
// Add issues table
const issuesData = audit.issues.map(issue => ["Issue", issue]);
await mcp.call("add_table", {
document_id: auditDoc.document_id,
rows: [["Type", "Description"], ...issuesData]
});
await mcp.call("save_document", {
document_id: auditDoc.document_id,
output_path: `./qa/audit_${Date.now()}.docx`
});
return audit;
}
}
```
## Security Configuration Examples
### Enterprise Security Setup
```bash
#!/bin/bash
# Enterprise security configuration script
# Readonly mode for document review workstations
export DOCX_MCP_READONLY=true
# Whitelist only analysis and export commands
export DOCX_MCP_WHITELIST="open_document,extract_text,get_metadata,get_document_structure,analyze_formatting,get_word_count,search_text,export_to_markdown,export_to_html,list_documents,get_security_info"
# Sandbox mode for processing untrusted documents
export DOCX_MCP_SANDBOX=true
# Resource limits
export DOCX_MCP_MAX_SIZE=10485760 # 10MB max file size
export DOCX_MCP_MAX_DOCS=5 # Max 5 open documents
# Disable external tools and network
export DOCX_MCP_NO_EXTERNAL_TOOLS=true
export DOCX_MCP_NO_NETWORK=true
echo "🔒 Starting DOCX MCP Server in Enterprise Security Mode"
./target/release/docx-mcp
```
### Development Environment Setup
```bash
#!/bin/bash
# Development environment with full features
# Allow all operations but with reasonable limits
export DOCX_MCP_MAX_SIZE=104857600 # 100MB max file size
export DOCX_MCP_MAX_DOCS=25 # Max 25 open documents
# Enable all features
unset DOCX_MCP_READONLY
unset DOCX_MCP_SANDBOX
unset DOCX_MCP_WHITELIST
unset DOCX_MCP_BLACKLIST
echo "🚀 Starting DOCX MCP Server in Development Mode"
./target/release/docx-mcp
```
These examples demonstrate the full power and flexibility of the DOCX MCP server for professional document workflows, from simple document creation to complex enterprise automation systems.
+503
View File
@@ -0,0 +1,503 @@
#!/usr/bin/env python3
"""
Advanced automation example using the DOCX MCP Server.
This demonstrates how to build document automation workflows.
"""
import json
import asyncio
import csv
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Any
# This would normally be your MCP client library
# For demonstration, we're showing the structure
class MCPClient:
"""Mock MCP Client for demonstration"""
async def call(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""Call an MCP tool"""
# In reality, this would communicate with the MCP server
print(f"Calling {tool_name} with {arguments}")
return {"success": True, "result": {}}
# Initialize client
mcp = MCPClient()
# === Example 1: Generate Monthly Reports ===
async def generate_monthly_report(month: int, year: int, data: Dict[str, Any]):
"""Generate a comprehensive monthly report"""
# Create new document
doc = await mcp.call("create_document", {})
doc_id = doc["result"]["document_id"]
# Add title page
await mcp.call("add_heading", {
"document_id": doc_id,
"text": f"{data['company_name']}",
"level": 1
})
await mcp.call("add_heading", {
"document_id": doc_id,
"text": f"Monthly Report - {datetime(year, month, 1).strftime('%B %Y')}",
"level": 2
})
await mcp.call("add_page_break", {"document_id": doc_id})
# Executive Summary
await mcp.call("add_heading", {
"document_id": doc_id,
"text": "Executive Summary",
"level": 1
})
await mcp.call("add_paragraph", {
"document_id": doc_id,
"text": data["executive_summary"],
"style": {
"font_size": 12,
"alignment": "justify"
}
})
# Key Metrics Table
await mcp.call("add_heading", {
"document_id": doc_id,
"text": "Key Performance Indicators",
"level": 2
})
await mcp.call("add_table", {
"document_id": doc_id,
"rows": [
["Metric", "Target", "Actual", "Variance"],
["Revenue", f"${data['targets']['revenue']:,}", f"${data['actuals']['revenue']:,}",
f"{((data['actuals']['revenue'] / data['targets']['revenue'] - 1) * 100):.1f}%"],
["New Customers", str(data['targets']['customers']), str(data['actuals']['customers']),
f"{data['actuals']['customers'] - data['targets']['customers']:+d}"],
["Satisfaction Score", f"{data['targets']['satisfaction']}%", f"{data['actuals']['satisfaction']}%",
f"{data['actuals']['satisfaction'] - data['targets']['satisfaction']:+.1f}%"]
]
})
# Department Reports
for dept in data['departments']:
await mcp.call("add_heading", {
"document_id": doc_id,
"text": f"{dept['name']} Department",
"level": 2
})
await mcp.call("add_paragraph", {
"document_id": doc_id,
"text": dept['summary']
})
if dept.get('achievements'):
await mcp.call("add_list", {
"document_id": doc_id,
"items": dept['achievements'],
"ordered": False
})
# Action Items
await mcp.call("add_heading", {
"document_id": doc_id,
"text": "Action Items for Next Month",
"level": 2
})
await mcp.call("add_table", {
"document_id": doc_id,
"rows": [
["Action", "Owner", "Due Date", "Priority"],
*[[item['action'], item['owner'], item['due_date'], item['priority']]
for item in data['action_items']]
]
})
# Add footer
await mcp.call("set_footer", {
"document_id": doc_id,
"text": f"Confidential - {data['company_name']} - Page"
})
# Save as DOCX
filename = f"monthly_report_{year}_{month:02d}.docx"
await mcp.call("save_document", {
"document_id": doc_id,
"output_path": f"./reports/{filename}"
})
# Convert to PDF
await mcp.call("convert_to_pdf", {
"document_id": doc_id,
"output_path": f"./reports/{filename.replace('.docx', '.pdf')}"
})
# Generate thumbnail
await mcp.call("convert_to_images", {
"document_id": doc_id,
"output_dir": "./reports/thumbnails/",
"format": "png",
"dpi": 72
})
await mcp.call("close_document", {"document_id": doc_id})
return filename
# === Example 2: Mail Merge ===
async def mail_merge(template_path: str, csv_path: str, output_dir: str):
"""Perform mail merge with CSV data"""
# Read CSV data
with open(csv_path, 'r') as f:
reader = csv.DictReader(f)
recipients = list(reader)
generated_files = []
for recipient in recipients:
# Open template
template = await mcp.call("open_document", {"path": template_path})
doc_id = template["result"]["document_id"]
# Extract template text
text_result = await mcp.call("extract_text", {"document_id": doc_id})
text = text_result["result"]["text"]
# Replace placeholders
for field, value in recipient.items():
placeholder = f"{{{{{field}}}}}"
if placeholder in text:
await mcp.call("find_and_replace", {
"document_id": doc_id,
"find_text": placeholder,
"replace_text": value
})
# Save personalized document
output_filename = f"{recipient.get('name', 'document').replace(' ', '_')}.docx"
output_path = f"{output_dir}/{output_filename}"
await mcp.call("save_document", {
"document_id": doc_id,
"output_path": output_path
})
# Convert to PDF
pdf_path = output_path.replace('.docx', '.pdf')
await mcp.call("convert_to_pdf", {
"document_id": doc_id,
"output_path": pdf_path
})
generated_files.append({
"recipient": recipient['name'],
"docx": output_path,
"pdf": pdf_path
})
await mcp.call("close_document", {"document_id": doc_id})
# Create summary document
summary = await mcp.call("create_document", {})
summary_id = summary["result"]["document_id"]
await mcp.call("add_heading", {
"document_id": summary_id,
"text": "Mail Merge Summary",
"level": 1
})
await mcp.call("add_paragraph", {
"document_id": summary_id,
"text": f"Generated {len(generated_files)} documents on {datetime.now().strftime('%Y-%m-%d %H:%M')}"
})
# Add summary table
rows = [["Recipient", "DOCX File", "PDF File"]]
for file_info in generated_files:
rows.append([
file_info['recipient'],
file_info['docx'],
file_info['pdf']
])
await mcp.call("add_table", {
"document_id": summary_id,
"rows": rows
})
await mcp.call("save_document", {
"document_id": summary_id,
"output_path": f"{output_dir}/merge_summary.docx"
})
await mcp.call("close_document", {"document_id": summary_id})
return generated_files
# === Example 3: Document Pipeline ===
async def document_processing_pipeline(input_dir: str):
"""Process multiple documents through a pipeline"""
input_path = Path(input_dir)
docx_files = list(input_path.glob("*.docx"))
results = []
for docx_file in docx_files:
print(f"Processing {docx_file.name}...")
# Open document
doc = await mcp.call("open_document", {"path": str(docx_file)})
doc_id = doc["result"]["document_id"]
# Add watermark (header)
await mcp.call("set_header", {
"document_id": doc_id,
"text": "DRAFT - CONFIDENTIAL"
})
# Add footer with date
await mcp.call("set_footer", {
"document_id": doc_id,
"text": f"Processed on {datetime.now().strftime('%Y-%m-%d')}"
})
# Extract text for indexing
text_result = await mcp.call("extract_text", {"document_id": doc_id})
text = text_result["result"]["text"]
word_count = len(text.split())
# Save modified document
output_docx = f"./processed/{docx_file.stem}_processed.docx"
await mcp.call("save_document", {
"document_id": doc_id,
"output_path": output_docx
})
# Convert to PDF
output_pdf = output_docx.replace('.docx', '.pdf')
await mcp.call("convert_to_pdf", {
"document_id": doc_id,
"output_path": output_pdf
})
# Generate thumbnail
await mcp.call("convert_to_images", {
"document_id": doc_id,
"output_dir": "./processed/thumbnails/",
"format": "jpg",
"dpi": 96
})
results.append({
"original": docx_file.name,
"word_count": word_count,
"docx": output_docx,
"pdf": output_pdf
})
await mcp.call("close_document", {"document_id": doc_id})
# Create index document
index = await mcp.call("create_document", {})
index_id = index["result"]["document_id"]
await mcp.call("add_heading", {
"document_id": index_id,
"text": "Document Processing Report",
"level": 1
})
await mcp.call("add_paragraph", {
"document_id": index_id,
"text": f"Processed {len(results)} documents"
})
# Statistics table
rows = [["Original File", "Word Count", "Output DOCX", "Output PDF"]]
for result in results:
rows.append([
result['original'],
str(result['word_count']),
result['docx'],
result['pdf']
])
await mcp.call("add_table", {
"document_id": index_id,
"rows": rows
})
await mcp.call("save_document", {
"document_id": index_id,
"output_path": "./processed/index.docx"
})
await mcp.call("close_document", {"document_id": index_id})
return results
# === Example 4: Contract Generator ===
async def generate_contract(contract_type: str, parties: Dict[str, Any], terms: Dict[str, Any]):
"""Generate a legal contract based on type and terms"""
doc = await mcp.call("create_document", {})
doc_id = doc["result"]["document_id"]
# Title
await mcp.call("add_heading", {
"document_id": doc_id,
"text": f"{contract_type.upper()} AGREEMENT",
"level": 1
})
# Date and parties
await mcp.call("add_paragraph", {
"document_id": doc_id,
"text": f"This Agreement is entered into as of {terms['date']} between:"
})
await mcp.call("add_list", {
"document_id": doc_id,
"items": [
f"{parties['party1']['name']}, a {parties['party1']['type']} (\"Party 1\")",
f"{parties['party2']['name']}, a {parties['party2']['type']} (\"Party 2\")"
],
"ordered": False
})
# Terms sections
section_num = 1
for section_title, section_content in terms['sections'].items():
await mcp.call("add_heading", {
"document_id": doc_id,
"text": f"{section_num}. {section_title}",
"level": 2
})
if isinstance(section_content, list):
await mcp.call("add_list", {
"document_id": doc_id,
"items": section_content,
"ordered": True
})
else:
await mcp.call("add_paragraph", {
"document_id": doc_id,
"text": section_content
})
section_num += 1
# Signature block
await mcp.call("add_page_break", {"document_id": doc_id})
await mcp.call("add_heading", {
"document_id": doc_id,
"text": "SIGNATURES",
"level": 2
})
signature_table = [
["Party 1:", "", "Party 2:", ""],
["", "", "", ""],
["_" * 30, "", "_" * 30, ""],
["Name:", parties['party1']['signatory'], "Name:", parties['party2']['signatory']],
["Title:", parties['party1']['title'], "Title:", parties['party2']['title']],
["Date:", "_" * 20, "Date:", "_" * 20]
]
await mcp.call("add_table", {
"document_id": doc_id,
"rows": signature_table
})
# Save and convert
filename = f"{contract_type.lower().replace(' ', '_')}_{datetime.now().strftime('%Y%m%d')}"
await mcp.call("save_document", {
"document_id": doc_id,
"output_path": f"./contracts/{filename}.docx"
})
await mcp.call("convert_to_pdf", {
"document_id": doc_id,
"output_path": f"./contracts/{filename}.pdf"
})
await mcp.call("close_document", {"document_id": doc_id})
return filename
# === Main execution ===
async def main():
"""Run example automations"""
print("Document Automation Examples")
print("=" * 40)
# Example data for monthly report
report_data = {
"company_name": "TechCorp Industries",
"executive_summary": "This month showed strong growth across all departments...",
"targets": {"revenue": 1000000, "customers": 50, "satisfaction": 85},
"actuals": {"revenue": 1150000, "customers": 62, "satisfaction": 88.5},
"departments": [
{
"name": "Sales",
"summary": "Sales exceeded targets by 15%",
"achievements": ["Closed 3 enterprise deals", "Expanded into new market"]
},
{
"name": "Engineering",
"summary": "Delivered 2 major features on schedule",
"achievements": ["Reduced bug count by 30%", "Improved performance by 25%"]
}
],
"action_items": [
{"action": "Hire 2 senior developers", "owner": "HR", "due_date": "2024-02-15", "priority": "High"},
{"action": "Launch marketing campaign", "owner": "Marketing", "due_date": "2024-02-01", "priority": "Medium"}
]
}
# Generate monthly report
print("\n1. Generating monthly report...")
report_file = await generate_monthly_report(1, 2024, report_data)
print(f" ✓ Generated: {report_file}")
# Contract generation
print("\n2. Generating service agreement...")
contract_file = await generate_contract(
"Service Agreement",
{
"party1": {"name": "ABC Corp", "type": "corporation", "signatory": "John Smith", "title": "CEO"},
"party2": {"name": "XYZ Ltd", "type": "limited company", "signatory": "Jane Doe", "title": "Director"}
},
{
"date": "January 15, 2024",
"sections": {
"Scope of Services": "Party 2 agrees to provide consulting services...",
"Payment Terms": ["Monthly fee of $10,000", "Payment due within 30 days", "Late fee of 1.5% per month"],
"Term and Termination": "This agreement shall commence on the date first written above...",
"Confidentiality": "Both parties agree to maintain strict confidentiality..."
}
}
)
print(f" ✓ Generated: {contract_file}")
print("\n✅ All automation examples completed!")
if __name__ == "__main__":
# Create necessary directories
for dir_path in ["./reports", "./reports/thumbnails", "./contracts", "./processed", "./processed/thumbnails"]:
Path(dir_path).mkdir(parents=True, exist_ok=True)
# Run examples
asyncio.run(main())
+166
View File
@@ -0,0 +1,166 @@
# Claude Desktop Examples
These are real examples you can use with Claude Desktop once the DOCX MCP server is configured.
## Basic Document Creation
```
You: Create a new Word document with a professional letterhead for "Acme Corp" and save it as letterhead.docx
```
Claude will create a document with:
- Company name as heading
- Address and contact details
- Professional formatting
- Save to the specified file
## Invoice Generation
```
You: Generate an invoice for client "TechStart Inc" with these items:
- 10 hours consulting at $150/hour
- 1 software license at $500
- Add 10% tax
Save as invoice_2024_001.docx and convert to PDF
```
## Batch Processing
```
You: I have 5 DOCX files in the ./reports folder. Please:
1. Add page numbers to each
2. Set the header to "Confidential - Internal Use Only"
3. Convert all to PDF
4. Create a summary document listing all reports
```
## Data-Driven Documents
```
You: Create a sales report from this data:
Q1: $1.2M (15% growth)
Q2: $1.5M (25% growth)
Q3: $1.3M (8% growth)
Q4: $1.8M (38% growth)
Include:
- Executive summary
- Quarterly breakdown table
- Year-over-year comparison
- Recommendations section
Convert to PDF when done
```
## Template Operations
```
You: Open template.docx and replace these placeholders:
- {{CLIENT_NAME}} with "John Smith"
- {{DATE}} with today's date
- {{PROJECT}} with "Website Redesign"
- {{AMOUNT}} with "$5,000"
Save as contract_john_smith.docx
```
## Document Merging
```
You: Merge these documents in order:
1. cover_page.docx
2. executive_summary.docx
3. main_report.docx
4. appendix.docx
Add page numbers and a table of contents, then save as final_report.docx
```
## Text Extraction and Analysis
```
You: Extract all text from the documents in ./legal folder and:
1. Find all mentions of "liability"
2. Create a summary document with each mention and its context
3. Add a table showing which document contains which terms
```
## Report Formatting
```
You: Format this markdown content as a professional Word document:
# Project Status Report
## Overview
Project is on track...
## Milestones
- [x] Phase 1 complete
- [ ] Phase 2 in progress
## Budget
Current spend: $45,000 of $100,000
Add proper styling, convert checkboxes to a status table, and export as PDF.
```
## Document Comparison
```
You: Open contract_v1.docx and contract_v2.docx, then:
1. Extract text from both
2. Create a new document highlighting the differences
3. Add a summary table of all changes
4. Save as contract_comparison.docx
```
## Automated Documentation
```
You: Create API documentation from this OpenAPI spec file (api.yaml):
1. Generate a Word document with proper formatting
2. Include endpoint descriptions in a table
3. Add request/response examples
4. Create a PDF version for distribution
```
## Meeting Minutes Template
```
You: Create a meeting minutes template with:
- Company header
- Date, time, attendees fields
- Agenda items section
- Action items table with owner and due date columns
- Next meeting section
Save as meeting_template.docx
```
## Bulk Conversion
```
You: Convert all Word documents in my Downloads folder to:
1. PDF files in ./pdfs folder
2. PNG images (first page only) in ./thumbnails folder
3. Create an index.docx with links to all documents
```
## Complex Formatting
```
You: Create a technical specification document with:
1. Title page with document version and date
2. Table of contents (auto-generated)
3. Multiple heading levels
4. Code blocks with syntax highlighting effect
5. Diagrams placeholder sections
6. Numbered requirements list
7. Glossary table at the end
8. Footer with page numbers
```
## Mail Merge Simulation
```
You: I have a CSV with client data (clients.csv). For each client:
1. Create a personalized letter using template.docx
2. Replace all placeholders with client data
3. Save as PDF with client name in filename
4. Create a summary document listing all generated files
```
+144
View File
@@ -0,0 +1,144 @@
{
"claude_desktop": {
"description": "Claude Desktop configuration",
"file_location_macos": "~/Library/Application Support/Claude/claude_desktop_config.json",
"file_location_windows": "%APPDATA%\\Claude\\claude_desktop_config.json",
"config": {
"mcpServers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
},
"cursor": {
"description": "Cursor IDE configuration",
"file_location": "~/.cursor/config.json or Settings UI",
"config": {
"mcp": {
"servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
}
},
"windsurf": {
"description": "Windsurf (Codeium) configuration",
"file_location": "~/.windsurf/config.json",
"config": {
"mcp": {
"servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
}
},
"continue_dev": {
"description": "Continue.dev configuration",
"file_location": "~/.continue/config.json",
"config": {
"models": [
{
"title": "Claude 3.5 Sonnet",
"provider": "anthropic",
"model": "claude-3-5-sonnet-20241022",
"apiKey": "your-api-key",
"mcp_servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": []
}
}
}
]
}
},
"vscode_mcp": {
"description": "VS Code with MCP Extension",
"file_location": ".vscode/settings.json",
"config": {
"mcp.servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
},
"zed": {
"description": "Zed editor configuration",
"file_location": "~/.config/zed/settings.json",
"config": {
"assistant": {
"mcp_servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
}
},
"neovim": {
"description": "Neovim with MCP support",
"file_location": "~/.config/nvim/mcp.json",
"config": {
"servers": {
"docx": {
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
}
}
}
},
"multiple_servers_example": {
"description": "Example with multiple MCP servers",
"config": {
"mcpServers": {
"docx": {
"command": "/path/to/docx-mcp/target/release/docx-mcp",
"args": [],
"env": {
"RUST_LOG": "info"
}
},
"filesystem": {
"command": "/path/to/filesystem-mcp",
"args": ["--root", "/home/user/documents"]
},
"github": {
"command": "/path/to/github-mcp",
"args": [],
"env": {
"GITHUB_TOKEN": "ghp_..."
}
}
}
}
}
}
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
Example client to test the DOCX MCP Server.
This demonstrates how to interact with the server using JSON-RPC.
"""
import json
import sys
import asyncio
import websockets
async def call_tool(websocket, tool_name, arguments):
"""Call a tool on the MCP server"""
request = {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": tool_name,
"arguments": arguments
},
"id": 1
}
await websocket.send(json.dumps(request))
response = await websocket.recv()
return json.loads(response)
async def main():
# Connect to the MCP server (adjust the URI as needed)
uri = "ws://localhost:3000" # Default MCP server port
async with websockets.connect(uri) as websocket:
print("Connected to DOCX MCP Server")
# Example 1: Create a new document
print("\n1. Creating new document...")
result = await call_tool(websocket, "create_document", {})
doc_id = result["result"]["document_id"]
print(f" Document created with ID: {doc_id}")
# Example 2: Add a heading
print("\n2. Adding heading...")
result = await call_tool(websocket, "add_heading", {
"document_id": doc_id,
"text": "Sample Document",
"level": 1
})
print(" Heading added")
# Example 3: Add a paragraph with styling
print("\n3. Adding styled paragraph...")
result = await call_tool(websocket, "add_paragraph", {
"document_id": doc_id,
"text": "This is a sample paragraph with custom styling.",
"style": {
"font_size": 14,
"bold": True,
"color": "#0066CC",
"alignment": "center"
}
})
print(" Styled paragraph added")
# Example 4: Add a table
print("\n4. Adding table...")
result = await call_tool(websocket, "add_table", {
"document_id": doc_id,
"rows": [
["Product", "Price", "Quantity"],
["Widget A", "$10.99", "100"],
["Widget B", "$15.99", "75"],
["Widget C", "$8.99", "150"]
]
})
print(" Table added")
# Example 5: Add a numbered list
print("\n5. Adding numbered list...")
result = await call_tool(websocket, "add_list", {
"document_id": doc_id,
"items": [
"First item in the list",
"Second item with more text",
"Third and final item"
],
"ordered": True
})
print(" Numbered list added")
# Example 6: Set header and footer
print("\n6. Setting header and footer...")
result = await call_tool(websocket, "set_header", {
"document_id": doc_id,
"text": "Sample Document Header"
})
result = await call_tool(websocket, "set_footer", {
"document_id": doc_id,
"text": "Page 1 | Confidential"
})
print(" Header and footer set")
# Example 7: Save the document
print("\n7. Saving document...")
result = await call_tool(websocket, "save_document", {
"document_id": doc_id,
"output_path": "./sample_output.docx"
})
print(" Document saved to sample_output.docx")
# Example 8: Convert to PDF
print("\n8. Converting to PDF...")
result = await call_tool(websocket, "convert_to_pdf", {
"document_id": doc_id,
"output_path": "./sample_output.pdf"
})
if result["result"]["success"]:
print(" Document converted to PDF")
else:
print(f" PDF conversion failed: {result['result'].get('error', 'Unknown error')}")
# Example 9: Convert to images
print("\n9. Converting to images...")
result = await call_tool(websocket, "convert_to_images", {
"document_id": doc_id,
"output_dir": "./images/",
"format": "png",
"dpi": 150
})
if result["result"]["success"]:
print(f" Document converted to images: {result['result']['images']}")
else:
print(f" Image conversion failed: {result['result'].get('error', 'Unknown error')}")
# Example 10: Extract text
print("\n10. Extracting text...")
result = await call_tool(websocket, "extract_text", {
"document_id": doc_id
})
text = result["result"]["text"]
print(f" Extracted text (first 100 chars): {text[:100]}...")
# Example 11: Get metadata
print("\n11. Getting metadata...")
result = await call_tool(websocket, "get_metadata", {
"document_id": doc_id
})
metadata = result["result"]["metadata"]
print(f" Document metadata: {json.dumps(metadata, indent=2)}")
# Example 12: Close the document
print("\n12. Closing document...")
result = await call_tool(websocket, "close_document", {
"document_id": doc_id
})
print(" Document closed")
print("\n✅ All tests completed successfully!")
if __name__ == "__main__":
asyncio.run(main())
+224
View File
@@ -0,0 +1,224 @@
# Justfile for docx-mcp project
# Usage: just <command>
# Install just: https://github.com/casey/just
# Default recipe
default:
@just --list
# Build the project
build:
cargo build --all-features
# Build for release
build-release:
cargo build --release --all-features
# Run all tests
test:
./scripts/run_tests.sh
# Run only unit tests
test-unit:
./scripts/run_tests.sh --unit-only
# Run only integration tests
test-integration:
./scripts/run_tests.sh --integration-only
# Run all tests including slow ones
test-all:
./scripts/run_tests.sh --all
# Run performance tests
test-performance:
./scripts/run_tests.sh --performance
# Generate coverage report
coverage:
./scripts/run_tests.sh --coverage
# Run benchmarks
bench:
cargo bench --all-features
# Check code formatting
fmt-check:
cargo fmt --all -- --check
# Format code
fmt:
cargo fmt --all
# Run Clippy lints
clippy:
cargo clippy --all-targets --all-features -- -D warnings
# Fix Clippy issues automatically where possible
clippy-fix:
cargo clippy --all-targets --all-features --fix
# Run security audit
audit:
cargo audit
# Check dependencies for issues
deny:
cargo deny check
# Clean build artifacts
clean:
cargo clean
# Update dependencies
update:
cargo update
# Install development tools
install-dev-tools:
cargo install cargo-audit cargo-deny cargo-llvm-cov
# Run the application in development mode
dev:
RUST_LOG=debug cargo run --all-features
# Run the application in release mode
run:
cargo run --release --all-features
# Generate documentation
docs:
cargo doc --all-features --no-deps --open
# Check documentation
docs-check:
cargo doc --all-features --no-deps
# Package the project for distribution
package:
cargo package
# Publish to crates.io (dry run)
publish-dry:
cargo publish --dry-run
# Publish to crates.io
publish:
cargo publish
# Docker build
docker-build:
docker build -t docx-mcp:latest .
# Docker run
docker-run:
docker run -p 8080:8080 docx-mcp:latest
# Run pre-commit checks (formatting, linting, tests)
pre-commit: fmt-check clippy test-unit
# Full CI pipeline simulation
ci: pre-commit test audit
# Quick development cycle (format, build, test)
dev-cycle: fmt build test-unit
# Setup development environment
setup:
rustup component add rustfmt clippy llvm-tools-preview
just install-dev-tools
# Generate sample documents for testing
generate-samples:
cargo run --bin generate-test-docs --features=test-utils
# Run stress tests
stress-test:
STRESS_TEST=1 cargo test --release --test performance_tests -- --ignored --test-threads=1
# Profile the application
profile:
cargo build --release --all-features
perf record -g target/release/docx-mcp
perf report
# Memory usage analysis
memory-check:
cargo build --all-features
valgrind --tool=memcheck --leak-check=full target/debug/docx-mcp
# Run with different Rust versions (requires rustup)
test-msrv:
rustup install 1.70.0
rustup run 1.70.0 cargo test
# Check for outdated dependencies
outdated:
cargo install cargo-outdated
cargo outdated
# Security scan
security-scan: audit deny
# Performance profiling with flamegraph
flamegraph:
cargo install flamegraph
cargo flamegraph --bin docx-mcp
# Generate changelog (requires git-cliff)
changelog:
git cliff --output CHANGELOG.md
# Prepare a release
prepare-release version:
# Update version in Cargo.toml
sed -i 's/^version = ".*"/version = "{{version}}"/' Cargo.toml
# Update dependencies to use new version
cargo update
# Run full test suite
just ci
# Generate changelog
just changelog
# Commit changes
git add .
git commit -m "chore: prepare release {{version}}"
git tag -a "v{{version}}" -m "Release {{version}}"
# Show project statistics
stats:
@echo "=== Project Statistics ==="
@echo "Lines of code:"
@find src -name "*.rs" -type f -exec wc -l {} + | tail -n 1
@echo ""
@echo "Test coverage:"
@just coverage --quiet | grep "Overall coverage" || echo "Run 'just coverage' first"
@echo ""
@echo "Dependencies:"
@cargo tree --depth 1 | wc -l
@echo ""
@echo "Binary size (release):"
@if [ -f "target/release/docx-mcp" ]; then ls -lh target/release/docx-mcp | awk '{print $5}'; else echo "Run 'just build-release' first"; fi
# Watch for changes and run tests
watch:
cargo install cargo-watch
cargo watch -x "test --lib"
# Watch for changes and run specific test
watch-test test_name:
cargo watch -x "test {{test_name}}"
# Initialize git hooks
init-hooks:
#!/usr/bin/env bash
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/bash
just pre-commit
EOF
chmod +x .git/hooks/pre-commit
echo "Pre-commit hook installed"
# Remove git hooks
remove-hooks:
rm -f .git/hooks/pre-commit
echo "Pre-commit hook removed"
+312
View File
@@ -0,0 +1,312 @@
#!/bin/bash
# Comprehensive test runner script for docx-mcp
# Usage: ./scripts/run_tests.sh [OPTIONS]
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Default options
RUN_UNIT_TESTS=true
RUN_INTEGRATION_TESTS=true
RUN_PERFORMANCE_TESTS=false
RUN_BENCHMARKS=false
RUN_SECURITY_AUDIT=true
RUN_COVERAGE=false
VERBOSE=false
QUIET=false
CLEAN_FIRST=false
# Function to print colored output
print_status() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Function to show help
show_help() {
cat << EOF
Usage: $0 [OPTIONS]
Test runner script for docx-mcp project
OPTIONS:
-h, --help Show this help message
-u, --unit-only Run only unit tests
-i, --integration-only Run only integration tests
-p, --performance Include performance tests (slow)
-b, --benchmarks Run benchmarks (slow)
-s, --skip-security Skip security audit
-c, --coverage Generate coverage report
-v, --verbose Verbose output
-q, --quiet Quiet output (errors only)
--clean Clean build artifacts first
--all Run all tests including slow ones
Examples:
$0 # Run standard test suite
$0 -u # Run only unit tests
$0 --all # Run all tests including performance
$0 -c --verbose # Generate coverage with verbose output
EOF
}
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
show_help
exit 0
;;
-u|--unit-only)
RUN_UNIT_TESTS=true
RUN_INTEGRATION_TESTS=false
shift
;;
-i|--integration-only)
RUN_UNIT_TESTS=false
RUN_INTEGRATION_TESTS=true
shift
;;
-p|--performance)
RUN_PERFORMANCE_TESTS=true
shift
;;
-b|--benchmarks)
RUN_BENCHMARKS=true
shift
;;
-s|--skip-security)
RUN_SECURITY_AUDIT=false
shift
;;
-c|--coverage)
RUN_COVERAGE=true
shift
;;
-v|--verbose)
VERBOSE=true
shift
;;
-q|--quiet)
QUIET=true
shift
;;
--clean)
CLEAN_FIRST=true
shift
;;
--all)
RUN_UNIT_TESTS=true
RUN_INTEGRATION_TESTS=true
RUN_PERFORMANCE_TESTS=true
RUN_BENCHMARKS=true
shift
;;
*)
print_error "Unknown option: $1"
show_help
exit 1
;;
esac
done
# Set up output redirection based on quiet flag
if [ "$QUIET" = true ]; then
CARGO_OUTPUT="--quiet"
else
CARGO_OUTPUT=""
fi
if [ "$VERBOSE" = true ]; then
CARGO_OUTPUT="$CARGO_OUTPUT --verbose"
fi
print_status "Starting docx-mcp test suite"
# Check if we're in the right directory
if [ ! -f "Cargo.toml" ]; then
print_error "Cargo.toml not found. Please run this script from the project root."
exit 1
fi
# Check if Rust is installed
if ! command -v cargo &> /dev/null; then
print_error "Cargo not found. Please install Rust first."
exit 1
fi
# Clean build artifacts if requested
if [ "$CLEAN_FIRST" = true ]; then
print_status "Cleaning build artifacts..."
cargo clean $CARGO_OUTPUT
fi
# Check formatting
print_status "Checking code formatting..."
if ! cargo fmt --all -- --check; then
print_error "Code formatting issues found. Run 'cargo fmt' to fix."
exit 1
fi
print_success "Code formatting OK"
# Run Clippy lints
print_status "Running Clippy lints..."
if ! cargo clippy --all-targets --all-features $CARGO_OUTPUT -- -D warnings; then
print_error "Clippy lints failed"
exit 1
fi
print_success "Clippy lints passed"
# Build the project
print_status "Building project..."
if ! cargo build --all-features $CARGO_OUTPUT; then
print_error "Build failed"
exit 1
fi
print_success "Build completed"
# Initialize test results tracking
TESTS_PASSED=0
TESTS_FAILED=0
FAILED_TESTS=()
# Function to run a test and track results
run_test() {
local test_name="$1"
local test_command="$2"
print_status "Running $test_name..."
if eval $test_command; then
print_success "$test_name passed"
((TESTS_PASSED++))
else
print_error "$test_name failed"
((TESTS_FAILED++))
FAILED_TESTS+=("$test_name")
fi
}
# Run unit tests
if [ "$RUN_UNIT_TESTS" = true ]; then
run_test "unit tests" "cargo test --lib $CARGO_OUTPUT"
run_test "doc tests" "cargo test --doc $CARGO_OUTPUT"
fi
# Run integration tests
if [ "$RUN_INTEGRATION_TESTS" = true ]; then
run_test "DOCX handler tests" "cargo test --test docx_handler_tests $CARGO_OUTPUT"
run_test "MCP integration tests" "cargo test --test mcp_integration_tests $CARGO_OUTPUT"
run_test "security tests" "cargo test --test security_tests $CARGO_OUTPUT"
run_test "converter tests" "cargo test --test converter_tests $CARGO_OUTPUT"
run_test "end-to-end workflow tests" "cargo test --test e2e_workflow_tests $CARGO_OUTPUT"
fi
# Run performance tests (if requested)
if [ "$RUN_PERFORMANCE_TESTS" = true ]; then
print_warning "Running performance tests (this may take a while)..."
run_test "performance tests" "cargo test --test performance_tests $CARGO_OUTPUT --release"
fi
# Run benchmarks (if requested)
if [ "$RUN_BENCHMARKS" = true ]; then
print_warning "Running benchmarks (this may take a while)..."
run_test "benchmarks" "cargo bench --all-features $CARGO_OUTPUT"
fi
# Run security audit
if [ "$RUN_SECURITY_AUDIT" = true ]; then
print_status "Running security audit..."
# Install cargo-audit if not present
if ! command -v cargo-audit &> /dev/null; then
print_status "Installing cargo-audit..."
cargo install cargo-audit
fi
run_test "security audit" "cargo audit"
# Check for denied dependencies if cargo-deny is available
if command -v cargo-deny &> /dev/null; then
run_test "dependency check" "cargo deny check"
else
print_warning "cargo-deny not found, skipping dependency checks"
fi
fi
# Generate coverage report (if requested)
if [ "$RUN_COVERAGE" = true ]; then
print_status "Generating coverage report..."
# Check if cargo-llvm-cov is installed
if ! command -v cargo-llvm-cov &> /dev/null; then
print_status "Installing cargo-llvm-cov..."
cargo install cargo-llvm-cov
fi
if cargo llvm-cov --all-features --workspace --html; then
print_success "Coverage report generated in target/llvm-cov/html/"
# Also generate lcov format for CI
if cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info; then
print_success "LCOV format generated as lcov.info"
fi
else
print_error "Coverage generation failed"
((TESTS_FAILED++))
FAILED_TESTS+=("coverage generation")
fi
fi
# Test different feature configurations
print_status "Testing different feature configurations..."
run_test "minimal features" "cargo test --no-default-features $CARGO_OUTPUT"
run_test "all features" "cargo test --all-features $CARGO_OUTPUT"
# Check that package builds for release
print_status "Verifying release build..."
run_test "release build" "cargo build --release --all-features $CARGO_OUTPUT"
# Verify package can be published (dry run)
print_status "Verifying package..."
run_test "package verification" "cargo package --dry-run $CARGO_OUTPUT"
# Print final results
echo ""
print_status "============ Test Results Summary ============"
if [ $TESTS_FAILED -eq 0 ]; then
print_success "All tests passed! ($TESTS_PASSED/$((TESTS_PASSED + TESTS_FAILED)))"
echo ""
print_status "Ready for deployment! 🚀"
exit 0
else
print_error "Some tests failed! ($TESTS_PASSED passed, $TESTS_FAILED failed)"
echo ""
print_error "Failed tests:"
for test in "${FAILED_TESTS[@]}"; do
echo -e " ${RED}${NC} $test"
done
echo ""
print_error "Please fix the failing tests before proceeding."
exit 1
fi
+868
View File
@@ -0,0 +1,868 @@
use anyhow::{Context, Result};
use docx_rs::*;
use std::collections::HashMap;
use std::fs::File;
use std::io::Read;
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use base64;
/// Advanced DOCX manipulation features
pub struct AdvancedDocxHandler;
impl AdvancedDocxHandler {
pub fn new() -> Self {
Self
}
/// Create a document with professional template
pub fn create_from_template(&self, template_type: DocumentTemplate) -> Result<Docx> {
let mut docx = Docx::new();
match template_type {
DocumentTemplate::BusinessLetter => {
docx = self.apply_business_letter_template(docx)?;
}
DocumentTemplate::Resume => {
docx = self.apply_resume_template(docx)?;
}
DocumentTemplate::Report => {
docx = self.apply_report_template(docx)?;
}
DocumentTemplate::Invoice => {
docx = self.apply_invoice_template(docx)?;
}
DocumentTemplate::Contract => {
docx = self.apply_contract_template(docx)?;
}
DocumentTemplate::Memo => {
docx = self.apply_memo_template(docx)?;
}
DocumentTemplate::Newsletter => {
docx = self.apply_newsletter_template(docx)?;
}
}
Ok(docx)
}
/// Add a table of contents
pub fn add_table_of_contents(&self, docx: Docx) -> Result<Docx> {
let toc = TableOfContents::new()
.heading_text("Table of Contents")
.heading_style("TOCHeading");
let mut docx = docx.add_table_of_contents(toc);
// Add instruction text
let instruction = Paragraph::new()
.add_run(
Run::new()
.add_text("Right-click and select 'Update Field' to refresh the table of contents")
.italic()
.size(20)
.color("808080")
);
docx = docx.add_paragraph(instruction);
docx = docx.add_paragraph(Paragraph::new().add_run(Run::new().add_break(BreakType::Page)));
Ok(docx)
}
/// Add an image to the document
pub fn add_image(
&self,
docx: Docx,
image_data: &[u8],
width_px: u32,
height_px: u32,
alt_text: Option<&str>
) -> Result<Docx> {
// Convert pixels to EMUs (English Metric Units)
// 1 pixel = 9525 EMUs
let width_emu = width_px * 9525;
let height_emu = height_px * 9525;
let drawing = Drawing::new()
.inline(
Inline::new()
.extent(width_emu, height_emu)
.graphic(
Graphic::new()
.graphic_data(
GraphicData::new()
.pic(
Pic::new()
.blip_fill(image_data.to_vec())
)
)
)
);
let paragraph = Paragraph::new()
.add_run(Run::new().add_drawing(drawing));
Ok(docx.add_paragraph(paragraph))
}
/// Add a chart to the document
pub fn add_chart(&self, docx: Docx, chart_type: ChartType, data: ChartData) -> Result<Docx> {
// Charts in DOCX are complex and usually require embedding Excel data
// For now, we'll create a table representation
let mut table = Table::new(vec![]);
// Add headers
let mut header_cells = vec![TableCell::new().add_paragraph(
Paragraph::new().add_run(Run::new().add_text("Category").bold())
)];
for series in &data.series {
header_cells.push(
TableCell::new().add_paragraph(
Paragraph::new().add_run(Run::new().add_text(&series.name).bold())
)
);
}
table = table.add_row(TableRow::new(header_cells));
// Add data rows
for (i, category) in data.categories.iter().enumerate() {
let mut row_cells = vec![TableCell::new().add_paragraph(
Paragraph::new().add_run(Run::new().add_text(category))
)];
for series in &data.series {
if let Some(value) = series.values.get(i) {
row_cells.push(
TableCell::new().add_paragraph(
Paragraph::new().add_run(Run::new().add_text(&value.to_string()))
)
);
}
}
table = table.add_row(TableRow::new(row_cells));
}
// Add title for the chart
let title = Paragraph::new()
.add_run(Run::new().add_text(&format!("{:?}: {}", chart_type, data.title)).bold())
.align(AlignmentType::Center);
Ok(docx.add_paragraph(title).add_table(table))
}
/// Add a hyperlink
pub fn add_hyperlink(&self, docx: Docx, text: &str, url: &str) -> Result<Docx> {
let hyperlink = Hyperlink::new(url, HyperlinkType::External)
.add_run(Run::new().add_text(text).color("0000FF").underline("single"));
let paragraph = Paragraph::new().add_hyperlink(hyperlink);
Ok(docx.add_paragraph(paragraph))
}
/// Add a bookmark
pub fn add_bookmark(&self, docx: Docx, bookmark_name: &str, text: &str) -> Result<Docx> {
let bookmark_id = Uuid::new_v4().to_string();
let bookmark_start = BookmarkStart::new(&bookmark_id, bookmark_name);
let bookmark_end = BookmarkEnd::new(&bookmark_id);
let paragraph = Paragraph::new()
.add_bookmark_start(bookmark_start)
.add_run(Run::new().add_text(text))
.add_bookmark_end(bookmark_end);
Ok(docx.add_paragraph(paragraph))
}
/// Add a cross-reference
pub fn add_cross_reference(&self, docx: Docx, bookmark_name: &str, display_text: &str) -> Result<Docx> {
// Cross-references in DOCX use field codes
let field = ComplexField::new()
.instruction(&format!("REF {} \\h", bookmark_name))
.default_text(display_text);
let paragraph = Paragraph::new().add_complex_field(field);
Ok(docx.add_paragraph(paragraph))
}
/// Add document properties and metadata
pub fn set_document_properties(&self, docx: Docx, properties: DocumentProperties) -> Result<Docx> {
let docx = docx
.title(&properties.title)
.subject(&properties.subject)
.creator(&properties.author)
.keywords(&properties.keywords.join(", "))
.description(&properties.description);
if let Some(company) = properties.company {
docx.company(&company);
}
if let Some(manager) = properties.manager {
docx.manager(&manager);
}
Ok(docx)
}
/// Add a custom styled section
pub fn add_section(&self, docx: Docx, section_config: SectionConfig) -> Result<Docx> {
let mut section = SectionProperty::new();
// Page size
match section_config.page_size {
PageSize::A4 => {
section = section.page_size(11906, 16838); // A4 in twips
}
PageSize::Letter => {
section = section.page_size(12240, 15840); // Letter in twips
}
PageSize::Legal => {
section = section.page_size(12240, 20160); // Legal in twips
}
PageSize::A3 => {
section = section.page_size(16838, 23811); // A3 in twips
}
}
// Orientation
if section_config.landscape {
section = section.page_size(
section.page_size.1,
section.page_size.0
);
}
// Margins (convert mm to twips: 1mm = 56.7 twips)
section = section.page_margin(
PageMargin::new()
.top((section_config.margins.top * 56.7) as i32)
.bottom((section_config.margins.bottom * 56.7) as i32)
.left((section_config.margins.left * 56.7) as i32)
.right((section_config.margins.right * 56.7) as i32)
.header((section_config.margins.header * 56.7) as i32)
.footer((section_config.margins.footer * 56.7) as i32)
);
// Columns
if section_config.columns > 1 {
section = section.columns(section_config.columns);
}
Ok(docx.add_section(section))
}
/// Add a watermark
pub fn add_watermark(&self, docx: Docx, text: &str, style: WatermarkStyle) -> Result<Docx> {
let watermark = match style {
WatermarkStyle::Diagonal => {
Run::new()
.add_text(text)
.size(144) // Large size
.color("C0C0C0") // Light gray
.bold()
}
WatermarkStyle::Horizontal => {
Run::new()
.add_text(text)
.size(100)
.color("E0E0E0")
}
};
// Watermarks are typically added to headers
let header = Header::new().add_paragraph(
Paragraph::new()
.add_run(watermark)
.align(AlignmentType::Center)
);
Ok(docx.header(header))
}
/// Add footnote
pub fn add_footnote(&self, docx: Docx, reference_text: &str, footnote_text: &str) -> Result<Docx> {
let footnote_id = Uuid::new_v4().to_string();
let footnote = Footnote::new(&footnote_id)
.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text(footnote_text))
);
let paragraph = Paragraph::new()
.add_run(Run::new().add_text(reference_text))
.add_footnote_reference(&footnote_id);
Ok(docx.add_paragraph(paragraph).add_footnote(footnote))
}
/// Add endnote
pub fn add_endnote(&self, docx: Docx, reference_text: &str, endnote_text: &str) -> Result<Docx> {
let endnote_id = Uuid::new_v4().to_string();
let endnote = Endnote::new(&endnote_id)
.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text(endnote_text))
);
let paragraph = Paragraph::new()
.add_run(Run::new().add_text(reference_text))
.add_endnote_reference(&endnote_id);
Ok(docx.add_paragraph(paragraph).add_endnote(endnote))
}
/// Add custom styles
pub fn add_custom_style(&self, docx: Docx, style: CustomStyle) -> Result<Docx> {
let style_def = Style::new(&style.id, StyleType::Paragraph)
.name(&style.name)
.based_on(&style.based_on.unwrap_or_else(|| "Normal".to_string()));
let mut paragraph_property = ParagraphProperty::new();
if let Some(spacing) = style.spacing {
paragraph_property = paragraph_property
.line_spacing(LineSpacing::new(SpacingType::Auto, spacing.before, spacing.after));
}
if let Some(indent) = style.indent {
paragraph_property = paragraph_property
.indent(Some(indent.left), Some(indent.right), Some(indent.first_line), None);
}
let mut run_property = RunProperty::new();
if let Some(font) = style.font {
run_property = run_property.fonts(RunFonts::new().ascii(&font).east_asia(&font));
}
if let Some(size) = style.size {
run_property = run_property.size(size);
}
if style.bold {
run_property = run_property.bold();
}
if style.italic {
run_property = run_property.italic();
}
if let Some(color) = style.color {
run_property = run_property.color(&color);
}
let style_def = style_def
.paragraph_property(paragraph_property)
.run_property(run_property);
Ok(docx.add_style(style_def))
}
/// Mail merge functionality
pub fn prepare_mail_merge_template(&self, docx: Docx, fields: Vec<String>) -> Result<Docx> {
let mut docx = docx;
for field in fields {
let merge_field = ComplexField::new()
.instruction(&format!("MERGEFIELD {} \\* MERGEFORMAT", field))
.default_text(&format!("«{}»", field));
let paragraph = Paragraph::new()
.add_complex_field(merge_field);
docx = docx.add_paragraph(paragraph);
}
Ok(docx)
}
/// Add comments (annotations)
pub fn add_comment(&self, docx: Docx, text: &str, comment: &str, author: &str) -> Result<Docx> {
let comment_id = Uuid::new_v4().to_string();
let date = Utc::now();
let comment_obj = Comment::new(&comment_id, author)
.date(date)
.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text(comment))
);
let comment_range_start = CommentRangeStart::new(&comment_id);
let comment_range_end = CommentRangeEnd::new(&comment_id);
let comment_reference = CommentReference::new(&comment_id);
let paragraph = Paragraph::new()
.add_comment_range_start(comment_range_start)
.add_run(Run::new().add_text(text))
.add_comment_range_end(comment_range_end)
.add_run(Run::new().add_comment_reference(comment_reference));
Ok(docx.add_paragraph(paragraph).add_comment(comment_obj))
}
// Template helper methods
fn apply_business_letter_template(&self, mut docx: Docx) -> Result<Docx> {
// Add sender info placeholder
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Your Name]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[Your Address]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[City, State ZIP]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[Your Email]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[Your Phone]"))
);
docx = docx.add_paragraph(Paragraph::new());
// Date
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Date]"))
);
docx = docx.add_paragraph(Paragraph::new());
// Recipient info
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Recipient Name]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[Title]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[Company]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[Address]"))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_text("[City, State ZIP]"))
);
docx = docx.add_paragraph(Paragraph::new());
// Salutation
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("Dear [Recipient Name]:"))
);
docx = docx.add_paragraph(Paragraph::new());
// Body placeholder
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Letter body paragraph 1]"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Letter body paragraph 2]"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Letter body paragraph 3]"))
);
docx = docx.add_paragraph(Paragraph::new());
// Closing
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("Sincerely,"))
);
docx = docx.add_paragraph(Paragraph::new());
docx = docx.add_paragraph(Paragraph::new());
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Your Name]"))
);
Ok(docx)
}
fn apply_resume_template(&self, mut docx: Docx) -> Result<Docx> {
// Name header
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[YOUR NAME]").size(32).bold())
.align(AlignmentType::Center)
);
// Contact info
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Email] | [Phone] | [LinkedIn] | [Location]").size(22))
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(Paragraph::new().add_run(Run::new().add_text("").size(12)));
// Professional Summary
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("PROFESSIONAL SUMMARY").size(24).bold())
.style("Heading2")
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[2-3 lines summarizing your experience and key skills]"))
);
// Experience
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("EXPERIENCE").size(24).bold())
.style("Heading2")
);
// Education
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("EDUCATION").size(24).bold())
.style("Heading2")
);
// Skills
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("SKILLS").size(24).bold())
.style("Heading2")
);
Ok(docx)
}
fn apply_report_template(&self, mut docx: Docx) -> Result<Docx> {
// Title page
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text(""))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_break(BreakType::TextWrapping))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[REPORT TITLE]").size(36).bold())
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Subtitle or Description]").size(24))
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_break(BreakType::TextWrapping))
.add_run(Run::new().add_break(BreakType::TextWrapping))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("Prepared by:").size(20))
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Author Name]").size(20))
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Date]").size(20))
.align(AlignmentType::Center)
);
// Page break
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_break(BreakType::Page))
);
// Table of Contents placeholder
docx = self.add_table_of_contents(docx)?;
// Executive Summary
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("Executive Summary").size(28).bold())
.style("Heading1")
);
Ok(docx)
}
fn apply_invoice_template(&self, mut docx: Docx) -> Result<Docx> {
// Company header
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[COMPANY NAME]").size(32).bold())
.align(AlignmentType::Right)
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("INVOICE").size(28).bold())
.align(AlignmentType::Right)
);
// Invoice details table
let invoice_info = Table::new(vec![
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("Invoice #:"))),
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("[INV-0001]"))),
])
.add_row(TableRow::new(vec![
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("Date:"))),
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("[Date]"))),
]))
.add_row(TableRow::new(vec![
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("Due Date:"))),
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("[Due Date]"))),
]));
docx = docx.add_table(invoice_info);
Ok(docx)
}
fn apply_contract_template(&self, mut docx: Docx) -> Result<Docx> {
// Contract title
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[CONTRACT TYPE] AGREEMENT").size(28).bold())
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(Paragraph::new());
// Parties
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("This Agreement is entered into as of [Date] between:"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Party 1 Name], a [Entity Type] (\"Party 1\")"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("and"))
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Party 2 Name], a [Entity Type] (\"Party 2\")"))
);
Ok(docx)
}
fn apply_memo_template(&self, mut docx: Docx) -> Result<Docx> {
// Memo header
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("MEMORANDUM").size(24).bold())
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(Paragraph::new());
// Memo fields
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("TO: ").bold())
.add_run(Run::new().add_text("[Recipient(s)]"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("FROM: ").bold())
.add_run(Run::new().add_text("[Sender]"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("DATE: ").bold())
.add_run(Run::new().add_text("[Date]"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("SUBJECT: ").bold())
.add_run(Run::new().add_text("[Subject]"))
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("_").repeat(70))
);
Ok(docx)
}
fn apply_newsletter_template(&self, mut docx: Docx) -> Result<Docx> {
// Newsletter header
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[NEWSLETTER TITLE]").size(36).bold())
.align(AlignmentType::Center)
);
docx = docx.add_paragraph(
Paragraph::new()
.add_run(Run::new().add_text("[Issue #] | [Date]").size(18))
.align(AlignmentType::Center)
);
// Two-column layout simulation
let columns = SectionProperty::new().columns(2);
docx = docx.add_section(columns);
Ok(docx)
}
}
// Supporting types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DocumentTemplate {
BusinessLetter,
Resume,
Report,
Invoice,
Contract,
Memo,
Newsletter,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentProperties {
pub title: String,
pub subject: String,
pub author: String,
pub keywords: Vec<String>,
pub description: String,
pub company: Option<String>,
pub manager: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SectionConfig {
pub page_size: PageSize,
pub landscape: bool,
pub margins: Margins,
pub columns: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PageSize {
A4,
Letter,
Legal,
A3,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Margins {
pub top: f32,
pub bottom: f32,
pub left: f32,
pub right: f32,
pub header: f32,
pub footer: f32,
}
impl Default for Margins {
fn default() -> Self {
Self {
top: 25.4, // 1 inch in mm
bottom: 25.4,
left: 25.4,
right: 25.4,
header: 12.7, // 0.5 inch
footer: 12.7,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ChartType {
Bar,
Column,
Line,
Pie,
Area,
Scatter,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChartData {
pub title: String,
pub categories: Vec<String>,
pub series: Vec<ChartSeries>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChartSeries {
pub name: String,
pub values: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum WatermarkStyle {
Diagonal,
Horizontal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomStyle {
pub id: String,
pub name: String,
pub based_on: Option<String>,
pub font: Option<String>,
pub size: Option<usize>,
pub bold: bool,
pub italic: bool,
pub color: Option<String>,
pub spacing: Option<StyleSpacing>,
pub indent: Option<StyleIndent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StyleSpacing {
pub before: i32,
pub after: i32,
pub line: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StyleIndent {
pub left: i32,
pub right: i32,
pub first_line: i32,
}
+435
View File
@@ -0,0 +1,435 @@
use anyhow::{Context, Result};
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
use printpdf::*;
use std::fs::{self, File};
use std::io::{BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::NamedTempFile;
use tracing::{debug, info, warn};
use crate::pure_converter::PureRustConverter;
pub struct DocumentConverter {
pure_converter: PureRustConverter,
prefer_external_tools: bool,
}
impl DocumentConverter {
pub fn new() -> Self {
Self {
pure_converter: PureRustConverter::new(),
prefer_external_tools: false, // Default to pure Rust implementation
}
}
pub fn docx_to_pdf(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
if self.prefer_external_tools {
// Try external tools first if preferred
// Method 1: Try LibreOffice if available
if self.try_libreoffice_conversion(docx_path, pdf_path).is_ok() {
info!("Successfully converted DOCX to PDF using LibreOffice");
return Ok(());
}
// Method 2: Try unoconv if available
if self.try_unoconv_conversion(docx_path, pdf_path).is_ok() {
info!("Successfully converted DOCX to PDF using unoconv");
return Ok(());
}
}
// Use pure Rust implementation (default)
self.pure_converter.docx_to_pdf_pure(docx_path, pdf_path)?;
info!("Successfully converted DOCX to PDF using pure Rust implementation");
Ok(())
}
fn try_libreoffice_conversion(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
let output = Command::new("libreoffice")
.args(&[
"--headless",
"--invisible",
"--nodefault",
"--nolockcheck",
"--nologo",
"--norestore",
"--convert-to",
"pdf",
"--outdir",
pdf_path.parent().unwrap().to_str().unwrap(),
docx_path.to_str().unwrap(),
])
.output();
match output {
Ok(output) if output.status.success() => {
// LibreOffice creates the PDF with the same base name
let temp_pdf = pdf_path.parent().unwrap()
.join(docx_path.file_stem().unwrap())
.with_extension("pdf");
if temp_pdf != pdf_path {
fs::rename(&temp_pdf, pdf_path)?;
}
Ok(())
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("LibreOffice conversion failed: {}", stderr)
}
Err(e) => {
debug!("LibreOffice not available: {}", e);
anyhow::bail!("LibreOffice not available")
}
}
}
fn try_unoconv_conversion(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
let output = Command::new("unoconv")
.args(&[
"-f", "pdf",
"-o", pdf_path.to_str().unwrap(),
docx_path.to_str().unwrap(),
])
.output();
match output {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("unoconv conversion failed: {}", stderr)
}
Err(e) => {
debug!("unoconv not available: {}", e);
anyhow::bail!("unoconv not available")
}
}
}
fn basic_docx_to_pdf(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
// Extract text from DOCX
let text = dotext::extract_text(docx_path)
.with_context(|| format!("Failed to extract text from {:?}", docx_path))?;
// Create a basic PDF with the extracted text
let (doc, page1, layer1) = PdfDocument::new("Document", Mm(210.0), Mm(297.0), "Layer 1");
let current_layer = doc.get_page(page1).get_layer(layer1);
// Load a basic font
let font = doc.add_builtin_font(BuiltinFont::Helvetica)?;
// Split text into lines and add to PDF
let lines: Vec<&str> = text.text.lines().collect();
let mut y_position = Mm(280.0);
let line_height = Mm(5.0);
for line in lines {
if y_position < Mm(20.0) {
// Add new page if needed
let (page, layer) = doc.add_page(Mm(210.0), Mm(297.0), "Page layer");
let current_layer = doc.get_page(page).get_layer(layer);
y_position = Mm(280.0);
}
current_layer.use_text(line, 12.0, Mm(10.0), y_position, &font);
y_position -= line_height;
}
doc.save(&mut BufWriter::new(File::create(pdf_path)?))?;
Ok(())
}
pub fn pdf_to_images(
&self,
pdf_path: &Path,
output_dir: &Path,
format: ImageFormat,
dpi: u32,
) -> Result<Vec<PathBuf>> {
// Try multiple methods for PDF to image conversion
// Method 1: Try pdftoppm if available
if let Ok(images) = self.try_pdftoppm_conversion(pdf_path, output_dir, format, dpi) {
info!("Successfully converted PDF to images using pdftoppm");
return Ok(images);
}
// Method 2: Try ImageMagick if available
if let Ok(images) = self.try_imagemagick_conversion(pdf_path, output_dir, format, dpi) {
info!("Successfully converted PDF to images using ImageMagick");
return Ok(images);
}
// Method 3: Try Ghostscript if available
if let Ok(images) = self.try_ghostscript_conversion(pdf_path, output_dir, format, dpi) {
info!("Successfully converted PDF to images using Ghostscript");
return Ok(images);
}
anyhow::bail!("No PDF to image converter available. Please install pdftoppm, ImageMagick, or Ghostscript")
}
fn try_pdftoppm_conversion(
&self,
pdf_path: &Path,
output_dir: &Path,
format: ImageFormat,
dpi: u32,
) -> Result<Vec<PathBuf>> {
fs::create_dir_all(output_dir)?;
let output_prefix = output_dir.join("page");
let format_arg = match format {
ImageFormat::Png => "-png",
ImageFormat::Jpeg => "-jpeg",
_ => "-png",
};
let output = Command::new("pdftoppm")
.args(&[
format_arg,
"-r", &dpi.to_string(),
pdf_path.to_str().unwrap(),
output_prefix.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("pdftoppm failed: {}", stderr);
}
// Collect generated image files
let extension = match format {
ImageFormat::Png => "png",
ImageFormat::Jpeg => "jpg",
_ => "png",
};
let mut images = Vec::new();
for entry in fs::read_dir(output_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension() == Some(std::ffi::OsStr::new(extension)) {
images.push(path);
}
}
images.sort();
Ok(images)
}
fn try_imagemagick_conversion(
&self,
pdf_path: &Path,
output_dir: &Path,
format: ImageFormat,
dpi: u32,
) -> Result<Vec<PathBuf>> {
fs::create_dir_all(output_dir)?;
let extension = match format {
ImageFormat::Png => "png",
ImageFormat::Jpeg => "jpg",
_ => "png",
};
let output_pattern = output_dir.join(format!("page-%03d.{}", extension));
let output = Command::new("convert")
.args(&[
"-density", &dpi.to_string(),
pdf_path.to_str().unwrap(),
"-quality", "100",
output_pattern.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("ImageMagick convert failed: {}", stderr);
}
// Collect generated image files
let mut images = Vec::new();
for entry in fs::read_dir(output_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension() == Some(std::ffi::OsStr::new(extension)) {
images.push(path);
}
}
images.sort();
Ok(images)
}
fn try_ghostscript_conversion(
&self,
pdf_path: &Path,
output_dir: &Path,
format: ImageFormat,
dpi: u32,
) -> Result<Vec<PathBuf>> {
fs::create_dir_all(output_dir)?;
let device = match format {
ImageFormat::Png => "png16m",
ImageFormat::Jpeg => "jpeg",
_ => "png16m",
};
let extension = match format {
ImageFormat::Png => "png",
ImageFormat::Jpeg => "jpg",
_ => "png",
};
let output_pattern = output_dir.join(format!("page-%03d.{}", extension));
let output = Command::new("gs")
.args(&[
"-dNOPAUSE",
"-dBATCH",
"-sDEVICE", device,
&format!("-r{}", dpi),
"-dTextAlphaBits=4",
"-dGraphicsAlphaBits=4",
&format!("-sOutputFile={}", output_pattern.to_str().unwrap()),
pdf_path.to_str().unwrap(),
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Ghostscript failed: {}", stderr);
}
// Collect generated image files
let mut images = Vec::new();
for entry in fs::read_dir(output_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension() == Some(std::ffi::OsStr::new(extension)) {
images.push(path);
}
}
images.sort();
Ok(images)
}
pub fn docx_to_images(
&self,
docx_path: &Path,
output_dir: &Path,
format: ImageFormat,
dpi: u32,
) -> Result<Vec<PathBuf>> {
// First convert DOCX to PDF
let temp_pdf = NamedTempFile::new()?.into_temp_path();
self.docx_to_pdf(docx_path, &temp_pdf)?;
// Then convert PDF to images
let images = self.pdf_to_images(&temp_pdf, output_dir, format, dpi)?;
Ok(images)
}
pub fn create_thumbnail(
&self,
image_path: &Path,
output_path: &Path,
width: u32,
height: u32,
) -> Result<()> {
let img = image::open(image_path)
.with_context(|| format!("Failed to open image {:?}", image_path))?;
let thumbnail = img.thumbnail(width, height);
thumbnail.save(output_path)
.with_context(|| format!("Failed to save thumbnail to {:?}", output_path))?;
info!("Created thumbnail {}x{} at {:?}", width, height, output_path);
Ok(())
}
pub fn merge_pdfs(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> {
// Try using pdftk if available
if self.try_pdftk_merge(pdf_paths, output_path).is_ok() {
info!("Successfully merged PDFs using pdftk");
return Ok(());
}
// Fallback to lopdf for merging
self.merge_pdfs_with_lopdf(pdf_paths, output_path)?;
info!("Successfully merged PDFs using lopdf");
Ok(())
}
fn try_pdftk_merge(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> {
let mut args = Vec::new();
for path in pdf_paths {
args.push(path.to_str().unwrap());
}
args.push("cat");
args.push("output");
args.push(output_path.to_str().unwrap());
let output = Command::new("pdftk")
.args(&args)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("pdftk merge failed: {}", stderr);
}
Ok(())
}
fn merge_pdfs_with_lopdf(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> {
use lopdf::{Document, Object, ObjectId};
let mut merged = Document::new();
merged.version = "1.5".to_string();
for pdf_path in pdf_paths {
let mut doc = Document::load(pdf_path)?;
// Merge pages
for page_id in doc.get_pages().values() {
merged.add_object(doc.get_object(*page_id)?.clone());
}
}
merged.save(output_path)?;
Ok(())
}
pub fn split_pdf(&self, pdf_path: &Path, output_dir: &Path) -> Result<Vec<PathBuf>> {
use lopdf::Document;
fs::create_dir_all(output_dir)?;
let doc = Document::load(pdf_path)?;
let pages = doc.get_pages();
let mut output_paths = Vec::new();
for (i, (_, page_id)) in pages.iter().enumerate() {
let mut single_page = Document::new();
single_page.version = doc.version.clone();
// Clone the page to the new document
single_page.add_object(doc.get_object(*page_id)?.clone());
let output_path = output_dir.join(format!("page_{:03}.pdf", i + 1));
single_page.save(&output_path)?;
output_paths.push(output_path);
}
info!("Split PDF into {} pages", output_paths.len());
Ok(output_paths)
}
}
+408
View File
@@ -0,0 +1,408 @@
use anyhow::{Context, Result};
use docx_rs::*;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocxMetadata {
pub id: String,
pub path: PathBuf,
pub created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub size_bytes: u64,
pub page_count: Option<usize>,
pub word_count: Option<usize>,
pub author: Option<String>,
pub title: Option<String>,
pub subject: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocxStyle {
pub font_family: Option<String>,
pub font_size: Option<usize>,
pub bold: Option<bool>,
pub italic: Option<bool>,
pub underline: Option<bool>,
pub color: Option<String>,
pub alignment: Option<String>,
pub line_spacing: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableData {
pub rows: Vec<Vec<String>>,
pub headers: Option<Vec<String>>,
pub border_style: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageData {
pub data: Vec<u8>,
pub width: Option<u32>,
pub height: Option<u32>,
pub alt_text: Option<String>,
}
pub struct DocxHandler {
temp_dir: PathBuf,
pub documents: std::collections::HashMap<String, DocxMetadata>,
}
impl DocxHandler {
pub fn new() -> Result<Self> {
let temp_dir = std::env::temp_dir().join("docx-mcp");
fs::create_dir_all(&temp_dir)?;
Ok(Self {
temp_dir,
documents: std::collections::HashMap::new(),
})
}
#[cfg(test)]
pub fn new_with_temp_dir(temp_dir: &Path) -> Result<Self> {
let temp_dir = temp_dir.to_path_buf();
fs::create_dir_all(&temp_dir)?;
Ok(Self {
temp_dir,
documents: std::collections::HashMap::new(),
})
}
pub fn create_document(&mut self) -> Result<String> {
let doc_id = Uuid::new_v4().to_string();
let doc_path = self.temp_dir.join(format!("{}.docx", doc_id));
let docx = Docx::new();
let file = File::create(&doc_path)?;
docx.build().pack(file)?;
let metadata = DocxMetadata {
id: doc_id.clone(),
path: doc_path,
created_at: Utc::now(),
modified_at: Utc::now(),
size_bytes: 0,
page_count: Some(1),
word_count: Some(0),
author: None,
title: None,
subject: None,
};
self.documents.insert(doc_id.clone(), metadata);
info!("Created new document with ID: {}", doc_id);
Ok(doc_id)
}
pub fn open_document(&mut self, path: &Path) -> Result<String> {
let doc_id = Uuid::new_v4().to_string();
let doc_path = self.temp_dir.join(format!("{}.docx", doc_id));
fs::copy(path, &doc_path)
.with_context(|| format!("Failed to copy document from {:?}", path))?;
let file_metadata = fs::metadata(&doc_path)?;
let metadata = DocxMetadata {
id: doc_id.clone(),
path: doc_path,
created_at: Utc::now(),
modified_at: Utc::now(),
size_bytes: file_metadata.len(),
page_count: None,
word_count: None,
author: None,
title: None,
subject: None,
};
self.documents.insert(doc_id.clone(), metadata);
info!("Opened document from {:?} with ID: {}", path, doc_id);
Ok(doc_id)
}
pub fn add_paragraph(&mut self, doc_id: &str, text: &str, style: Option<DocxStyle>) -> Result<()> {
let metadata = self.documents.get(doc_id)
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
let mut file = File::open(&metadata.path)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
let mut docx = Docx::from_reader(&buffer[..])?;
let mut paragraph = Paragraph::new().add_run(Run::new().add_text(text));
if let Some(style) = style {
let mut run = Run::new().add_text(text);
if let Some(size) = style.font_size {
run = run.size(size);
}
if style.bold == Some(true) {
run = run.bold();
}
if style.italic == Some(true) {
run = run.italic();
}
if style.underline == Some(true) {
run = run.underline("single");
}
if let Some(color) = style.color {
run = run.color(color);
}
paragraph = Paragraph::new().add_run(run);
if let Some(alignment) = style.alignment {
paragraph = match alignment.as_str() {
"left" => paragraph.align(AlignmentType::Left),
"center" => paragraph.align(AlignmentType::Center),
"right" => paragraph.align(AlignmentType::Right),
"justify" => paragraph.align(AlignmentType::Justified),
_ => paragraph,
};
}
}
docx = docx.add_paragraph(paragraph);
let file = File::create(&metadata.path)?;
docx.build().pack(file)?;
info!("Added paragraph to document {}", doc_id);
Ok(())
}
pub fn add_heading(&mut self, doc_id: &str, text: &str, level: usize) -> Result<()> {
let metadata = self.documents.get(doc_id)
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
let mut file = File::open(&metadata.path)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
let mut docx = Docx::from_reader(&buffer[..])?;
let heading_style = match level {
1 => "Heading1",
2 => "Heading2",
3 => "Heading3",
4 => "Heading4",
5 => "Heading5",
6 => "Heading6",
_ => "Heading1",
};
let paragraph = Paragraph::new()
.add_run(Run::new().add_text(text))
.style(heading_style);
docx = docx.add_paragraph(paragraph);
let file = File::create(&metadata.path)?;
docx.build().pack(file)?;
info!("Added heading level {} to document {}", level, doc_id);
Ok(())
}
pub fn add_table(&mut self, doc_id: &str, table_data: TableData) -> Result<()> {
let metadata = self.documents.get(doc_id)
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
let mut file = File::open(&metadata.path)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
let mut docx = Docx::from_reader(&buffer[..])?;
let col_count = table_data.rows.get(0).map(|r| r.len()).unwrap_or(0);
let mut table = Table::new(vec![TableCell::new(); col_count]);
for row_data in table_data.rows {
let mut cells = Vec::new();
for cell_text in row_data {
let cell = TableCell::new()
.add_paragraph(Paragraph::new().add_run(Run::new().add_text(cell_text)));
cells.push(cell);
}
while cells.len() < col_count {
cells.push(TableCell::new());
}
table = table.add_row(TableRow::new(cells));
}
docx = docx.add_table(table);
let file = File::create(&metadata.path)?;
docx.build().pack(file)?;
info!("Added table to document {}", doc_id);
Ok(())
}
pub fn add_list(&mut self, doc_id: &str, items: Vec<String>, ordered: bool) -> Result<()> {
let metadata = self.documents.get(doc_id)
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
let mut file = File::open(&metadata.path)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
let mut docx = Docx::from_reader(&buffer[..])?;
let numbering_id = if ordered { 1 } else { 2 };
for item in items {
let paragraph = Paragraph::new()
.add_run(Run::new().add_text(item))
.numbering(NumberingId::new(numbering_id), IndentLevel::new(0));
docx = docx.add_paragraph(paragraph);
}
let file = File::create(&metadata.path)?;
docx.build().pack(file)?;
info!("Added {} list to document {}", if ordered { "ordered" } else { "unordered" }, doc_id);
Ok(())
}
pub fn add_page_break(&mut self, doc_id: &str) -> Result<()> {
let metadata = self.documents.get(doc_id)
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
let mut file = File::open(&metadata.path)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
let mut docx = Docx::from_reader(&buffer[..])?;
let paragraph = Paragraph::new().add_run(Run::new().add_break(BreakType::Page));
docx = docx.add_paragraph(paragraph);
let file = File::create(&metadata.path)?;
docx.build().pack(file)?;
info!("Added page break to document {}", doc_id);
Ok(())
}
pub fn set_header(&mut self, doc_id: &str, text: &str) -> Result<()> {
let metadata = self.documents.get(doc_id)
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
let mut file = File::open(&metadata.path)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
let mut docx = Docx::from_reader(&buffer[..])?;
let header = Header::new().add_paragraph(
Paragraph::new().add_run(Run::new().add_text(text))
);
docx = docx.header(header);
let file = File::create(&metadata.path)?;
docx.build().pack(file)?;
info!("Set header for document {}", doc_id);
Ok(())
}
pub fn set_footer(&mut self, doc_id: &str, text: &str) -> Result<()> {
let metadata = self.documents.get(doc_id)
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
let mut file = File::open(&metadata.path)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
let mut docx = Docx::from_reader(&buffer[..])?;
let footer = Footer::new().add_paragraph(
Paragraph::new().add_run(Run::new().add_text(text))
);
docx = docx.footer(footer);
let file = File::create(&metadata.path)?;
docx.build().pack(file)?;
info!("Set footer for document {}", doc_id);
Ok(())
}
pub fn find_and_replace(&mut self, doc_id: &str, find_text: &str, replace_text: &str) -> Result<usize> {
let metadata = self.documents.get(doc_id)
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
// Note: This is a simplified implementation
// Real implementation would need to parse the DOCX XML structure
// and perform replacements while preserving formatting
warn!("Find and replace operation requires advanced XML manipulation");
Ok(0)
}
pub fn extract_text(&self, doc_id: &str) -> Result<String> {
let metadata = self.documents.get(doc_id)
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
// Use pure Rust text extraction
use crate::pure_converter::PureRustConverter;
let converter = PureRustConverter::new();
let text = converter.extract_text_from_docx(&metadata.path)
.with_context(|| format!("Failed to extract text from document {}", doc_id))?;
Ok(text)
}
pub fn get_metadata(&self, doc_id: &str) -> Result<DocxMetadata> {
self.documents.get(doc_id)
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))
.map(|m| m.clone())
}
pub fn save_document(&self, doc_id: &str, output_path: &Path) -> Result<()> {
let metadata = self.documents.get(doc_id)
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
fs::copy(&metadata.path, output_path)
.with_context(|| format!("Failed to save document to {:?}", output_path))?;
info!("Saved document {} to {:?}", doc_id, output_path);
Ok(())
}
pub fn close_document(&mut self, doc_id: &str) -> Result<()> {
let metadata = self.documents.remove(doc_id)
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
if metadata.path.exists() {
fs::remove_file(&metadata.path)?;
}
info!("Closed document {}", doc_id);
Ok(())
}
pub fn list_documents(&self) -> Vec<DocxMetadata> {
self.documents.values().cloned().collect()
}
}
+1091
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
use once_cell::sync::Lazy;
// Conditionally embed fonts if they exist
// If fonts don't exist, we'll use empty placeholders and rely on PDF built-in fonts
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
pub static LIBERATION_SANS_REGULAR: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Regular.ttf");
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
pub static LIBERATION_SANS_REGULAR: &[u8] = &[];
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
pub static LIBERATION_SANS_BOLD: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Bold.ttf");
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
pub static LIBERATION_SANS_BOLD: &[u8] = &[];
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
pub static LIBERATION_SANS_ITALIC: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Italic.ttf");
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
pub static LIBERATION_SANS_ITALIC: &[u8] = &[];
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
pub static LIBERATION_MONO_REGULAR: &[u8] = include_bytes!("../assets/fonts/LiberationMono-Regular.ttf");
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
pub static LIBERATION_MONO_REGULAR: &[u8] = &[];
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
pub const EMBEDDED_FONT_REGULAR: &[u8] = include_bytes!("../assets/fonts/NotoSans-Regular.ttf");
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
pub const EMBEDDED_FONT_REGULAR: &[u8] = &[];
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
pub const EMBEDDED_FONT_BOLD: &[u8] = include_bytes!("../assets/fonts/NotoSans-Bold.ttf");
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
pub const EMBEDDED_FONT_BOLD: &[u8] = &[];
pub struct EmbeddedFonts {
pub regular: &'static [u8],
pub bold: &'static [u8],
pub italic: &'static [u8],
pub mono: &'static [u8],
}
pub static FONTS: Lazy<EmbeddedFonts> = Lazy::new(|| {
EmbeddedFonts {
regular: LIBERATION_SANS_REGULAR,
bold: LIBERATION_SANS_BOLD,
italic: LIBERATION_SANS_ITALIC,
mono: LIBERATION_MONO_REGULAR,
}
});
+43
View File
@@ -0,0 +1,43 @@
use anyhow::Result;
use mcp_server::{Server, ServerBuilder, ServerOptions};
use mcp_core::ToolManager;
use tracing::info;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
mod docx_tools;
mod docx_handler;
mod converter;
mod pure_converter;
mod advanced_docx;
mod security;
#[cfg(feature = "embedded-fonts")]
mod fonts;
use docx_tools::DocxToolsProvider;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::registry()
.with(fmt::layer())
.with(EnvFilter::from_default_env())
.init();
// Load security configuration from environment
let security_config = security::SecurityConfig::from_env();
info!("Starting DOCX MCP Server - Security: {}", security_config.get_summary());
let docx_provider = DocxToolsProvider::new_with_security(security_config);
let options = ServerOptions::default()
.with_name("docx-mcp-server")
.with_version("0.1.0");
let server = ServerBuilder::new(options)
.with_tool_provider(docx_provider)
.build();
server.run().await?;
Ok(())
}
+423
View File
@@ -0,0 +1,423 @@
use anyhow::{Context, Result};
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
use printpdf::*;
use std::fs::{self, File};
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
use tracing::{debug, info, warn};
use roxmltree;
use zip::ZipArchive;
use rusttype::{Font, Scale};
use lopdf;
pub struct PureRustConverter;
impl PureRustConverter {
pub fn new() -> Self {
Self
}
/// Extract text from DOCX using pure Rust XML parsing
pub fn extract_text_from_docx(&self, docx_path: &Path) -> Result<String> {
let file = File::open(docx_path)?;
let mut archive = ZipArchive::new(file)?;
// Find the main document XML
let mut document_xml = String::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let name = file.name().to_string();
if name == "word/document.xml" {
file.read_to_string(&mut document_xml)?;
break;
}
}
if document_xml.is_empty() {
anyhow::bail!("No document.xml found in DOCX file");
}
// Parse XML and extract text
let doc = roxmltree::Document::parse(&document_xml)?;
let mut text = String::new();
// Extract text from all w:t elements
for node in doc.descendants() {
if node.tag_name().name() == "t" {
if let Some(node_text) = node.text() {
text.push_str(node_text);
text.push(' ');
}
}
// Handle line breaks
if node.tag_name().name() == "br" || node.tag_name().name() == "p" {
text.push('\n');
}
}
Ok(text.trim().to_string())
}
/// Convert DOCX to PDF using pure Rust (no external dependencies)
pub fn docx_to_pdf_pure(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
// Extract text from DOCX
let text = self.extract_text_from_docx(docx_path)
.with_context(|| format!("Failed to extract text from {:?}", docx_path))?;
// Create PDF with extracted text
self.create_pdf_from_text(&text, pdf_path)?;
info!("Successfully converted DOCX to PDF using pure Rust");
Ok(())
}
/// Create a PDF from text content
pub fn create_pdf_from_text(&self, text: &str, pdf_path: &Path) -> Result<()> {
let (doc, page1, layer1) = PdfDocument::new("Document", Mm(210.0), Mm(297.0), "Layer 1");
let current_layer = doc.get_page(page1).get_layer(layer1);
// Use embedded font or built-in font
let font = doc.add_builtin_font(BuiltinFont::Helvetica)?;
// Configure text layout
let font_size = 11.0;
let line_height = Mm(5.0);
let margin_left = Mm(20.0);
let margin_top = Mm(280.0);
let margin_bottom = Mm(20.0);
let page_width = Mm(210.0);
let page_height = Mm(297.0);
let text_width = page_width - (margin_left * 2.0);
let lines: Vec<&str> = text.lines().collect();
let mut current_page = page1;
let mut current_layer = layer1;
let mut y_position = margin_top;
for line in lines {
// Check if we need a new page
if y_position < margin_bottom {
let (new_page, new_layer) = doc.add_page(Mm(210.0), Mm(297.0), "Page layer");
current_page = new_page;
current_layer = new_layer;
y_position = margin_top;
}
// Word wrap if line is too long
let words: Vec<&str> = line.split_whitespace().collect();
let mut current_line = String::new();
let max_chars_per_line = 80; // Approximate
for word in words {
if current_line.len() + word.len() + 1 > max_chars_per_line {
// Write current line
if !current_line.is_empty() {
doc.get_page(current_page)
.get_layer(current_layer)
.use_text(&current_line, font_size, margin_left, y_position, &font);
y_position -= line_height;
current_line.clear();
// Check for new page
if y_position < margin_bottom {
let (new_page, new_layer) = doc.add_page(Mm(210.0), Mm(297.0), "Page layer");
current_page = new_page;
current_layer = new_layer;
y_position = margin_top;
}
}
}
if !current_line.is_empty() {
current_line.push(' ');
}
current_line.push_str(word);
}
// Write remaining text in line
if !current_line.is_empty() {
doc.get_page(current_page)
.get_layer(current_layer)
.use_text(&current_line, font_size, margin_left, y_position, &font);
y_position -= line_height;
}
}
// Save PDF
doc.save(&mut BufWriter::new(File::create(pdf_path)?))?;
Ok(())
}
/// Convert PDF to images using pure Rust
pub fn pdf_to_images_pure(
&self,
pdf_path: &Path,
output_dir: &Path,
format: ImageFormat,
) -> Result<Vec<PathBuf>> {
// Parse PDF
let doc = lopdf::Document::load(pdf_path)?;
let pages = doc.get_pages();
fs::create_dir_all(output_dir)?;
let mut output_paths = Vec::new();
// For each page, render to image
for (page_num, (_page_num, _page_id)) in pages.iter().enumerate() {
// Create a blank image for the page
// In a real implementation, you would render the PDF content
let img = self.render_pdf_page_to_image(&doc, page_num)?;
// Save image
let extension = match format {
ImageFormat::Png => "png",
ImageFormat::Jpeg => "jpg",
_ => "png",
};
let output_path = output_dir.join(format!("page_{:03}.{}", page_num + 1, extension));
img.save_with_format(&output_path, format)?;
output_paths.push(output_path);
}
Ok(output_paths)
}
/// Render a PDF page to image (simplified implementation)
fn render_pdf_page_to_image(&self, _doc: &lopdf::Document, _page_num: usize) -> Result<DynamicImage> {
// This is a simplified implementation
// A full implementation would parse PDF content and render it
// Create a white image as placeholder
let width = 1240; // A4 at 150 DPI
let height = 1754; // A4 at 150 DPI
let mut img = RgbaImage::new(width, height);
// Fill with white background
for pixel in img.pixels_mut() {
*pixel = Rgba([255, 255, 255, 255]);
}
// Add a simple text indicator
// In production, you would properly render PDF content
Ok(DynamicImage::ImageRgba8(img))
}
/// Convert DOCX to images using pure Rust
pub fn docx_to_images_pure(
&self,
docx_path: &Path,
output_dir: &Path,
format: ImageFormat,
) -> Result<Vec<PathBuf>> {
// First convert to PDF
let temp_pdf = NamedTempFile::new()?.into_temp_path();
self.docx_to_pdf_pure(docx_path, &temp_pdf)?;
// Then convert PDF to images
self.pdf_to_images_pure(&temp_pdf, output_dir, format)
}
/// Create a thumbnail from an image
pub fn create_thumbnail(
&self,
image_path: &Path,
output_path: &Path,
width: u32,
height: u32,
) -> Result<()> {
let img = image::open(image_path)
.with_context(|| format!("Failed to open image {:?}", image_path))?;
let thumbnail = img.thumbnail(width, height);
thumbnail.save(output_path)
.with_context(|| format!("Failed to save thumbnail to {:?}", output_path))?;
info!("Created thumbnail {}x{} at {:?}", width, height, output_path);
Ok(())
}
/// Merge multiple PDFs using pure Rust
pub fn merge_pdfs_pure(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> {
use lopdf::{Document, Object, ObjectId};
// Create a new document for merging
let mut merged_doc = Document::with_version("1.5");
// Track page tree
let mut all_pages = Vec::new();
for pdf_path in pdf_paths {
let doc = Document::load(pdf_path)?;
// Get pages from the document
let pages = doc.get_pages();
for (_page_num, page_id) in pages.iter() {
// Clone the page object
if let Ok(page_obj) = doc.get_object(*page_id) {
let new_id = merged_doc.new_object_id();
merged_doc.objects.insert(new_id, page_obj.clone());
all_pages.push(new_id);
}
}
}
// Build the page tree for merged document
let pages_id = merged_doc.new_object_id();
let pages_dict = lopdf::dictionary! {
"Type" => "Pages",
"Kids" => all_pages.iter().map(|id| Object::Reference(*id)).collect::<Vec<_>>(),
"Count" => all_pages.len() as i32,
};
merged_doc.objects.insert(pages_id, Object::Dictionary(pages_dict));
// Update catalog
let catalog_id = merged_doc.new_object_id();
let catalog = lopdf::dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
};
merged_doc.objects.insert(catalog_id, Object::Dictionary(catalog));
merged_doc.trailer.set("Root", Object::Reference(catalog_id));
// Save the merged PDF
merged_doc.save(output_path)?;
info!("Successfully merged {} PDFs into {:?}", pdf_paths.len(), output_path);
Ok(())
}
/// Split a PDF into individual pages using pure Rust
pub fn split_pdf_pure(&self, pdf_path: &Path, output_dir: &Path) -> Result<Vec<PathBuf>> {
use lopdf::Document;
fs::create_dir_all(output_dir)?;
let doc = Document::load(pdf_path)?;
let pages = doc.get_pages();
let mut output_paths = Vec::new();
for (i, (_page_num, page_id)) in pages.iter().enumerate() {
// Create a new document with just this page
let mut single_page_doc = Document::with_version("1.5");
// Clone the page
if let Ok(page_obj) = doc.get_object(*page_id) {
let new_page_id = single_page_doc.new_object_id();
single_page_doc.objects.insert(new_page_id, page_obj.clone());
// Create page tree
let pages_id = single_page_doc.new_object_id();
let pages_dict = lopdf::dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(new_page_id)],
"Count" => 1,
};
single_page_doc.objects.insert(pages_id, Object::Dictionary(pages_dict));
// Create catalog
let catalog_id = single_page_doc.new_object_id();
let catalog = lopdf::dictionary! {
"Type" => "Catalog",
"Pages" => Object::Reference(pages_id),
};
single_page_doc.objects.insert(catalog_id, Object::Dictionary(catalog));
single_page_doc.trailer.set("Root", Object::Reference(catalog_id));
// Save the page
let output_path = output_dir.join(format!("page_{:03}.pdf", i + 1));
single_page_doc.save(&output_path)?;
output_paths.push(output_path);
}
}
info!("Split PDF into {} pages", output_paths.len());
Ok(output_paths)
}
/// Parse and render markdown to PDF
pub fn markdown_to_pdf(&self, markdown: &str, pdf_path: &Path) -> Result<()> {
use pulldown_cmark::{Parser, Event, Tag, TagEnd};
let parser = Parser::new(markdown);
let mut plain_text = String::new();
let mut in_code_block = false;
let mut list_depth = 0;
for event in parser {
match event {
Event::Text(text) => {
if in_code_block {
plain_text.push_str(" ");
} else if list_depth > 0 {
plain_text.push_str(&" ".repeat(list_depth));
}
plain_text.push_str(&text);
}
Event::Start(tag) => {
match tag {
Tag::Heading { level, .. } => {
plain_text.push('\n');
plain_text.push_str(&"#".repeat(level as usize));
plain_text.push(' ');
}
Tag::Paragraph => {
if !plain_text.is_empty() {
plain_text.push_str("\n\n");
}
}
Tag::List(_) => {
list_depth += 1;
plain_text.push('\n');
}
Tag::Item => {
plain_text.push_str("");
}
Tag::CodeBlock(_) => {
in_code_block = true;
plain_text.push_str("\n\n");
}
Tag::Emphasis => plain_text.push('*'),
Tag::Strong => plain_text.push_str("**"),
_ => {}
}
}
Event::End(tag) => {
match tag {
TagEnd::Heading(_) => plain_text.push_str("\n\n"),
TagEnd::Paragraph => plain_text.push('\n'),
TagEnd::List(_) => {
list_depth = list_depth.saturating_sub(1);
plain_text.push('\n');
}
TagEnd::Item => plain_text.push('\n'),
TagEnd::CodeBlock => {
in_code_block = false;
plain_text.push_str("\n\n");
}
TagEnd::Emphasis => plain_text.push('*'),
TagEnd::Strong => plain_text.push_str("**"),
_ => {}
}
}
Event::Code(code) => {
plain_text.push('`');
plain_text.push_str(&code);
plain_text.push('`');
}
Event::SoftBreak => plain_text.push(' '),
Event::HardBreak => plain_text.push('\n'),
_ => {}
}
}
self.create_pdf_from_text(&plain_text, pdf_path)?;
Ok(())
}
}
+397
View File
@@ -0,0 +1,397 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::env;
use tracing::{debug, info, warn};
/// Security configuration for the MCP server
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityConfig {
/// If true, only allow read-only operations
pub readonly_mode: bool,
/// Whitelist of allowed commands (if set, only these commands are allowed)
pub command_whitelist: Option<HashSet<String>>,
/// Blacklist of forbidden commands (if set, these commands are blocked)
pub command_blacklist: Option<HashSet<String>>,
/// Maximum document size in bytes (default: 100MB)
pub max_document_size: usize,
/// Maximum number of open documents (default: 50)
pub max_open_documents: usize,
/// Allow external tool usage (LibreOffice, etc.)
pub allow_external_tools: bool,
/// Allow network operations (downloading templates, fonts, etc.)
pub allow_network: bool,
/// Sandbox mode - restricts file operations to temp directory only
pub sandbox_mode: bool,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
readonly_mode: false,
command_whitelist: None,
command_blacklist: None,
max_document_size: 100 * 1024 * 1024, // 100MB
max_open_documents: 50,
allow_external_tools: true,
allow_network: true,
sandbox_mode: false,
}
}
}
impl SecurityConfig {
/// Load configuration from environment variables
pub fn from_env() -> Self {
let mut config = Self::default();
// Check for readonly mode
if env::var("DOCX_MCP_READONLY").unwrap_or_default() == "true" {
config.readonly_mode = true;
info!("Running in READONLY mode - only viewing operations allowed");
}
// Check for command whitelist
if let Ok(whitelist) = env::var("DOCX_MCP_WHITELIST") {
let commands: HashSet<String> = whitelist
.split(',')
.map(|s| s.trim().to_string())
.collect();
config.command_whitelist = Some(commands.clone());
info!("Command whitelist enabled with {} commands", commands.len());
}
// Check for command blacklist
if let Ok(blacklist) = env::var("DOCX_MCP_BLACKLIST") {
let commands: HashSet<String> = blacklist
.split(',')
.map(|s| s.trim().to_string())
.collect();
config.command_blacklist = Some(commands.clone());
info!("Command blacklist enabled with {} blocked commands", commands.len());
}
// Check for sandbox mode
if env::var("DOCX_MCP_SANDBOX").unwrap_or_default() == "true" {
config.sandbox_mode = true;
config.allow_external_tools = false;
config.allow_network = false;
info!("Running in SANDBOX mode - restricted file operations");
}
// Check for external tools permission
if env::var("DOCX_MCP_NO_EXTERNAL_TOOLS").unwrap_or_default() == "true" {
config.allow_external_tools = false;
info!("External tools disabled");
}
// Check for network permission
if env::var("DOCX_MCP_NO_NETWORK").unwrap_or_default() == "true" {
config.allow_network = false;
info!("Network operations disabled");
}
// Max document size
if let Ok(size) = env::var("DOCX_MCP_MAX_SIZE") {
if let Ok(bytes) = size.parse::<usize>() {
config.max_document_size = bytes;
info!("Max document size set to {} bytes", bytes);
}
}
// Max open documents
if let Ok(max) = env::var("DOCX_MCP_MAX_DOCS") {
if let Ok(count) = max.parse::<usize>() {
config.max_open_documents = count;
info!("Max open documents set to {}", count);
}
}
config
}
/// Check if a command is allowed based on security configuration
pub fn is_command_allowed(&self, command: &str) -> bool {
// First check if it's a readonly command
let readonly_commands = Self::get_readonly_commands();
let is_readonly_command = readonly_commands.contains(command);
// In readonly mode, only allow readonly commands
if self.readonly_mode && !is_readonly_command {
debug!("Command '{}' blocked: readonly mode", command);
return false;
}
// Check whitelist (if set, only whitelisted commands are allowed)
if let Some(ref whitelist) = self.command_whitelist {
if !whitelist.contains(command) {
debug!("Command '{}' blocked: not in whitelist", command);
return false;
}
}
// Check blacklist (if set, blacklisted commands are blocked)
if let Some(ref blacklist) = self.command_blacklist {
if blacklist.contains(command) {
debug!("Command '{}' blocked: in blacklist", command);
return false;
}
}
// Additional checks for specific command categories
if command.starts_with("convert_") && !self.allow_external_tools {
debug!("Command '{}' blocked: external tools disabled", command);
return false;
}
true
}
/// Get list of readonly commands
pub fn get_readonly_commands() -> HashSet<&'static str> {
let mut commands = HashSet::new();
// Document viewing commands
commands.insert("open_document");
commands.insert("extract_text");
commands.insert("get_metadata");
commands.insert("list_documents");
commands.insert("get_document_info");
commands.insert("read_paragraph");
commands.insert("read_table");
commands.insert("read_section");
commands.insert("search_text");
commands.insert("get_document_structure");
commands.insert("get_styles");
commands.insert("get_headers_footers");
commands.insert("get_page_count");
commands.insert("get_word_count");
commands.insert("get_table_of_contents");
commands.insert("list_bookmarks");
commands.insert("list_hyperlinks");
commands.insert("list_comments");
commands.insert("list_footnotes");
commands.insert("list_endnotes");
commands.insert("get_document_properties");
// Analysis commands
commands.insert("analyze_formatting");
commands.insert("check_spelling");
commands.insert("check_grammar");
commands.insert("get_statistics");
commands.insert("compare_documents");
// Export commands (readonly as they don't modify the original)
commands.insert("export_to_json");
commands.insert("export_to_markdown");
commands.insert("export_to_html");
commands.insert("create_preview");
commands
}
/// Get list of write commands (for documentation)
pub fn get_write_commands() -> HashSet<&'static str> {
let mut commands = HashSet::new();
// Document creation/modification
commands.insert("create_document");
commands.insert("save_document");
commands.insert("close_document");
// Content addition
commands.insert("add_paragraph");
commands.insert("add_heading");
commands.insert("add_table");
commands.insert("add_list");
commands.insert("add_page_break");
commands.insert("add_section_break");
commands.insert("add_image");
commands.insert("add_chart");
commands.insert("add_shape");
commands.insert("add_hyperlink");
commands.insert("add_bookmark");
commands.insert("add_footnote");
commands.insert("add_endnote");
commands.insert("add_comment");
commands.insert("add_watermark");
// Content modification
commands.insert("edit_paragraph");
commands.insert("delete_paragraph");
commands.insert("find_and_replace");
commands.insert("update_table");
commands.insert("update_style");
commands.insert("set_header");
commands.insert("set_footer");
commands.insert("set_margins");
commands.insert("set_page_size");
commands.insert("apply_template");
commands.insert("apply_style");
commands.insert("apply_theme");
// Document operations
commands.insert("merge_documents");
commands.insert("split_document");
commands.insert("convert_to_pdf");
commands.insert("convert_to_images");
commands.insert("protect_document");
commands.insert("unprotect_document");
commands.insert("track_changes");
commands.insert("accept_changes");
commands.insert("reject_changes");
commands
}
/// Check if a file path is allowed based on sandbox configuration
pub fn is_path_allowed(&self, path: &std::path::Path) -> bool {
if !self.sandbox_mode {
return true;
}
// In sandbox mode, only allow operations in temp directory
let temp_dir = std::env::temp_dir();
if let Ok(canonical_path) = path.canonicalize() {
if let Ok(canonical_temp) = temp_dir.canonicalize() {
return canonical_path.starts_with(canonical_temp);
}
}
false
}
/// Get a summary of current security settings
pub fn get_summary(&self) -> String {
let mut summary = Vec::new();
if self.readonly_mode {
summary.push("📖 READONLY MODE");
}
if self.sandbox_mode {
summary.push("🔒 SANDBOX MODE");
}
if let Some(ref whitelist) = self.command_whitelist {
summary.push(&format!("✅ Whitelist: {} commands", whitelist.len()));
}
if let Some(ref blacklist) = self.command_blacklist {
summary.push(&format!("🚫 Blacklist: {} commands", blacklist.len()));
}
if !self.allow_external_tools {
summary.push("🔧 No external tools");
}
if !self.allow_network {
summary.push("🌐 No network access");
}
if summary.is_empty() {
"Standard mode (all features enabled)".to_string()
} else {
summary.join(" | ")
}
}
}
/// Security middleware to check commands before execution
pub struct SecurityMiddleware {
config: SecurityConfig,
}
impl SecurityMiddleware {
pub fn new(config: SecurityConfig) -> Self {
Self { config }
}
/// Check if a command should be allowed to execute
pub fn check_command(&self, command: &str, arguments: &serde_json::Value) -> Result<(), SecurityError> {
// Check if command is allowed
if !self.config.is_command_allowed(command) {
return Err(SecurityError::CommandNotAllowed(command.to_string()));
}
// Check file paths in arguments if in sandbox mode
if self.config.sandbox_mode {
self.check_paths_in_arguments(arguments)?;
}
// Check document size limits for open/create operations
if command == "open_document" {
if let Some(path) = arguments.get("path").and_then(|v| v.as_str()) {
self.check_file_size(path)?;
}
}
Ok(())
}
fn check_paths_in_arguments(&self, arguments: &serde_json::Value) -> Result<(), SecurityError> {
// Recursively check all string values that look like paths
match arguments {
serde_json::Value::String(s) => {
if s.contains('/') || s.contains('\\') {
let path = std::path::Path::new(s);
if !self.config.is_path_allowed(path) {
return Err(SecurityError::PathNotAllowed(s.to_string()));
}
}
}
serde_json::Value::Object(map) => {
for value in map.values() {
self.check_paths_in_arguments(value)?;
}
}
serde_json::Value::Array(arr) => {
for value in arr {
self.check_paths_in_arguments(value)?;
}
}
_ => {}
}
Ok(())
}
fn check_file_size(&self, path: &str) -> Result<(), SecurityError> {
let file_path = std::path::Path::new(path);
if let Ok(metadata) = std::fs::metadata(file_path) {
if metadata.len() as usize > self.config.max_document_size {
return Err(SecurityError::FileTooLarge {
size: metadata.len() as usize,
max_size: self.config.max_document_size,
});
}
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum SecurityError {
#[error("Command not allowed: {0}")]
CommandNotAllowed(String),
#[error("Path not allowed in sandbox mode: {0}")]
PathNotAllowed(String),
#[error("File too large: {size} bytes (max: {max_size} bytes)")]
FileTooLarge { size: usize, max_size: usize },
#[error("Maximum number of open documents exceeded")]
TooManyDocuments,
#[error("Operation requires external tools which are disabled")]
ExternalToolsDisabled,
#[error("Operation requires network access which is disabled")]
NetworkDisabled,
}
+497
View File
@@ -0,0 +1,497 @@
use anyhow::Result;
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
use docx_mcp::pure_converter::PureRustConverter;
use tempfile::TempDir;
use std::path::{Path, PathBuf};
use std::fs;
use pretty_assertions::assert_eq;
use rstest::*;
fn setup_test_handler_with_content() -> (DocxHandler, String, TempDir) {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
let doc_id = handler.create_document().unwrap();
// Add comprehensive content for testing
handler.add_heading(&doc_id, "Test Document Title", 1).unwrap();
handler.add_paragraph(&doc_id, "This is a comprehensive test document with various content types.", None).unwrap();
handler.add_heading(&doc_id, "Table Example", 2).unwrap();
let table_data = TableData {
rows: vec![
vec!["Product".to_string(), "Price".to_string(), "Quantity".to_string()],
vec!["Widget A".to_string(), "$10.00".to_string(), "5".to_string()],
vec!["Widget B".to_string(), "$15.00".to_string(), "3".to_string()],
],
headers: Some(vec!["Product".to_string(), "Price".to_string(), "Quantity".to_string()]),
border_style: Some("single".to_string()),
};
handler.add_table(&doc_id, table_data).unwrap();
handler.add_heading(&doc_id, "List Example", 2).unwrap();
let list_items = vec![
"First important point".to_string(),
"Second key feature".to_string(),
"Third critical aspect".to_string(),
];
handler.add_list(&doc_id, list_items, false).unwrap();
handler.add_paragraph(&doc_id, "Conclusion: This document demonstrates various formatting capabilities.", None).unwrap();
(handler, doc_id, temp_dir)
}
#[test]
fn test_pure_converter_creation() {
let converter = PureRustConverter::new();
// Just verify it can be created without panicking
assert!(true);
}
#[test]
fn test_extract_text_from_docx() -> Result<()> {
let (handler, doc_id, _temp_dir) = setup_test_handler_with_content();
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let extracted_text = converter.extract_text_from_docx(&metadata.path)?;
// Should contain all the content we added
assert!(extracted_text.contains("Test Document Title"));
assert!(extracted_text.contains("comprehensive test document"));
assert!(extracted_text.contains("Table Example"));
assert!(extracted_text.contains("Widget A"));
assert!(extracted_text.contains("First important point"));
assert!(extracted_text.contains("Conclusion"));
Ok(())
}
#[test]
fn test_extract_text_empty_document() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
let doc_id = handler.create_document().unwrap();
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let extracted_text = converter.extract_text_from_docx(&metadata.path)?;
// Empty document should return empty or whitespace-only text
assert!(extracted_text.trim().is_empty());
Ok(())
}
#[test]
fn test_convert_docx_to_pdf_basic() -> Result<()> {
let (handler, doc_id, temp_dir) = setup_test_handler_with_content();
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let output_path = temp_dir.path().join("test_output.pdf");
converter.convert_docx_to_pdf(&metadata.path, &output_path)?;
// Verify PDF file was created
assert!(output_path.exists());
// Check file size is reasonable (should be larger than empty PDF)
let file_size = fs::metadata(&output_path)?.len();
assert!(file_size > 1000); // PDF should be at least 1KB
// Verify it's actually a PDF file (starts with PDF signature)
let pdf_content = fs::read(&output_path)?;
assert!(pdf_content.starts_with(b"%PDF"));
Ok(())
}
#[test]
fn test_convert_docx_to_pdf_with_complex_content() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
let doc_id = handler.create_document().unwrap();
// Add content with special characters and formatting
handler.add_paragraph(&doc_id, "Special characters: éñüñ, 中文, русский, العربية", None)?;
let style = DocxStyle {
font_family: Some("Arial".to_string()),
font_size: Some(16),
bold: Some(true),
italic: Some(false),
underline: Some(true),
color: Some("#FF0000".to_string()),
alignment: Some("center".to_string()),
line_spacing: Some(1.5),
};
handler.add_paragraph(&doc_id, "Bold and underlined text", Some(style))?;
// Add multiple headings
for level in 1..=3 {
handler.add_heading(&doc_id, &format!("Heading Level {}", level), level)?;
}
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let output_path = temp_dir.path().join("complex_output.pdf");
converter.convert_docx_to_pdf(&metadata.path, &output_path)?;
assert!(output_path.exists());
let file_size = fs::metadata(&output_path)?.len();
assert!(file_size > 2000); // Should be larger due to more content
Ok(())
}
#[test]
fn test_convert_docx_to_images() -> Result<()> {
let (handler, doc_id, temp_dir) = setup_test_handler_with_content();
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let output_dir = temp_dir.path().join("images");
fs::create_dir_all(&output_dir)?;
let image_paths = converter.convert_docx_to_images(&metadata.path, &output_dir)?;
// Should generate at least one image
assert!(!image_paths.is_empty());
// Verify all generated images exist
for image_path in &image_paths {
assert!(image_path.exists(), "Generated image should exist: {:?}", image_path);
let file_size = fs::metadata(image_path)?.len();
assert!(file_size > 100, "Image file should have reasonable size");
// Verify it's a PNG file (our default format)
if image_path.extension().and_then(|s| s.to_str()) == Some("png") {
let image_content = fs::read(image_path)?;
assert!(image_content.starts_with(&[0x89, 0x50, 0x4E, 0x47]), "Should be valid PNG");
}
}
Ok(())
}
#[test]
fn test_convert_docx_to_images_custom_format() -> Result<()> {
let (handler, doc_id, temp_dir) = setup_test_handler_with_content();
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let output_dir = temp_dir.path().join("jpeg_images");
fs::create_dir_all(&output_dir)?;
let image_paths = converter.convert_docx_to_images_with_format(&metadata.path, &output_dir, "jpeg", 150)?;
assert!(!image_paths.is_empty());
for image_path in &image_paths {
assert!(image_path.exists());
// Verify JPEG format
if image_path.extension().and_then(|s| s.to_str()) == Some("jpg") ||
image_path.extension().and_then(|s| s.to_str()) == Some("jpeg") {
let image_content = fs::read(image_path)?;
assert!(image_content.starts_with(&[0xFF, 0xD8, 0xFF]), "Should be valid JPEG");
}
}
Ok(())
}
#[test]
fn test_pdf_generation_with_embedded_fonts() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
let doc_id = handler.create_document().unwrap();
// Add text that might require different fonts
handler.add_paragraph(&doc_id, "Regular ASCII text", None)?;
handler.add_paragraph(&doc_id, "Unicode: àáâãäå çèéêë ìíîï ñòóôõö ùúûü ýÿ", None)?;
handler.add_paragraph(&doc_id, "Math symbols: ∑ ∏ ∫ √ ≤ ≥ ≠ ± ∞", None)?;
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let output_path = temp_dir.path().join("embedded_fonts.pdf");
converter.convert_docx_to_pdf(&metadata.path, &output_path)?;
assert!(output_path.exists());
let file_size = fs::metadata(&output_path)?.len();
assert!(file_size > 5000); // Should be larger due to embedded fonts
Ok(())
}
#[test]
fn test_batch_conversion() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
// Create multiple documents
let mut doc_paths = Vec::new();
for i in 0..3 {
let doc_id = handler.create_document().unwrap();
handler.add_paragraph(&doc_id, &format!("Document {} content", i), None)?;
let metadata = handler.get_metadata(&doc_id)?;
doc_paths.push(metadata.path);
}
let converter = PureRustConverter::new();
let output_dir = temp_dir.path().join("batch_output");
fs::create_dir_all(&output_dir)?;
// Convert all documents to PDF
for (i, doc_path) in doc_paths.iter().enumerate() {
let output_path = output_dir.join(format!("document_{}.pdf", i));
converter.convert_docx_to_pdf(doc_path, &output_path)?;
assert!(output_path.exists());
}
// Verify all PDFs were created
let pdf_files: Vec<_> = fs::read_dir(&output_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("pdf"))
.collect();
assert_eq!(pdf_files.len(), 3);
Ok(())
}
#[test]
fn test_error_handling_invalid_docx() {
let temp_dir = TempDir::new().unwrap();
let converter = PureRustConverter::new();
// Create a fake DOCX file (actually just text)
let fake_docx = temp_dir.path().join("fake.docx");
fs::write(&fake_docx, "This is not a DOCX file").unwrap();
// Should handle the error gracefully
let result = converter.extract_text_from_docx(&fake_docx);
assert!(result.is_err());
let output_path = temp_dir.path().join("output.pdf");
let result = converter.convert_docx_to_pdf(&fake_docx, &output_path);
assert!(result.is_err());
}
#[test]
fn test_error_handling_nonexistent_file() {
let temp_dir = TempDir::new().unwrap();
let converter = PureRustConverter::new();
let nonexistent = temp_dir.path().join("nonexistent.docx");
let result = converter.extract_text_from_docx(&nonexistent);
assert!(result.is_err());
let output_path = temp_dir.path().join("output.pdf");
let result = converter.convert_docx_to_pdf(&nonexistent, &output_path);
assert!(result.is_err());
}
#[test]
fn test_large_document_conversion() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
let doc_id = handler.create_document().unwrap();
// Create a large document with many pages
for i in 0..50 {
handler.add_heading(&doc_id, &format!("Section {}", i + 1), 1)?;
for j in 0..10 {
let content = format!("This is paragraph {} in section {}. It contains enough text to make the document substantial and test the conversion capabilities with larger files.", j + 1, i + 1);
handler.add_paragraph(&doc_id, &content, None)?;
}
if i % 10 == 9 {
handler.add_page_break(&doc_id)?;
}
}
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
// Test PDF conversion
let pdf_path = temp_dir.path().join("large_document.pdf");
converter.convert_docx_to_pdf(&metadata.path, &pdf_path)?;
assert!(pdf_path.exists());
let pdf_size = fs::metadata(&pdf_path)?.len();
assert!(pdf_size > 50000); // Should be a substantial PDF
// Test image conversion (but only first few pages to avoid excessive test time)
let images_dir = temp_dir.path().join("large_images");
fs::create_dir_all(&images_dir)?;
let image_paths = converter.convert_docx_to_images(&metadata.path, &images_dir)?;
assert!(!image_paths.is_empty());
// Should generate multiple images for multiple pages
assert!(image_paths.len() >= 2);
Ok(())
}
#[test]
fn test_text_extraction_accuracy() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
let doc_id = handler.create_document().unwrap();
// Add specific test content
let test_sentences = vec![
"The quick brown fox jumps over the lazy dog.",
"Pack my box with five dozen liquor jugs.",
"How vexingly quick daft zebras jump!",
"Sphinx of black quartz, judge my vow.",
];
for sentence in &test_sentences {
handler.add_paragraph(&doc_id, sentence, None)?;
}
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let extracted_text = converter.extract_text_from_docx(&metadata.path)?;
// Verify all sentences are present in the extracted text
for sentence in &test_sentences {
assert!(extracted_text.contains(sentence),
"Extracted text should contain: '{}'", sentence);
}
// Check word count accuracy
let expected_words: usize = test_sentences.iter()
.map(|s| s.split_whitespace().count())
.sum();
let extracted_words = extracted_text.split_whitespace().count();
// Should be approximately equal (allowing for minor differences)
let word_diff = if extracted_words > expected_words {
extracted_words - expected_words
} else {
expected_words - extracted_words
};
assert!(word_diff <= 5, "Word count difference too large: expected ~{}, got {}", expected_words, extracted_words);
Ok(())
}
#[test]
fn test_conversion_with_different_page_sizes() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
let doc_id = handler.create_document().unwrap();
handler.add_paragraph(&doc_id, "This document tests page size handling during conversion.", None)?;
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
// Test different output formats and sizes
let test_cases = vec![
("a4.pdf", "A4"),
("letter.pdf", "Letter"),
("legal.pdf", "Legal"),
];
for (filename, _page_size) in test_cases {
let output_path = temp_dir.path().join(filename);
// Note: In a full implementation, you'd pass page_size to the converter
converter.convert_docx_to_pdf(&metadata.path, &output_path)?;
assert!(output_path.exists());
let file_size = fs::metadata(&output_path)?.len();
assert!(file_size > 500); // Reasonable minimum size
}
Ok(())
}
// Parametrized test for different image formats
#[rstest]
#[case("png", &[0x89, 0x50, 0x4E, 0x47])]
#[case("jpeg", &[0xFF, 0xD8, 0xFF])]
fn test_image_format_conversion(#[case] format: &str, #[case] signature: &[u8]) -> Result<()> {
let (handler, doc_id, temp_dir) = setup_test_handler_with_content();
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let output_dir = temp_dir.path().join(format!("{}_images", format));
fs::create_dir_all(&output_dir)?;
let image_paths = converter.convert_docx_to_images_with_format(&metadata.path, &output_dir, format, 100)?;
assert!(!image_paths.is_empty());
for image_path in &image_paths {
assert!(image_path.exists());
let image_content = fs::read(image_path)?;
assert!(image_content.starts_with(signature),
"Image should have correct format signature for {}", format);
}
Ok(())
}
#[test]
fn test_conversion_thread_safety() -> Result<()> {
use std::sync::Arc;
use std::thread;
let temp_dir = TempDir::new().unwrap();
let temp_path = Arc::new(temp_dir.path().to_path_buf());
let handles: Vec<_> = (0..3).map(|i| {
let temp_path = Arc::clone(&temp_path);
thread::spawn(move || -> Result<()> {
let mut handler = DocxHandler::new_with_temp_dir(&temp_path)?;
let doc_id = handler.create_document()?;
handler.add_paragraph(&doc_id, &format!("Thread {} test content", i), None)?;
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let pdf_path = temp_path.join(format!("thread_{}.pdf", i));
converter.convert_docx_to_pdf(&metadata.path, &pdf_path)?;
assert!(pdf_path.exists());
Ok(())
})
}).collect();
// Wait for all threads to complete
for handle in handles {
handle.join().unwrap()?;
}
// Verify all PDFs were created
let pdf_count = fs::read_dir(&temp_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("pdf"))
.count();
assert_eq!(pdf_count, 3);
Ok(())
}
+314
View File
@@ -0,0 +1,314 @@
use anyhow::Result;
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
use tempfile::TempDir;
use std::path::PathBuf;
use pretty_assertions::assert_eq;
use rstest::*;
use chrono::Utc;
fn setup_test_handler() -> (DocxHandler, TempDir) {
let temp_dir = TempDir::new().unwrap();
let handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
(handler, temp_dir)
}
#[fixture]
fn handler_and_doc() -> (DocxHandler, String, TempDir) {
let (mut handler, temp_dir) = setup_test_handler();
let doc_id = handler.create_document().unwrap();
(handler, doc_id, temp_dir)
}
#[test]
fn test_create_document() {
let (mut handler, _temp_dir) = setup_test_handler();
let doc_id = handler.create_document().unwrap();
assert!(!doc_id.is_empty());
// Document should be in the handler's registry
assert!(handler.documents.contains_key(&doc_id));
let metadata = handler.get_metadata(&doc_id).unwrap();
assert_eq!(metadata.id, doc_id);
assert!(metadata.path.exists());
}
#[test]
fn test_add_paragraph() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
let result = handler.add_paragraph(&doc_id, "Test paragraph", None);
assert!(result.is_ok());
// Verify content was added by extracting text
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains("Test paragraph"));
}
#[test]
fn test_add_paragraph_with_style() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
let style = DocxStyle {
font_family: Some("Arial".to_string()),
font_size: Some(14),
bold: Some(true),
italic: Some(false),
underline: Some(false),
color: Some("#FF0000".to_string()),
alignment: Some("center".to_string()),
line_spacing: Some(1.5),
};
let result = handler.add_paragraph(&doc_id, "Styled paragraph", Some(style));
assert!(result.is_ok());
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains("Styled paragraph"));
}
#[test]
fn test_add_heading() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
for level in 1..=6 {
let heading_text = format!("Heading Level {}", level);
let result = handler.add_heading(&doc_id, &heading_text, level);
assert!(result.is_ok(), "Failed to add heading level {}", level);
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains(&heading_text));
}
}
#[test]
fn test_add_table() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
let table_data = TableData {
rows: vec![
vec!["Name".to_string(), "Age".to_string(), "City".to_string()],
vec!["John".to_string(), "30".to_string(), "NYC".to_string()],
vec!["Jane".to_string(), "25".to_string(), "LA".to_string()],
],
headers: Some(vec!["Name".to_string(), "Age".to_string(), "City".to_string()]),
border_style: Some("single".to_string()),
};
let result = handler.add_table(&doc_id, table_data);
assert!(result.is_ok());
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains("John"));
assert!(text.contains("Jane"));
assert!(text.contains("NYC"));
}
#[test]
fn test_add_list() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
let items = vec![
"First item".to_string(),
"Second item".to_string(),
"Third item".to_string(),
];
// Test unordered list
let result = handler.add_list(&doc_id, items.clone(), false);
assert!(result.is_ok());
// Test ordered list
let result = handler.add_list(&doc_id, items.clone(), true);
assert!(result.is_ok());
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains("First item"));
assert!(text.contains("Second item"));
assert!(text.contains("Third item"));
}
#[test]
fn test_set_header_footer() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
let header_result = handler.set_header(&doc_id, "Document Header");
assert!(header_result.is_ok());
let footer_result = handler.set_footer(&doc_id, "Document Footer");
assert!(footer_result.is_ok());
}
#[test]
fn test_add_page_break() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
handler.add_paragraph(&doc_id, "Before page break", None).unwrap();
let result = handler.add_page_break(&doc_id);
assert!(result.is_ok());
handler.add_paragraph(&doc_id, "After page break", None).unwrap();
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains("Before page break"));
assert!(text.contains("After page break"));
}
#[test]
fn test_extract_text_empty_document() {
let (handler, doc_id, _temp_dir) = handler_and_doc();
let text = handler.extract_text(&doc_id).unwrap();
// Empty document might have some default content or be truly empty
assert!(text.is_empty() || text.trim().is_empty());
}
#[test]
fn test_save_and_close_document() {
let (mut handler, doc_id, temp_dir) = handler_and_doc();
handler.add_paragraph(&doc_id, "Test content", None).unwrap();
let save_path = temp_dir.path().join("test_output.docx");
let save_result = handler.save_document(&doc_id, &save_path);
assert!(save_result.is_ok());
assert!(save_path.exists());
let close_result = handler.close_document(&doc_id);
assert!(close_result.is_ok());
assert!(!handler.documents.contains_key(&doc_id));
}
#[test]
fn test_open_existing_document() {
let (mut handler, doc_id, temp_dir) = handler_and_doc();
// Create and save a document
handler.add_paragraph(&doc_id, "Original content", None).unwrap();
let save_path = temp_dir.path().join("existing.docx");
handler.save_document(&doc_id, &save_path).unwrap();
handler.close_document(&doc_id).unwrap();
// Open the saved document
let opened_doc_id = handler.open_document(&save_path).unwrap();
assert_ne!(opened_doc_id, doc_id); // Should be a new ID
let text = handler.extract_text(&opened_doc_id).unwrap();
assert!(text.contains("Original content"));
}
#[test]
fn test_list_documents() {
let (mut handler, _temp_dir) = setup_test_handler();
// Initially should be empty
let docs = handler.list_documents();
let initial_count = docs.len();
// Create some documents
let _doc1 = handler.create_document().unwrap();
let _doc2 = handler.create_document().unwrap();
let _doc3 = handler.create_document().unwrap();
let docs = handler.list_documents();
assert_eq!(docs.len(), initial_count + 3);
}
#[test]
fn test_document_not_found_error() {
let (handler, _temp_dir) = setup_test_handler();
let fake_id = "nonexistent-document-id";
let result = handler.extract_text(fake_id);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Document not found"));
}
#[test]
fn test_get_metadata() {
let (handler, doc_id, _temp_dir) = handler_and_doc();
let metadata = handler.get_metadata(&doc_id).unwrap();
assert_eq!(metadata.id, doc_id);
assert!(metadata.path.exists());
assert!(metadata.created_at <= Utc::now());
assert!(metadata.modified_at <= Utc::now());
assert_eq!(metadata.page_count, Some(1));
assert_eq!(metadata.word_count, Some(0));
}
#[test]
fn test_concurrent_document_operations() {
use std::sync::Arc;
use std::sync::Mutex;
use std::thread;
let (handler, _temp_dir) = setup_test_handler();
let handler = Arc::new(Mutex::new(handler));
let handles: Vec<_> = (0..5).map(|i| {
let handler = Arc::clone(&handler);
thread::spawn(move || {
let doc_id = {
let mut h = handler.lock().unwrap();
h.create_document().unwrap()
};
{
let mut h = handler.lock().unwrap();
h.add_paragraph(&doc_id, &format!("Thread {} content", i), None).unwrap();
}
{
let h = handler.lock().unwrap();
let text = h.extract_text(&doc_id).unwrap();
assert!(text.contains(&format!("Thread {} content", i)));
}
doc_id
})
}).collect();
let doc_ids: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// All documents should be different
let mut unique_ids = doc_ids.clone();
unique_ids.sort();
unique_ids.dedup();
assert_eq!(unique_ids.len(), doc_ids.len());
}
#[test]
fn test_large_document_creation() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
// Add many paragraphs to test performance
for i in 0..100 {
let content = format!("Paragraph number {} with some content to make it realistic", i);
handler.add_paragraph(&doc_id, &content, None).unwrap();
}
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains("Paragraph number 0"));
assert!(text.contains("Paragraph number 99"));
// Verify word count
let words: Vec<&str> = text.split_whitespace().collect();
assert!(words.len() > 1000); // Should have many words
}
#[test]
fn test_special_characters_in_content() {
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
let special_content = "Special chars: éñüñdéd, 中文, русский, العربية, 🚀📝✨";
handler.add_paragraph(&doc_id, special_content, None).unwrap();
let text = handler.extract_text(&doc_id).unwrap();
assert!(text.contains("éñüñdéd"));
assert!(text.contains("🚀📝✨"));
}
+910
View File
@@ -0,0 +1,910 @@
use anyhow::Result;
use docx_mcp::docx_tools::DocxToolsProvider;
use docx_mcp::security::SecurityConfig;
use mcp_core::{ToolProvider, ToolResult};
use serde_json::json;
use tempfile::TempDir;
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use pretty_assertions::assert_eq;
use tokio_test;
/// Test complete document creation workflow from start to finish
#[tokio::test]
async fn test_complete_document_workflow() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
std::env::set_var("TMPDIR", temp_dir.path());
let provider = DocxToolsProvider::new();
// Step 1: Create a new document
let create_result = provider.call_tool("create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
value["document_id"].as_str().unwrap().to_string()
},
ToolResult::Error(e) => panic!("Failed to create document: {}", e),
};
// Step 2: Add document structure
let title_result = provider.call_tool("add_heading", json!({
"document_id": doc_id,
"text": "Annual Report 2024",
"level": 1
})).await;
assert!(matches!(title_result, ToolResult::Success(_)));
// Step 3: Add introduction
let intro_result = provider.call_tool("add_paragraph", json!({
"document_id": doc_id,
"text": "This annual report provides a comprehensive overview of our company's performance, achievements, and strategic direction for the year 2024.",
"style": {
"font_size": 12,
"alignment": "justify"
}
})).await;
assert!(matches!(intro_result, ToolResult::Success(_)));
// Step 4: Add executive summary section
let exec_heading_result = provider.call_tool("add_heading", json!({
"document_id": doc_id,
"text": "Executive Summary",
"level": 2
})).await;
assert!(matches!(exec_heading_result, ToolResult::Success(_)));
let exec_content = provider.call_tool("add_list", json!({
"document_id": doc_id,
"items": [
"Record revenue growth of 15% year-over-year",
"Successful expansion into three new markets",
"Launch of five innovative products",
"Achievement of carbon neutrality goals",
"Increased employee satisfaction by 20%"
],
"ordered": false
})).await;
assert!(matches!(exec_content, ToolResult::Success(_)));
// Step 5: Add financial data table
let financial_heading = provider.call_tool("add_heading", json!({
"document_id": doc_id,
"text": "Financial Highlights",
"level": 2
})).await;
assert!(matches!(financial_heading, ToolResult::Success(_)));
let table_result = provider.call_tool("add_table", json!({
"document_id": doc_id,
"rows": [
["Metric", "2023", "2024", "Change"],
["Revenue ($M)", "120.5", "138.6", "+15%"],
["Operating Income ($M)", "24.1", "29.3", "+22%"],
["Net Income ($M)", "18.2", "22.7", "+25%"],
["Employees", "1,250", "1,420", "+14%"]
]
})).await;
assert!(matches!(table_result, ToolResult::Success(_)));
// Step 6: Add page break and new section
let page_break_result = provider.call_tool("add_page_break", json!({
"document_id": doc_id
})).await;
assert!(matches!(page_break_result, ToolResult::Success(_)));
let strategy_heading = provider.call_tool("add_heading", json!({
"document_id": doc_id,
"text": "Strategic Initiatives",
"level": 2
})).await;
assert!(matches!(strategy_heading, ToolResult::Success(_)));
// Step 7: Add multiple paragraphs with different styles
let bold_paragraph = provider.call_tool("add_paragraph", json!({
"document_id": doc_id,
"text": "Digital Transformation: Our commitment to digital innovation remains at the forefront of our strategic priorities.",
"style": {
"bold": true,
"font_size": 13
}
})).await;
assert!(matches!(bold_paragraph, ToolResult::Success(_)));
let regular_paragraph = provider.call_tool("add_paragraph", json!({
"document_id": doc_id,
"text": "Throughout 2024, we have invested significantly in technology infrastructure, data analytics capabilities, and employee digital skills development. This comprehensive approach has resulted in improved operational efficiency and enhanced customer experience across all touchpoints."
})).await;
assert!(matches!(regular_paragraph, ToolResult::Success(_)));
// Step 8: Set document header and footer
let header_result = provider.call_tool("set_header", json!({
"document_id": doc_id,
"text": "Annual Report 2024 | Confidential"
})).await;
assert!(matches!(header_result, ToolResult::Success(_)));
let footer_result = provider.call_tool("set_footer", json!({
"document_id": doc_id,
"text": "© 2024 Company Name. All rights reserved."
})).await;
assert!(matches!(footer_result, ToolResult::Success(_)));
// Step 9: Verify document content
let extract_result = provider.call_tool("extract_text", json!({
"document_id": doc_id
})).await;
match extract_result {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
// Verify all content is present
assert!(text.contains("Annual Report 2024"));
assert!(text.contains("Executive Summary"));
assert!(text.contains("Record revenue growth"));
assert!(text.contains("Financial Highlights"));
assert!(text.contains("Revenue ($M)"));
assert!(text.contains("138.6"));
assert!(text.contains("Strategic Initiatives"));
assert!(text.contains("Digital Transformation"));
println!("Document contains {} characters of text", text.len());
assert!(text.len() > 1000, "Document should have substantial content");
},
ToolResult::Error(e) => panic!("Failed to extract text: {}", e),
}
// Step 10: Get document metadata
let metadata_result = provider.call_tool("get_metadata", json!({
"document_id": doc_id
})).await;
match metadata_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let metadata = &value["metadata"];
assert_eq!(metadata["id"], doc_id);
assert!(metadata["path"].is_string());
},
ToolResult::Error(e) => panic!("Failed to get metadata: {}", e),
}
// Step 11: Export to different formats
let output_dir = temp_dir.path().join("exports");
fs::create_dir_all(&output_dir)?;
// Export to PDF
let pdf_path = output_dir.join("annual_report.pdf");
let pdf_result = provider.call_tool("convert_to_pdf", json!({
"document_id": doc_id,
"output_path": pdf_path.to_str().unwrap()
})).await;
assert!(matches!(pdf_result, ToolResult::Success(_)));
assert!(pdf_path.exists());
// Export to markdown
let md_path = output_dir.join("annual_report.md");
let md_result = provider.call_tool("export_to_markdown", json!({
"document_id": doc_id,
"output_path": md_path.to_str().unwrap()
})).await;
assert!(matches!(md_result, ToolResult::Success(_)));
assert!(md_path.exists());
// Step 12: Save the original document
let save_path = output_dir.join("annual_report.docx");
let save_result = provider.call_tool("save_document", json!({
"document_id": doc_id,
"output_path": save_path.to_str().unwrap()
})).await;
assert!(matches!(save_result, ToolResult::Success(_)));
assert!(save_path.exists());
println!("Complete workflow test successful! Generated files:");
println!("- PDF: {:?}", pdf_path);
println!("- Markdown: {:?}", md_path);
println!("- DOCX: {:?}", save_path);
Ok(())
}
/// Test document editing and revision workflow
#[tokio::test]
async fn test_document_editing_workflow() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
std::env::set_var("TMPDIR", temp_dir.path());
let provider = DocxToolsProvider::new();
// Create initial document
let create_result = provider.call_tool("create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Add initial content
provider.call_tool("add_heading", json!({
"document_id": doc_id,
"text": "Project Status Report",
"level": 1
})).await;
provider.call_tool("add_paragraph", json!({
"document_id": doc_id,
"text": "Current project status and upcoming milestones."
})).await;
// Add tasks list
provider.call_tool("add_heading", json!({
"document_id": doc_id,
"text": "Current Tasks",
"level": 2
})).await;
provider.call_tool("add_list", json!({
"document_id": doc_id,
"items": [
"Complete user interface design",
"Implement backend API",
"Write unit tests",
"Deploy to staging environment"
],
"ordered": true
})).await;
// Search for specific content
let search_result = provider.call_tool("search_text", json!({
"document_id": doc_id,
"search_term": "backend",
"case_sensitive": false
})).await;
match search_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let matches = value["matches"].as_array().unwrap();
assert!(!matches.is_empty());
assert!(value["total_matches"].as_u64().unwrap() > 0);
},
ToolResult::Error(e) => panic!("Search failed: {}", e),
}
// Get word count before modifications
let word_count_before = provider.call_tool("get_word_count", json!({
"document_id": doc_id
})).await;
let initial_word_count = match word_count_before {
ToolResult::Success(value) => {
value["statistics"]["words"].as_u64().unwrap()
},
_ => panic!("Failed to get word count"),
};
// Add more content (simulating document expansion)
provider.call_tool("add_heading", json!({
"document_id": doc_id,
"text": "Completed Items",
"level": 2
})).await;
provider.call_tool("add_table", json!({
"document_id": doc_id,
"rows": [
["Task", "Completed Date", "Notes"],
["Requirements gathering", "2024-01-15", "All stakeholders interviewed"],
["Architecture design", "2024-01-22", "Approved by tech committee"],
["Database schema", "2024-01-28", "Optimized for performance"]
]
})).await;
// Add risks section
provider.call_tool("add_heading", json!({
"document_id": doc_id,
"text": "Identified Risks",
"level": 2
})).await;
provider.call_tool("add_paragraph", json!({
"document_id": doc_id,
"text": "The following risks have been identified and mitigation strategies are in place:",
"style": {
"italic": true
}
})).await;
provider.call_tool("add_list", json!({
"document_id": doc_id,
"items": [
"Resource constraints may delay delivery",
"Third-party API changes could impact integration",
"Security requirements may require additional development time"
],
"ordered": false
})).await;
// Get word count after modifications
let word_count_after = provider.call_tool("get_word_count", json!({
"document_id": doc_id
})).await;
let final_word_count = match word_count_after {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let stats = &value["statistics"];
let words = stats["words"].as_u64().unwrap();
let chars = stats["characters"].as_u64().unwrap();
let sentences = stats["sentences"].as_u64().unwrap();
println!("Document statistics: {} words, {} characters, {} sentences",
words, chars, sentences);
assert!(words > 0);
assert!(chars > 0);
assert!(sentences > 0);
words
},
ToolResult::Error(e) => panic!("Failed to get final word count: {}", e),
};
// Verify document grew
assert!(final_word_count > initial_word_count,
"Document should have more words after additions: {} -> {}",
initial_word_count, final_word_count);
// Perform find and replace operation
let replace_result = provider.call_tool("find_and_replace", json!({
"document_id": doc_id,
"find_text": "backend",
"replace_text": "server-side",
"case_sensitive": false
})).await;
match replace_result {
ToolResult::Success(value) => {
// Note: The actual implementation might return different result structure
println!("Find and replace completed: {:?}", value);
},
ToolResult::Error(_) => {
// This is acceptable as find_and_replace might not be fully implemented
println!("Find and replace not fully implemented yet");
}
}
// Final verification
let final_text = provider.call_tool("extract_text", json!({
"document_id": doc_id
})).await;
match final_text {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
// Verify all sections are present
assert!(text.contains("Project Status Report"));
assert!(text.contains("Current Tasks"));
assert!(text.contains("Completed Items"));
assert!(text.contains("Identified Risks"));
assert!(text.contains("Requirements gathering"));
assert!(text.contains("Resource constraints"));
println!("Final document contains {} characters", text.len());
},
ToolResult::Error(e) => panic!("Failed to extract final text: {}", e),
}
Ok(())
}
/// Test collaborative workflow with multiple document operations
#[tokio::test]
async fn test_collaborative_workflow() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
std::env::set_var("TMPDIR", temp_dir.path());
let provider = DocxToolsProvider::new();
let mut document_ids = Vec::new();
// Simulate multiple team members creating documents
let team_members = vec!["Alice", "Bob", "Charlie"];
for member in &team_members {
// Each member creates a document
let create_result = provider.call_tool("create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document for {}", member),
};
// Add member-specific content
provider.call_tool("add_heading", json!({
"document_id": doc_id,
"text": format!("{}'s Weekly Report", member),
"level": 1
})).await;
provider.call_tool("add_paragraph", json!({
"document_id": doc_id,
"text": format!("This week, {} focused on the following activities and achievements.", member)
})).await;
// Add achievements
let achievements = match member {
&"Alice" => vec![
"Completed user research interviews",
"Created wireframes for new features",
"Updated design system documentation"
],
&"Bob" => vec![
"Implemented new API endpoints",
"Optimized database queries",
"Fixed critical security vulnerability"
],
&"Charlie" => vec![
"Deployed version 2.1 to production",
"Set up monitoring dashboards",
"Conducted security audit"
],
_ => vec!["General tasks completed"],
};
provider.call_tool("add_list", json!({
"document_id": doc_id,
"items": achievements,
"ordered": false
})).await;
// Add metrics table
provider.call_tool("add_heading", json!({
"document_id": doc_id,
"text": "Key Metrics",
"level": 2
})).await;
let metrics = match member {
&"Alice" => vec![
vec!["Interviews Conducted", "8"],
vec!["Designs Created", "12"],
vec!["User Stories", "15"]
],
&"Bob" => vec![
vec!["Lines of Code", "2,450"],
vec!["Tests Written", "23"],
vec!["Bugs Fixed", "7"]
],
&"Charlie" => vec![
vec!["Deployments", "3"],
vec!["Issues Resolved", "11"],
vec!["System Uptime", "99.9%"]
],
_ => vec![vec!["Tasks", "5"]],
};
let mut table_rows = vec![vec!["Metric".to_string(), "Value".to_string()]];
for metric in metrics {
table_rows.push(metric.iter().map(|s| s.to_string()).collect());
}
provider.call_tool("add_table", json!({
"document_id": doc_id,
"rows": table_rows
})).await;
document_ids.push((member.to_string(), doc_id));
}
// List all documents
let list_result = provider.call_tool("list_documents", json!({})).await;
match list_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let documents = value["documents"].as_array().unwrap();
assert!(documents.len() >= 3, "Should have at least 3 documents");
println!("Found {} documents in the system", documents.len());
},
ToolResult::Error(e) => panic!("Failed to list documents: {}", e),
}
// Generate a summary document combining all reports
let summary_result = provider.call_tool("create_document", json!({})).await;
let summary_id = match summary_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create summary document"),
};
// Add summary header
provider.call_tool("add_heading", json!({
"document_id": summary_id,
"text": "Team Weekly Summary Report",
"level": 1
})).await;
provider.call_tool("add_paragraph", json!({
"document_id": summary_id,
"text": "This document summarizes the key activities and achievements from all team members this week."
})).await;
// Add content from each team member's document
for (member, doc_id) in &document_ids {
provider.call_tool("add_heading", json!({
"document_id": summary_id,
"text": format!("{} Highlights", member),
"level": 2
})).await;
// Extract text from member's document
let extract_result = provider.call_tool("extract_text", json!({
"document_id": doc_id
})).await;
match extract_result {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
// Extract key points (simplified - would be more sophisticated in real implementation)
let lines: Vec<&str> = text.lines().collect();
let summary_text = if lines.len() > 10 {
format!("Key activities include multiple achievements in their focus areas. Full details available in {}'s individual report.", member)
} else {
format!("Summary content from {}'s report.", member)
};
provider.call_tool("add_paragraph", json!({
"document_id": summary_id,
"text": summary_text
})).await;
},
ToolResult::Error(e) => {
println!("Warning: Could not extract text from {}'s document: {}", member, e);
}
}
}
// Add team totals table
provider.call_tool("add_heading", json!({
"document_id": summary_id,
"text": "Team Totals",
"level": 2
})).await;
provider.call_tool("add_table", json!({
"document_id": summary_id,
"rows": [
["Team Member", "Documents Created", "Key Focus"],
["Alice", "1", "Design & Research"],
["Bob", "1", "Development & Security"],
["Charlie", "1", "Operations & Deployment"],
["Total", "3", "Full-stack delivery"]
]
})).await;
// Convert all documents to PDF for archival
let archive_dir = temp_dir.path().join("weekly_archive");
fs::create_dir_all(&archive_dir)?;
for (member, doc_id) in &document_ids {
let pdf_path = archive_dir.join(format!("{}_weekly_report.pdf", member.to_lowercase()));
provider.call_tool("convert_to_pdf", json!({
"document_id": doc_id,
"output_path": pdf_path.to_str().unwrap()
})).await;
if pdf_path.exists() {
println!("Archived {}'s report to PDF", member);
}
}
// Archive summary document
let summary_pdf = archive_dir.join("team_summary.pdf");
provider.call_tool("convert_to_pdf", json!({
"document_id": summary_id,
"output_path": summary_pdf.to_str().unwrap()
})).await;
// Verify all PDFs were created
let pdf_count = fs::read_dir(&archive_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("pdf"))
.count();
assert!(pdf_count >= 3, "Should have created at least 3 PDF files");
println!("Successfully archived {} PDF documents", pdf_count);
Ok(())
}
/// Test security-restricted workflow
#[tokio::test]
async fn test_security_restricted_workflow() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
std::env::set_var("TMPDIR", temp_dir.path());
// Create a restrictive security configuration
let mut whitelist = HashSet::new();
whitelist.insert("open_document".to_string());
whitelist.insert("extract_text".to_string());
whitelist.insert("get_metadata".to_string());
whitelist.insert("search_text".to_string());
whitelist.insert("get_word_count".to_string());
whitelist.insert("list_documents".to_string());
whitelist.insert("get_security_info".to_string());
let security_config = SecurityConfig {
readonly_mode: true,
sandbox_mode: true,
command_whitelist: Some(whitelist),
max_document_size: 1024 * 1024, // 1MB
max_open_documents: 5,
allow_external_tools: false,
allow_network: false,
};
let provider = DocxToolsProvider::new_with_security(security_config);
// Test security info
let security_info = provider.call_tool("get_security_info", json!({})).await;
match security_info {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let security = &value["security"];
assert_eq!(security["readonly_mode"], true);
assert_eq!(security["sandbox_mode"], true);
println!("Security configuration: {}", security["summary"].as_str().unwrap());
},
ToolResult::Error(e) => panic!("Failed to get security info: {}", e),
}
// Test that write operations are blocked
let create_result = provider.call_tool("create_document", json!({})).await;
match create_result {
ToolResult::Success(value) => {
// Should fail security check
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
},
ToolResult::Error(e) => {
assert!(e.contains("Security check failed") || e.contains("Command not allowed"));
println!("Create document correctly blocked: {}", e);
}
}
// Test that add_paragraph is blocked
let paragraph_result = provider.call_tool("add_paragraph", json!({
"document_id": "test",
"text": "This should be blocked"
})).await;
match paragraph_result {
ToolResult::Success(value) => {
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
},
ToolResult::Error(e) => {
assert!(e.contains("Security check failed") || e.contains("Command not allowed"));
println!("Add paragraph correctly blocked: {}", e);
}
}
// Create a test document externally (outside security restrictions)
let unrestricted_provider = DocxToolsProvider::new();
let create_result = unrestricted_provider.call_tool("create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create test document"),
};
// Add content with unrestricted provider
unrestricted_provider.call_tool("add_heading", json!({
"document_id": doc_id,
"text": "Security Test Document",
"level": 1
})).await;
unrestricted_provider.call_tool("add_paragraph", json!({
"document_id": doc_id,
"text": "This document is used to test readonly access capabilities in a security-restricted environment."
})).await;
unrestricted_provider.call_tool("add_list", json!({
"document_id": doc_id,
"items": [
"Test text extraction",
"Test search functionality",
"Test metadata retrieval",
"Test word counting"
],
"ordered": true
})).await;
// Now test readonly operations with restricted provider
// These should work because they're in the whitelist
// Test text extraction
let extract_result = provider.call_tool("extract_text", json!({
"document_id": doc_id
})).await;
match extract_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let text = value["text"].as_str().unwrap();
assert!(text.contains("Security Test Document"));
assert!(text.contains("Test text extraction"));
println!("Text extraction successful: {} characters", text.len());
},
ToolResult::Error(e) => panic!("Text extraction should work: {}", e),
}
// Test search functionality
let search_result = provider.call_tool("search_text", json!({
"document_id": doc_id,
"search_term": "security",
"case_sensitive": false
})).await;
match search_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
assert!(value["total_matches"].as_u64().unwrap() > 0);
println!("Search successful: found {} matches", value["total_matches"]);
},
ToolResult::Error(e) => panic!("Search should work: {}", e),
}
// Test metadata retrieval
let metadata_result = provider.call_tool("get_metadata", json!({
"document_id": doc_id
})).await;
match metadata_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let metadata = &value["metadata"];
assert_eq!(metadata["id"], doc_id);
println!("Metadata retrieval successful");
},
ToolResult::Error(e) => panic!("Metadata retrieval should work: {}", e),
}
// Test word counting
let word_count_result = provider.call_tool("get_word_count", json!({
"document_id": doc_id
})).await;
match word_count_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let stats = &value["statistics"];
assert!(stats["words"].as_u64().unwrap() > 0);
println!("Word count successful: {} words", stats["words"]);
},
ToolResult::Error(e) => panic!("Word count should work: {}", e),
}
// Test document listing
let list_result = provider.call_tool("list_documents", json!({})).await;
match list_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
println!("Document listing successful");
},
ToolResult::Error(e) => panic!("Document listing should work: {}", e),
}
// Test that conversion operations are blocked (not in whitelist)
let pdf_result = provider.call_tool("convert_to_pdf", json!({
"document_id": doc_id,
"output_path": "/tmp/test.pdf"
})).await;
match pdf_result {
ToolResult::Success(value) => {
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
},
ToolResult::Error(e) => {
assert!(e.contains("Security check failed") || e.contains("Command not allowed"));
println!("PDF conversion correctly blocked: {}", e);
}
}
println!("Security-restricted workflow test completed successfully");
Ok(())
}
/// Test error recovery workflow
#[tokio::test]
async fn test_error_recovery_workflow() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
std::env::set_var("TMPDIR", temp_dir.path());
let provider = DocxToolsProvider::new();
// Test recovery from invalid document ID
let invalid_ops = vec![
("extract_text", json!({"document_id": "nonexistent-123"})),
("add_paragraph", json!({"document_id": "fake-456", "text": "test"})),
("get_metadata", json!({"document_id": "invalid-789"})),
("get_word_count", json!({"document_id": "missing-000"})),
];
for (operation, args) in invalid_ops {
let result = provider.call_tool(operation, args).await;
match result {
ToolResult::Success(value) => {
// Should indicate failure
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
assert!(value.get("error").is_some());
println!("{} correctly handled invalid document ID", operation);
},
ToolResult::Error(e) => {
assert!(e.contains("Document not found") || e.contains("not found"));
println!("{} correctly returned error for invalid document: {}", operation, e);
}
}
}
// Test recovery from invalid arguments
let invalid_arg_ops = vec![
("add_heading", json!({"document_id": "test", "level": 10})), // Invalid level
("add_paragraph", json!({"text": "missing document_id"})), // Missing required field
("add_table", json!({"document_id": "test", "rows": "not_an_array"})), // Wrong type
];
for (operation, args) in invalid_arg_ops {
let result = provider.call_tool(operation, args).await;
match result {
ToolResult::Success(value) => {
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
println!("{} handled invalid arguments gracefully", operation);
},
ToolResult::Error(e) => {
println!("{} returned error for invalid arguments: {}", operation, e);
}
}
}
// Test successful operation after errors
let create_result = provider.call_tool("create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
value["document_id"].as_str().unwrap().to_string()
},
ToolResult::Error(e) => panic!("Should be able to create document after errors: {}", e),
};
// Verify normal operations work after handling errors
let paragraph_result = provider.call_tool("add_paragraph", json!({
"document_id": doc_id,
"text": "This should work after error recovery"
})).await;
match paragraph_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
println!("Normal operations work after error handling");
},
ToolResult::Error(e) => panic!("Normal operation should work after errors: {}", e),
}
// Test that the document has the expected content
let extract_result = provider.call_tool("extract_text", json!({
"document_id": doc_id
})).await;
match extract_result {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
assert!(text.contains("This should work after error recovery"));
println!("Error recovery workflow completed successfully");
},
ToolResult::Error(e) => panic!("Text extraction failed: {}", e),
}
Ok(())
}
+457
View File
@@ -0,0 +1,457 @@
//! Test fixtures and helper data for the docx-mcp test suite
use anyhow::Result;
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
use serde_json::{json, Value};
use std::collections::HashMap;
use tempfile::TempDir;
pub mod sample_documents;
pub mod test_data;
/// Common test fixture for creating a handler with a temporary directory
pub fn create_test_handler() -> (DocxHandler, TempDir) {
let temp_dir = TempDir::new().unwrap();
let handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
(handler, temp_dir)
}
/// Create a handler with a document containing basic content
pub fn create_handler_with_document() -> (DocxHandler, String, TempDir) {
let (mut handler, temp_dir) = create_test_handler();
let doc_id = handler.create_document().unwrap();
(handler, doc_id, temp_dir)
}
/// Standard document styles for testing
pub struct TestStyles;
impl TestStyles {
pub fn basic() -> DocxStyle {
DocxStyle {
font_family: Some("Calibri".to_string()),
font_size: Some(11),
bold: Some(false),
italic: Some(false),
underline: Some(false),
color: Some("#000000".to_string()),
alignment: Some("left".to_string()),
line_spacing: Some(1.15),
}
}
pub fn heading() -> DocxStyle {
DocxStyle {
font_family: Some("Calibri".to_string()),
font_size: Some(16),
bold: Some(true),
italic: Some(false),
underline: Some(false),
color: Some("#1f4e79".to_string()),
alignment: Some("left".to_string()),
line_spacing: Some(1.15),
}
}
pub fn emphasis() -> DocxStyle {
DocxStyle {
font_family: Some("Calibri".to_string()),
font_size: Some(11),
bold: Some(true),
italic: Some(true),
underline: Some(false),
color: Some("#c55a11".to_string()),
alignment: Some("left".to_string()),
line_spacing: Some(1.15),
}
}
pub fn centered() -> DocxStyle {
DocxStyle {
font_family: Some("Calibri".to_string()),
font_size: Some(11),
bold: Some(false),
italic: Some(false),
underline: Some(false),
color: Some("#000000".to_string()),
alignment: Some("center".to_string()),
line_spacing: Some(1.15),
}
}
}
/// Standard table data for testing
pub struct TestTables;
impl TestTables {
pub fn simple_2x2() -> TableData {
TableData {
rows: vec![
vec!["Row 1 Col 1".to_string(), "Row 1 Col 2".to_string()],
vec!["Row 2 Col 1".to_string(), "Row 2 Col 2".to_string()],
],
headers: None,
border_style: Some("single".to_string()),
}
}
pub fn with_headers() -> TableData {
TableData {
rows: vec![
vec!["Name".to_string(), "Age".to_string(), "City".to_string()],
vec!["John".to_string(), "30".to_string(), "New York".to_string()],
vec!["Jane".to_string(), "25".to_string(), "Los Angeles".to_string()],
vec!["Bob".to_string(), "35".to_string(), "Chicago".to_string()],
],
headers: Some(vec!["Name".to_string(), "Age".to_string(), "City".to_string()]),
border_style: Some("single".to_string()),
}
}
pub fn financial_data() -> TableData {
TableData {
rows: vec![
vec!["Quarter".to_string(), "Revenue".to_string(), "Profit".to_string(), "Growth".to_string()],
vec!["Q1 2024".to_string(), "$1.2M".to_string(), "$240K".to_string(), "15%".to_string()],
vec!["Q2 2024".to_string(), "$1.4M".to_string(), "$290K".to_string(), "18%".to_string()],
vec!["Q3 2024".to_string(), "$1.6M".to_string(), "$340K".to_string(), "22%".to_string()],
vec!["Q4 2024".to_string(), "$1.8M".to_string(), "$380K".to_string(), "25%".to_string()],
],
headers: Some(vec!["Quarter".to_string(), "Revenue".to_string(), "Profit".to_string(), "Growth".to_string()]),
border_style: Some("single".to_string()),
}
}
pub fn large_table(rows: usize, cols: usize) -> TableData {
let mut table_rows = Vec::new();
// Header row
let header_row: Vec<String> = (0..cols)
.map(|i| format!("Column {}", i + 1))
.collect();
table_rows.push(header_row.clone());
// Data rows
for row in 0..rows {
let data_row: Vec<String> = (0..cols)
.map(|col| format!("R{}C{}", row + 1, col + 1))
.collect();
table_rows.push(data_row);
}
TableData {
rows: table_rows,
headers: Some(header_row),
border_style: Some("single".to_string()),
}
}
}
/// Standard list data for testing
pub struct TestLists;
impl TestLists {
pub fn simple_bullets() -> Vec<String> {
vec![
"First bullet point".to_string(),
"Second bullet point".to_string(),
"Third bullet point".to_string(),
]
}
pub fn numbered_steps() -> Vec<String> {
vec![
"Open the application".to_string(),
"Navigate to the settings menu".to_string(),
"Select the desired configuration".to_string(),
"Save your changes".to_string(),
"Restart the application".to_string(),
]
}
pub fn features_list() -> Vec<String> {
vec![
"Advanced document editing capabilities".to_string(),
"Real-time collaboration tools".to_string(),
"Cloud synchronization".to_string(),
"Version control and history tracking".to_string(),
"Export to multiple formats (PDF, HTML, Markdown)".to_string(),
"Template library with professional designs".to_string(),
"Advanced formatting and styling options".to_string(),
]
}
pub fn technical_requirements() -> Vec<String> {
vec![
"Rust 1.70 or higher".to_string(),
"Memory: 2GB RAM minimum, 4GB recommended".to_string(),
"Storage: 500MB available space".to_string(),
"Network: Internet connection for cloud features".to_string(),
"OS: Windows 10, macOS 10.15, or Linux (Ubuntu 20.04+)".to_string(),
]
}
pub fn large_list(item_count: usize) -> Vec<String> {
(1..=item_count)
.map(|i| format!("List item number {} with descriptive content", i))
.collect()
}
}
/// Sample text content for testing
pub struct TestContent;
impl TestContent {
pub fn lorem_ipsum() -> &'static str {
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
}
pub fn technical_paragraph() -> &'static str {
"This application leverages cutting-edge Rust technology to provide high-performance document processing capabilities. The architecture is built on modern asynchronous programming patterns, ensuring efficient resource utilization and scalability. Key features include memory-safe operations, zero-cost abstractions, and excellent concurrent processing performance."
}
pub fn business_paragraph() -> &'static str {
"Our comprehensive business solution addresses the evolving needs of modern enterprises through innovative technology and streamlined workflows. With a focus on productivity enhancement and cost reduction, this platform delivers measurable value across multiple departments and use cases. The solution integrates seamlessly with existing infrastructure while providing robust security and compliance features."
}
pub fn multilingual_content() -> Vec<(&'static str, &'static str)> {
vec![
("English", "The quick brown fox jumps over the lazy dog."),
("Spanish", "El zorro marrón rápido salta sobre el perro perezoso."),
("French", "Le renard brun rapide saute par-dessus le chien paresseux."),
("German", "Der schnelle braune Fuchs springt über den faulen Hund."),
("Italian", "La volpe marrone veloce salta sopra il cane pigro."),
("Portuguese", "A raposa marrom rápida pula sobre o cão preguiçoso."),
("Japanese", "素早い茶色のキツネは怠惰な犬を飛び越える。"),
("Chinese", "敏捷的棕色狐狸跳过懒狗。"),
("Korean", "빠른 갈색 여우가 게으른 개를 뛰어넘는다."),
("Russian", "Быстрая коричневая лиса прыгает через ленивую собаку."),
]
}
pub fn special_characters() -> &'static str {
"Special characters test: àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚě"
}
pub fn symbols_and_math() -> &'static str {
"Mathematical symbols: ∑ ∏ ∫ √ ≤ ≥ ≠ ± ∞ ∂ ∇ Ω α β γ δ ε θ λ μ π σ φ ψ ω"
}
pub fn long_paragraph(sentence_count: usize) -> String {
let sentences = vec![
"This is a comprehensive test of document processing capabilities.",
"The system handles various types of content efficiently and accurately.",
"Performance optimization ensures smooth operation even with large documents.",
"Advanced formatting features provide professional document appearance.",
"Error handling mechanisms maintain system stability under all conditions.",
"Security features protect sensitive information throughout the process.",
"Integration capabilities allow seamless workflow with existing systems.",
"User-friendly interfaces make complex operations simple and intuitive.",
"Scalable architecture supports growing business requirements.",
"Continuous improvements ensure the solution remains cutting-edge.",
];
let mut result = String::new();
for i in 0..sentence_count {
let sentence = sentences[i % sentences.len()];
result.push_str(sentence);
if i < sentence_count - 1 {
result.push(' ');
}
}
result
}
}
/// MCP tool call arguments for testing
pub struct TestMcpArgs;
impl TestMcpArgs {
pub fn create_document() -> Value {
json!({})
}
pub fn add_paragraph(doc_id: &str, text: &str, style: Option<DocxStyle>) -> Value {
let mut args = json!({
"document_id": doc_id,
"text": text
});
if let Some(s) = style {
args["style"] = json!({
"font_family": s.font_family,
"font_size": s.font_size,
"bold": s.bold,
"italic": s.italic,
"underline": s.underline,
"color": s.color,
"alignment": s.alignment,
"line_spacing": s.line_spacing
});
}
args
}
pub fn add_heading(doc_id: &str, text: &str, level: usize) -> Value {
json!({
"document_id": doc_id,
"text": text,
"level": level
})
}
pub fn add_table(doc_id: &str, table_data: &TableData) -> Value {
json!({
"document_id": doc_id,
"rows": table_data.rows
})
}
pub fn add_list(doc_id: &str, items: &[String], ordered: bool) -> Value {
json!({
"document_id": doc_id,
"items": items,
"ordered": ordered
})
}
pub fn extract_text(doc_id: &str) -> Value {
json!({
"document_id": doc_id
})
}
pub fn search_text(doc_id: &str, search_term: &str, case_sensitive: bool) -> Value {
json!({
"document_id": doc_id,
"search_term": search_term,
"case_sensitive": case_sensitive
})
}
pub fn get_metadata(doc_id: &str) -> Value {
json!({
"document_id": doc_id
})
}
pub fn convert_to_pdf(doc_id: &str, output_path: &str) -> Value {
json!({
"document_id": doc_id,
"output_path": output_path
})
}
pub fn save_document(doc_id: &str, output_path: &str) -> Value {
json!({
"document_id": doc_id,
"output_path": output_path
})
}
}
/// Performance test data generators
pub struct PerformanceData;
impl PerformanceData {
pub fn create_large_document(handler: &mut DocxHandler, paragraph_count: usize) -> Result<String> {
let doc_id = handler.create_document()?;
handler.add_heading(&doc_id, "Performance Test Document", 1)?;
for i in 0..paragraph_count {
if i % 50 == 0 && i > 0 {
handler.add_heading(&doc_id, &format!("Section {}", i / 50), 2)?;
}
let content = format!(
"This is paragraph {} in our performance test document. It contains substantial text content to simulate real-world usage patterns and test system performance under realistic load conditions. The paragraph includes various punctuation marks, numbers like {}, and other elements that affect processing performance.",
i + 1, (i + 1) * 7
);
handler.add_paragraph(&doc_id, &content, None)?;
// Add tables periodically
if i % 100 == 99 {
let table_data = TestTables::simple_2x2();
handler.add_table(&doc_id, table_data)?;
}
}
Ok(doc_id)
}
pub fn create_complex_document(handler: &mut DocxHandler) -> Result<String> {
let doc_id = handler.create_document()?;
// Add comprehensive content with all features
handler.add_heading(&doc_id, "Complex Document Test", 1)?;
handler.set_header(&doc_id, "Complex Document Header")?;
handler.set_footer(&doc_id, "Complex Document Footer")?;
handler.add_paragraph(&doc_id, TestContent::business_paragraph(), Some(TestStyles::basic()))?;
handler.add_heading(&doc_id, "Technical Details", 2)?;
handler.add_paragraph(&doc_id, TestContent::technical_paragraph(), None)?;
let features_list = TestLists::features_list();
handler.add_list(&doc_id, features_list, false)?;
handler.add_heading(&doc_id, "Financial Overview", 2)?;
let financial_table = TestTables::financial_data();
handler.add_table(&doc_id, financial_table)?;
handler.add_page_break(&doc_id)?;
handler.add_heading(&doc_id, "Multilingual Content", 2)?;
for (language, text) in TestContent::multilingual_content() {
handler.add_paragraph(&doc_id, &format!("{}: {}", language, text), None)?;
}
handler.add_heading(&doc_id, "Special Characters", 2)?;
handler.add_paragraph(&doc_id, TestContent::special_characters(), None)?;
handler.add_paragraph(&doc_id, TestContent::symbols_and_math(), None)?;
Ok(doc_id)
}
}
/// Error testing utilities
pub struct ErrorTestCases;
impl ErrorTestCases {
pub fn invalid_document_ids() -> Vec<&'static str> {
vec![
"nonexistent-123",
"fake-document-id",
"invalid-uuid",
"",
" ",
"null",
"undefined",
]
}
pub fn invalid_mcp_calls() -> Vec<(&'static str, Value)> {
vec![
("add_paragraph", json!({"text": "missing document_id"})),
("add_heading", json!({"document_id": "test", "level": 10})),
("add_table", json!({"document_id": "test", "rows": "not_an_array"})),
("add_list", json!({"document_id": "test", "items": 123})),
("search_text", json!({"document_id": "test"})), // Missing search_term
("convert_to_pdf", json!({"document_id": "test"})), // Missing output_path
]
}
pub fn security_blocked_operations() -> Vec<(&'static str, Value)> {
vec![
("create_document", json!({})),
("add_paragraph", json!({"document_id": "test", "text": "blocked"})),
("save_document", json!({"document_id": "test", "output_path": "/tmp/test.docx"})),
("convert_to_pdf", json!({"document_id": "test", "output_path": "/tmp/test.pdf"})),
("find_and_replace", json!({"document_id": "test", "find_text": "a", "replace_text": "b"})),
]
}
}
+509
View File
@@ -0,0 +1,509 @@
//! Sample document templates and content for testing
use anyhow::Result;
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
use super::{TestStyles, TestTables, TestLists, TestContent};
/// Creates a business letter document for testing
pub fn create_business_letter(handler: &mut DocxHandler) -> Result<String> {
let doc_id = handler.create_document()?;
// Header
handler.set_header(&doc_id, "ACME Corporation | 123 Business St, City, State 12345")?;
// Date
handler.add_paragraph(&doc_id, "December 15, 2024", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "", None)?; // Empty line
// Recipient
handler.add_paragraph(&doc_id, "Ms. Jane Smith", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "Director of Operations", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "XYZ Company", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "456 Corporate Ave", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "Business City, State 67890", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "", None)?; // Empty line
// Subject
handler.add_paragraph(&doc_id, "RE: Partnership Proposal", Some(TestStyles::emphasis()))?;
handler.add_paragraph(&doc_id, "", None)?; // Empty line
// Salutation
handler.add_paragraph(&doc_id, "Dear Ms. Smith,", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "", None)?; // Empty line
// Body paragraphs
handler.add_paragraph(&doc_id,
"I am writing to propose a strategic partnership between ACME Corporation and XYZ Company that would benefit both organizations significantly. Our companies share similar values and complementary strengths that could create substantial value for our respective customers.",
Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id,
"ACME Corporation has been a leader in technology solutions for over 15 years, with a strong track record of innovation and customer satisfaction. We believe that combining our technical expertise with your operational excellence would create a powerful synergy in the marketplace.",
Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id,
"The proposed partnership would include joint product development, shared marketing initiatives, and coordinated customer support efforts. We estimate this collaboration could increase revenue for both companies by 25% within the first year.",
Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id,
"I would welcome the opportunity to discuss this proposal in more detail at your convenience. Please let me know when you might be available for a meeting or conference call.",
Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "", None)?; // Empty line
// Closing
handler.add_paragraph(&doc_id, "Sincerely,", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "", None)?; // Space for signature
handler.add_paragraph(&doc_id, "", None)?; // Space for signature
handler.add_paragraph(&doc_id, "John Doe", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "Chief Executive Officer", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "ACME Corporation", Some(TestStyles::basic()))?;
// Footer
handler.set_footer(&doc_id, "ACME Corporation - Confidential and Proprietary")?;
Ok(doc_id)
}
/// Creates a technical report document for testing
pub fn create_technical_report(handler: &mut DocxHandler) -> Result<String> {
let doc_id = handler.create_document()?;
// Title page
handler.add_paragraph(&doc_id, "", None)?; // Empty line for spacing
handler.add_paragraph(&doc_id, "", None)?;
handler.add_paragraph(&doc_id, "", None)?;
handler.add_heading(&doc_id, "System Performance Analysis Report", 1)?;
handler.add_paragraph(&doc_id, "", None)?;
handler.add_paragraph(&doc_id, "Quarterly Assessment - Q4 2024", Some(TestStyles::centered()))?;
handler.add_paragraph(&doc_id, "", None)?;
handler.add_paragraph(&doc_id, "Prepared by: Technical Team", Some(TestStyles::centered()))?;
handler.add_paragraph(&doc_id, "Date: December 15, 2024", Some(TestStyles::centered()))?;
handler.add_page_break(&doc_id)?;
// Executive Summary
handler.add_heading(&doc_id, "Executive Summary", 1)?;
handler.add_paragraph(&doc_id,
"This report provides a comprehensive analysis of system performance metrics for Q4 2024. Key findings include significant improvements in response times, enhanced security measures, and successful implementation of new monitoring capabilities.",
Some(TestStyles::basic()))?;
let summary_points = vec![
"Average response time improved by 35%".to_string(),
"System uptime achieved 99.97%".to_string(),
"Security incidents reduced by 60%".to_string(),
"User satisfaction increased to 94%".to_string(),
];
handler.add_list(&doc_id, summary_points, false)?;
// Performance Metrics
handler.add_heading(&doc_id, "Performance Metrics", 1)?;
handler.add_heading(&doc_id, "Response Time Analysis", 2)?;
handler.add_paragraph(&doc_id,
"Response time measurements were collected continuously throughout Q4 2024. The data shows consistent improvement across all service endpoints.",
Some(TestStyles::basic()))?;
let response_time_data = TableData {
rows: vec![
vec!["Service".to_string(), "Q3 2024 (ms)".to_string(), "Q4 2024 (ms)".to_string(), "Improvement".to_string()],
vec!["Authentication".to_string(), "245".to_string(), "158".to_string(), "35.5%".to_string()],
vec!["Database Query".to_string(), "892".to_string(), "623".to_string(), "30.2%".to_string()],
vec!["File Processing".to_string(), "1,240".to_string(), "789".to_string(), "36.4%".to_string()],
vec!["Report Generation".to_string(), "3,450".to_string(), "2,180".to_string(), "36.8%".to_string()],
],
headers: Some(vec!["Service".to_string(), "Q3 2024 (ms)".to_string(), "Q4 2024 (ms)".to_string(), "Improvement".to_string()]),
border_style: Some("single".to_string()),
};
handler.add_table(&doc_id, response_time_data)?;
handler.add_heading(&doc_id, "System Reliability", 2)?;
handler.add_paragraph(&doc_id,
"System reliability metrics demonstrate exceptional stability and availability throughout the quarter.",
Some(TestStyles::basic()))?;
let reliability_data = TableData {
rows: vec![
vec!["Metric".to_string(), "Target".to_string(), "Actual".to_string(), "Status".to_string()],
vec!["Uptime".to_string(), "99.9%".to_string(), "99.97%".to_string(), "✓ Exceeded".to_string()],
vec!["MTBF (hours)".to_string(), "720".to_string(), "892".to_string(), "✓ Exceeded".to_string()],
vec!["Recovery Time (min)".to_string(), "15".to_string(), "8.5".to_string(), "✓ Exceeded".to_string()],
],
headers: Some(vec!["Metric".to_string(), "Target".to_string(), "Actual".to_string(), "Status".to_string()]),
border_style: Some("single".to_string()),
};
handler.add_table(&doc_id, reliability_data)?;
// Security Analysis
handler.add_heading(&doc_id, "Security Analysis", 1)?;
handler.add_paragraph(&doc_id,
"Security monitoring and incident response capabilities were significantly enhanced during Q4 2024.",
Some(TestStyles::basic()))?;
let security_improvements = vec![
"Implemented advanced threat detection algorithms".to_string(),
"Enhanced encryption protocols for data transmission".to_string(),
"Deployed automated incident response systems".to_string(),
"Conducted comprehensive security audits".to_string(),
"Updated access control mechanisms".to_string(),
];
handler.add_list(&doc_id, security_improvements, true)?;
// Recommendations
handler.add_heading(&doc_id, "Recommendations", 1)?;
handler.add_paragraph(&doc_id,
"Based on the analysis conducted, the following recommendations are proposed for Q1 2025:",
Some(TestStyles::basic()))?;
let recommendations = vec![
"Continue performance optimization initiatives".to_string(),
"Expand monitoring coverage to include new services".to_string(),
"Implement predictive analytics for proactive maintenance".to_string(),
"Enhance disaster recovery procedures".to_string(),
"Invest in additional security training for staff".to_string(),
];
handler.add_list(&doc_id, recommendations, true)?;
// Footer
handler.set_footer(&doc_id, "Technical Report Q4 2024 - Confidential")?;
Ok(doc_id)
}
/// Creates a meeting minutes document for testing
pub fn create_meeting_minutes(handler: &mut DocxHandler) -> Result<String> {
let doc_id = handler.create_document()?;
// Header
handler.add_heading(&doc_id, "Project Steering Committee Meeting Minutes", 1)?;
handler.add_paragraph(&doc_id, "", None)?;
// Meeting details
let meeting_details = TableData {
rows: vec![
vec!["Date:".to_string(), "December 15, 2024".to_string()],
vec!["Time:".to_string(), "2:00 PM - 3:30 PM PST".to_string()],
vec!["Location:".to_string(), "Conference Room A / Virtual".to_string()],
vec!["Chair:".to_string(), "Sarah Johnson".to_string()],
vec!["Secretary:".to_string(), "Mike Chen".to_string()],
],
headers: None,
border_style: Some("single".to_string()),
};
handler.add_table(&doc_id, meeting_details)?;
// Attendees
handler.add_heading(&doc_id, "Attendees", 2)?;
let attendees = vec![
"Sarah Johnson (Chair) - Project Director".to_string(),
"Mike Chen (Secretary) - Technical Lead".to_string(),
"Lisa Wang - Product Manager".to_string(),
"David Rodriguez - Engineering Manager".to_string(),
"Jennifer Kim - QA Manager".to_string(),
"Alex Thompson - DevOps Lead".to_string(),
];
handler.add_list(&doc_id, attendees, false)?;
// Agenda Items
handler.add_heading(&doc_id, "Agenda Items Discussed", 2)?;
handler.add_heading(&doc_id, "1. Project Status Update", 3)?;
handler.add_paragraph(&doc_id,
"Mike Chen presented the current project status, highlighting that development is 85% complete and on schedule for the January 31st deadline.",
Some(TestStyles::basic()))?;
let status_highlights = vec![
"Core functionality implementation: 100% complete".to_string(),
"User interface development: 90% complete".to_string(),
"Testing and QA: 70% complete".to_string(),
"Documentation: 60% complete".to_string(),
];
handler.add_list(&doc_id, status_highlights, false)?;
handler.add_heading(&doc_id, "2. Budget Review", 3)?;
handler.add_paragraph(&doc_id,
"Lisa Wang reported that the project is currently 5% under budget with strong cost controls in place.",
Some(TestStyles::basic()))?;
let budget_data = TableData {
rows: vec![
vec!["Category".to_string(), "Budgeted".to_string(), "Actual".to_string(), "Remaining".to_string()],
vec!["Development".to_string(), "$180,000".to_string(), "$168,000".to_string(), "$12,000".to_string()],
vec!["Testing".to_string(), "$45,000".to_string(), "$38,000".to_string(), "$7,000".to_string()],
vec!["Infrastructure".to_string(), "$30,000".to_string(), "$28,000".to_string(), "$2,000".to_string()],
vec!["Total".to_string(), "$255,000".to_string(), "$234,000".to_string(), "$21,000".to_string()],
],
headers: Some(vec!["Category".to_string(), "Budgeted".to_string(), "Actual".to_string(), "Remaining".to_string()]),
border_style: Some("single".to_string()),
};
handler.add_table(&doc_id, budget_data)?;
handler.add_heading(&doc_id, "3. Risk Assessment", 3)?;
handler.add_paragraph(&doc_id,
"David Rodriguez presented the updated risk register with mitigation strategies for identified risks.",
Some(TestStyles::basic()))?;
let risks = vec![
"Third-party API integration delays - Medium risk, mitigation plan in place".to_string(),
"Resource availability during holidays - Low risk, backup resources identified".to_string(),
"Performance requirements validation - Medium risk, load testing scheduled".to_string(),
];
handler.add_list(&doc_id, risks, false)?;
// Action Items
handler.add_heading(&doc_id, "Action Items", 2)?;
let action_items_data = TableData {
rows: vec![
vec!["Action Item".to_string(), "Owner".to_string(), "Due Date".to_string(), "Status".to_string()],
vec!["Complete load testing scenarios".to_string(), "Jennifer Kim".to_string(), "Dec 22, 2024".to_string(), "In Progress".to_string()],
vec!["Finalize API integration testing".to_string(), "Mike Chen".to_string(), "Dec 20, 2024".to_string(), "Not Started".to_string()],
vec!["Update project documentation".to_string(), "Lisa Wang".to_string(), "Jan 10, 2025".to_string(), "Not Started".to_string()],
vec!["Prepare deployment checklist".to_string(), "Alex Thompson".to_string(), "Jan 15, 2025".to_string(), "Not Started".to_string()],
],
headers: Some(vec!["Action Item".to_string(), "Owner".to_string(), "Due Date".to_string(), "Status".to_string()]),
border_style: Some("single".to_string()),
};
handler.add_table(&doc_id, action_items_data)?;
// Next Meeting
handler.add_heading(&doc_id, "Next Meeting", 2)?;
handler.add_paragraph(&doc_id,
"The next steering committee meeting is scheduled for January 5, 2025, at 2:00 PM PST in Conference Room A.",
Some(TestStyles::basic()))?;
// Footer
handler.set_footer(&doc_id, "Project Steering Committee - Meeting Minutes")?;
Ok(doc_id)
}
/// Creates a product specification document for testing
pub fn create_product_spec(handler: &mut DocxHandler) -> Result<String> {
let doc_id = handler.create_document()?;
// Title page
handler.add_paragraph(&doc_id, "", None)?;
handler.add_paragraph(&doc_id, "", None)?;
handler.add_heading(&doc_id, "Product Requirements Specification", 1)?;
handler.add_paragraph(&doc_id, "", None)?;
handler.add_paragraph(&doc_id, "Document Management System v2.0", Some(TestStyles::centered()))?;
handler.add_paragraph(&doc_id, "", None)?;
handler.add_paragraph(&doc_id, "Version 1.0", Some(TestStyles::centered()))?;
handler.add_paragraph(&doc_id, "December 15, 2024", Some(TestStyles::centered()))?;
handler.add_page_break(&doc_id)?;
// Table of Contents (simplified)
handler.add_heading(&doc_id, "Table of Contents", 1)?;
let toc_items = vec![
"1. Introduction".to_string(),
"2. System Overview".to_string(),
"3. Functional Requirements".to_string(),
"4. Non-Functional Requirements".to_string(),
"5. User Interface Requirements".to_string(),
"6. System Architecture".to_string(),
"7. Security Requirements".to_string(),
];
handler.add_list(&doc_id, toc_items, true)?;
// Introduction
handler.add_heading(&doc_id, "1. Introduction", 1)?;
handler.add_heading(&doc_id, "1.1 Purpose", 2)?;
handler.add_paragraph(&doc_id,
"This document specifies the requirements for the Document Management System version 2.0. The system is designed to provide comprehensive document storage, retrieval, and collaboration capabilities for enterprise users.",
Some(TestStyles::basic()))?;
handler.add_heading(&doc_id, "1.2 Scope", 2)?;
handler.add_paragraph(&doc_id,
"The Document Management System will support multiple file formats, version control, user collaboration, and advanced search capabilities. The system will be deployed as a web-based application with mobile support.",
Some(TestStyles::basic()))?;
// System Overview
handler.add_heading(&doc_id, "2. System Overview", 1)?;
handler.add_paragraph(&doc_id,
"The Document Management System consists of several integrated components working together to provide a seamless document management experience.",
Some(TestStyles::basic()))?;
let system_components = vec![
"Document Storage Engine".to_string(),
"Version Control System".to_string(),
"Search and Indexing Service".to_string(),
"User Authentication and Authorization".to_string(),
"Collaboration Tools".to_string(),
"Reporting and Analytics".to_string(),
];
handler.add_list(&doc_id, system_components, false)?;
// Functional Requirements
handler.add_heading(&doc_id, "3. Functional Requirements", 1)?;
handler.add_heading(&doc_id, "3.1 Document Upload and Storage", 2)?;
let upload_requirements = vec![
"FR-001: System shall support upload of files up to 100MB in size".to_string(),
"FR-002: System shall support common file formats (PDF, DOCX, XLSX, PPTX, TXT)".to_string(),
"FR-003: System shall automatically generate file metadata upon upload".to_string(),
"FR-004: System shall provide drag-and-drop upload functionality".to_string(),
];
handler.add_list(&doc_id, upload_requirements, false)?;
handler.add_heading(&doc_id, "3.2 Search and Retrieval", 2)?;
let search_requirements = vec![
"FR-005: System shall provide full-text search capabilities".to_string(),
"FR-006: System shall support advanced search with multiple criteria".to_string(),
"FR-007: System shall provide search result ranking and relevance scoring".to_string(),
"FR-008: System shall support search within specific document types".to_string(),
];
handler.add_list(&doc_id, search_requirements, false)?;
// Non-Functional Requirements
handler.add_heading(&doc_id, "4. Non-Functional Requirements", 1)?;
let nfr_data = TableData {
rows: vec![
vec!["Requirement".to_string(), "Specification".to_string(), "Priority".to_string()],
vec!["Performance".to_string(), "Page load time < 3 seconds".to_string(), "High".to_string()],
vec!["Scalability".to_string(), "Support 1000+ concurrent users".to_string(), "High".to_string()],
vec!["Availability".to_string(), "99.9% uptime".to_string(), "High".to_string()],
vec!["Security".to_string(), "Role-based access control".to_string(), "Critical".to_string()],
vec!["Usability".to_string(), "Intuitive interface, minimal training".to_string(), "Medium".to_string()],
],
headers: Some(vec!["Requirement".to_string(), "Specification".to_string(), "Priority".to_string()]),
border_style: Some("single".to_string()),
};
handler.add_table(&doc_id, nfr_data)?;
// Security Requirements
handler.add_heading(&doc_id, "7. Security Requirements", 1)?;
handler.add_paragraph(&doc_id,
"Security is paramount for the Document Management System. The following security measures must be implemented:",
Some(TestStyles::basic()))?;
let security_requirements = vec![
"SEC-001: All data transmission must use HTTPS/TLS 1.3".to_string(),
"SEC-002: User passwords must meet complexity requirements".to_string(),
"SEC-003: System must support multi-factor authentication".to_string(),
"SEC-004: All user actions must be logged for audit purposes".to_string(),
"SEC-005: Document access must be controlled by user permissions".to_string(),
"SEC-006: System must support data encryption at rest".to_string(),
];
handler.add_list(&doc_id, security_requirements, true)?;
// Footer
handler.set_footer(&doc_id, "Product Requirements Specification v1.0 - Confidential")?;
Ok(doc_id)
}
/// Creates a test document with international content
pub fn create_multilingual_document(handler: &mut DocxHandler) -> Result<String> {
let doc_id = handler.create_document()?;
handler.add_heading(&doc_id, "Multilingual Content Test Document", 1)?;
handler.add_paragraph(&doc_id,
"This document contains text in multiple languages to test internationalization and Unicode support.",
Some(TestStyles::basic()))?;
for (language, text) in TestContent::multilingual_content() {
handler.add_heading(&doc_id, language, 2)?;
handler.add_paragraph(&doc_id, text, Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "", None)?; // Empty line
}
handler.add_heading(&doc_id, "Special Characters and Symbols", 2)?;
handler.add_paragraph(&doc_id, TestContent::special_characters(), Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, TestContent::symbols_and_math(), Some(TestStyles::basic()))?;
// Currency symbols
handler.add_paragraph(&doc_id, "Currency symbols: $ € £ ¥ ₹ ₽ ₩ ₪ ₫ ₱", Some(TestStyles::basic()))?;
// Emoji (if supported)
handler.add_paragraph(&doc_id, "Emoji test: 📄 📝 💼 🔒 🌍 ✅ ❌ ⚠️", Some(TestStyles::basic()))?;
Ok(doc_id)
}
/// Creates a document with complex formatting for testing
pub fn create_formatted_document(handler: &mut DocxHandler) -> Result<String> {
let doc_id = handler.create_document()?;
handler.add_heading(&doc_id, "Formatting Test Document", 1)?;
// Different paragraph styles
handler.add_paragraph(&doc_id, "This paragraph uses the default style.", Some(TestStyles::basic()))?;
handler.add_paragraph(&doc_id, "This paragraph uses bold formatting.", Some(DocxStyle {
bold: Some(true),
..TestStyles::basic()
}))?;
handler.add_paragraph(&doc_id, "This paragraph uses italic formatting.", Some(DocxStyle {
italic: Some(true),
..TestStyles::basic()
}))?;
handler.add_paragraph(&doc_id, "This paragraph is centered.", Some(TestStyles::centered()))?;
handler.add_paragraph(&doc_id, "This paragraph uses emphasis styling.", Some(TestStyles::emphasis()))?;
// Different font sizes
handler.add_heading(&doc_id, "Font Size Tests", 2)?;
for size in [8, 10, 12, 14, 16, 18, 24] {
let style = DocxStyle {
font_size: Some(size),
..TestStyles::basic()
};
handler.add_paragraph(&doc_id, &format!("This text is {} point size.", size), Some(style))?;
}
// Color tests
handler.add_heading(&doc_id, "Color Tests", 2)?;
let colors = vec![
("#000000", "Black"),
("#FF0000", "Red"),
("#00FF00", "Green"),
("#0000FF", "Blue"),
("#FF00FF", "Magenta"),
("#00FFFF", "Cyan"),
("#800080", "Purple"),
];
for (color_code, color_name) in colors {
let style = DocxStyle {
color: Some(color_code.to_string()),
..TestStyles::basic()
};
handler.add_paragraph(&doc_id, &format!("This text is in {}", color_name), Some(style))?;
}
// Alignment tests
handler.add_heading(&doc_id, "Alignment Tests", 2)?;
let alignments = vec![
("left", "Left aligned text"),
("center", "Center aligned text"),
("right", "Right aligned text"),
("justify", "Justified text that should span the full width of the line when there is enough content to make it meaningful"),
];
for (alignment, text) in alignments {
let style = DocxStyle {
alignment: Some(alignment.to_string()),
..TestStyles::basic()
};
handler.add_paragraph(&doc_id, text, Some(style))?;
}
// Complex table with formatting
handler.add_heading(&doc_id, "Formatted Table", 2)?;
let formatted_table = TableData {
rows: vec![
vec!["Item".to_string(), "Price".to_string(), "Discount".to_string(), "Final Price".to_string()],
vec!["Widget A".to_string(), "$100.00".to_string(), "10%".to_string(), "$90.00".to_string()],
vec!["Widget B".to_string(), "$150.00".to_string(), "15%".to_string(), "$127.50".to_string()],
vec!["Widget C".to_string(), "$200.00".to_string(), "20%".to_string(), "$160.00".to_string()],
vec!["Total".to_string(), "$450.00".to_string(), "".to_string(), "$377.50".to_string()],
],
headers: Some(vec!["Item".to_string(), "Price".to_string(), "Discount".to_string(), "Final Price".to_string()]),
border_style: Some("single".to_string()),
};
handler.add_table(&doc_id, formatted_table)?;
Ok(doc_id)
}
+392
View File
@@ -0,0 +1,392 @@
//! Test data generators and utilities
use serde_json::{json, Value};
use std::collections::HashMap;
/// Generates test data for various document types and scenarios
pub struct TestDataGenerator;
impl TestDataGenerator {
/// Generate test paragraphs with varying complexity
pub fn generate_paragraphs(count: usize, complexity: ParagraphComplexity) -> Vec<String> {
let base_sentences = match complexity {
ParagraphComplexity::Simple => vec![
"This is a simple sentence.",
"Another basic statement follows.",
"The text remains straightforward.",
"No complex structures here.",
"Plain language is used throughout.",
],
ParagraphComplexity::Medium => vec![
"This sentence demonstrates moderate complexity with additional clauses and descriptive elements.",
"Furthermore, the content includes various punctuation marks, numbers like 123, and technical terms.",
"The writing style incorporates both simple and compound sentence structures for variety.",
"Additionally, references to specific dates (December 15, 2024) and percentages (85%) are included.",
"These paragraphs simulate realistic document content found in business communications.",
],
ParagraphComplexity::Complex => vec![
"This comprehensive sentence exemplifies sophisticated linguistic structures, incorporating multiple subordinate clauses, technical terminology, and complex syntactical arrangements that challenge both human readers and automated processing systems.",
"Moreover, the content integrates diverse elements including numerical data (such as 42.7% improvement rates), temporal references (spanning Q3 2024 through Q1 2025), geographical locations (Silicon Valley, New York, London), and industry-specific jargon that reflects real-world document complexity.",
"The methodology employed in generating these test paragraphs considers various factors: readability indices, sentence length distribution, vocabulary diversity, and the inclusion of special characters (e.g., àáâãäå, €£¥, ∑∏∫) to ensure comprehensive testing coverage.",
"Consequently, these multi-faceted paragraphs serve as effective benchmarks for evaluating system performance under realistic conditions, while simultaneously providing sufficient content variation to identify potential edge cases and optimization opportunities.",
],
};
(0..count)
.map(|i| {
let sentence_count = match complexity {
ParagraphComplexity::Simple => 2 + (i % 3),
ParagraphComplexity::Medium => 3 + (i % 4),
ParagraphComplexity::Complex => 2 + (i % 3),
};
let mut paragraph = String::new();
for j in 0..sentence_count {
let sentence = &base_sentences[j % base_sentences.len()];
if j > 0 {
paragraph.push(' ');
}
paragraph.push_str(sentence);
}
paragraph
})
.collect()
}
/// Generate table data with specified dimensions and content type
pub fn generate_table_data(rows: usize, cols: usize, content_type: TableContentType) -> Vec<Vec<String>> {
let mut table_data = Vec::new();
// Generate header row
let headers: Vec<String> = (0..cols)
.map(|i| match content_type {
TableContentType::Generic => format!("Column {}", i + 1),
TableContentType::Financial => match i {
0 => "Period".to_string(),
1 => "Revenue".to_string(),
2 => "Expenses".to_string(),
3 => "Profit".to_string(),
_ => format!("Metric {}", i + 1),
},
TableContentType::Personnel => match i {
0 => "Name".to_string(),
1 => "Department".to_string(),
2 => "Role".to_string(),
3 => "Start Date".to_string(),
_ => format!("Field {}", i + 1),
},
TableContentType::Technical => match i {
0 => "Component".to_string(),
1 => "Version".to_string(),
2 => "Status".to_string(),
3 => "Last Updated".to_string(),
_ => format!("Attribute {}", i + 1),
},
})
.collect();
table_data.push(headers);
// Generate data rows
for row in 0..rows {
let row_data: Vec<String> = (0..cols)
.map(|col| match content_type {
TableContentType::Generic => format!("R{}C{}", row + 1, col + 1),
TableContentType::Financial => match col {
0 => format!("Q{} 2024", (row % 4) + 1),
1 => format!("${:.1}M", 100.0 + row as f64 * 12.5),
2 => format!("${:.1}M", 70.0 + row as f64 * 8.2),
3 => format!("${:.1}M", 30.0 + row as f64 * 4.3),
_ => format!("{:.1}%", 15.0 + row as f64 * 2.1),
},
TableContentType::Personnel => match col {
0 => format!("Employee {}", row + 1),
1 => ["Engineering", "Sales", "Marketing", "Operations"][(row % 4)].to_string(),
2 => ["Manager", "Developer", "Analyst", "Specialist"][(row % 4)].to_string(),
3 => format!("2024-{:02}-{:02}", ((row % 12) + 1), ((row % 28) + 1)),
_ => format!("Data {}", row + 1),
},
TableContentType::Technical => match col {
0 => format!("Component-{}", row + 1),
1 => format!("v{}.{}.{}", (row % 3) + 1, (row % 5), (row % 10)),
2 => ["Active", "Pending", "Deprecated", "Testing"][(row % 4)].to_string(),
3 => format!("2024-12-{:02}", ((row % 28) + 1)),
_ => format!("Value {}", row + 1),
},
})
.collect();
table_data.push(row_data);
}
table_data
}
/// Generate list items with specified count and category
pub fn generate_list_items(count: usize, category: ListCategory) -> Vec<String> {
let base_items = match category {
ListCategory::Tasks => vec![
"Complete project documentation",
"Review code changes and pull requests",
"Update system configuration files",
"Run comprehensive test suite",
"Deploy to staging environment",
"Conduct security audit",
"Optimize database performance",
"Update user interface components",
"Implement new feature requirements",
"Fix reported bugs and issues",
],
ListCategory::Features => vec![
"Advanced search and filtering capabilities",
"Real-time collaboration tools",
"Automated backup and recovery",
"Multi-language support",
"Mobile-responsive design",
"Integration with third-party services",
"Customizable dashboard and reports",
"Role-based access control",
"API for external integrations",
"Advanced analytics and insights",
],
ListCategory::Requirements => vec![
"System must support 1000+ concurrent users",
"Response time must be under 200ms for 95% of requests",
"Uptime must exceed 99.9% availability",
"Data must be encrypted both in transit and at rest",
"User interface must be accessible (WCAG 2.1 AA)",
"System must support multi-factor authentication",
"Backup processes must complete within 2 hours",
"Security patches must be applied within 24 hours",
"System must scale horizontally to handle peak loads",
"Audit logs must be maintained for minimum 7 years",
],
ListCategory::Benefits => vec![
"Increased operational efficiency by 35%",
"Reduced manual processing time by 60%",
"Improved data accuracy and consistency",
"Enhanced security and compliance posture",
"Better user experience and satisfaction",
"Lower total cost of ownership",
"Faster time-to-market for new features",
"Improved scalability and performance",
"Better decision-making through analytics",
"Reduced maintenance and support costs",
],
};
(0..count)
.map(|i| {
let base_item = &base_items[i % base_items.len()];
if count > base_items.len() {
format!("{} (item {})", base_item, i + 1)
} else {
base_item.clone()
}
})
.collect()
}
/// Generate realistic business data for testing
pub fn generate_business_data() -> BusinessDataSet {
BusinessDataSet {
companies: vec![
"Acme Corporation".to_string(),
"Global Tech Solutions".to_string(),
"Innovation Partners LLC".to_string(),
"Digital Dynamics Inc".to_string(),
"Future Systems Ltd".to_string(),
],
departments: vec![
"Engineering".to_string(),
"Sales & Marketing".to_string(),
"Human Resources".to_string(),
"Operations".to_string(),
"Finance & Accounting".to_string(),
"Research & Development".to_string(),
],
positions: vec![
"Software Engineer".to_string(),
"Product Manager".to_string(),
"Sales Representative".to_string(),
"Data Analyst".to_string(),
"Project Manager".to_string(),
"UX Designer".to_string(),
],
locations: vec![
"San Francisco, CA".to_string(),
"New York, NY".to_string(),
"Austin, TX".to_string(),
"Seattle, WA".to_string(),
"Boston, MA".to_string(),
"Chicago, IL".to_string(),
],
}
}
/// Generate MCP tool call test data
pub fn generate_mcp_test_calls() -> Vec<McpTestCall> {
vec![
McpTestCall {
tool_name: "create_document".to_string(),
args: json!({}),
expected_success: true,
expected_result_keys: vec!["success".to_string(), "document_id".to_string()],
},
McpTestCall {
tool_name: "add_paragraph".to_string(),
args: json!({
"document_id": "test-doc-id",
"text": "Test paragraph content"
}),
expected_success: true,
expected_result_keys: vec!["success".to_string()],
},
McpTestCall {
tool_name: "add_heading".to_string(),
args: json!({
"document_id": "test-doc-id",
"text": "Test Heading",
"level": 1
}),
expected_success: true,
expected_result_keys: vec!["success".to_string()],
},
McpTestCall {
tool_name: "extract_text".to_string(),
args: json!({
"document_id": "test-doc-id"
}),
expected_success: true,
expected_result_keys: vec!["success".to_string(), "text".to_string()],
},
McpTestCall {
tool_name: "get_metadata".to_string(),
args: json!({
"document_id": "test-doc-id"
}),
expected_success: true,
expected_result_keys: vec!["success".to_string(), "metadata".to_string()],
},
]
}
/// Generate performance test scenarios
pub fn generate_performance_scenarios() -> Vec<PerformanceScenario> {
vec![
PerformanceScenario {
name: "Small Document".to_string(),
paragraph_count: 10,
table_count: 1,
list_count: 2,
expected_max_time_ms: 1000,
},
PerformanceScenario {
name: "Medium Document".to_string(),
paragraph_count: 100,
table_count: 5,
list_count: 10,
expected_max_time_ms: 5000,
},
PerformanceScenario {
name: "Large Document".to_string(),
paragraph_count: 500,
table_count: 20,
list_count: 30,
expected_max_time_ms: 15000,
},
PerformanceScenario {
name: "Extra Large Document".to_string(),
paragraph_count: 1000,
table_count: 50,
list_count: 50,
expected_max_time_ms: 30000,
},
]
}
}
/// Complexity levels for generated paragraphs
#[derive(Debug, Clone)]
pub enum ParagraphComplexity {
Simple,
Medium,
Complex,
}
/// Content types for generated tables
#[derive(Debug, Clone)]
pub enum TableContentType {
Generic,
Financial,
Personnel,
Technical,
}
/// Categories for generated lists
#[derive(Debug, Clone)]
pub enum ListCategory {
Tasks,
Features,
Requirements,
Benefits,
}
/// Business data set for realistic testing
#[derive(Debug, Clone)]
pub struct BusinessDataSet {
pub companies: Vec<String>,
pub departments: Vec<String>,
pub positions: Vec<String>,
pub locations: Vec<String>,
}
/// MCP tool call test data
#[derive(Debug, Clone)]
pub struct McpTestCall {
pub tool_name: String,
pub args: Value,
pub expected_success: bool,
pub expected_result_keys: Vec<String>,
}
/// Performance test scenario data
#[derive(Debug, Clone)]
pub struct PerformanceScenario {
pub name: String,
pub paragraph_count: usize,
pub table_count: usize,
pub list_count: usize,
pub expected_max_time_ms: u64,
}
/// Utility functions for test data validation
pub struct TestDataValidator;
impl TestDataValidator {
/// Validate that text contains expected content
pub fn validate_text_content(text: &str, expected_keywords: &[&str]) -> bool {
expected_keywords.iter().all(|keyword| text.contains(keyword))
}
/// Validate table structure
pub fn validate_table_structure(rows: &[Vec<String>], expected_cols: usize) -> bool {
!rows.is_empty() && rows.iter().all(|row| row.len() == expected_cols)
}
/// Validate MCP response structure
pub fn validate_mcp_response(response: &Value, expected_keys: &[String]) -> bool {
expected_keys.iter().all(|key| response.get(key).is_some())
}
/// Generate hash for test data consistency checking
pub fn generate_content_hash(content: &str) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
hasher.finish()
}
}
+558
View File
@@ -0,0 +1,558 @@
use docx_mcp::docx_tools::DocxToolsProvider;
use docx_mcp::security::SecurityConfig;
use mcp_core::{ToolProvider, ToolResult};
use serde_json::json;
use tempfile::TempDir;
use tokio_test;
use pretty_assertions::assert_eq;
use rstest::*;
async fn create_test_provider() -> (DocxToolsProvider, TempDir) {
let temp_dir = TempDir::new().unwrap();
std::env::set_var("TMPDIR", temp_dir.path());
let provider = DocxToolsProvider::new();
(provider, temp_dir)
}
async fn create_test_provider_with_security(config: SecurityConfig) -> (DocxToolsProvider, TempDir) {
let temp_dir = TempDir::new().unwrap();
std::env::set_var("TMPDIR", temp_dir.path());
let provider = DocxToolsProvider::new_with_security(config);
(provider, temp_dir)
}
#[tokio::test]
async fn test_list_tools_default_config() {
let (provider, _temp_dir) = create_test_provider().await;
let tools = provider.list_tools().await;
// Should have all tools in default configuration
assert!(tools.len() > 20);
let tool_names: Vec<_> = tools.iter().map(|t| &t.name).collect();
assert!(tool_names.contains(&&"create_document".to_string()));
assert!(tool_names.contains(&&"add_paragraph".to_string()));
assert!(tool_names.contains(&&"convert_to_pdf".to_string()));
assert!(tool_names.contains(&&"extract_text".to_string()));
assert!(tool_names.contains(&&"get_security_info".to_string()));
}
#[tokio::test]
async fn test_list_tools_readonly_config() {
let config = SecurityConfig {
readonly_mode: true,
..Default::default()
};
let (provider, _temp_dir) = create_test_provider_with_security(config).await;
let tools = provider.list_tools().await;
let tool_names: Vec<_> = tools.iter().map(|t| &t.name).collect();
// Should include readonly tools
assert!(tool_names.contains(&&"extract_text".to_string()));
assert!(tool_names.contains(&&"get_metadata".to_string()));
assert!(tool_names.contains(&&"search_text".to_string()));
// Should not include write tools
assert!(!tool_names.contains(&&"create_document".to_string()));
assert!(!tool_names.contains(&&"add_paragraph".to_string()));
assert!(!tool_names.contains(&&"save_document".to_string()));
}
#[tokio::test]
async fn test_create_document_tool() {
let (provider, _temp_dir) = create_test_provider().await;
let result = provider.call_tool("create_document", json!({})).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
assert!(value["document_id"].is_string());
let doc_id = value["document_id"].as_str().unwrap();
assert!(!doc_id.is_empty());
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
#[tokio::test]
async fn test_add_paragraph_tool() {
let (provider, _temp_dir) = create_test_provider().await;
// First create a document
let create_result = provider.call_tool("create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Add paragraph
let args = json!({
"document_id": doc_id,
"text": "Test paragraph content"
});
let result = provider.call_tool("add_paragraph", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
// Verify content was added
let extract_args = json!({"document_id": doc_id});
let extract_result = provider.call_tool("extract_text", extract_args).await;
match extract_result {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
assert!(text.contains("Test paragraph content"));
}
ToolResult::Error(e) => panic!("Failed to extract text: {}", e),
}
}
#[tokio::test]
async fn test_add_paragraph_with_style() {
let (provider, _temp_dir) = create_test_provider().await;
let create_result = provider.call_tool("create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
let args = json!({
"document_id": doc_id,
"text": "Styled paragraph",
"style": {
"font_size": 16,
"bold": true,
"color": "#FF0000",
"alignment": "center"
}
});
let result = provider.call_tool("add_paragraph", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
#[tokio::test]
async fn test_add_table_tool() {
let (provider, _temp_dir) = create_test_provider().await;
let create_result = provider.call_tool("create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
let args = json!({
"document_id": doc_id,
"rows": [
["Name", "Age", "City"],
["Alice", "30", "New York"],
["Bob", "25", "Los Angeles"]
]
});
let result = provider.call_tool("add_table", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
// Verify table content
let extract_args = json!({"document_id": doc_id});
let extract_result = provider.call_tool("extract_text", extract_args).await;
match extract_result {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
assert!(text.contains("Alice"));
assert!(text.contains("New York"));
}
ToolResult::Error(e) => panic!("Failed to extract text: {}", e),
}
}
#[tokio::test]
async fn test_add_heading_tool() {
let (provider, _temp_dir) = create_test_provider().await;
let create_result = provider.call_tool("create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Test different heading levels
for level in 1..=6 {
let args = json!({
"document_id": doc_id,
"text": format!("Heading Level {}", level),
"level": level
});
let result = provider.call_tool("add_heading", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
}
ToolResult::Error(e) => panic!("Expected success for level {}, got error: {}", level, e),
}
}
}
#[tokio::test]
async fn test_add_list_tool() {
let (provider, _temp_dir) = create_test_provider().await;
let create_result = provider.call_tool("create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Test ordered list
let ordered_args = json!({
"document_id": doc_id,
"items": ["First item", "Second item", "Third item"],
"ordered": true
});
let result = provider.call_tool("add_list", ordered_args).await;
assert!(matches!(result, ToolResult::Success(_)));
// Test unordered list
let unordered_args = json!({
"document_id": doc_id,
"items": ["Bullet one", "Bullet two", "Bullet three"],
"ordered": false
});
let result = provider.call_tool("add_list", unordered_args).await;
assert!(matches!(result, ToolResult::Success(_)));
}
#[tokio::test]
async fn test_get_metadata_tool() {
let (provider, _temp_dir) = create_test_provider().await;
let create_result = provider.call_tool("create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
let args = json!({"document_id": doc_id});
let result = provider.call_tool("get_metadata", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let metadata = &value["metadata"];
assert_eq!(metadata["id"], doc_id);
assert!(metadata["path"].is_string());
assert!(metadata["created_at"].is_string());
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
#[tokio::test]
async fn test_search_text_tool() {
let (provider, _temp_dir) = create_test_provider().await;
let create_result = provider.call_tool("create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Add some content to search
let add_args = json!({
"document_id": doc_id,
"text": "This is a test document with searchable content. The word test appears multiple times."
});
provider.call_tool("add_paragraph", add_args).await;
// Search for text
let search_args = json!({
"document_id": doc_id,
"search_term": "test",
"case_sensitive": false
});
let result = provider.call_tool("search_text", search_args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let matches = value["matches"].as_array().unwrap();
assert!(matches.len() > 0);
assert!(value["total_matches"].as_u64().unwrap() > 0);
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
#[tokio::test]
async fn test_get_word_count_tool() {
let (provider, _temp_dir) = create_test_provider().await;
let create_result = provider.call_tool("create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Add content with known word count
let content = "This sentence has exactly five words. This is another sentence with seven words total.";
let add_args = json!({
"document_id": doc_id,
"text": content
});
provider.call_tool("add_paragraph", add_args).await;
let args = json!({"document_id": doc_id});
let result = provider.call_tool("get_word_count", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let stats = &value["statistics"];
assert!(stats["words"].as_u64().unwrap() > 10);
assert!(stats["characters"].as_u64().unwrap() > 0);
assert!(stats["sentences"].as_u64().unwrap() >= 2);
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
#[tokio::test]
async fn test_get_security_info_tool() {
let config = SecurityConfig {
readonly_mode: true,
sandbox_mode: true,
..Default::default()
};
let (provider, _temp_dir) = create_test_provider_with_security(config).await;
let result = provider.call_tool("get_security_info", json!({})).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let security = &value["security"];
assert_eq!(security["readonly_mode"], true);
assert_eq!(security["sandbox_mode"], true);
assert!(security["summary"].is_string());
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
#[tokio::test]
async fn test_readonly_mode_blocks_write_operations() {
let config = SecurityConfig {
readonly_mode: true,
..Default::default()
};
let (provider, _temp_dir) = create_test_provider_with_security(config).await;
// Should fail to create document in readonly mode
let result = provider.call_tool("create_document", json!({})).await;
match result {
ToolResult::Error(e) => {
assert!(e.contains("Security check failed"));
assert!(e.contains("Command not allowed"));
}
ToolResult::Success(_) => panic!("Expected security error, got success"),
}
}
#[tokio::test]
async fn test_document_not_found_error() {
let (provider, _temp_dir) = create_test_provider().await;
let args = json!({"document_id": "nonexistent-doc-id"});
let result = provider.call_tool("extract_text", args).await;
match result {
ToolResult::Success(value) => {
assert!(!value["success"].as_bool().unwrap());
assert!(value["error"].as_str().unwrap().contains("Document not found"));
}
ToolResult::Error(_) => {
// This is also acceptable - depends on implementation
}
}
}
#[tokio::test]
async fn test_invalid_tool_name() {
let (provider, _temp_dir) = create_test_provider().await;
let result = provider.call_tool("nonexistent_tool", json!({})).await;
match result {
ToolResult::Success(value) => {
assert!(!value["success"].as_bool().unwrap());
assert!(value["error"].as_str().unwrap().contains("Unknown tool"));
}
ToolResult::Error(e) => {
assert!(e.contains("Unknown tool"));
}
}
}
#[tokio::test]
async fn test_multiple_documents() {
let (provider, _temp_dir) = create_test_provider().await;
let mut doc_ids = Vec::new();
// Create multiple documents
for i in 0..3 {
let result = provider.call_tool("create_document", json!({})).await;
let doc_id = match result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document {}", i),
};
// Add unique content to each
let args = json!({
"document_id": doc_id,
"text": format!("Document {} content", i)
});
provider.call_tool("add_paragraph", args).await;
doc_ids.push(doc_id);
}
// List documents
let list_result = provider.call_tool("list_documents", json!({})).await;
match list_result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
let documents = value["documents"].as_array().unwrap();
assert!(documents.len() >= 3);
}
ToolResult::Error(e) => panic!("Failed to list documents: {}", e),
}
// Verify each document has its unique content
for (i, doc_id) in doc_ids.iter().enumerate() {
let args = json!({"document_id": doc_id});
let result = provider.call_tool("extract_text", args).await;
match result {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
assert!(text.contains(&format!("Document {} content", i)));
}
ToolResult::Error(e) => panic!("Failed to extract text from document {}: {}", i, e),
}
}
}
#[tokio::test]
async fn test_export_to_markdown() {
let (provider, temp_dir) = create_test_provider().await;
let create_result = provider.call_tool("create_document", json!({})).await;
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Add content
provider.call_tool("add_heading", json!({
"document_id": doc_id,
"text": "Test Document",
"level": 1
})).await;
provider.call_tool("add_paragraph", json!({
"document_id": doc_id,
"text": "This is a test paragraph."
})).await;
// Export to markdown
let output_path = temp_dir.path().join("test_export.md");
let args = json!({
"document_id": doc_id,
"output_path": output_path.to_str().unwrap()
});
let result = provider.call_tool("export_to_markdown", args).await;
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap());
assert!(output_path.exists());
let content = std::fs::read_to_string(&output_path).unwrap();
assert!(content.contains("# Test Document"));
assert!(content.contains("test paragraph"));
}
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
}
}
// Parametrized test using rstest
#[rstest]
#[case("create_document", json!({}))]
#[case("list_documents", json!({}))]
#[case("get_security_info", json!({}))]
#[tokio::test]
async fn test_tools_without_document_id(#[case] tool_name: &str, #[case] args: serde_json::Value) {
let (provider, _temp_dir) = create_test_provider().await;
let result = provider.call_tool(tool_name, args).await;
// These tools should work without requiring a document_id
match result {
ToolResult::Success(value) => {
assert!(value["success"].as_bool().unwrap_or(false));
}
ToolResult::Error(e) => panic!("Tool {} failed: {}", tool_name, e),
}
}
#[tokio::test]
async fn test_tool_input_validation() {
let (provider, _temp_dir) = create_test_provider().await;
// Missing required arguments should fail gracefully
let result = provider.call_tool("add_paragraph", json!({})).await;
match result {
ToolResult::Success(value) => {
// Should fail due to missing document_id
assert!(!value["success"].as_bool().unwrap_or(true));
}
ToolResult::Error(_) => {
// This is also acceptable
}
}
}
+573
View File
@@ -0,0 +1,573 @@
use anyhow::Result;
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
use docx_mcp::pure_converter::PureRustConverter;
use docx_mcp::docx_tools::DocxToolsProvider;
use docx_mcp::security::SecurityConfig;
use mcp_core::{ToolProvider, ToolResult};
use serde_json::json;
use tempfile::TempDir;
use std::time::{Duration, Instant};
use std::sync::{Arc, Mutex};
use std::thread;
use pretty_assertions::assert_eq;
const PERFORMANCE_TIMEOUT: Duration = Duration::from_secs(30);
const STRESS_TEST_ITERATIONS: usize = 100;
#[test]
fn test_large_document_performance() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
let start = Instant::now();
let doc_id = handler.create_document().unwrap();
let creation_time = start.elapsed();
println!("Document creation took: {:?}", creation_time);
assert!(creation_time < Duration::from_millis(500), "Document creation should be fast");
let start = Instant::now();
// Add substantial content
for i in 0..1000 {
if i % 50 == 0 {
handler.add_heading(&doc_id, &format!("Section {}", i / 50 + 1), 2)?;
}
let content = format!(
"This is paragraph number {} in our performance test. It contains enough text to make the test meaningful and simulate real-world usage patterns. The paragraph includes various punctuation marks, numbers like {}, and other elements that might affect processing performance.",
i, i * 7
);
handler.add_paragraph(&doc_id, &content, None)?;
// Add a table every 100 paragraphs
if i % 100 == 99 {
let table_data = TableData {
rows: vec![
vec!["Item".to_string(), "Value".to_string(), "Status".to_string()],
vec![format!("Item {}", i), format!("${}.00", i * 10), "Active".to_string()],
],
headers: Some(vec!["Item".to_string(), "Value".to_string(), "Status".to_string()]),
border_style: Some("single".to_string()),
};
handler.add_table(&doc_id, table_data)?;
}
}
let content_addition_time = start.elapsed();
println!("Adding 1000 paragraphs took: {:?}", content_addition_time);
assert!(content_addition_time < PERFORMANCE_TIMEOUT, "Content addition took too long");
// Test text extraction performance
let start = Instant::now();
let text = handler.extract_text(&doc_id)?;
let extraction_time = start.elapsed();
println!("Text extraction took: {:?}", extraction_time);
println!("Extracted text length: {} characters", text.len());
assert!(extraction_time < Duration::from_secs(10), "Text extraction should be reasonably fast");
assert!(text.len() > 100000, "Should extract substantial amount of text");
// Test PDF conversion performance
let metadata = handler.get_metadata(&doc_id)?;
let converter = PureRustConverter::new();
let pdf_path = temp_dir.path().join("large_performance_test.pdf");
let start = Instant::now();
converter.convert_docx_to_pdf(&metadata.path, &pdf_path)?;
let conversion_time = start.elapsed();
println!("PDF conversion took: {:?}", conversion_time);
assert!(conversion_time < PERFORMANCE_TIMEOUT, "PDF conversion took too long");
assert!(pdf_path.exists(), "PDF should be created");
let pdf_size = std::fs::metadata(&pdf_path)?.len();
println!("Generated PDF size: {} bytes", pdf_size);
assert!(pdf_size > 50000, "PDF should have substantial size");
Ok(())
}
#[test]
fn test_concurrent_document_stress() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let temp_path = Arc::new(temp_dir.path().to_path_buf());
let results = Arc::new(Mutex::new(Vec::new()));
let thread_count = 8;
let operations_per_thread = 20;
let start = Instant::now();
let handles: Vec<_> = (0..thread_count).map(|thread_id| {
let temp_path = Arc::clone(&temp_path);
let results = Arc::clone(&results);
thread::spawn(move || -> Result<()> {
let mut handler = DocxHandler::new_with_temp_dir(&temp_path)?;
let mut local_results = Vec::new();
for op_id in 0..operations_per_thread {
let doc_start = Instant::now();
// Create document
let doc_id = handler.create_document()?;
// Add varied content
handler.add_heading(&doc_id, &format!("Thread {} Document {}", thread_id, op_id), 1)?;
for i in 0..10 {
let content = format!("Thread {} operation {} paragraph {}", thread_id, op_id, i);
handler.add_paragraph(&doc_id, &content, None)?;
}
// Add a small table
let table_data = TableData {
rows: vec![
vec!["Col1".to_string(), "Col2".to_string()],
vec![format!("T{}", thread_id), format!("O{}", op_id)],
],
headers: None,
border_style: Some("single".to_string()),
};
handler.add_table(&doc_id, table_data)?;
// Extract text
let text = handler.extract_text(&doc_id)?;
assert!(text.contains(&format!("Thread {} Document {}", thread_id, op_id)));
let doc_duration = doc_start.elapsed();
local_results.push((thread_id, op_id, doc_duration));
// Cleanup
handler.close_document(&doc_id)?;
}
// Store results
{
let mut results_guard = results.lock().unwrap();
results_guard.extend(local_results);
}
Ok(())
})
}).collect();
// Wait for all threads
for handle in handles {
handle.join().unwrap()?;
}
let total_duration = start.elapsed();
let results_guard = results.lock().unwrap();
println!("Concurrent stress test completed in: {:?}", total_duration);
println!("Total operations: {}", results_guard.len());
let avg_duration = results_guard.iter()
.map(|(_, _, duration)| duration.as_millis())
.sum::<u128>() as f64 / results_guard.len() as f64;
println!("Average operation duration: {:.2}ms", avg_duration);
// Verify all operations completed
assert_eq!(results_guard.len(), thread_count * operations_per_thread);
assert!(total_duration < Duration::from_secs(60), "Stress test took too long");
assert!(avg_duration < 1000.0, "Average operation should be under 1 second");
Ok(())
}
#[test]
fn test_memory_intensive_operations() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
let mut doc_ids = Vec::new();
// Create many documents simultaneously
for i in 0..50 {
let doc_id = handler.create_document()?;
// Add substantial content to each
handler.add_heading(&doc_id, &format!("Memory Test Document {}", i), 1)?;
for j in 0..100 {
let content = format!(
"Document {} paragraph {}. This paragraph contains substantial text content to test memory usage patterns. It includes various data that might accumulate in memory during processing and needs to be handled efficiently by the system.",
i, j
);
handler.add_paragraph(&doc_id, &content, None)?;
}
// Add a large table
let mut table_rows = vec![vec!["ID".to_string(), "Name".to_string(), "Description".to_string()]];
for k in 0..20 {
table_rows.push(vec![
format!("ID-{}", k),
format!("Item-{}", k),
format!("Description for item {} in document {}", k, i),
]);
}
let table_data = TableData {
rows: table_rows,
headers: Some(vec!["ID".to_string(), "Name".to_string(), "Description".to_string()]),
border_style: Some("single".to_string()),
};
handler.add_table(&doc_id, table_data)?;
doc_ids.push(doc_id);
}
println!("Created {} documents with substantial content", doc_ids.len());
// Test that all documents are accessible
for (i, doc_id) in doc_ids.iter().enumerate() {
let text = handler.extract_text(doc_id)?;
assert!(text.contains(&format!("Memory Test Document {}", i)));
assert!(text.len() > 10000, "Document should have substantial text");
}
// Test batch operations
let start = Instant::now();
let mut total_text_length = 0;
for doc_id in &doc_ids {
let text = handler.extract_text(doc_id)?;
total_text_length += text.len();
}
let batch_extraction_time = start.elapsed();
println!("Batch text extraction took: {:?}", batch_extraction_time);
println!("Total extracted text: {} characters", total_text_length);
assert!(batch_extraction_time < Duration::from_secs(30), "Batch extraction should be reasonable");
assert!(total_text_length > 500000, "Should extract substantial total text");
// Cleanup all documents
for doc_id in doc_ids {
handler.close_document(&doc_id)?;
}
Ok(())
}
#[test]
fn test_mcp_tool_performance() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
std::env::set_var("TMPDIR", temp_dir.path());
let provider = DocxToolsProvider::new();
let mut operation_times = Vec::new();
// Test document creation performance
let start = Instant::now();
let create_result = tokio_test::block_on(async {
provider.call_tool("create_document", json!({})).await
});
let creation_time = start.elapsed();
operation_times.push(("create_document", creation_time));
let doc_id = match create_result {
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
_ => panic!("Failed to create document"),
};
// Test paragraph addition performance
let start = Instant::now();
for i in 0..100 {
let args = json!({
"document_id": doc_id,
"text": format!("Performance test paragraph {} with substantial content for timing measurements", i)
});
let result = tokio_test::block_on(async {
provider.call_tool("add_paragraph", args).await
});
match result {
ToolResult::Success(_) => {},
ToolResult::Error(e) => panic!("Failed to add paragraph {}: {}", i, e),
}
}
let paragraph_addition_time = start.elapsed();
operation_times.push(("add_100_paragraphs", paragraph_addition_time));
// Test heading performance
let start = Instant::now();
for level in 1..=6 {
let args = json!({
"document_id": doc_id,
"text": format!("Heading Level {}", level),
"level": level
});
tokio_test::block_on(async {
provider.call_tool("add_heading", args).await
});
}
let heading_time = start.elapsed();
operation_times.push(("add_headings", heading_time));
// Test table performance
let start = Instant::now();
let table_args = json!({
"document_id": doc_id,
"rows": [
["Product", "Price", "Quantity", "Total"],
["Item 1", "$10.00", "5", "$50.00"],
["Item 2", "$15.00", "3", "$45.00"],
["Item 3", "$12.00", "7", "$84.00"],
["Item 4", "$8.00", "10", "$80.00"]
]
});
tokio_test::block_on(async {
provider.call_tool("add_table", table_args).await
});
let table_time = start.elapsed();
operation_times.push(("add_table", table_time));
// Test text extraction performance
let start = Instant::now();
let extract_args = json!({"document_id": doc_id});
let extract_result = tokio_test::block_on(async {
provider.call_tool("extract_text", extract_args).await
});
let extraction_time = start.elapsed();
operation_times.push(("extract_text", extraction_time));
match extract_result {
ToolResult::Success(value) => {
let text = value["text"].as_str().unwrap();
println!("Extracted text length: {} characters", text.len());
assert!(text.len() > 5000, "Should extract substantial text");
},
ToolResult::Error(e) => panic!("Text extraction failed: {}", e),
}
// Test metadata retrieval performance
let start = Instant::now();
let metadata_args = json!({"document_id": doc_id});
tokio_test::block_on(async {
provider.call_tool("get_metadata", metadata_args).await
});
let metadata_time = start.elapsed();
operation_times.push(("get_metadata", metadata_time));
// Print performance results
println!("\nMCP Tool Performance Results:");
for (operation, duration) in &operation_times {
println!("{}: {:?}", operation, duration);
}
// Verify reasonable performance
for (operation, duration) in &operation_times {
match operation.as_ref() {
"create_document" => assert!(duration < &Duration::from_millis(500), "Document creation too slow"),
"add_100_paragraphs" => assert!(duration < &Duration::from_secs(10), "Paragraph addition too slow"),
"extract_text" => assert!(duration < &Duration::from_secs(5), "Text extraction too slow"),
_ => assert!(duration < &Duration::from_secs(2), "Operation {} too slow", operation),
}
}
Ok(())
}
#[test]
fn test_security_overhead_performance() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
std::env::set_var("TMPDIR", temp_dir.path());
// Test with default (permissive) security
let default_provider = DocxToolsProvider::new();
// Test with restrictive security
let restrictive_config = SecurityConfig {
readonly_mode: true,
sandbox_mode: true,
max_document_size: 1024 * 1024, // 1MB
max_open_documents: 10,
allow_external_tools: false,
allow_network: false,
..Default::default()
};
let restrictive_provider = DocxToolsProvider::new_with_security(restrictive_config);
let operations = vec![
("list_documents", json!({})),
("get_security_info", json!({})),
];
for (operation, args) in operations {
// Test default provider
let start = Instant::now();
let _result = tokio_test::block_on(async {
default_provider.call_tool(operation, args.clone()).await
});
let default_time = start.elapsed();
// Test restrictive provider
let start = Instant::now();
let _result = tokio_test::block_on(async {
restrictive_provider.call_tool(operation, args.clone()).await
});
let restrictive_time = start.elapsed();
println!("Operation {}: Default={:?}, Restrictive={:?}",
operation, default_time, restrictive_time);
// Security overhead should be minimal
let overhead_ratio = restrictive_time.as_nanos() as f64 / default_time.as_nanos() as f64;
assert!(overhead_ratio < 3.0, "Security overhead too high for {}: {}x", operation, overhead_ratio);
}
Ok(())
}
#[test]
fn test_conversion_performance_scaling() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let converter = PureRustConverter::new();
let document_sizes = vec![10, 50, 100, 250];
let mut performance_data = Vec::new();
for &size in &document_sizes {
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path())?;
let doc_id = handler.create_document()?;
// Create document with specified number of paragraphs
handler.add_heading(&doc_id, &format!("Test Document - {} paragraphs", size), 1)?;
for i in 0..size {
let content = format!("Paragraph {} content for performance scaling test. This paragraph contains enough text to make the performance test meaningful and realistic.", i);
handler.add_paragraph(&doc_id, &content, None)?;
if i % 20 == 19 {
handler.add_heading(&doc_id, &format!("Section {}", i / 20 + 1), 2)?;
}
}
let metadata = handler.get_metadata(&doc_id)?;
// Test text extraction scaling
let start = Instant::now();
let text = handler.extract_text(&doc_id)?;
let extraction_time = start.elapsed();
// Test PDF conversion scaling
let pdf_path = temp_dir.path().join(format!("scale_test_{}.pdf", size));
let start = Instant::now();
converter.convert_docx_to_pdf(&metadata.path, &pdf_path)?;
let conversion_time = start.elapsed();
performance_data.push((size, text.len(), extraction_time, conversion_time));
println!("Size: {} paragraphs, Text: {} chars, Extract: {:?}, Convert: {:?}",
size, text.len(), extraction_time, conversion_time);
handler.close_document(&doc_id)?;
}
// Analyze scaling behavior
for i in 1..performance_data.len() {
let (prev_size, _, prev_extract, prev_convert) = performance_data[i-1];
let (curr_size, _, curr_extract, curr_convert) = performance_data[i];
let size_ratio = curr_size as f64 / prev_size as f64;
let extract_ratio = curr_extract.as_nanos() as f64 / prev_extract.as_nanos() as f64;
let convert_ratio = curr_convert.as_nanos() as f64 / prev_convert.as_nanos() as f64;
println!("Size {}{}: Extract scaling {:.2}, Convert scaling {:.2}",
prev_size, curr_size, extract_ratio / size_ratio, convert_ratio / size_ratio);
// Performance should scale reasonably (not exponentially)
assert!(extract_ratio / size_ratio < 3.0, "Text extraction scaling too poor");
assert!(convert_ratio / size_ratio < 5.0, "PDF conversion scaling too poor");
}
Ok(())
}
#[test]
fn test_error_handling_performance() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
std::env::set_var("TMPDIR", temp_dir.path());
let provider = DocxToolsProvider::new();
let error_operations = vec![
("extract_text", json!({"document_id": "nonexistent"})),
("add_paragraph", json!({"document_id": "fake", "text": "test"})),
("get_metadata", json!({"document_id": "invalid"})),
("unknown_tool", json!({})),
];
for (operation, args) in error_operations {
let start = Instant::now();
let result = tokio_test::block_on(async {
provider.call_tool(operation, args).await
});
let error_time = start.elapsed();
println!("Error handling for {}: {:?}", operation, error_time);
// Error handling should be fast
assert!(error_time < Duration::from_millis(100),
"Error handling for {} too slow: {:?}", operation, error_time);
// Should return appropriate error
match result {
ToolResult::Error(_) | ToolResult::Success(_) => {}, // Both are acceptable for error cases
}
}
Ok(())
}
#[test]
fn test_resource_cleanup_performance() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path())?;
let document_count = 50;
let mut doc_ids = Vec::new();
// Create many documents
let creation_start = Instant::now();
for i in 0..document_count {
let doc_id = handler.create_document()?;
handler.add_paragraph(&doc_id, &format!("Document {} content", i), None)?;
doc_ids.push(doc_id);
}
let creation_time = creation_start.elapsed();
println!("Created {} documents in {:?}", document_count, creation_time);
// Verify all documents exist
let initial_count = handler.list_documents().len();
assert_eq!(initial_count, document_count);
// Test cleanup performance
let cleanup_start = Instant::now();
for doc_id in doc_ids {
handler.close_document(&doc_id)?;
}
let cleanup_time = cleanup_start.elapsed();
println!("Cleaned up {} documents in {:?}", document_count, cleanup_time);
// Verify cleanup worked
let final_count = handler.list_documents().len();
assert_eq!(final_count, 0);
// Cleanup should be reasonably fast
assert!(cleanup_time < Duration::from_secs(5), "Cleanup took too long");
let avg_cleanup_time = cleanup_time.as_nanos() / document_count as u128;
println!("Average cleanup time per document: {}ns", avg_cleanup_time);
Ok(())
}
+347
View File
@@ -0,0 +1,347 @@
use docx_mcp::security::{SecurityConfig, SecurityMiddleware, SecurityError};
use serde_json::json;
use std::collections::HashSet;
use pretty_assertions::assert_eq;
use rstest::*;
#[test]
fn test_default_security_config() {
let config = SecurityConfig::default();
assert!(!config.readonly_mode);
assert!(config.command_whitelist.is_none());
assert!(config.command_blacklist.is_none());
assert_eq!(config.max_document_size, 100 * 1024 * 1024);
assert_eq!(config.max_open_documents, 50);
assert!(config.allow_external_tools);
assert!(config.allow_network);
assert!(!config.sandbox_mode);
}
#[test]
fn test_readonly_mode_allows_only_safe_commands() {
let config = SecurityConfig {
readonly_mode: true,
..Default::default()
};
// Should allow readonly commands
assert!(config.is_command_allowed("open_document"));
assert!(config.is_command_allowed("extract_text"));
assert!(config.is_command_allowed("get_metadata"));
assert!(config.is_command_allowed("search_text"));
assert!(config.is_command_allowed("export_to_markdown"));
// Should block write commands
assert!(!config.is_command_allowed("create_document"));
assert!(!config.is_command_allowed("add_paragraph"));
assert!(!config.is_command_allowed("save_document"));
assert!(!config.is_command_allowed("find_and_replace"));
assert!(!config.is_command_allowed("convert_to_pdf"));
}
#[test]
fn test_command_whitelist() {
let mut whitelist = HashSet::new();
whitelist.insert("open_document".to_string());
whitelist.insert("extract_text".to_string());
let config = SecurityConfig {
command_whitelist: Some(whitelist),
..Default::default()
};
// Should allow whitelisted commands
assert!(config.is_command_allowed("open_document"));
assert!(config.is_command_allowed("extract_text"));
// Should block non-whitelisted commands
assert!(!config.is_command_allowed("create_document"));
assert!(!config.is_command_allowed("add_paragraph"));
assert!(!config.is_command_allowed("get_metadata"));
}
#[test]
fn test_command_blacklist() {
let mut blacklist = HashSet::new();
blacklist.insert("save_document".to_string());
blacklist.insert("convert_to_pdf".to_string());
let config = SecurityConfig {
command_blacklist: Some(blacklist),
..Default::default()
};
// Should allow non-blacklisted commands
assert!(config.is_command_allowed("open_document"));
assert!(config.is_command_allowed("extract_text"));
assert!(config.is_command_allowed("add_paragraph"));
// Should block blacklisted commands
assert!(!config.is_command_allowed("save_document"));
assert!(!config.is_command_allowed("convert_to_pdf"));
}
#[test]
fn test_whitelist_overrides_blacklist() {
let mut whitelist = HashSet::new();
whitelist.insert("save_document".to_string());
let mut blacklist = HashSet::new();
blacklist.insert("save_document".to_string());
let config = SecurityConfig {
command_whitelist: Some(whitelist),
command_blacklist: Some(blacklist),
..Default::default()
};
// Whitelist should take precedence
assert!(config.is_command_allowed("save_document"));
}
#[test]
fn test_external_tools_restriction() {
let config = SecurityConfig {
allow_external_tools: false,
..Default::default()
};
// Should block conversion commands that might use external tools
assert!(!config.is_command_allowed("convert_to_pdf"));
assert!(!config.is_command_allowed("convert_to_images"));
// Should allow other commands
assert!(config.is_command_allowed("open_document"));
assert!(config.is_command_allowed("add_paragraph"));
}
#[test]
fn test_security_middleware_command_check() {
let config = SecurityConfig {
readonly_mode: true,
..Default::default()
};
let middleware = SecurityMiddleware::new(config);
let safe_args = json!({"document_id": "test"});
// Should pass readonly commands
let result = middleware.check_command("extract_text", &safe_args);
assert!(result.is_ok());
// Should fail write commands
let result = middleware.check_command("add_paragraph", &safe_args);
assert!(matches!(result, Err(SecurityError::CommandNotAllowed(_))));
}
#[test]
fn test_sandbox_mode_path_restrictions() {
let config = SecurityConfig {
sandbox_mode: true,
..Default::default()
};
let middleware = SecurityMiddleware::new(config);
// Should allow temp directory paths
let temp_args = json!({"path": "/tmp/docx-mcp/test.docx"});
let result = middleware.check_command("open_document", &temp_args);
assert!(result.is_ok());
// Should block paths outside temp directory
let home_args = json!({"path": "/home/user/documents/test.docx"});
let result = middleware.check_command("open_document", &home_args);
assert!(matches!(result, Err(SecurityError::PathNotAllowed(_))));
}
#[test]
fn test_file_size_limits() {
use tempfile::NamedTempFile;
use std::io::Write;
let config = SecurityConfig {
max_document_size: 100, // 100 bytes limit
..Default::default()
};
let middleware = SecurityMiddleware::new(config);
// Create a test file larger than limit
let mut temp_file = NamedTempFile::new().unwrap();
let large_content = vec![0u8; 200]; // 200 bytes
temp_file.write_all(&large_content).unwrap();
temp_file.flush().unwrap();
let args = json!({"path": temp_file.path().to_str().unwrap()});
let result = middleware.check_command("open_document", &args);
assert!(matches!(result, Err(SecurityError::FileTooLarge { .. })));
}
#[test]
fn test_readonly_commands_list() {
let readonly_commands = SecurityConfig::get_readonly_commands();
// Should include expected readonly commands
assert!(readonly_commands.contains("open_document"));
assert!(readonly_commands.contains("extract_text"));
assert!(readonly_commands.contains("get_metadata"));
assert!(readonly_commands.contains("search_text"));
assert!(readonly_commands.contains("analyze_formatting"));
// Should not include write commands
assert!(!readonly_commands.contains("create_document"));
assert!(!readonly_commands.contains("add_paragraph"));
assert!(!readonly_commands.contains("save_document"));
}
#[test]
fn test_write_commands_list() {
let write_commands = SecurityConfig::get_write_commands();
// Should include expected write commands
assert!(write_commands.contains("create_document"));
assert!(write_commands.contains("add_paragraph"));
assert!(write_commands.contains("save_document"));
assert!(write_commands.contains("find_and_replace"));
// Should not include readonly commands
assert!(!write_commands.contains("open_document"));
assert!(!write_commands.contains("extract_text"));
assert!(!write_commands.contains("get_metadata"));
}
#[test]
fn test_security_summary() {
let config = SecurityConfig {
readonly_mode: true,
sandbox_mode: true,
allow_external_tools: false,
..Default::default()
};
let summary = config.get_summary();
assert!(summary.contains("READONLY MODE"));
assert!(summary.contains("SANDBOX MODE"));
assert!(summary.contains("No external tools"));
}
#[test]
fn test_combined_security_modes() {
let mut whitelist = HashSet::new();
whitelist.insert("open_document".to_string());
whitelist.insert("extract_text".to_string());
let config = SecurityConfig {
readonly_mode: true,
sandbox_mode: true,
command_whitelist: Some(whitelist),
allow_external_tools: false,
allow_network: false,
max_document_size: 1024,
..Default::default()
};
// Should only allow whitelisted readonly commands
assert!(config.is_command_allowed("open_document"));
assert!(config.is_command_allowed("extract_text"));
// Should block everything else
assert!(!config.is_command_allowed("get_metadata")); // Not in whitelist
assert!(!config.is_command_allowed("add_paragraph")); // Not readonly
assert!(!config.is_command_allowed("convert_to_pdf")); // External tools disabled
}
#[test]
fn test_recursive_path_argument_checking() {
let config = SecurityConfig {
sandbox_mode: true,
..Default::default()
};
let middleware = SecurityMiddleware::new(config);
// Complex nested arguments with paths
let nested_args = json!({
"document_id": "test",
"options": {
"output_path": "/home/user/bad/path.docx",
"settings": {
"temp_file": "/tmp/safe/path.tmp"
}
},
"files": [
"/home/user/another/bad/path.docx",
"/tmp/docx-mcp/safe/path.docx"
]
});
let result = middleware.check_command("some_command", &nested_args);
assert!(matches!(result, Err(SecurityError::PathNotAllowed(_))));
}
#[test]
fn test_security_error_messages() {
let error = SecurityError::CommandNotAllowed("dangerous_command".to_string());
assert!(error.to_string().contains("dangerous_command"));
let error = SecurityError::PathNotAllowed("/bad/path".to_string());
assert!(error.to_string().contains("/bad/path"));
let error = SecurityError::FileTooLarge { size: 2000, max_size: 1000 };
assert!(error.to_string().contains("2000"));
assert!(error.to_string().contains("1000"));
}
#[fixture]
fn readonly_config() -> SecurityConfig {
SecurityConfig {
readonly_mode: true,
..Default::default()
}
}
#[fixture]
fn sandbox_config() -> SecurityConfig {
SecurityConfig {
sandbox_mode: true,
allow_external_tools: false,
allow_network: false,
..Default::default()
}
}
#[fixture]
fn restrictive_config() -> SecurityConfig {
let mut whitelist = HashSet::new();
whitelist.insert("open_document".to_string());
whitelist.insert("extract_text".to_string());
SecurityConfig {
readonly_mode: true,
sandbox_mode: true,
command_whitelist: Some(whitelist),
max_document_size: 1024 * 1024, // 1MB
max_open_documents: 5,
allow_external_tools: false,
allow_network: false,
}
}
#[rstest]
#[case("open_document", true)]
#[case("extract_text", true)]
#[case("get_metadata", true)]
#[case("create_document", false)]
#[case("add_paragraph", false)]
#[case("save_document", false)]
fn test_readonly_mode_commands(readonly_config: SecurityConfig, #[case] command: &str, #[case] expected: bool) {
assert_eq!(readonly_config.is_command_allowed(command), expected);
}
#[rstest]
#[case("open_document", true)]
#[case("extract_text", true)]
#[case("add_paragraph", false)] // Not in whitelist
#[case("get_metadata", false)] // Not in whitelist
fn test_restrictive_mode_commands(restrictive_config: SecurityConfig, #[case] command: &str, #[case] expected: bool) {
assert_eq!(restrictive_config.is_command_allowed(command), expected);
}