use std::cmp::Ordering;
use std::io::Write;
use anyhow::Result;
use indexmap::IndexSet;
use crate::markdown::{
SchemaUse, definition_section_tokens, detail_level_name, estimate_tokens,
inline_expansion_tokens, render, service_is_visible, short_schema_reference, visible_endpoints,
};
use crate::models::{
AdditionalProperties, ApiDocumentation, DocConfig, Endpoint, OperationRef, OperationSelector,
Schema,
};
use crate::utils::resolve_schema_reference;
#[derive(Debug, Clone, PartialEq)]
pub struct EndpointCost {
pub operation: String,
pub services: Vec<String>,
pub tokens: usize,
pub share: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SchemaCost {
pub name: String,
pub uses: usize,
pub definition_tokens: usize,
pub inline_per_use_tokens: usize,
pub inline_total_tokens: usize,
pub amplification: Option<f64>,
pub cyclic: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CostReport {
pub detail_name: String,
pub inline_schemas: bool,
pub total_tokens: usize,
pub endpoints: Vec<EndpointCost>,
pub schemas: Vec<SchemaCost>,
pub endpoint_hotspots: Vec<usize>,
pub schema_hotspots: Vec<usize>,
}
const HOTSPOT_LIMIT: usize = 5;
pub fn compute(doc: &ApiDocumentation, config: &DocConfig) -> Result<CostReport> {
let mut whole = Vec::new();
let rendered = render(&mut whole, doc, config)?;
let visible = visible_endpoints(doc, config);
let total_tokens = if visible.is_empty() {
0
} else {
estimate_tokens(&whole)
};
let endpoints: Vec<EndpointCost> = visible
.iter()
.map(|endpoint| {
let tokens = estimate_slice(doc, config, endpoint)?;
let share = share_of(tokens, total_tokens);
Ok(EndpointCost {
operation: format!("{} {}", endpoint.method, endpoint.path),
services: endpoint.services.clone(),
tokens,
share,
})
})
.collect::<Result<Vec<_>>>()?;
let mut schemas = Vec::new();
for (reference, use_stats) in &rendered.schema_uses {
schemas.push(schema_cost(doc, config, reference, use_stats));
}
let endpoint_hotspots = hotspot_indices(&endpoints, HOTSPOT_LIMIT, |cost| (cost.tokens, 0));
let schema_hotspots = schema_hotspot_indices(&schemas, HOTSPOT_LIMIT);
Ok(CostReport {
detail_name: detail_level_name(&config.detail_level).to_string(),
inline_schemas: config.inline_schemas,
total_tokens,
endpoints,
schemas,
endpoint_hotspots,
schema_hotspots,
})
}
fn estimate_slice(
doc: &ApiDocumentation,
config: &DocConfig,
endpoint: &Endpoint,
) -> Result<usize> {
let mut slice_config = config.clone();
let mut operations = IndexSet::new();
operations.insert(OperationRef {
method: endpoint.method.clone(),
path: endpoint.path.clone(),
});
slice_config.operation_selector = Some(OperationSelector {
operations,
operation_ids: IndexSet::new(),
});
if let Some(service) = slice_service(config, &endpoint.services) {
slice_config.service_filter = Some(vec![service.to_string()]);
}
let mut buffer = Vec::new();
render(&mut buffer, doc, &slice_config)?;
Ok(estimate_tokens(&buffer))
}
fn slice_service<'a>(config: &DocConfig, services: &'a [String]) -> Option<&'a str> {
match &config.service_filter {
Some(_) => services
.iter()
.find(|service| service_is_visible(service, config))
.map(String::as_str),
None => services.first().map(String::as_str),
}
}
fn schema_cost(
doc: &ApiDocumentation,
config: &DocConfig,
reference: &str,
use_stats: &SchemaUse,
) -> SchemaCost {
let definition_depth = definition_depth(config, use_stats.min_depth);
let definition_tokens = definition_section_tokens(doc, config, reference, definition_depth);
let inline_per_use_tokens = inline_expansion_tokens(doc, config, reference);
let inline_total_tokens = use_stats.count * inline_per_use_tokens;
let amplification =
(definition_tokens > 0).then(|| inline_total_tokens as f64 / definition_tokens as f64);
SchemaCost {
name: short_schema_reference(reference),
uses: use_stats.count,
definition_tokens,
inline_per_use_tokens,
inline_total_tokens,
amplification,
cyclic: schema_reaches_itself(doc, reference),
}
}
fn definition_depth(config: &DocConfig, min_use_depth: usize) -> usize {
match (config.inline_schemas, config.schema_depth) {
(true, _) => 0,
(false, Some(_)) => min_use_depth + 1,
(false, None) => 0,
}
}
fn schema_reaches_itself(doc: &ApiDocumentation, reference: &str) -> bool {
fn walk(
doc: &ApiDocumentation,
reference: &str,
target: &str,
visited: &mut IndexSet<String>,
) -> bool {
if !visited.insert(reference.to_string()) {
return false;
}
let Some(resolved) = resolve_schema_reference(reference, doc) else {
return false;
};
let mut child_refs = Vec::new();
collect_child_refs(resolved, &mut child_refs);
child_refs
.iter()
.any(|child| child == target || walk(doc, child, target, visited))
}
let mut visited = IndexSet::new();
walk(doc, reference, reference, &mut visited)
}
fn collect_child_refs(schema: &Schema, refs: &mut Vec<String>) {
if let Some(reference) = &schema.reference {
refs.push(reference.clone());
return;
}
if let Some(properties) = &schema.properties {
for child in properties.values() {
collect_child_refs(child, refs);
}
}
if let Some(items) = &schema.items {
collect_child_refs(items, refs);
}
if let Some(AdditionalProperties::Schema(child)) = &schema.additional_properties {
collect_child_refs(child, refs);
}
for variants in [&schema.all_of, &schema.one_of, &schema.any_of] {
for variant in variants.iter().flatten() {
collect_child_refs(variant, refs);
}
}
}
fn hotspot_indices<T>(items: &[T], limit: usize, key: impl Fn(&T) -> (usize, usize)) -> Vec<usize> {
let mut order: Vec<usize> = (0..items.len()).collect();
order.sort_by(|&a, &b| key(&items[b]).cmp(&key(&items[a])).then(a.cmp(&b)));
order.truncate(limit);
order
}
fn schema_hotspot_indices(schemas: &[SchemaCost], limit: usize) -> Vec<usize> {
fn ratio_desc(a: &Option<f64>, b: &Option<f64>) -> Ordering {
match (a, b) {
(Some(x), Some(y)) => y.total_cmp(x),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
}
}
let mut order: Vec<usize> = (0..schemas.len()).collect();
order.sort_by(|&a, &b| {
ratio_desc(&schemas[a].amplification, &schemas[b].amplification)
.then(
schemas[b]
.inline_total_tokens
.cmp(&schemas[a].inline_total_tokens),
)
.then(
schemas[b]
.definition_tokens
.cmp(&schemas[a].definition_tokens),
)
.then(a.cmp(&b))
});
order.truncate(limit);
order
}
fn share_of(tokens: usize, total: usize) -> f64 {
if total == 0 {
0.0
} else {
tokens as f64 / total as f64 * 100.0
}
}
fn endpoint_label(cost: &EndpointCost) -> String {
let mut label = cost.operation.clone();
let mut seen: Vec<&str> = cost
.services
.first()
.map(String::as_str)
.into_iter()
.collect();
for tag in cost.services.iter().skip(1) {
if seen.contains(&tag.as_str()) {
continue;
}
seen.push(tag);
label.push_str(&format!(" (+{tag})"));
}
label
}
const MODE_HEADER: &str = "TOKEN COST ANALYSIS";
const ENDPOINTS_HEADER: &str = "ENDPOINTS";
const SCHEMAS_HEADER: &str = "SCHEMAS";
const HOTSPOTS_HEADER: &str = "HOTSPOTS";
const TOTAL_LABEL: &str = "TOTAL";
const TOKENS_HEADER: &str = "~TOKENS";
const SHARE_HEADER: &str = "SHARE";
const OPERATION_HEADER: &str = "OPERATION";
const SCHEMA_HEADER: &str = "SCHEMA";
const USES_HEADER: &str = "USES";
const DEF_HEADER: &str = "DEF ~TOKENS";
const PER_USE_HEADER: &str = "INLINE/USE";
const INLINE_TOTAL_HEADER: &str = "INLINE TOTAL";
const AMPLIFICATION_HEADER: &str = "AMPLIF.";
const NOTES_HEADER: &str = "NOTES";
const CYCLE_NOTE: &str = "cycle";
const GAP: &str = " ";
fn digits(value: usize) -> usize {
value.to_string().len()
}
pub fn write_costs<W: Write>(writer: &mut W, report: &CostReport) -> Result<()> {
writeln!(writer, "{MODE_HEADER}")?;
writeln!(
writer,
"~TOKENS are chars/4 estimates over rendered output — not tokenizer counts and not billing figures."
)?;
let schema_mode = if report.inline_schemas {
"inline (every use site expands; no definitions section)"
} else {
"linked (definitions rendered once; use sites link)"
};
writeln!(
writer,
"Mode: --detail {}, schemas {}. {TOTAL_LABEL}: ~{} tokens (one render of the whole filtered document).",
report.detail_name, schema_mode, report.total_tokens
)?;
writeln!(writer)?;
writeln!(writer, "{ENDPOINTS_HEADER}")?;
if report.endpoints.is_empty() {
writeln!(writer, "(no endpoints visible after filters)")?;
} else {
writeln!(
writer,
"One render per endpoint alone: its service section, the document frame and the schema"
)?;
writeln!(
writer,
"definitions it links. Rows overlap through the frame and shared schemas, so they neither"
)?;
writeln!(
writer,
"sum to nor subtract from TOTAL. Multi-tag endpoints are measured once, under their first"
)?;
writeln!(
writer,
"service (or the first one a `--service-filter` keeps); a service-grouped document renders"
)?;
writeln!(
writer,
"them once per tag. A ` (+tag)` suffix marks the extra tags, rendered verbatim after the"
)?;
writeln!(writer, "operation label.")?;
write_endpoint_table(writer, report)?;
}
writeln!(writer)?;
writeln!(writer, "{SCHEMAS_HEADER}")?;
if report.schemas.is_empty() {
writeln!(
writer,
"(no component schemas rendered at this detail level and filters)"
)?;
} else {
let schema_notes: &[&str] = if report.inline_schemas {
&[
"USES counts the expansion sites in the analyzed inline render: every rendered row",
"that expands this schema, including repeats from multi-tag endpoints. INLINE TOTAL",
"is the modeled cost of expanding the schema at every use site (uses × one measured,",
"cycle-guarded expansion), not the cost of this inline output. DEF ~TOKENS is the",
"hypothetical Schema Definitions entry the linked mode would render once — inline",
"mode emits no definitions — so the ratio compares reading the schema once against",
"expanding it at every use site. A cycle note marks a schema that transitively",
"reaches itself; inline expansion cuts it.",
]
} else {
&[
"DEF is the Schema Definitions entry read once. INLINE TOTAL is a separate analysis: the",
"modeled cost of expanding the schema at every use site (uses × one measured, cycle-guarded",
"expansion), not the cost of the output in linked mode. USES counts the linked render's",
"rows: body rows plus the reference rows inside each rendered definition. Internal rows",
"have no inline counterpart, and a schema referenced only inside another definition expands",
"with it, so real --inline-schemas output can differ from the model in either direction.",
"A cycle note marks a schema that transitively reaches itself; inline expansion cuts it.",
]
};
for line in schema_notes {
writeln!(writer, "{line}")?;
}
write_schema_table(writer, report)?;
}
writeln!(writer)?;
writeln!(writer, "{HOTSPOTS_HEADER}")?;
writeln!(
writer,
"Most expensive slices; shares are of TOTAL and overlap with each other."
)?;
if report.endpoint_hotspots.is_empty() && report.schema_hotspots.is_empty() {
writeln!(writer, "(nothing to rank)")?;
} else {
for (rank, &index) in report.endpoint_hotspots.iter().enumerate() {
let cost = &report.endpoints[index];
writeln!(
writer,
"{}. {} — ~{} tokens ({:.1}% of TOTAL)",
rank + 1,
endpoint_label(cost),
cost.tokens,
cost.share
)?;
}
if !report.schema_hotspots.is_empty() {
writeln!(
writer,
"Most amplifying schemas (inline model vs reading the definition once):"
)?;
for (rank, &index) in report.schema_hotspots.iter().enumerate() {
let cost = &report.schemas[index];
writeln!(
writer,
"{}. {} — {} use(s), ~{} tokens inline vs ~{} read once{}",
rank + 1,
cost.name,
cost.uses,
cost.inline_total_tokens,
cost.definition_tokens,
if cost.cyclic { " (cycle)" } else { "" }
)?;
}
}
}
Ok(())
}
fn write_endpoint_table<W: Write>(writer: &mut W, report: &CostReport) -> Result<()> {
let share_cells: Vec<String> = report
.endpoints
.iter()
.map(|cost| format!("{:.1}%", cost.share))
.collect();
let share_width = share_cells
.iter()
.map(String::len)
.chain([SHARE_HEADER.len()])
.max()
.unwrap_or(SHARE_HEADER.len());
let tokens_width = report
.endpoints
.iter()
.map(|cost| digits(cost.tokens))
.chain([TOKENS_HEADER.len()])
.max()
.unwrap_or(TOKENS_HEADER.len());
writeln!(
writer,
"{TOKENS_HEADER:>tokens_width$}{GAP}{SHARE_HEADER:>share_width$}{GAP}{OPERATION_HEADER}"
)?;
for (cost, share) in report.endpoints.iter().zip(&share_cells) {
writeln!(
writer,
"{:>tokens_width$}{GAP}{:>share_width$}{GAP}{}",
cost.tokens,
share,
endpoint_label(cost)
)?;
}
Ok(())
}
fn write_schema_table<W: Write>(writer: &mut W, report: &CostReport) -> Result<()> {
let name_width = report
.schemas
.iter()
.map(|cost| cost.name.chars().count())
.chain([SCHEMA_HEADER.len()])
.max()
.unwrap_or(SCHEMA_HEADER.len());
let def_width = report
.schemas
.iter()
.map(|cost| digits(cost.definition_tokens))
.chain([DEF_HEADER.len()])
.max()
.unwrap_or(DEF_HEADER.len());
let per_use_width = report
.schemas
.iter()
.map(|cost| digits(cost.inline_per_use_tokens))
.chain([PER_USE_HEADER.len()])
.max()
.unwrap_or(PER_USE_HEADER.len());
let total_width = report
.schemas
.iter()
.map(|cost| digits(cost.inline_total_tokens))
.chain([INLINE_TOTAL_HEADER.len()])
.max()
.unwrap_or(INLINE_TOTAL_HEADER.len());
let has_notes = report.schemas.iter().any(|cost| cost.cyclic);
write!(
writer,
"{SCHEMA_HEADER:<name_width$}{GAP}{USES_HEADER:>4}{GAP}{DEF_HEADER:>def_width$}{GAP}{PER_USE_HEADER:>per_use_width$}{GAP}{INLINE_TOTAL_HEADER:>total_width$}{GAP}{AMPLIFICATION_HEADER:>8}"
)?;
if has_notes {
write!(writer, "{GAP}{NOTES_HEADER}")?;
}
writeln!(writer)?;
for cost in &report.schemas {
let amplification = match cost.amplification {
Some(value) => format!("{value:.1}x"),
None => "-".to_string(),
};
write!(
writer,
"{:<name_width$}{GAP}{:>4}{GAP}{:>def_width$}{GAP}{:>per_use_width$}{GAP}{:>total_width$}{GAP}{:>8}",
cost.name,
cost.uses,
cost.definition_tokens,
cost.inline_per_use_tokens,
cost.inline_total_tokens,
amplification
)?;
if has_notes && cost.cyclic {
write!(writer, "{GAP}{CYCLE_NOTE}")?;
}
writeln!(writer)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn render_to_string(report: &CostReport) -> String {
let mut buffer = Vec::new();
write_costs(&mut buffer, report).unwrap();
String::from_utf8(buffer).unwrap()
}
fn sample_report() -> CostReport {
CostReport {
detail_name: "full".into(),
inline_schemas: false,
total_tokens: 2000,
endpoints: vec![
EndpointCost {
operation: "GET /users/{id}".into(),
services: vec!["users".into()],
tokens: 100,
share: share_of(100, 2000),
},
EndpointCost {
operation: "POST /users".into(),
services: vec!["users".into(), "items".into()],
tokens: 400,
share: share_of(400, 2000),
},
],
schemas: vec![
SchemaCost {
name: "Pet".into(),
uses: 3,
definition_tokens: 456,
inline_per_use_tokens: 450,
inline_total_tokens: 1350,
amplification: Some(1350.0 / 456.0),
cyclic: false,
},
SchemaCost {
name: "Node".into(),
uses: 1,
definition_tokens: 890,
inline_per_use_tokens: 120,
inline_total_tokens: 120,
amplification: Some(120.0 / 890.0),
cyclic: true,
},
],
endpoint_hotspots: vec![1, 0],
schema_hotspots: vec![0, 1],
}
}
#[test]
fn hotspots_rank_descending_with_row_order_ties() {
let items = vec![10usize, 30, 30, 5];
let order = hotspot_indices(&items, 4, |&value| (value, 0));
assert_eq!(order, vec![1, 2, 0, 3]);
assert_eq!(hotspot_indices(&items, 2, |&value| (value, 0)), vec![1, 2]);
}
#[test]
fn hotspots_use_the_secondary_key_before_row_order() {
let items = vec![(5usize, 100usize), (5, 200)];
let order = hotspot_indices(&items, 2, |&(_, definition)| (0, definition));
assert_eq!(order, vec![1, 0]);
}
#[test]
fn schema_hotspots_rank_by_ratio_over_absolute_size() {
let schemas = vec![
SchemaCost {
name: "Large".into(),
uses: 1,
definition_tokens: 1000,
inline_per_use_tokens: 1100,
inline_total_tokens: 1100,
amplification: Some(1.1),
cyclic: false,
},
SchemaCost {
name: "Small".into(),
uses: 10,
definition_tokens: 10,
inline_per_use_tokens: 10,
inline_total_tokens: 100,
amplification: Some(10.0),
cyclic: false,
},
SchemaCost {
name: "Twin".into(),
uses: 1,
definition_tokens: 100,
inline_per_use_tokens: 1000,
inline_total_tokens: 1000,
amplification: Some(10.0),
cyclic: false,
},
SchemaCost {
name: "Empty".into(),
uses: 5,
definition_tokens: 0,
inline_per_use_tokens: 4,
inline_total_tokens: 20,
amplification: None,
cyclic: false,
},
];
assert_eq!(schema_hotspot_indices(&schemas, 4), vec![2, 1, 0, 3]);
assert_eq!(schema_hotspot_indices(&schemas, 2), vec![2, 1]);
}
#[test]
fn share_is_zero_without_a_total() {
assert_eq!(share_of(123, 0), 0.0);
assert_eq!(share_of(50, 200), 25.0);
}
#[test]
fn endpoint_label_renders_each_extra_tag_once_verbatim() {
let label = |services: &[&str]| EndpointCost {
operation: "POST /users".into(),
services: services.iter().map(|tag| tag.to_string()).collect(),
tokens: 0,
share: 0.0,
};
assert_eq!(endpoint_label(&label(&["users"])), "POST /users");
assert_eq!(
endpoint_label(&label(&["users", "items"])),
"POST /users (+items)"
);
assert_eq!(endpoint_label(&label(&["users", "users"])), "POST /users");
assert_eq!(
endpoint_label(&label(&["users", "items", "users"])),
"POST /users (+items)"
);
}
#[test]
fn slices_narrow_to_the_first_service_the_filter_keeps() {
let services = vec!["users".to_string(), "items".to_string()];
let config = DocConfig::unfiltered();
assert_eq!(slice_service(&config, &services), Some("users"));
let mut filtered = config.clone();
filtered.service_filter = Some(vec!["ITEMS".into()]);
assert_eq!(slice_service(&filtered, &services), Some("items"));
filtered.service_filter = Some(vec!["users".into(), "items".into()]);
assert_eq!(slice_service(&filtered, &services), Some("users"));
}
#[test]
fn report_labels_estimates_and_separates_the_inline_model() {
let text = render_to_string(&sample_report());
assert!(text.starts_with("TOKEN COST ANALYSIS\n"));
assert!(text.contains("chars/4 estimates"));
assert!(text.contains("not billing figures"));
assert!(text.contains("not the cost of the output in linked mode"));
assert!(text.contains("measured once, under their first"));
}
#[test]
fn report_prints_endpoint_rows_and_hotspots() {
let text = render_to_string(&sample_report());
assert!(text.contains("GET /users/{id}"), "{text}");
assert!(text.contains("POST /users (+items)"), "{text}");
assert!(
text.contains("1. POST /users (+items) — ~400 tokens (20.0% of TOTAL)"),
"{text}"
);
assert!(
text.contains("2. GET /users/{id} — ~100 tokens (5.0% of TOTAL)"),
"{text}"
);
assert!(
text.contains("1. Pet — 3 use(s), ~1350 tokens inline vs ~456 read once"),
"{text}"
);
}
#[test]
fn report_marks_cyclic_schemas() {
let text = render_to_string(&sample_report());
assert!(text.contains("cycle"), "{text}");
}
#[test]
fn empty_report_is_headers_and_notes_only() {
let text = render_to_string(&CostReport::default());
assert!(
text.contains("(no endpoints visible after filters)"),
"{text}"
);
assert!(text.contains("(no component schemas rendered"), "{text}");
assert!(text.contains("(nothing to rank)"), "{text}");
assert!(text.contains("TOTAL: ~0 tokens"), "{text}");
}
#[test]
fn inline_mode_is_labeled_in_the_mode_line() {
let mut report = sample_report();
report.inline_schemas = true;
let text = render_to_string(&report);
assert!(text.contains("schemas inline"), "{text}");
}
}