car-server-core 0.24.1

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! m365 platform action tools, gated on the signed-in account's entitlements.
//!
//! First tool: `parslee.m365.generate_document` — generate a Word document from
//! a natural-language brief and save it to the user's connected drive
//! (OneDrive / Google Drive), via m365's
//! `POST /api/v1/orgs/{orgId}/documents/word/generate`.
//!
//! Gated on the `aie` product ("M365 AI Employees — the core platform", which
//! owns document generation) through [`crate::parslee_capabilities::gate_on_product`]:
//! if the account isn't signed in, has no active org, or lacks the entitlement,
//! the call fails fast with a clear message instead of a doomed request that
//! would 403 (or silently consume quota). Override the gating product with
//! `PARSLEE_M365_DOC_PRODUCT` if the platform taxonomy shifts.

use serde_json::{json, Value};

use crate::parslee_capabilities::{ci, gate_on_product};

const DEFAULT_DOC_PRODUCT: &str = "aie";
const DOC_PRODUCT_ENV: &str = "PARSLEE_M365_DOC_PRODUCT";

fn doc_product() -> String {
    car_secrets::resolve_env_or_keychain(DOC_PRODUCT_ENV)
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| DEFAULT_DOC_PRODUCT.to_string())
}

/// Handler for `parslee.m365.generate_document`.
///
/// Params: `{ content_brief: string (20–2000 chars), output_file_path: string,
/// document_type?: string (Report|Proposal|Memo|Letter|Contract|
/// ExecutiveSummary|MeetingMinutes|ProjectPlan, default Report),
/// title?: string, author?: string }`.
pub async fn generate_document(params: &Value) -> Result<Value, String> {
    let (body, document_type, output_file_path) = build_doc_request(params)?;

    let gate = gate_on_product(&doc_product()).await?;
    let url = format!(
        "{}/api/v1/orgs/{}/documents/word/generate",
        gate.api_base.trim_end_matches('/'),
        gate.org_id
    );

    let client = reqwest::Client::new();
    let resp = client
        .post(&url)
        .bearer_auth(&gate.session.access_token)
        .json(&body)
        .send()
        .await
        .map_err(|e| format!("m365 document generate request: {e}"))?;
    let status = resp.status();
    let text = resp.text().await.unwrap_or_default();
    if !status.is_success() {
        return Err(format!("m365 document generate: HTTP {status}: {text}"));
    }
    let raw: Value = serde_json::from_str(&text)
        .map_err(|e| format!("parse m365 document response: {e}"))?;

    Ok(normalize_response(&raw, &document_type, &output_file_path))
}

/// Validate params and build the m365 request body. Pure — no I/O — so the
/// validation rules (which mirror the server's: 20–2000 char brief, required
/// output path) are unit-testable. Returns `(body, document_type, output_path)`.
fn build_doc_request(params: &Value) -> Result<(Value, String, String), String> {
    let content_brief = params
        .get("content_brief")
        .and_then(Value::as_str)
        .unwrap_or("")
        .trim();
    let output_file_path = params
        .get("output_file_path")
        .and_then(Value::as_str)
        .unwrap_or("")
        .trim();
    let document_type = params
        .get("document_type")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or("Report")
        .to_string();

    let brief_len = content_brief.chars().count();
    if brief_len < 20 {
        return Err("content_brief is required and must be at least 20 characters".to_string());
    }
    if brief_len > 2000 {
        return Err("content_brief cannot exceed 2000 characters".to_string());
    }
    if output_file_path.is_empty() {
        return Err(
            "output_file_path is required (e.g. \"Generated/quarterly-report.docx\")".to_string(),
        );
    }

    let mut body = json!({
        "contentBrief": content_brief,
        "documentType": document_type,
        "outputFilePath": output_file_path,
    });
    if let Some(title) = params
        .get("title")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|s| !s.is_empty())
    {
        body["title"] = json!(title);
    }
    if let Some(author) = params
        .get("author")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|s| !s.is_empty())
    {
        body["author"] = json!(author);
    }

    Ok((body, document_type, output_file_path.to_string()))
}

