use chrono::Utc;
use serde_json::json;
use std::fs;
use std::io::IsTerminal;
use std::process::Command;
use std::time::Instant;
use crate::commands::coverage;
use crate::output::Envelope;
use crate::report::brief_md::{self, BriefInput};
use crate::report::markdown::{self, RenderError};
use crate::report::sources;
use crate::report::template::{self, Slots};
use crate::session::{active, config, layout};
const CMD: &str = "research report";
const SUPPORTED_FORMATS: &[&str] = &["rich-html", "brief-md"];
const FUTURE_FORMATS: &[&str] = &["slides-reveal", "json-export"];
pub fn run(
slug_arg: Option<&str>,
format: &str,
open: bool,
_no_open: bool,
stdout: bool,
output: Option<&str>,
) -> Envelope {
if !SUPPORTED_FORMATS.contains(&format) {
return if FUTURE_FORMATS.contains(&format) {
Envelope::fail(
CMD,
"FORMAT_NOT_IMPLEMENTED",
format!("format '{format}' is declared in the spec but not yet implemented"),
)
.with_details(json!({ "requested": format, "supported": SUPPORTED_FORMATS }))
} else {
Envelope::fail(
CMD,
"FORMAT_UNSUPPORTED",
format!("unknown format '{format}'"),
)
.with_details(json!({ "requested": format, "supported": SUPPORTED_FORMATS }))
};
}
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}")),
};
if !has_non_empty_overview(&md) {
return Envelope::fail(
CMD,
"MISSING_OVERVIEW",
"session.md lacks a non-placeholder `## Overview` section — edit it and retry",
)
.with_context(json!({ "session": slug }));
}
let start = Instant::now();
if format == "brief-md" {
return run_brief_md(&slug, &cfg.topic, &md, start, stdout, output);
}
let tags_str = if cfg.tags.is_empty() {
String::new()
} else {
format!(" · tagged {}", cfg.tags.join(", "))
};
let subtitle = format!(
"Session: <code>{}</code>{} · preset <code>{}</code>",
slug, tags_str, cfg.preset
);
let session_dir = layout::session_dir(&slug);
let rendered = match markdown::render_body(&md, &session_dir) {
Ok(r) => r,
Err(RenderError::DiagramOutOfBounds(p)) => {
return Envelope::fail(
CMD,
"DIAGRAM_OUT_OF_BOUNDS",
format!(
"diagram path '{}' resolves outside session_dir/diagrams/",
p.display()
),
)
.with_context(json!({ "session": slug }));
}
};
if let Some(env) = report_ready_preflight(&slug) {
return env;
}
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 diagrams_inlined = rendered.diagrams_inlined;
let sources_count = sources_section.count;
let total_bytes = sources_section.total_bytes;
let session_footer = format!(
"Session · {} · {} accepted source{} · {} bytes",
session_dir.display(),
sources_count,
if sources_count == 1 { "" } else { "s" },
total_bytes,
);
let slots = Slots {
title: cfg.topic.clone(),
subtitle,
aside_quote: rendered.aside_html,
body_html: rendered.body_html,
sources_html: sources_section.html,
generated_at: Utc::now().to_rfc3339(),
session_footer,
};
let html = template::render(&slots);
let output_path = layout::session_dir(&slug).join("report-rich.html");
if let Err(e) = fs::write(&output_path, &html) {
return Envelope::fail(CMD, "RENDER_FAILED", format!("write report: {e}"))
.with_context(json!({ "session": slug }));
}
let duration_ms = start.elapsed().as_millis() as u64;
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 {
let spawn_result = if cfg!(target_os = "macos") {
Command::new("open").arg(&output_path).spawn()
} else {
Command::new("xdg-open").arg(&output_path).spawn()
};
if let Err(e) = spawn_result {
eprintln!("⚠ open failed: {e}");
}
}
}
Envelope::ok(
CMD,
json!({
"format": format,
"output_path": output_path.display().to_string(),
"bytes": html.len(),
"duration_ms": duration_ms,
"open_skipped": open_skipped,
"warnings": warnings,
"diagrams_inlined": diagrams_inlined,
"sources_count": sources_count,
"total_bytes": total_bytes,
"phase": "C",
}),
)
.with_context(json!({ "session": slug }))
}
fn report_ready_preflight(slug: &str) -> Option<Envelope> {
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 mut env =
Envelope::fail(CMD, "IO_ERROR", reason).with_context(json!({ "session": slug }));
if !details.is_null() {
env = env.with_details(details);
}
return Some(env);
}
if coverage.data["report_ready"] == json!(true) {
return None;
}
Some(
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": coverage.data["report_ready_blockers"].clone(),
})),
)
}
fn run_brief_md(
slug: &str,
topic: &str,
md: &str,
start: Instant,
stdout: bool,
output: Option<&str>,
) -> Envelope {
let jsonl_path = layout::session_jsonl(slug);
let brief = brief_md::build(BriefInput {
topic,
slug,
md,
jsonl_path: &jsonl_path,
});
let bytes = brief.text.len() as u64;
let duration_ms = start.elapsed().as_millis() as u64;
let output_path: Option<std::path::PathBuf> = if stdout {
print!("{}", brief.text);
None
} else {
let path = match output {
Some(p) => std::path::PathBuf::from(p),
None => layout::session_dir(slug).join("report-brief.md"),
};
if let Err(e) = fs::write(&path, &brief.text) {
return Envelope::fail(CMD, "RENDER_FAILED", format!("write brief: {e}"))
.with_context(json!({ "session": slug }));
}
Some(path)
};
Envelope::ok(
CMD,
json!({
"format": "brief-md",
"output_path": output_path.as_ref().map(|p| p.display().to_string()),
"stdout": stdout,
"bytes": bytes,
"warnings": brief.warnings,
"duration_ms": duration_ms,
}),
)
.with_context(json!({ "session": slug }))
}
fn has_non_empty_overview(md: &str) -> bool {
let mut in_overview = false;
for line in md.lines() {
let trimmed = line.trim();
if trimmed.starts_with("## Overview") {
in_overview = true;
continue;
}
if in_overview {
if trimmed.starts_with("## ") {
return false;
}
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("<!--") {
continue;
}
return true;
}
}
false
}
fn should_skip_open() -> bool {
if std::env::var("RESEARCH_NO_OPEN").is_ok() {
return true;
}
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()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn has_non_empty_overview_detects_placeholder() {
let md = "# T\n\n## Overview\n<!-- fill in -->\n\n## Findings\n";
assert!(!has_non_empty_overview(md));
}
#[test]
fn has_non_empty_overview_detects_real_content() {
let md = "# T\n\n## Overview\nReal paragraph here.\n\n## Findings\n";
assert!(has_non_empty_overview(md));
}
#[test]
fn has_non_empty_overview_when_overview_is_last_section() {
let md = "# T\n\n## Overview\nReal content at the end of file.\n";
assert!(has_non_empty_overview(md));
}
#[test]
fn has_non_empty_overview_missing_section() {
let md = "# T\n\n## Findings\nstuff\n";
assert!(!has_non_empty_overview(md));
}
}