#[cfg(feature = "office")]
#[cfg_attr(feature = "schema_gen", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SectorOverviewInput {
pub sector_name: String,
pub date: String,
pub author: String,
pub executive_summary: String,
pub key_themes: Vec<String>,
pub market_size_and_growth: String,
pub key_drivers: Vec<String>,
pub headwinds: Vec<String>,
pub coverage_universe: Vec<Vec<String>>,
pub valuation_summary: Vec<Vec<String>>,
pub top_picks: Vec<(String, String)>,
pub avoid_list: Vec<(String, String)>,
pub conclusion: String,
}
#[cfg(feature = "office")]
pub fn sector_overview_to_doc(input: &SectorOverviewInput) -> crate::office::WordDocSpec {
use crate::office::types::{DocSection, WordDocSpec, WorkbookProperties};
let mut sections: Vec<DocSection> = Vec::new();
sections.push(build_cover(input));
sections.push(build_text_section("Executive Summary", &input.executive_summary));
if !input.key_themes.is_empty() {
sections.push(build_bullet_section("Key Themes", &input.key_themes));
}
sections.push(build_text_section("Market Size and Growth", &input.market_size_and_growth));
if !input.key_drivers.is_empty() {
sections.push(build_bullet_section("Key Drivers", &input.key_drivers));
}
if !input.headwinds.is_empty() {
sections.push(build_bullet_section("Headwinds", &input.headwinds));
}
sections.push(build_table_section(
"Coverage Universe",
&input.coverage_universe,
"No coverage universe provided.",
));
sections.push(build_table_section(
"Valuation Summary",
&input.valuation_summary,
"No valuation summary provided.",
));
if !input.top_picks.is_empty() {
sections.push(build_pair_table_section(
"Top Picks",
&["Ticker", "Thesis"],
&input.top_picks,
));
}
if !input.avoid_list.is_empty() {
sections.push(build_pair_table_section(
"Names to Avoid",
&["Ticker", "Reason"],
&input.avoid_list,
));
}
sections.push(build_text_section("Conclusion", &input.conclusion));
sections.push(build_footer(input));
WordDocSpec {
sections,
properties: WorkbookProperties {
title: Some(format!("{}: Sector Overview", input.sector_name)),
author: Some(input.author.clone()),
company: None,
subject: Some("Equity Research — Sector Overview".into()),
},
}
}
#[cfg(feature = "office")]
fn build_cover(input: &SectorOverviewInput) -> crate::office::types::DocSection {
use crate::office::types::{DocBlock, DocSection, TextRun};
DocSection {
blocks: vec![
DocBlock::Heading {
level: 1,
text: format!("{}: Sector Overview", input.sector_name),
},
DocBlock::Paragraph {
runs: vec![TextRun { text: "Equity Research".into(), bold: true, italic: false }],
},
DocBlock::Paragraph {
runs: vec![TextRun { text: input.date.clone(), bold: false, italic: true }],
},
DocBlock::Paragraph {
runs: vec![TextRun {
text: format!("Author: {}", input.author),
bold: false,
italic: true,
}],
},
],
}
}
#[cfg(feature = "office")]
fn build_text_section(heading: &str, body: &str) -> crate::office::types::DocSection {
use crate::office::types::{DocBlock, DocSection, TextRun};
let mut blocks = vec![DocBlock::Heading { level: 2, text: heading.to_owned() }];
for para in split_paragraphs(body) {
blocks.push(DocBlock::Paragraph {
runs: vec![TextRun { text: para, bold: false, italic: false }],
});
}
DocSection { blocks }
}
#[cfg(feature = "office")]
fn build_bullet_section(heading: &str, items: &[String]) -> crate::office::types::DocSection {
use crate::office::types::{DocBlock, DocSection};
DocSection {
blocks: vec![
DocBlock::Heading { level: 2, text: heading.to_owned() },
DocBlock::BulletList { items: items.to_vec() },
],
}
}
#[cfg(feature = "office")]
fn build_table_section(
heading: &str,
rows: &[Vec<String>],
fallback: &str,
) -> crate::office::types::DocSection {
use crate::office::types::{DocBlock, DocSection, TextRun};
let mut blocks = vec![DocBlock::Heading { level: 2, text: heading.to_owned() }];
if rows.is_empty() {
blocks.push(DocBlock::Paragraph {
runs: vec![TextRun { text: fallback.to_owned(), bold: false, italic: false }],
});
} else {
let headers = rows[0].clone();
let data = rows[1..].to_vec();
blocks.push(DocBlock::Table { headers, rows: data });
}
DocSection { blocks }
}
#[cfg(feature = "office")]
fn build_pair_table_section(
heading: &str,
col_headers: &[&str; 2],
pairs: &[(String, String)],
) -> crate::office::types::DocSection {
use crate::office::types::{DocBlock, DocSection};
let headers = vec![col_headers[0].to_owned(), col_headers[1].to_owned()];
let rows: Vec<Vec<String>> = pairs
.iter()
.map(|(a, b)| vec![a.clone(), b.clone()])
.collect();
DocSection {
blocks: vec![
DocBlock::Heading { level: 2, text: heading.to_owned() },
DocBlock::Table { headers, rows },
],
}
}
#[cfg(feature = "office")]
fn build_footer(input: &SectorOverviewInput) -> crate::office::types::DocSection {
use crate::office::types::{DocBlock, DocSection, TextRun};
DocSection {
blocks: vec![DocBlock::Paragraph {
runs: vec![TextRun {
text: format!("{}, Equity Research", input.author),
bold: false,
italic: true,
}],
}],
}
}
#[cfg(feature = "office")]
fn split_paragraphs(text: &str) -> Vec<String> {
text.split("\n\n")
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.collect()
}
#[cfg(all(test, feature = "office"))]
mod tests {
use super::*;
use crate::office::types::DocBlock;
fn minimal_input() -> SectorOverviewInput {
SectorOverviewInput {
sector_name: "Technology".into(),
date: "2026-05-08".into(),
author: "Jane Analyst, CFA".into(),
executive_summary: "The technology sector remains the engine of global growth.\n\nCloud, AI, and semiconductors are the dominant sub-themes.".into(),
key_themes: vec!["AI adoption accelerating across enterprises".into()],
market_size_and_growth: "The global tech market is estimated at $5.5T in 2026.".into(),
key_drivers: vec!["Enterprise cloud migration".into()],
headwinds: vec!["Macro-driven capex belt-tightening".into()],
coverage_universe: vec![
vec!["Ticker".into(), "Company".into(), "Market Cap".into(), "Rating".into(), "Target Price".into()],
vec!["MSFT".into(), "Microsoft Corp".into(), "$3.0T".into(), "BUY".into(), "$480".into()],
],
valuation_summary: vec![
vec!["Ticker".into(), "EV/EBITDA".into(), "P/E".into()],
vec!["MSFT".into(), "24x".into(), "34x".into()],
],
top_picks: vec![("MSFT".into(), "Cloud + AI leadership at reasonable multiple".into())],
avoid_list: vec![],
conclusion: "We maintain a constructive sector stance with selective stock picking.".into(),
}
}
#[test]
fn sector_overview_to_doc_basic() {
let input = minimal_input();
let doc = sector_overview_to_doc(&input);
let count = doc.sections.len();
assert!(
(8..=12).contains(&count),
"expected 8-12 sections, got {count}"
);
let first_block = &doc.sections[0].blocks[0];
match first_block {
DocBlock::Heading { level, text } => {
assert_eq!(*level, 1, "cover heading must be level 1");
assert!(
text.contains(&input.sector_name),
"cover heading must contain sector name, got: {text}"
);
}
other => panic!("expected Heading level 1, got: {other:?}"),
}
let title = doc.properties.title.as_deref().unwrap_or("");
assert!(
title.contains(&input.sector_name),
"properties.title should contain sector_name; got: {title:?}"
);
}
#[test]
fn sector_overview_to_doc_round_trips_through_writer() {
use crate::office::docx::write_word_doc;
use tempfile::tempdir;
let input = minimal_input();
let doc = sector_overview_to_doc(&input);
let dir = tempdir().expect("tempdir creation failed");
let path = dir.path().join("sector_overview.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"
);
}
}