opencrabs 0.3.62

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
//! Document generation tool (#357).
//!
//! `parse_document` reads documents; this is the missing write side. One
//! `generate_document` tool with structured input, dispatching to a native
//! Rust backend per format so document creation works in the distributed
//! binary with zero host dependencies (no Python, no LibreOffice).
//!
//! Backends land format by format; each format module stays small and pure
//! (spec in, file out) so it is testable without the tool plumbing.

pub(crate) mod docx;
pub(crate) mod pdf;
pub(crate) mod pptx;
pub(crate) mod xlsx;

use super::error::{Result, ToolError};
use super::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolResult};
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::Value;

/// Generate Document Tool: creates XLSX (native, with formulas), DOCX and
/// PDF (native, styled blocks) files from structured content, plus PPTX via
/// the host's python-pptx when available. The native backends run inside
/// the binary with zero host dependencies.
pub struct GenerateDocumentTool;

#[derive(Debug, Deserialize)]
pub(crate) struct GenerateDocumentInput {
    /// Output format. Currently: "xlsx".
    pub format: String,

    /// Path the generated file is written to.
    pub output: String,

    /// XLSX: worksheets to create (required when format is "xlsx").
    #[serde(default)]
    pub sheets: Vec<xlsx::SheetSpec>,

    /// DOCX/PDF: content blocks in order (required for those formats).
    #[serde(default)]
    pub blocks: Vec<docx::BlockSpec>,

    /// PDF: document title for the PDF metadata (optional; defaults to the
    /// output file stem).
    #[serde(default)]
    pub title: Option<String>,

    /// PPTX: slides to create (required when format is "pptx").
    #[serde(default)]
    pub slides: Vec<pptx::SlideSpec>,

    /// Optional visual styling, interpreted per format (PDF: brand colors,
    /// page furniture, zebra; XLSX: header fill, zebra, freeze, autofilter,
    /// tab color). Defaults to the plain look.
    #[serde(default)]
    pub style: Option<Value>,
}

#[async_trait]
impl Tool for GenerateDocumentTool {
    fn name(&self) -> &str {
        "generate_document"
    }

    fn description(&self) -> &str {
        "Create documents from structured content. Formats: \"xlsx\", \"docx\", \
        \"pdf\" (all generated natively) and \"pptx\" (needs python-pptx on the \
        host). \
        XLSX: pass `sheets`, each with a `name` and `rows` (array of rows, each row \
        an array of cells). A cell is a string, number, or boolean; a string \
        starting with \"=\" is written as a live Excel FORMULA (e.g. \
        \"=SUM(B2:B10)\"), so totals recalculate when the user edits the file. \
        Optional per sheet: `header_bold` (default true), `column_widths`. \
        DOCX and PDF: pass `blocks` in order, each one of: {type:\"heading\", text, \
        level 1-3}, {type:\"paragraph\", text, bold?}, {type:\"list\", items:[...], \
        ordered?}, {type:\"table\", rows:[[...]], header_bold?}. In Word output, \
        headings become real styles and lists real numbering; PDF output is A4 \
        with automatic wrapping and page breaks (optional `title` sets the PDF \
        metadata title; optional `style` adds brand colors, a page header/footer \
        with page numbers, and zebra table rows: use it when the user wants a \
        polished or branded report). \
        PPTX: pass `slides`, each {title, bullets?:[...], notes?}. \
        Use this instead of CSV/markdown files whenever the user asks for a \
        spreadsheet, formulas, a Word document, a PDF, or a slide deck. \
        On channels, deliver the generated file as a downloadable attachment in \
        the same turn: telegram_send `send_document` (document_url takes the \
        local path), whatsapp_send `send_document`, or discord_send `send_file`. \
        Do not just paste the file path unless the user asked where it is saved."
    }

