use crate::analysis::density::IssueSeverity;
use crate::analysis::AnalysisResult;
use crate::recommend::Recommendations;
use anyhow::Result;
pub fn generate_text(result: &AnalysisResult, recommendations: &Recommendations) -> String {
let mut output = String::new();
output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
output.push_str(&format!(" STT Optimization Report - {}\n", result.source));
output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
output.push_str("📊 Dataset Summary\n");
output.push_str(&format!(" Features: {:>12}\n", format_number(result.feature_count)));
output.push_str(&format!(" Time Range: {}\n", result.temporal.time_range_description()));
output.push_str(&format!(
" Spatial Bounds: [{:.2}, {:.2}] to [{:.2}, {:.2}]\n",
result.bounds.min_lon,
result.bounds.min_lat,
result.bounds.max_lon,
result.bounds.max_lat
));
output.push_str(&format!(" Geometry Type: {} ({})\n",
result.geometry.dominant_type,
format_type_distribution(&result.geometry.type_distribution)
));
output.push('\n');
output.push_str("🗺️ Spatial Analysis\n");
output.push_str(&format!(" Distribution: {}\n", result.spatial.distribution));
for z in [4, 6, 8, 10, 12, 14, 16].iter() {
if let Some(cov) = result.spatial.zoom_coverage.iter().find(|c| c.zoom == *z) {
output.push_str(&format!(
" Coverage at z{}: {:.2}% ({} tiles)\n",
z, cov.coverage_percent, cov.occupied_tiles
));
}
}
output.push_str(&format!(
" Recommended Zoom: {}-{}\n",
result.spatial.recommended_min_zoom,
result.spatial.recommended_max_zoom
));
if !result.spatial.hotspots.is_empty() {
output.push_str(" Hotspots:\n");
for (i, hotspot) in result.spatial.hotspots.iter().take(3).enumerate() {
let name = hotspot.name.as_deref().unwrap_or("Unknown region");
output.push_str(&format!(
" {}. {} ({} features)\n",
i + 1, name, format_number(hotspot.feature_count)
));
}
}
output.push('\n');
output.push_str("⏰ Temporal Analysis\n");
output.push_str(&format!(" Duration: {}\n", result.temporal.duration_human));
output.push_str(&format!(" Distribution: {}\n", result.temporal.distribution));
output.push_str(&format!(
" Events/day avg: {:.1}\n",
result.temporal.events_per_day.avg
));
output.push_str(&format!(
" Unique times: {}\n",
format_number(result.temporal.unique_timestamps)
));
output.push_str(&format!(
" Suggested bucket: {}\n",
result.temporal.recommended_bucket_human
));
output.push('\n');
output.push_str("📐 Geometry Analysis\n");
output.push_str(&format!(" Complexity: {}\n", result.geometry.complexity));
output.push_str(&format!(
" Vertices (avg): {:.1}\n",
result.geometry.vertex_stats.avg
));
output.push_str(&format!(
" Vertices (p95): {}\n",
result.geometry.vertex_stats.p95
));
output.push_str(&format!(
" Avg size/feat: {} bytes\n",
result.geometry.size_stats.avg as usize
));
output.push_str(&format!(
" Total size: {}\n",
format_bytes(result.geometry.size_stats.total)
));
output.push('\n');
output.push_str("💾 Size Estimation\n");
output.push_str(&format!(
" Est. tiles: {} (at recommended settings)\n",
format_number(result.density.estimated_tile_count)
));
output.push_str(&format!(
" Est. archive: {} compressed\n",
format_bytes(result.density.estimated_archive_size)
));
output.push('\n');
if let Some(m) = &result.measured {
output.push_str("🔬 Measured Encoding (sampled)\n");
output.push_str(&format!(
" Sample: {} features ({})\n",
format_number(m.features),
m.geometry_kind
));
output.push_str(&format!(
" Bytes/feature: {:.1} compressed\n",
m.bytes_per_feature
));
output.push_str(&format!(" Zstd ratio: {:.2}x\n", m.zstd_ratio));
if !m.per_column.is_empty() {
output.push_str(" Top columns:\n");
for c in m.per_column.iter().take(5) {
output.push_str(&format!(
" {:<18} {:>5.1}% ({})\n",
c.name,
c.share * 100.0,
format_bytes(c.compressed_bytes)
));
}
}
output.push('\n');
}
if !result.density.issues.is_empty() {
output.push_str("⚠️ Issues\n");
for issue in &result.density.issues {
let icon = match issue.severity {
IssueSeverity::Error => "❌",
IssueSeverity::Warning => "⚠️",
IssueSeverity::Info => "ℹ️",
};
output.push_str(&format!(" {} {}\n", icon, issue.description));
output.push_str(&format!(" → {}\n", issue.suggestion));
}
output.push('\n');
}
output.push_str("💡 Recommendations\n");
output.push_str(&format!(
" --min-zoom {}\n",
recommendations.min_zoom
));
output.push_str(&format!(
" --max-zoom {}\n",
recommendations.max_zoom
));
output.push('\n');
output.push_str(&format!(
" Confidence: {}%\n",
recommendations.confidence
));
output.push('\n');
if !recommendations.advice.is_empty() {
output.push_str("🧭 Advisor\n");
for advice in &recommendations.advice {
let mut line = format!(" {}", advice.flag);
if let Some(value) = &advice.value {
line.push(' ');
line.push_str(value);
}
if let Some(projected) = &advice.projected {
line.push_str(&format!(" [{}]", projected));
}
line.push_str(&format!(" ({} confidence)", advice.confidence));
if advice.lossy {
line.push_str(" [LOSSY - opt-in]");
}
output.push_str(&line);
output.push('\n');
output.push_str(&format!(" → {}\n", advice.why));
}
output.push('\n');
}
output.push_str("📋 Suggested Command:\n");
output.push_str(&format!(
" stt-build --input {} --output {} \\\n",
result.source,
result.source.trim_end_matches(".parquet").trim_end_matches(".geoparquet")
));
output.push_str(&format!(
" --time-field timestamp --min-zoom {} --max-zoom {}\n",
recommendations.min_zoom, recommendations.max_zoom
));
output.push('\n');
output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
output
}
pub fn generate_json(result: &AnalysisResult, recommendations: &Recommendations) -> Result<String> {
let report = serde_json::json!({
"source": result.source,
"feature_count": result.feature_count,
"bounds": {
"min_lon": result.bounds.min_lon,
"min_lat": result.bounds.min_lat,
"max_lon": result.bounds.max_lon,
"max_lat": result.bounds.max_lat,
},
"spatial": {
"distribution": format!("{}", result.spatial.distribution),
"recommended_min_zoom": result.spatial.recommended_min_zoom,
"recommended_max_zoom": result.spatial.recommended_max_zoom,
"hotspots": result.spatial.hotspots.iter().map(|h| {
serde_json::json!({
"lon": h.lon,
"lat": h.lat,
"feature_count": h.feature_count,
"name": h.name,
})
}).collect::<Vec<_>>(),
"zoom_coverage": result.spatial.zoom_coverage.iter().map(|c| {
serde_json::json!({
"zoom": c.zoom,
"coverage_percent": c.coverage_percent,
"occupied_tiles": c.occupied_tiles,
"avg_features_per_tile": c.avg_features_per_tile,
})
}).collect::<Vec<_>>(),
},
"temporal": {
"time_start": result.temporal.time_start,
"time_end": result.temporal.time_end,
"duration_ms": result.temporal.duration_ms,
"duration_human": result.temporal.duration_human,
"distribution": format!("{}", result.temporal.distribution),
"unique_timestamps": result.temporal.unique_timestamps,
"recommended_bucket_ms": result.temporal.recommended_bucket_ms,
"recommended_bucket_human": result.temporal.recommended_bucket_human,
"events_per_day": {
"avg": result.temporal.events_per_day.avg,
"min": result.temporal.events_per_day.min,
"max": result.temporal.events_per_day.max,
},
},
"geometry": {
"dominant_type": result.geometry.dominant_type,
"type_distribution": result.geometry.type_distribution,
"complexity": format!("{}", result.geometry.complexity),
"vertex_stats": {
"min": result.geometry.vertex_stats.min,
"max": result.geometry.vertex_stats.max,
"avg": result.geometry.vertex_stats.avg,
"median": result.geometry.vertex_stats.median,
"p95": result.geometry.vertex_stats.p95,
},
"size_stats": {
"min": result.geometry.size_stats.min,
"max": result.geometry.size_stats.max,
"avg": result.geometry.size_stats.avg,
"total": result.geometry.size_stats.total,
},
},
"density": {
"per_zoom": result.density.per_zoom.iter().map(|z| {
serde_json::json!({
"zoom": z.zoom,
"tile_count": z.tile_count,
"avg_features_per_tile": z.avg_features_per_tile,
"median_features_per_tile": z.median_features_per_tile,
"max_features_per_tile": z.max_features_per_tile,
"oversized_tiles": z.oversized_tiles,
"undersized_tiles": z.undersized_tiles,
"estimated_size_uncompressed": z.estimated_size_uncompressed,
"estimated_size_compressed": z.estimated_size_compressed,
})
}).collect::<Vec<_>>(),
"estimated_tile_count": result.density.estimated_tile_count,
"estimated_archive_size": result.density.estimated_archive_size,
"issues": result.density.issues.iter().map(|i| {
serde_json::json!({
"severity": format!("{}", i.severity),
"description": i.description,
"suggestion": i.suggestion,
})
}).collect::<Vec<_>>(),
},
"measured": result.measured,
"recommendations": {
"min_zoom": recommendations.min_zoom,
"max_zoom": recommendations.max_zoom,
"temporal_bucket_ms": recommendations.temporal_bucket_ms,
"confidence": recommendations.confidence,
"explanations": recommendations.explanations,
},
"advice": recommendations.advice,
});
Ok(serde_json::to_string_pretty(&report)?)
}
fn format_number(n: usize) -> String {
let s = n.to_string();
let mut result = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.insert(0, ',');
}
result.insert(0, c);
}
result
}
fn format_bytes(bytes: usize) -> String {
const KB: usize = 1024;
const MB: usize = 1024 * KB;
const GB: usize = 1024 * MB;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} bytes", bytes)
}
}
fn format_type_distribution(dist: &std::collections::HashMap<String, usize>) -> String {
let total: usize = dist.values().sum();
if total == 0 {
return "N/A".to_string();
}
let mut parts: Vec<String> = dist
.iter()
.map(|(t, c)| {
let pct = *c as f64 / total as f64 * 100.0;
if pct > 99.0 {
format!("100% {}", t)
} else if pct > 1.0 {
format!("{:.0}% {}", pct, t)
} else {
String::new()
}
})
.filter(|s| !s.is_empty())
.collect();
parts.sort();
parts.reverse();
parts.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_number() {
assert_eq!(format_number(1000), "1,000");
assert_eq!(format_number(1000000), "1,000,000");
assert_eq!(format_number(123), "123");
}
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(500), "500 bytes");
assert_eq!(format_bytes(1024), "1.00 KB");
assert_eq!(format_bytes(1024 * 1024), "1.00 MB");
assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
}
}