Skip to main content

stt_optimize/
report.rs

1//! Report generation (text and JSON formats)
2//!
3//! Generates human-readable and machine-readable reports from analysis results.
4
5use crate::analysis::density::IssueSeverity;
6use crate::analysis::AnalysisResult;
7use crate::recommend::Recommendations;
8use anyhow::Result;
9
10/// Generate a text report
11pub fn generate_text(result: &AnalysisResult, recommendations: &Recommendations) -> String {
12    let mut output = String::new();
13
14    // Header
15    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
16    output.push_str(&format!("         STT Optimization Report - {}\n", result.source));
17    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
18
19    // Dataset Summary
20    output.push_str("📊 Dataset Summary\n");
21    output.push_str(&format!("  Features:        {:>12}\n", format_number(result.feature_count)));
22    output.push_str(&format!("  Time Range:      {}\n", result.temporal.time_range_description()));
23    output.push_str(&format!(
24        "  Spatial Bounds:  [{:.2}, {:.2}] to [{:.2}, {:.2}]\n",
25        result.spatial.zoom_coverage.first().map(|_| -180.0).unwrap_or(0.0),
26        -90.0,
27        180.0,
28        90.0
29    ));
30    output.push_str(&format!("  Geometry Type:   {} ({})\n", 
31        result.geometry.dominant_type,
32        format_type_distribution(&result.geometry.type_distribution)
33    ));
34    output.push('\n');
35
36    // Spatial Analysis
37    output.push_str("🗺️  Spatial Analysis\n");
38    output.push_str(&format!("  Distribution:    {}\n", result.spatial.distribution));
39    
40    // Show coverage at key zoom levels
41    for z in [4, 6, 8, 10, 12].iter() {
42        if let Some(cov) = result.spatial.zoom_coverage.iter().find(|c| c.zoom == *z) {
43            output.push_str(&format!(
44                "  Coverage at z{}:  {:.2}% ({} tiles)\n",
45                z, cov.coverage_percent, cov.occupied_tiles
46            ));
47        }
48    }
49    
50    output.push_str(&format!(
51        "  Recommended Zoom: {}-{}\n",
52        result.spatial.recommended_min_zoom,
53        result.spatial.recommended_max_zoom
54    ));
55
56    if !result.spatial.hotspots.is_empty() {
57        output.push_str("  Hotspots:\n");
58        for (i, hotspot) in result.spatial.hotspots.iter().take(3).enumerate() {
59            let name = hotspot.name.as_deref().unwrap_or("Unknown region");
60            output.push_str(&format!(
61                "    {}. {} ({} features)\n",
62                i + 1, name, format_number(hotspot.feature_count)
63            ));
64        }
65    }
66    output.push('\n');
67
68    // Temporal Analysis
69    output.push_str("⏰ Temporal Analysis\n");
70    output.push_str(&format!("  Duration:        {}\n", result.temporal.duration_human));
71    output.push_str(&format!("  Distribution:    {}\n", result.temporal.distribution));
72    output.push_str(&format!(
73        "  Events/day avg:  {:.1}\n",
74        result.temporal.events_per_day.avg
75    ));
76    output.push_str(&format!(
77        "  Unique times:    {}\n",
78        format_number(result.temporal.unique_timestamps)
79    ));
80    output.push_str(&format!(
81        "  Suggested bucket: {}\n",
82        result.temporal.recommended_bucket_human
83    ));
84    output.push('\n');
85
86    // Geometry Analysis
87    output.push_str("📐 Geometry Analysis\n");
88    output.push_str(&format!("  Complexity:      {}\n", result.geometry.complexity));
89    output.push_str(&format!(
90        "  Vertices (avg):  {:.1}\n",
91        result.geometry.vertex_stats.avg
92    ));
93    output.push_str(&format!(
94        "  Vertices (p95):  {}\n",
95        result.geometry.vertex_stats.p95
96    ));
97    output.push_str(&format!(
98        "  Avg size/feat:   {} bytes\n",
99        result.geometry.size_stats.avg as usize
100    ));
101    output.push_str(&format!(
102        "  Total size:      {}\n",
103        format_bytes(result.geometry.size_stats.total)
104    ));
105    output.push('\n');
106
107    // Size Estimation
108    output.push_str("💾 Size Estimation\n");
109    output.push_str(&format!(
110        "  Est. tiles:      {} (at recommended settings)\n",
111        format_number(result.density.estimated_tile_count)
112    ));
113    output.push_str(&format!(
114        "  Est. archive:    {} compressed\n",
115        format_bytes(result.density.estimated_archive_size)
116    ));
117    output.push('\n');
118
119    // Issues
120    if !result.density.issues.is_empty() {
121        output.push_str("⚠️  Issues\n");
122        for issue in &result.density.issues {
123            let icon = match issue.severity {
124                IssueSeverity::Error => "❌",
125                IssueSeverity::Warning => "⚠️",
126                IssueSeverity::Info => "ℹ️",
127            };
128            output.push_str(&format!("  {} {}\n", icon, issue.description));
129            output.push_str(&format!("     → {}\n", issue.suggestion));
130        }
131        output.push('\n');
132    }
133
134    // Recommendations
135    output.push_str("💡 Recommendations\n");
136    output.push_str(&format!(
137        "  --min-zoom {}\n",
138        recommendations.min_zoom
139    ));
140    output.push_str(&format!(
141        "  --max-zoom {}\n",
142        recommendations.max_zoom
143    ));
144    output.push('\n');
145
146    // Confidence
147    output.push_str(&format!(
148        "  Confidence: {}%\n",
149        recommendations.confidence
150    ));
151    output.push('\n');
152
153    // Suggested Command
154    output.push_str("📋 Suggested Command:\n");
155    // The suggested --output is the packed dataset DIRECTORY (the input's
156    // stem): stt-build's output is a directory tree, not a single file.
157    output.push_str(&format!(
158        "  stt-build --input {} --output {} \\\n",
159        result.source,
160        result.source.trim_end_matches(".parquet").trim_end_matches(".geoparquet")
161    ));
162    output.push_str(&format!(
163        "    --time-field timestamp --min-zoom {} --max-zoom {}\n",
164        recommendations.min_zoom, recommendations.max_zoom
165    ));
166    output.push('\n');
167
168    // Footer
169    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
170
171    output
172}
173
174/// Generate a JSON report
175pub fn generate_json(result: &AnalysisResult, recommendations: &Recommendations) -> Result<String> {
176    let report = serde_json::json!({
177        "source": result.source,
178        "feature_count": result.feature_count,
179        "spatial": {
180            "distribution": format!("{}", result.spatial.distribution),
181            "recommended_min_zoom": result.spatial.recommended_min_zoom,
182            "recommended_max_zoom": result.spatial.recommended_max_zoom,
183            "hotspots": result.spatial.hotspots.iter().map(|h| {
184                serde_json::json!({
185                    "lon": h.lon,
186                    "lat": h.lat,
187                    "feature_count": h.feature_count,
188                    "name": h.name,
189                })
190            }).collect::<Vec<_>>(),
191            "zoom_coverage": result.spatial.zoom_coverage.iter().map(|c| {
192                serde_json::json!({
193                    "zoom": c.zoom,
194                    "coverage_percent": c.coverage_percent,
195                    "occupied_tiles": c.occupied_tiles,
196                    "avg_features_per_tile": c.avg_features_per_tile,
197                })
198            }).collect::<Vec<_>>(),
199        },
200        "temporal": {
201            "time_start": result.temporal.time_start,
202            "time_end": result.temporal.time_end,
203            "duration_ms": result.temporal.duration_ms,
204            "duration_human": result.temporal.duration_human,
205            "distribution": format!("{}", result.temporal.distribution),
206            "unique_timestamps": result.temporal.unique_timestamps,
207            "recommended_bucket_ms": result.temporal.recommended_bucket_ms,
208            "recommended_bucket_human": result.temporal.recommended_bucket_human,
209            "events_per_day": {
210                "avg": result.temporal.events_per_day.avg,
211                "min": result.temporal.events_per_day.min,
212                "max": result.temporal.events_per_day.max,
213            },
214        },
215        "geometry": {
216            "dominant_type": result.geometry.dominant_type,
217            "type_distribution": result.geometry.type_distribution,
218            "complexity": format!("{}", result.geometry.complexity),
219            "vertex_stats": {
220                "min": result.geometry.vertex_stats.min,
221                "max": result.geometry.vertex_stats.max,
222                "avg": result.geometry.vertex_stats.avg,
223                "median": result.geometry.vertex_stats.median,
224                "p95": result.geometry.vertex_stats.p95,
225            },
226            "size_stats": {
227                "min": result.geometry.size_stats.min,
228                "max": result.geometry.size_stats.max,
229                "avg": result.geometry.size_stats.avg,
230                "total": result.geometry.size_stats.total,
231            },
232        },
233        "density": {
234            "recommended_chunk_size": result.density.recommended_chunk_size,
235            "estimated_tile_count": result.density.estimated_tile_count,
236            "estimated_archive_size": result.density.estimated_archive_size,
237            "issues": result.density.issues.iter().map(|i| {
238                serde_json::json!({
239                    "severity": format!("{}", i.severity),
240                    "description": i.description,
241                    "suggestion": i.suggestion,
242                })
243            }).collect::<Vec<_>>(),
244        },
245        "recommendations": {
246            "min_zoom": recommendations.min_zoom,
247            "max_zoom": recommendations.max_zoom,
248            "temporal_bucket_ms": recommendations.temporal_bucket_ms,
249            "confidence": recommendations.confidence,
250            "explanations": recommendations.explanations,
251        },
252    });
253
254    Ok(serde_json::to_string_pretty(&report)?)
255}
256
257/// Format a number with thousands separators
258fn format_number(n: usize) -> String {
259    let s = n.to_string();
260    let mut result = String::new();
261    for (i, c) in s.chars().rev().enumerate() {
262        if i > 0 && i % 3 == 0 {
263            result.insert(0, ',');
264        }
265        result.insert(0, c);
266    }
267    result
268}
269
270/// Format bytes as human-readable string
271fn format_bytes(bytes: usize) -> String {
272    const KB: usize = 1024;
273    const MB: usize = 1024 * KB;
274    const GB: usize = 1024 * MB;
275
276    if bytes >= GB {
277        format!("{:.2} GB", bytes as f64 / GB as f64)
278    } else if bytes >= MB {
279        format!("{:.2} MB", bytes as f64 / MB as f64)
280    } else if bytes >= KB {
281        format!("{:.2} KB", bytes as f64 / KB as f64)
282    } else {
283        format!("{} bytes", bytes)
284    }
285}
286
287/// Format type distribution as percentage string
288fn format_type_distribution(dist: &std::collections::HashMap<String, usize>) -> String {
289    let total: usize = dist.values().sum();
290    if total == 0 {
291        return "N/A".to_string();
292    }
293
294    let mut parts: Vec<String> = dist
295        .iter()
296        .map(|(t, c)| {
297            let pct = *c as f64 / total as f64 * 100.0;
298            if pct > 99.0 {
299                format!("100% {}", t)
300            } else if pct > 1.0 {
301                format!("{:.0}% {}", pct, t)
302            } else {
303                String::new()
304            }
305        })
306        .filter(|s| !s.is_empty())
307        .collect();
308
309    parts.sort();
310    parts.reverse();
311    parts.join(", ")
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_format_number() {
320        assert_eq!(format_number(1000), "1,000");
321        assert_eq!(format_number(1000000), "1,000,000");
322        assert_eq!(format_number(123), "123");
323    }
324
325    #[test]
326    fn test_format_bytes() {
327        assert_eq!(format_bytes(500), "500 bytes");
328        assert_eq!(format_bytes(1024), "1.00 KB");
329        assert_eq!(format_bytes(1024 * 1024), "1.00 MB");
330        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
331    }
332}
333
334