use std::error::Error;
use std::path::{Path, PathBuf};
use std::process::Command;
use clap::{Args, Subcommand, ValueEnum};
use formal_ai::conversation_context::conversation_context_to_lino;
use formal_ai::issue_report::{
truncate_records, ReportAttachment, ReportBody, ReportField, ReportLabels, ReportTurn,
COUNT_PLACEHOLDER,
};
use serde_json::Value;
use crate::cli_context::{exported_context, write_output, ContextSource};
const DEFAULT_INLINE_BYTES: usize = 50_000;
const DEFAULT_EXCERPT_BYTES: usize = 12_000;
const DEFAULT_SURFACE: &str = "agentic-cli";
const LINO: &str = "lino";
const TRACE_SEPARATOR: &str = ": ";
const ERROR_PLACEHOLDER: &str = "{error}";
const URL_PLACEHOLDER: &str = "{url}";
#[derive(Debug, Args)]
pub struct ReportArgs {
#[command(subcommand)]
action: ReportAction,
}
#[derive(Debug, Subcommand)]
enum ReportAction {
Body {
#[arg(long, default_value = "latest")]
session: String,
#[arg(long, value_enum, default_value_t = ContextSource::Both)]
source: ContextSource,
#[arg(long)]
db: Option<PathBuf>,
#[arg(long)]
log_dir: Option<PathBuf>,
#[arg(short, long, default_value = "-")]
output: PathBuf,
#[arg(long)]
context_output: Option<PathBuf>,
#[arg(long, default_value = DEFAULT_SURFACE)]
surface: String,
#[arg(long, default_value_t = DEFAULT_INLINE_BYTES)]
max_inline_bytes: usize,
#[arg(long, default_value_t = DEFAULT_EXCERPT_BYTES)]
max_excerpt_bytes: usize,
#[arg(long, value_enum, default_value_t = OversizeContext::Gist)]
oversize: OversizeContext,
#[arg(long, value_enum, default_value_t = GistVisibility::Secret)]
gist_visibility: GistVisibility,
},
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
enum OversizeContext {
Gist,
Excerpt,
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
enum GistVisibility {
Secret,
Public,
}
pub fn run_report(args: ReportArgs) -> Result<(), Box<dyn Error>> {
match args.action {
ReportAction::Body {
session,
source,
db,
log_dir,
output,
context_output,
surface,
max_inline_bytes,
max_excerpt_bytes,
oversize,
gist_visibility,
} => {
let (session, context) =
exported_context(&session, source, db.as_deref(), log_dir.as_deref())?;
let lino = conversation_context_to_lino(&session, &context);
let context_path = context_path(context_output, &session);
std::fs::write(&context_path, &lino)?;
let attachment = attach_context(
&lino,
&context_path,
&session,
&AttachmentSettings {
max_inline_bytes,
max_excerpt_bytes,
oversize,
gist_visibility,
},
)?;
let body = report_body(&session, source, &surface, &context, &lino, attachment);
write_output(&output, &body.render())?;
}
}
Ok(())
}
fn context_path(requested: Option<PathBuf>, session: &str) -> PathBuf {
requested.unwrap_or_else(|| std::env::temp_dir().join(context_filename(session)))
}
fn context_filename(session: &str) -> String {
let session: String = session
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
character
} else {
'-'
}
})
.collect();
format!("formal-ai-context-{session}.lino")
}
fn report_body(
session: &str,
source: ContextSource,
surface: &str,
context: &Value,
lino: &str,
attachment: ReportAttachment,
) -> ReportBody {
let mut labels = ReportLabels::from_seed();
labels.trace_heading = config("issue_report_export_heading");
let turns = report_turns(context);
ReportBody {
labels,
environment: vec![
ReportField::new(config("issue_report_version_label"), version()),
ReportField::new(config("issue_report_session_label"), session),
ReportField::new(config("issue_report_source_label"), source.name()),
ReportField::new(
config("issue_report_records_label"),
turns.len().to_string(),
),
ReportField::new(config("issue_report_bytes_label"), lino.len().to_string()),
ReportField::new(
config("issue_report_timestamp_label"),
formal_ai::memory::isoformat_now(),
),
],
user_context: vec![
ReportField::new(config("issue_report_surface_label"), surface),
ReportField::new(config("issue_report_language_label"), ui_language()),
],
turns,
earlier_omitted: 0,
reasoning_trace: export_trace(context),
attachments: vec![attachment],
}
}
fn version() -> String {
format!("{} ({})", env!("CARGO_PKG_VERSION"), std::env::consts::OS)
}
fn ui_language() -> String {
["LC_ALL", "LC_MESSAGES", "LANG"]
.into_iter()
.find_map(|name| {
std::env::var(name)
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
})
.unwrap_or_default()
}
fn export_trace(context: &Value) -> Vec<String> {
context
.get("metadata")
.and_then(Value::as_object)
.map(|metadata| {
metadata
.iter()
.filter(|(_, value)| !value.is_null())
.map(|(key, value)| match value {
Value::String(text) => format!("{key}{TRACE_SEPARATOR}{text}"),
other => format!("{key}{TRACE_SEPARATOR}{other}"),
})
.collect()
})
.unwrap_or_default()
}
fn report_turns(context: &Value) -> Vec<ReportTurn> {
context
.get("messages")
.and_then(Value::as_array)
.map(|messages| messages.iter().filter_map(report_turn).collect())
.unwrap_or_default()
}
fn report_turn(message: &Value) -> Option<ReportTurn> {
let role = message
.get("role")
.and_then(Value::as_str)
.unwrap_or("assistant");
let text = message_text(message);
let text = text.trim();
(!text.is_empty()).then(|| ReportTurn::new(role, text))
}
fn message_text(message: &Value) -> String {
let mut pieces: Vec<String> = Vec::new();
for key in ["content", "parts"] {
if let Some(value) = message.get(key) {
collect_text(value, &mut pieces);
}
}
if let Some(calls) = message.get("tool_calls").and_then(Value::as_array) {
for call in calls {
let name = call
.pointer("/function/name")
.and_then(Value::as_str)
.unwrap_or_default();
let arguments = call
.pointer("/function/arguments")
.and_then(Value::as_str)
.unwrap_or_default();
pieces.push(format!("{name}({arguments})"));
}
}
pieces.join("\n")
}
fn collect_text(value: &Value, pieces: &mut Vec<String>) {
match value {
Value::String(text) => pieces.push(text.clone()),
Value::Array(items) => {
for item in items {
collect_text(item, pieces);
}
}
Value::Object(object) => {
for key in ["text", "content", "data"] {
if let Some(child) = object.get(key) {
collect_text(child, pieces);
return;
}
}
}
_ => {}
}
}
struct AttachmentSettings {
max_inline_bytes: usize,
max_excerpt_bytes: usize,
oversize: OversizeContext,
gist_visibility: GistVisibility,
}
fn attach_context(
lino: &str,
path: &Path,
session: &str,
settings: &AttachmentSettings,
) -> Result<ReportAttachment, Box<dyn Error>> {
let heading = config("issue_report_context_heading");
if lino.len() <= settings.max_inline_bytes {
return Ok(ReportAttachment {
heading,
note: config("issue_report_context_note"),
language: String::from(LINO),
content: lino.to_owned(),
});
}
let label = config("issue_report_omitted_records");
let excerpt = truncate_records(lino, settings.max_excerpt_bytes, &label);
let note = match settings.oversize {
OversizeContext::Gist => {
let url = upload_gist(path, session, settings.gist_visibility)?;
config("issue_report_context_gist_note").replace(URL_PLACEHOLDER, &url)
}
OversizeContext::Excerpt => config("issue_report_context_excerpt_note")
.replace(COUNT_PLACEHOLDER, &excerpt.omitted.to_string()),
};
Ok(ReportAttachment {
heading,
note,
language: String::from(LINO),
content: excerpt.text,
})
}
fn upload_gist(
path: &Path,
session: &str,
visibility: GistVisibility,
) -> Result<String, Box<dyn Error>> {
let mut command = Command::new("gh");
command
.args(["gist", "create", "--filename"])
.arg(context_filename(session));
if visibility == GistVisibility::Public {
command.arg("--public");
}
let output = command.arg(path).output()?;
let url = String::from_utf8_lossy(&output.stdout)
.lines()
.rev()
.map(str::trim)
.find(|line| line.starts_with("https://"))
.map(ToOwned::to_owned);
match url {
Some(url) if output.status.success() => Ok(url),
_ => Err(config("issue_report_gist_failed")
.replace(
ERROR_PLACEHOLDER,
String::from_utf8_lossy(&output.stderr).trim(),
)
.into()),
}
}
fn config(key: &str) -> String {
formal_ai::seed::agent_info()
.remove(key)
.unwrap_or_else(|| key.to_owned())
}