/// Normalize the m365 `GenerateDocumentResponse` (camelCase) to a stable
/// snake_case shape, echoing back the requested type/path.
fn normalize_response(raw: &Value, document_type: &str, output_file_path: &str) -> Value {
    json!({
        "ok": true,
        "file_id": ci(raw, "fileId").cloned(),
        "file_name": ci(raw, "fileName").cloned(),
        "web_url": ci(raw, "webUrl").cloned(),
        "size": ci(raw, "size").cloned(),
        "generated_title": ci(raw, "generatedTitle").cloned(),
        "section_count": ci(raw, "sectionCount").cloned(),
        "generation_time_ms": ci(raw, "generationTimeMs").cloned(),
        "document_type": document_type,
        "output_file_path": output_file_path,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builds_request_with_defaults_and_optionals() {
        let p = json!({
            "content_brief": "Create a quarterly sales report for Q4 2024 highlighting growth",
            "output_file_path": "Generated/q4.docx",
            "title": "Q4 Report",
            "author": "Matt"
        });
        let (body, dtype, out) = build_doc_request(&p).unwrap();
        assert_eq!(body["contentBrief"].as_str().unwrap().len() > 20, true);
        assert_eq!(body["documentType"], "Report"); // defaulted
        assert_eq!(body["outputFilePath"], "Generated/q4.docx");
        assert_eq!(body["title"], "Q4 Report");
        assert_eq!(body["author"], "Matt");
        assert_eq!(dtype, "Report");
        assert_eq!(out, "Generated/q4.docx");
    }

    #[test]
    fn explicit_document_type_passes_through() {
        let p = json!({
            "content_brief": "Draft a formal business proposal for a new CRM rollout initiative",
            "output_file_path": "Generated/proposal.docx",
            "document_type": "Proposal"
        });
        let (body, dtype, _) = build_doc_request(&p).unwrap();
        assert_eq!(body["documentType"], "Proposal");
        assert_eq!(dtype, "Proposal");
        // Omitted optionals are absent, not null.
        assert!(body.get("title").is_none());
        assert!(body.get("author").is_none());
    }

    #[test]
    fn rejects_short_or_long_brief_and_missing_path() {
        assert!(build_doc_request(&json!({
            "content_brief": "too short",
            "output_file_path": "x.docx"
        }))
        .unwrap_err()
        .contains("at least 20"));

        let long = "a".repeat(2001);
        assert!(build_doc_request(&json!({
            "content_brief": long,
            "output_file_path": "x.docx"
        }))
        .unwrap_err()
        .contains("exceed 2000"));

        assert!(build_doc_request(&json!({
            "content_brief": "A perfectly valid content brief of sufficient length here",
            "output_file_path": "   "
        }))
        .unwrap_err()
        .contains("output_file_path is required"));
    }

    #[test]
    fn normalizes_response_camel_to_snake() {
        let raw = json!({
            "fileId": "f1", "fileName": "q4.docx", "webUrl": "https://drive/q4",
            "size": 12345, "generatedTitle": "Q4 Report", "sectionCount": 5,
            "generationTimeMs": 2200, "correlationId": "abc"
        });
        let n = normalize_response(&raw, "Report", "Generated/q4.docx");
        assert_eq!(n["ok"], true);
        assert_eq!(n["file_id"], "f1");
        assert_eq!(n["web_url"], "https://drive/q4");
        assert_eq!(n["generated_title"], "Q4 Report");
        assert_eq!(n["section_count"], 5);
        assert_eq!(n["generation_time_ms"], 2200);
        assert_eq!(n["document_type"], "Report");
        assert_eq!(n["output_file_path"], "Generated/q4.docx");
    }
}