#[cfg(feature = "office")]
use crate::office::types::{DocBlock, DocSection, TextRun, WordDocSpec, WorkbookProperties};
#[cfg(feature = "office")]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "schema_gen", derive(schemars::JsonSchema))]
pub struct IcMemoInput {
pub deal_name: String,
pub target_company: String,
pub date: String,
pub recommendation: String,
pub author: String,
pub investment_thesis: String,
pub key_metrics: Vec<(String, String)>,
pub financial_summary: Vec<Vec<String>>,
pub risks: Vec<String>,
pub mitigants: Vec<String>,
pub conclusion: String,
}
#[cfg(feature = "office")]
pub fn ic_memo_to_doc(input: &IcMemoInput) -> WordDocSpec {
let mut sections: Vec<DocSection> = Vec::new();
sections.push(build_cover(input));
sections.push(build_recommendation(input));
sections.push(build_thesis(input));
sections.push(build_key_metrics(input));
sections.push(build_financial_summary(input));
if !input.risks.is_empty() {
sections.push(build_list_section("Risks", &input.risks));
}
if !input.mitigants.is_empty() {
sections.push(build_list_section("Mitigants", &input.mitigants));
}
sections.push(build_conclusion(input));
sections.push(build_footer(input));
WordDocSpec {
sections,
properties: WorkbookProperties {
title: Some(format!("IC Memo \u{2014} {}", input.deal_name)),
author: Some(input.author.clone()),
company: None,
subject: Some("Investment Committee Memorandum".into()),
},
}
}
#[cfg(feature = "office")]
fn build_cover(input: &IcMemoInput) -> DocSection {
DocSection {
blocks: vec![
DocBlock::Heading {
level: 1,
text: "INVESTMENT COMMITTEE MEMORANDUM".into(),
},
DocBlock::Paragraph {
runs: vec![TextRun {
text: format!("{} \u{2014} {}", input.deal_name, input.target_company),
bold: true,
italic: false,
}],
},
DocBlock::Paragraph {
runs: vec![TextRun {
text: input.date.clone(),
bold: false,
italic: true,
}],
},
DocBlock::Paragraph { runs: vec![] },
],
}
}
#[cfg(feature = "office")]
fn build_recommendation(input: &IcMemoInput) -> DocSection {
DocSection {
blocks: vec![
DocBlock::Heading {
level: 2,
text: "Recommendation".into(),
},
DocBlock::Paragraph {
runs: vec![TextRun {
text: input.recommendation.clone(),
bold: true,
italic: false,
}],
},
],
}
}
#[cfg(feature = "office")]
fn build_thesis(input: &IcMemoInput) -> DocSection {
let mut blocks = vec![DocBlock::Heading {
level: 2,
text: "Investment Thesis".into(),
}];
for segment in input.investment_thesis.split("\n\n") {
let trimmed = segment.trim();
if !trimmed.is_empty() {
blocks.push(DocBlock::Paragraph {
runs: vec![TextRun {
text: trimmed.into(),
bold: false,
italic: false,
}],
});
}
}
DocSection { blocks }
}
#[cfg(feature = "office")]
fn build_key_metrics(input: &IcMemoInput) -> DocSection {
let rows: Vec<Vec<String>> = input
.key_metrics
.iter()
.map(|(label, value)| vec![label.clone(), value.clone()])
.collect();
DocSection {
blocks: vec![
DocBlock::Heading {
level: 2,
text: "Key Metrics".into(),
},
DocBlock::Table {
headers: vec!["Metric".into(), "Value".into()],
rows,
},
],
}
}
#[cfg(feature = "office")]
fn build_financial_summary(input: &IcMemoInput) -> DocSection {
let mut blocks = vec![DocBlock::Heading {
level: 2,
text: "Financial Summary".into(),
}];
if input.financial_summary.is_empty() {
blocks.push(DocBlock::Paragraph {
runs: vec![TextRun {
text: "No financial summary provided.".into(),
bold: false,
italic: false,
}],
});
} else {
let headers = input.financial_summary[0].clone();
let rows = input.financial_summary[1..].to_vec();
blocks.push(DocBlock::Table { headers, rows });
}
DocSection { blocks }
}
#[cfg(feature = "office")]
fn build_list_section(heading: &str, items: &[String]) -> DocSection {
DocSection {
blocks: vec![
DocBlock::Heading {
level: 2,
text: heading.into(),
},
DocBlock::NumberedList {
items: items.to_vec(),
},
],
}
}
#[cfg(feature = "office")]
fn build_conclusion(input: &IcMemoInput) -> DocSection {
DocSection {
blocks: vec![
DocBlock::Heading {
level: 2,
text: "Conclusion".into(),
},
DocBlock::Paragraph {
runs: vec![TextRun {
text: input.conclusion.clone(),
bold: false,
italic: false,
}],
},
],
}
}
#[cfg(feature = "office")]
fn build_footer(input: &IcMemoInput) -> DocSection {
DocSection {
blocks: vec![DocBlock::Paragraph {
runs: vec![TextRun {
text: format!("Prepared by {}", input.author),
bold: false,
italic: true,
}],
}],
}
}
#[cfg(all(test, feature = "office"))]
mod tests {
use super::*;
fn minimal_input() -> IcMemoInput {
IcMemoInput {
deal_name: "Project Atlas".into(),
target_company: "Acme Corp".into(),
date: "2026-05-08".into(),
recommendation: "APPROVE".into(),
author: "Jane Smith, CFA".into(),
investment_thesis: "Acme Corp is the market leader in widget manufacturing.\n\nThe company benefits from strong free cash flow and a defensible moat.".into(),
key_metrics: vec![("EV/EBITDA".into(), "8.5x".into())],
financial_summary: vec![
vec!["Year".into(), "Revenue".into(), "EBITDA".into()],
vec!["2026E".into(), "$500M".into(), "$100M".into()],
],
risks: vec![],
mitigants: vec![],
conclusion: "We recommend approval subject to final credit sign-off.".into(),
}
}
#[test]
fn ic_memo_to_doc_basic() {
let input = minimal_input();
let doc = ic_memo_to_doc(&input);
let count = doc.sections.len();
assert!(
(5..=9).contains(&count),
"expected 5-9 sections, got {count}"
);
let first_block = &doc.sections[0].blocks[0];
assert!(
matches!(
first_block,
DocBlock::Heading { level: 1, text }
if text == "INVESTMENT COMMITTEE MEMORANDUM"
),
"first block must be H1 INVESTMENT COMMITTEE MEMORANDUM, got: {first_block:?}"
);
let title = doc.properties.title.as_deref().unwrap_or("");
assert!(
title.contains(&input.deal_name),
"properties.title should contain deal_name; got: {title:?}"
);
}
#[test]
fn ic_memo_to_doc_round_trips_through_writer() {
use crate::office::docx::write_word_doc;
use tempfile::tempdir;
let input = minimal_input();
let doc = ic_memo_to_doc(&input);
let dir = tempdir().expect("tempdir creation failed");
let path = dir.path().join("ic_memo.docx");
let result = write_word_doc(&doc, &path).expect("write_word_doc failed");
assert!(path.exists(), "output file does not exist");
assert!(
result.bytes_written > 0,
"expected nonzero bytes, got {}",
result.bytes_written
);
let bytes = std::fs::read(&path).expect("failed to read output file");
assert_eq!(
&bytes[..4],
b"PK\x03\x04",
"docx must begin with ZIP magic bytes PK\\x03\\x04"
);
}
}