use chrono::Utc;
use serde_json::json;
use std::fs;
use std::io::IsTerminal;
use std::path::PathBuf;
use std::process::Command;
use std::time::Instant;
use crate::commands::coverage;
use crate::output::Envelope;
use crate::report::builder::{self, BuildError, ReportInput};
use crate::report::markdown::{self, RenderError};
use crate::report::pdf_local::{self, LocalPdfOptions};
use crate::report::sources;
use crate::report::template::{self, Slots};
use crate::report::wiki_render;
use crate::session::{
active, config,
event::{SessionEvent, SynthesizeStage},
layout, log,
};
const CMD: &str = "research synthesize";
pub fn run(
slug_arg: Option<&str>,
no_render: bool,
open: bool,
bilingual: bool,
pdf: bool,
pdf_output: Option<&str>,
) -> Envelope {
let slug = match slug_arg {
Some(s) => s.to_string(),
None => match active::get_active() {
Some(s) => s,
None => {
return Envelope::fail(
CMD,
"NO_ACTIVE_SESSION",
"no active session — pass <slug> or run `research new` first",
);
}
},
};
if !config::exists(&slug) {
return Envelope::fail(CMD, "SESSION_NOT_FOUND", format!("no session '{slug}'"))
.with_context(json!({ "session": slug }));
}
let cfg = match config::read(&slug) {
Ok(c) => c,
Err(e) => return Envelope::fail(CMD, "IO_ERROR", format!("read session.toml: {e}")),
};
let md = match fs::read_to_string(layout::session_md(&slug)) {
Ok(s) => s,
Err(e) => return Envelope::fail(CMD, "IO_ERROR", format!("read session.md: {e}")),
};
let events = log::read_all(&slug).unwrap_or_default();
let start = Instant::now();
let _ = log::append(
&slug,
&SessionEvent::SynthesizeStarted {
timestamp: Utc::now(),
no_render,
open,
bilingual,
bilingual_provider: requested_bilingual_provider(bilingual),
pdf,
pdf_provider: if pdf { Some("local".into()) } else { None },
note: None,
},
);
let input = ReportInput {
topic: &cfg.topic,
preset: &cfg.preset,
md: &md,
events: &events,
};
let built = match builder::build(&input) {
Ok(b) => b,
Err(BuildError::MissingOverview) => {
let _ = log::append(
&slug,
&SessionEvent::SynthesizeFailed {
timestamp: Utc::now(),
stage: SynthesizeStage::Build,
reason: "missing `## Overview` section".into(),
note: None,
},
);
return Envelope::fail(
CMD,
"MISSING_OVERVIEW",
"session.md lacks a non-placeholder `## Overview` section — edit it and retry",
)
.with_context(json!({ "session": slug }));
}
};
let coverage = coverage::run(Some(&slug));
if !coverage.ok {
let (reason, details) = if let Some(err) = coverage.error {
(
format!("coverage preflight failed: {}", err.message),
err.details,
)
} else {
(
"coverage preflight failed".to_string(),
serde_json::Value::Null,
)
};
let _ = log::append(
&slug,
&SessionEvent::SynthesizeFailed {
timestamp: Utc::now(),
stage: SynthesizeStage::Build,
reason: reason.clone(),
note: None,
},
);
let mut env =
Envelope::fail(CMD, "IO_ERROR", reason).with_context(json!({ "session": slug }));
if !details.is_null() {
env = env.with_details(details);
}
return env;
}
if coverage.data["report_ready"] != json!(true) {
let blockers = coverage.data["report_ready_blockers"].clone();
let blocker_summary = blockers
.as_array()
.map(|items| {
items
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join("; ")
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "coverage did not report blockers".to_string());
let _ = log::append(
&slug,
&SessionEvent::SynthesizeFailed {
timestamp: Utc::now(),
stage: SynthesizeStage::Build,
reason: format!("report not ready: {blocker_summary}"),
note: None,
},
);
return Envelope::fail(
CMD,
"REPORT_NOT_READY",
"session does not satisfy `research coverage` gates — fix blockers and retry",
)
.with_context(json!({ "session": slug }))
.with_details(json!({
"report_ready": coverage.data["report_ready"].clone(),
"report_ready_blockers": blockers,
}));
}
let report_json_path = layout::session_report_json(&slug);
let serialized = match serde_json::to_string_pretty(&built.json) {
Ok(s) => s,
Err(e) => {
let _ = log::append(
&slug,
&SessionEvent::SynthesizeFailed {
timestamp: Utc::now(),
stage: SynthesizeStage::Build,
reason: format!("serialize: {e}"),
note: None,
},
);
return Envelope::fail(CMD, "IO_ERROR", format!("serialize report: {e}"));
}
};
if let Err(e) = fs::write(&report_json_path, &serialized) {
let _ = log::append(
&slug,
&SessionEvent::SynthesizeFailed {
timestamp: Utc::now(),
stage: SynthesizeStage::Build,
reason: format!("write: {e}"),
note: None,
},
);
return Envelope::fail(CMD, "IO_ERROR", format!("write report.json: {e}"));
}
let mut report_html_path: Option<String> = None;
let mut report_html_abs: Option<PathBuf> = None;
let mut report_pdf_path: Option<String> = None;
let mut report_pdf_bytes: Option<u64> = None;
let mut render_error: Option<String> = None;
let mut render_warnings: Vec<String> = Vec::new();
if !no_render {
match render_rich_html(&slug, &md, &cfg.topic, &cfg.tags, &cfg.preset, bilingual) {
Ok((html_path, warnings)) => {
report_html_path = Some(rel_path(&html_path));
report_html_abs = Some(html_path);
render_warnings = warnings;
}
Err(e) => render_error = Some(e),
}
}
let duration_ms = start.elapsed().as_millis() as u64;
if let Some(err) = &render_error {
let _ = log::append(
&slug,
&SessionEvent::SynthesizeFailed {
timestamp: Utc::now(),
stage: SynthesizeStage::Render,
reason: err.clone(),
note: None,
},
);
}
if render_error.is_none() && pdf {
let Some(html_path) = report_html_abs.as_ref() else {
let reason =
"PDF conversion requires a rendered report.html; remove --no-render".to_string();
let _ = log::append(
&slug,
&SessionEvent::SynthesizeFailed {
timestamp: Utc::now(),
stage: SynthesizeStage::Render,
reason: reason.clone(),
note: None,
},
);
return Envelope::fail(CMD, "PDF_REQUIRES_HTML", reason)
.with_context(json!({ "session": slug }))
.with_details(json!({
"report_json_path": rel_path(&report_json_path),
"report_html_path": report_html_path,
}));
};
let output_path = match resolve_pdf_output(&slug, pdf_output) {
Ok(path) => path,
Err(e) => {
let reason = format!("pdf output path: {e}");
let _ = log::append(
&slug,
&SessionEvent::SynthesizeFailed {
timestamp: Utc::now(),
stage: SynthesizeStage::Render,
reason: reason.clone(),
note: None,
},
);
return Envelope::fail(CMD, "PDF_OUTPUT_INVALID", reason)
.with_context(json!({ "session": slug }))
.with_details(json!({
"report_json_path": rel_path(&report_json_path),
"report_html_path": report_html_path,
}));
}
};
match convert_pdf(html_path, &output_path) {
Ok(result) => {
report_pdf_path = Some(rel_path(&result.output_path));
report_pdf_bytes = Some(result.bytes);
}
Err(e) => {
let reason = e.to_string();
let _ = log::append(
&slug,
&SessionEvent::SynthesizeFailed {
timestamp: Utc::now(),
stage: SynthesizeStage::Render,
reason: format!("pdf local: {reason}"),
note: None,
},
);
return Envelope::fail(CMD, "PDF_CONVERSION_FAILED", reason)
.with_context(json!({ "session": slug }))
.with_details(json!({
"report_json_path": rel_path(&report_json_path),
"report_html_path": report_html_path,
"report_pdf_path": rel_path(&output_path),
"pdf": {
"requested": true,
"provider": "local",
"status": "failed",
},
}));
}
}
}
if render_error.is_none() {
let _ = log::append(
&slug,
&SessionEvent::SynthesizeCompleted {
timestamp: Utc::now(),
report_json_path: rel_path(&report_json_path),
report_html_path: report_html_path.clone(),
report_pdf_path: report_pdf_path.clone(),
accepted_sources: built.accepted_count,
rejected_sources: built.rejected_count,
duration_ms,
note: None,
},
);
}
let mut open_skipped: Option<&'static str> = None;
if open {
if should_skip_open() {
open_skipped = Some("non-interactive environment");
eprintln!("skipping open (non-interactive)");
} else if let Some(html) = &report_html_path {
let html_abs =
layout::session_dir(&slug).join(html.trim_start_matches(&format!("{slug}/")));
let spawn_result = if cfg!(target_os = "macos") {
Command::new("open").arg(&html_abs).spawn()
} else {
Command::new("xdg-open").arg(&html_abs).spawn()
};
if let Err(e) = spawn_result {
eprintln!("⚠ open failed: {e}");
}
}
}
if let Some(err) = render_error {
return Envelope::fail(CMD, "RENDER_FAILED", err)
.with_context(json!({ "session": slug }))
.with_details(json!({
"report_json_path": rel_path(&report_json_path),
"accepted_sources": built.accepted_count,
"rejected_sources": built.rejected_count,
}));
}
let mut all_warnings = built.warnings.clone();
all_warnings.extend(render_warnings);
let bilingual_provider = requested_bilingual_provider(bilingual);
let zh_paragraphs = report_html_abs
.as_ref()
.and_then(|path| count_zh_paragraphs(path).ok());
let bilingual_status = bilingual_status(bilingual, zh_paragraphs, &all_warnings);
Envelope::ok(
CMD,
json!({
"report_json_path": rel_path(&report_json_path),
"report_html_path": report_html_path,
"accepted_sources": built.accepted_count,
"rejected_sources": built.rejected_count,
"duration_ms": duration_ms,
"open_skipped": open_skipped,
"bilingual": {
"requested": bilingual,
"provider": bilingual_provider,
"status": bilingual_status,
"zh_paragraphs": zh_paragraphs,
},
"pdf": {
"requested": pdf,
"provider": if pdf { "local" } else { "none" },
"status": pdf_status(pdf, report_pdf_path.as_deref()),
"report_pdf_path": report_pdf_path,
"bytes": report_pdf_bytes,
},
"warnings": all_warnings,
}),
)
.with_context(json!({ "session": slug }))
}
struct PdfResult {
output_path: PathBuf,
bytes: u64,
}
fn convert_pdf(
html_path: &std::path::Path,
output_path: &std::path::Path,
) -> Result<PdfResult, String> {
pdf_local::convert_html_file(
html_path,
&LocalPdfOptions {
output_path: output_path.to_path_buf(),
},
)
.map(|r| PdfResult {
output_path: r.output_path,
bytes: r.bytes,
})
.map_err(|e| e.to_string())
}
fn resolve_pdf_output(slug: &str, pdf_output: Option<&str>) -> Result<PathBuf, std::io::Error> {
match pdf_output {
Some(path) => {
let raw = PathBuf::from(path);
if raw.is_absolute() {
Ok(raw)
} else {
std::env::current_dir().map(|cwd| cwd.join(raw))
}
}
None => Ok(layout::session_report_pdf(slug)),
}
}
fn pdf_status(requested: bool, report_pdf_path: Option<&str>) -> &'static str {
if !requested {
return "not_requested";
}
if report_pdf_path.is_some() {
"complete"
} else {
"missing"
}
}
fn render_rich_html(
slug: &str,
md: &str,
topic: &str,
tags: &[String],
preset: &str,
bilingual: bool,
) -> Result<(PathBuf, Vec<String>), String> {
let session_dir = layout::session_dir(slug);
let rendered = markdown::render_body(md, &session_dir).map_err(|e| match e {
RenderError::DiagramOutOfBounds(p) => format!(
"diagram_out_of_bounds: '{}' resolves outside session_dir/diagrams/",
p.display()
),
})?;
let sources_section = sources::build_from_jsonl(&layout::session_jsonl(slug));
let mut warnings = rendered.warnings.clone();
warnings.extend(sources_section.warnings.iter().cloned());
let wiki = wiki_render::render_wiki(slug, &session_dir).map_err(|e| match e {
RenderError::DiagramOutOfBounds(p) => format!(
"diagram_out_of_bounds (in wiki page): '{}' resolves outside session_dir/diagrams/",
p.display()
),
})?;
warnings.extend(wiki.warnings.iter().cloned());
if wiki.broken_links > 0 {
warnings.push(format!(
"broken_wiki_links: {} — see coverage",
wiki.broken_links
));
}
let orphan_diagrams_html = render_orphan_diagrams(slug, &session_dir, md);
let combined_body = match (wiki.page_count, orphan_diagrams_html.is_empty()) {
(0, true) => rendered.body_html.clone(),
(0, false) => format!("{}\n{}", rendered.body_html, orphan_diagrams_html),
(_, true) => format!("{}\n{}", rendered.body_html, wiki.html),
(_, false) => format!(
"{}\n{}\n{}",
rendered.body_html, wiki.html, orphan_diagrams_html
),
};
let body_html = if bilingual {
match crate::report::bilingual::inject_zh_translations(&combined_body) {
Ok((augmented, note)) => {
if let Some(n) = note {
warnings.push(n);
}
augmented
}
Err(e) => {
warnings.push(format!("bilingual_skipped: {e}"));
combined_body.clone()
}
}
} else {
combined_body
};
let tags_str = if tags.is_empty() {
String::new()
} else {
format!(" · tagged {}", tags.join(", "))
};
let subtitle = format!("Session: <code>{slug}</code>{tags_str} · preset <code>{preset}</code>");
let session_footer = format!(
"Session · {} · {} accepted source{} · {} bytes",
session_dir.display(),
sources_section.count,
if sources_section.count == 1 { "" } else { "s" },
sources_section.total_bytes,
);
let slots = Slots {
title: topic.to_string(),
subtitle,
aside_quote: rendered.aside_html,
body_html,
sources_html: sources_section.html,
generated_at: Utc::now().to_rfc3339(),
session_footer,
};
let html = template::render(&slots);
let html_path = layout::session_dir(slug).join("report.html");
fs::write(&html_path, &html).map_err(|e| format!("write report.html: {e}"))?;
Ok((html_path, warnings))
}
fn should_skip_open() -> bool {
if std::env::var("SYNTHESIZE_NO_OPEN").is_ok() {
return true;
}
if std::env::var("CI").is_ok() {
return true;
}
!std::io::stdin().is_terminal()
}
fn requested_bilingual_provider(bilingual: bool) -> Option<String> {
if !bilingual {
return None;
}
Some(
std::env::var("ASR_BILINGUAL_PROVIDER")
.or_else(|_| std::env::var("ASCENT_RESEARCH_BILINGUAL_PROVIDER"))
.unwrap_or_else(|_| default_bilingual_provider().to_string()),
)
}
fn default_bilingual_provider() -> &'static str {
#[cfg(feature = "provider-claude")]
{
"claude"
}
#[cfg(all(not(feature = "provider-claude"), feature = "provider-codex"))]
{
"codex"
}
#[cfg(not(any(feature = "provider-claude", feature = "provider-codex")))]
{
"none"
}
}
fn count_zh_paragraphs(path: &std::path::Path) -> Result<usize, std::io::Error> {
let html = fs::read_to_string(path)?;
Ok(html.matches(r#"class="tr-zh""#).count() + html.matches(r#"class='tr-zh'"#).count())
}
fn bilingual_status(
requested: bool,
zh_paragraphs: Option<usize>,
warnings: &[String],
) -> &'static str {
if !requested {
return "not_requested";
}
if warnings
.iter()
.any(|warning| warning.starts_with("bilingual_skipped:"))
{
return "skipped";
}
match zh_paragraphs {
Some(n) if n > 0 => "complete",
Some(_) => "missing_zh",
None => "unknown",
}
}
fn render_orphan_diagrams(_slug: &str, session_dir: &std::path::Path, md: &str) -> String {
let diagrams_dir = session_dir.join("diagrams");
let Ok(entries) = fs::read_dir(&diagrams_dir) else {
return String::new();
};
let mut on_disk: Vec<String> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("svg"))
.filter_map(|e| {
e.path()
.file_name()
.and_then(|s| s.to_str())
.map(str::to_string)
})
.collect();
on_disk.sort();
if on_disk.is_empty() {
return String::new();
}
let mut all_text = md.to_string();
if let Ok(entries) = fs::read_dir(session_dir.join("wiki")) {
for e in entries.flatten() {
if e.path().extension().and_then(|s| s.to_str()) == Some("md")
&& let Ok(body) = fs::read_to_string(e.path())
{
all_text.push('\n');
all_text.push_str(&body);
}
}
}
let orphans: Vec<String> = on_disk
.into_iter()
.filter(|f| !all_text.contains(&format!("diagrams/{f}")))
.collect();
if orphans.is_empty() {
return String::new();
}
let mut out = String::new();
out.push_str(r#"<section class="orphan-diagrams"><h2><span class="section-num">DIAGRAMS</span><span>Supplementary figures</span></h2>"#);
for fname in &orphans {
let path = diagrams_dir.join(fname);
let svg = match fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
if svg.len() > 512 * 1024 {
continue;
}
let caption = fname
.strip_suffix(".svg")
.unwrap_or(fname)
.replace('-', " ");
out.push_str(r#"<div class="diagram">"#);
out.push_str(&svg);
out.push_str(&format!("<p class=\"caption\">{caption}</p>"));
out.push_str("</div>");
}
out.push_str("</section>");
out
}
fn rel_path(p: &std::path::Path) -> String {
let comps: Vec<_> = p.components().collect();
let n = comps.len();
if n >= 2 {
format!(
"{}/{}",
comps[n - 2].as_os_str().to_string_lossy(),
comps[n - 1].as_os_str().to_string_lossy()
)
} else {
p.to_string_lossy().into_owned()
}
}