    fn input_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "format": {
                    "type": "string",
                    "enum": ["xlsx", "docx", "pdf", "pptx"],
                    "description": "Output format. \"xlsx\" creates an Excel workbook, \"docx\" a Word document, \"pdf\" a PDF, \"pptx\" a PowerPoint deck (requires python-pptx on the host)."
                },
                "output": {
                    "type": "string",
                    "description": "Path to write the generated file to (extension should match the format)."
                },
                "sheets": {
                    "type": "array",
                    "description": "Worksheets to create (xlsx). At least one required.",
                    "items": {
                        "type": "object",
                        "properties": {
                            "name": {
                                "type": "string",
                                "description": "Worksheet name (max 31 chars, Excel limit)."
                            },
                            "rows": {
                                "type": "array",
                                "description": "Rows of cells. A cell is a string, number, or boolean. Strings starting with \"=\" become live Excel formulas.",
                                "items": {"type": "array"}
                            },
                            "header_bold": {
                                "type": "boolean",
                                "description": "Bold the first row as a header (default: true)."
                            },
                            "column_widths": {
                                "type": "array",
                                "items": {"type": "number"},
                                "description": "Optional column widths, in Excel character units, applied left to right."
                            }
                        },
                        "required": ["name", "rows"]
                    }
                },
                "title": {
                    "type": "string",
                    "description": "PDF metadata title (pdf only; defaults to the output file stem)."
                },
                "blocks": {
                    "type": "array",
                    "description": "Content blocks in order (docx/pdf). At least one required.",
                    "items": {
                        "type": "object",
                        "properties": {
                            "type": {
                                "type": "string",
                                "enum": ["heading", "paragraph", "list", "table"],
                                "description": "Block kind."
                            },
                            "text": {"type": "string", "description": "Text for heading/paragraph blocks."},
                            "level": {"type": "integer", "minimum": 1, "maximum": 3, "description": "Heading level (default 1)."},
                            "bold": {"type": "boolean", "description": "Bold the paragraph text."},
                            "items": {"type": "array", "items": {"type": "string"}, "description": "List items."},
                            "ordered": {"type": "boolean", "description": "Numbered list instead of bullets (default false)."},
                            "rows": {"type": "array", "items": {"type": "array"}, "description": "Table rows of cells."},
                            "header_bold": {"type": "boolean", "description": "Bold the first table row (default true)."}
                        },
                        "required": ["type"]
                    }
                },
                "style": {
                    "type": "object",
                    "description": "Visual styling, interpreted per format. All fields optional. PDF: accent_color, text_color, page_header{text,logo_path}, page_footer{text,page_numbers}, zebra_rows. XLSX: header_fill, header_font_color, zebra_rows, freeze_header, autofilter, tab_color (hex colors); per-sheet column_formats on each sheet. DOCX: accent_color (heading color), page_header, page_footer, table_header_fill, zebra_rows. PPTX: template_path (existing .pptx whose master/branding the slides inherit; best branding lever), accent_color (title color), per-slide layout index. Use when the user wants polished or branded output.",
                    "properties": {
                        "accent_color": {"type": "string", "description": "Hex color (\"#0A84FF\") for headings, H1 underline bar, and table header separator."},
                        "text_color": {"type": "string", "description": "Hex color for body text (default near-black)."},
                        "page_header": {
                            "type": "object",
                            "properties": {
                                "text": {"type": "string", "description": "Brand/report name shown at the top of every page with an accent rule."},
                                "logo_path": {"type": "string", "description": "Local PNG/JPEG path drawn in the page header band."}
                            }
                        },
                        "page_footer": {
                            "type": "object",
                            "properties": {
                                "text": {"type": "string", "description": "Footer text on every page."},
                                "page_numbers": {"type": "boolean", "description": "Render \"Page N of M\" bottom-right."}
                            }
                        },
                        "zebra_rows": {"type": "boolean", "description": "Alternating light fills behind table rows."}
                    }
                },
                "slides": {
                    "type": "array",
                    "description": "Slides to create (pptx). At least one required.",
                    "items": {
                        "type": "object",
                        "properties": {
                            "title": {"type": "string", "description": "Slide title."},
                            "bullets": {"type": "array", "items": {"type": "string"}, "description": "Bullet lines for the slide body."},
                            "notes": {"type": "string", "description": "Optional speaker notes."}
                        },
                        "required": ["title"]
                    }
                }
            },
            "required": ["format", "output"]
        })
    }

    fn capabilities(&self) -> Vec<ToolCapability> {
        vec![ToolCapability::WriteFiles]
    }

    fn requires_approval(&self) -> bool {
        true // Writes a file to disk, same policy as write_file.
    }

    fn validate_input(&self, input: &Value) -> Result<()> {
        let parsed: GenerateDocumentInput = serde_json::from_value(input.clone())
            .map_err(|e| ToolError::InvalidInput(format!("Invalid input: {e}")))?;
        match parsed.format.as_str() {
            "xlsx" => {
                if parsed.sheets.is_empty() {
                    return Err(ToolError::InvalidInput(
                        "format \"xlsx\" requires at least one entry in `sheets`".to_string(),
                    ));
                }
                Ok(())
            }
            "docx" | "pdf" => {
                if parsed.blocks.is_empty() {
                    return Err(ToolError::InvalidInput(format!(
                        "format \"{}\" requires at least one entry in `blocks`",
                        parsed.format
                    )));
                }
                Ok(())
            }
            "pptx" => {
                if parsed.slides.is_empty() {
                    return Err(ToolError::InvalidInput(
                        "format \"pptx\" requires at least one entry in `slides`".to_string(),
                    ));
                }
                Ok(())
            }
            other => Err(ToolError::InvalidInput(format!(
                "Unsupported format \"{other}\" (supported: xlsx, docx, pdf, pptx)"
            ))),
        }
    }

    async fn execute(&self, input: Value, context: &ToolExecutionContext) -> Result<ToolResult> {
        let input: GenerateDocumentInput = serde_json::from_value(input)?;
        let path = super::error::resolve_tool_path(&input.output, &context.working_dir());
        if let Some(parent) = path.parent()
            && !parent.as_os_str().is_empty()
            && !parent.exists()
        {
            return Ok(ToolResult::error(format!(
                "Output directory does not exist: {}",
                parent.display()
            )));
        }
        match input.format.as_str() {
            "xlsx" => {
                let style: xlsx::XlsxStyle = match input.style.clone() {
                    Some(v) => match serde_json::from_value(v) {
                        Ok(st) => st,
                        Err(e) => {
                            return Ok(ToolResult::error(format!("Invalid xlsx style: {e}")));
                        }
                    },
                    None => xlsx::XlsxStyle::default(),
                };
                match xlsx::write_workbook(&path, &input.sheets, &style) {
                    Ok(summary) => Ok(ToolResult::success(format!(
                        "Created {} ({summary})",
                        path.display()
                    ))),
                    Err(e) => Ok(ToolResult::error(format!("Failed to create workbook: {e}"))),
                }
            }
            "docx" => {
                let style: docx::DocxStyle = match input.style.clone() {
                    Some(v) => match serde_json::from_value(v) {
                        Ok(st) => st,
                        Err(e) => {
                            return Ok(ToolResult::error(format!("Invalid docx style: {e}")));
                        }
                    },
                    None => docx::DocxStyle::default(),
                };
                match docx::write_document(&path, &input.blocks, &style) {
                    Ok(summary) => Ok(ToolResult::success(format!(
                        "Created {} ({summary})",
                        path.display()
                    ))),
                    Err(e) => Ok(ToolResult::error(format!("Failed to create document: {e}"))),
                }
            }
            "pdf" => {
                let title = input.title.clone().unwrap_or_else(|| {
                    path.file_stem()
                        .map(|s| s.to_string_lossy().into_owned())
                        .unwrap_or_else(|| "Document".to_string())
                });
                let style: pdf::StyleSpec = match input.style.clone() {
                    Some(v) => match serde_json::from_value(v) {
                        Ok(st) => st,
                        Err(e) => {
                            return Ok(ToolResult::error(format!("Invalid pdf style: {e}")));
                        }
                    },
                    None => pdf::StyleSpec::default(),
                };
                match pdf::write_pdf(&path, &input.blocks, &title, &style) {
                    Ok(summary) => Ok(ToolResult::success(format!(
                        "Created {} ({summary})",
                        path.display()
                    ))),
                    Err(e) => Ok(ToolResult::error(format!("Failed to create PDF: {e}"))),
                }
            }
            "pptx" => {
                let style: pptx::PptxStyle = match input.style.clone() {
                    Some(v) => match serde_json::from_value(v) {
                        Ok(st) => st,
                        Err(e) => {
                            return Ok(ToolResult::error(format!("Invalid pptx style: {e}")));
                        }
                    },
                    None => pptx::PptxStyle::default(),
                };
                match pptx::write_deck(&path, &input.slides, &style).await {
                    Ok(summary) => Ok(ToolResult::success(format!(
                        "Created {} ({summary})",
                        path.display()
                    ))),
                    Err(e) => Ok(ToolResult::error(format!("Failed to create deck: {e}"))),
                }
            }
            other => Ok(ToolResult::error(format!(
                "Unsupported format \"{other}\" (supported: xlsx, docx, pdf, pptx)"
            ))),
        }
    }
}