Skip to main content

sysml_core/
render.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use handlebars::Handlebars;
5use serde::Serialize;
6
7use crate::element::SysmlElement;
8use crate::graph::SysmlGraph;
9use crate::relationship::SysmlRelationship;
10use nomograph_core::traits::KnowledgeGraph;
11use nomograph_core::types::CheckType;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum BuiltinTemplate {
15    TraceabilityMatrix,
16    RequirementsTable,
17    CompletenessReport,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum RenderFormat {
22    Markdown,
23    Html,
24    Csv,
25}
26
27#[derive(Debug, thiserror::Error)]
28pub enum RenderError {
29    #[error("template error: {0}")]
30    Template(#[from] handlebars::RenderError),
31    #[error("template parse error: {0}")]
32    TemplateParse(#[from] Box<handlebars::TemplateError>),
33    #[error("io error: {0}")]
34    Io(#[from] std::io::Error),
35}
36
37#[derive(Serialize)]
38struct TraceabilityRow {
39    requirement: String,
40    kind: String,
41    file: String,
42    satisfied_by: Vec<String>,
43    verified_by: Vec<String>,
44    status: String,
45}
46
47#[derive(Serialize)]
48struct TraceabilityContext {
49    rows: Vec<TraceabilityRow>,
50    total_requirements: usize,
51    satisfied_count: usize,
52    verified_count: usize,
53    coverage_pct: String,
54}
55
56#[derive(Serialize)]
57struct RequirementRow {
58    name: String,
59    kind: String,
60    file: String,
61    line: u32,
62    doc: String,
63    member_count: usize,
64}
65
66#[derive(Serialize)]
67struct RequirementsContext {
68    rows: Vec<RequirementRow>,
69    total: usize,
70}
71
72#[derive(Serialize)]
73struct CheckSummary {
74    name: String,
75    count: usize,
76}
77
78#[derive(Serialize)]
79struct CompletenessContext {
80    files: usize,
81    elements: usize,
82    relationships: usize,
83    completeness_score: String,
84    checks: Vec<CheckSummary>,
85    total_findings: usize,
86    type_breakdown: Vec<TypeCount>,
87}
88
89#[derive(Serialize)]
90struct TypeCount {
91    kind: String,
92    count: usize,
93}
94
95const TRACEABILITY_MATRIX_MD: &str = r#"# Traceability Matrix
96
97| Requirement | Kind | Satisfied By | Verified By | Status |
98|-------------|------|-------------|-------------|--------|
99{{#each rows}}
100| {{requirement}} | {{kind}} | {{#each satisfied_by}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} | {{#each verified_by}}{{this}}{{#unless @last}}, {{/unless}}{{/each}} | {{status}} |
101{{/each}}
102
103**Summary**: {{total_requirements}} requirements, {{satisfied_count}} satisfied, {{verified_count}} verified ({{coverage_pct}}% coverage)
104"#;
105
106const TRACEABILITY_MATRIX_HTML: &str = r#"<html><head><title>Traceability Matrix</title>
107<style>table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:8px;text-align:left}th{background:#f4f4f4}.gap{background:#fee}.ok{background:#efe}</style>
108</head><body>
109<h1>Traceability Matrix</h1>
110<table><thead><tr><th>Requirement</th><th>Kind</th><th>Satisfied By</th><th>Verified By</th><th>Status</th></tr></thead><tbody>
111{{#each rows}}
112<tr class="{{#if (eq status "gap")}}gap{{else}}ok{{/if}}"><td>{{requirement}}</td><td>{{kind}}</td><td>{{#each satisfied_by}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</td><td>{{#each verified_by}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}</td><td>{{status}}</td></tr>
113{{/each}}
114</tbody></table>
115<p><strong>Summary</strong>: {{total_requirements}} requirements, {{satisfied_count}} satisfied, {{verified_count}} verified ({{coverage_pct}}% coverage)</p>
116</body></html>"#;
117
118const TRACEABILITY_MATRIX_CSV: &str = r#"Requirement,Kind,Satisfied By,Verified By,Status
119{{#each rows}}
120{{requirement}},{{kind}},"{{#each satisfied_by}}{{this}}{{#unless @last}}; {{/unless}}{{/each}}","{{#each verified_by}}{{this}}{{#unless @last}}; {{/unless}}{{/each}}",{{status}}
121{{/each}}"#;
122
123const REQUIREMENTS_TABLE_MD: &str = r#"# Requirements Table
124
125| # | Requirement | Kind | File | Line | Doc | Members |
126|---|-------------|------|------|------|-----|---------|
127{{#each rows}}
128| {{@index}} | {{name}} | {{kind}} | {{file}} | {{line}} | {{doc}} | {{member_count}} |
129{{/each}}
130
131**Total**: {{total}} requirements
132"#;
133
134const REQUIREMENTS_TABLE_HTML: &str = r#"<html><head><title>Requirements Table</title>
135<style>table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:8px;text-align:left}th{background:#f4f4f4}</style>
136</head><body>
137<h1>Requirements Table</h1>
138<table><thead><tr><th>#</th><th>Requirement</th><th>Kind</th><th>File</th><th>Line</th><th>Doc</th><th>Members</th></tr></thead><tbody>
139{{#each rows}}
140<tr><td>{{@index}}</td><td>{{name}}</td><td>{{kind}}</td><td>{{file}}</td><td>{{line}}</td><td>{{doc}}</td><td>{{member_count}}</td></tr>
141{{/each}}
142</tbody></table>
143<p><strong>Total</strong>: {{total}} requirements</p>
144</body></html>"#;
145
146const REQUIREMENTS_TABLE_CSV: &str = r#"#,Requirement,Kind,File,Line,Doc,Members
147{{#each rows}}
148{{@index}},{{name}},{{kind}},{{file}},{{line}},"{{doc}}",{{member_count}}
149{{/each}}"#;
150
151const COMPLETENESS_REPORT_MD: &str = r#"# Model Completeness Report
152
153## Overview
154
155| Metric | Value |
156|--------|-------|
157| Files | {{files}} |
158| Elements | {{elements}} |
159| Relationships | {{relationships}} |
160| Completeness Score | {{completeness_score}} |
161| Total Findings | {{total_findings}} |
162
163## Check Results
164
165| Check | Findings |
166|-------|----------|
167{{#each checks}}
168| {{name}} | {{count}} |
169{{/each}}
170
171## Type Breakdown
172
173| Kind | Count |
174|------|-------|
175{{#each type_breakdown}}
176| {{kind}} | {{count}} |
177{{/each}}
178"#;
179
180const COMPLETENESS_REPORT_HTML: &str = r#"<html><head><title>Model Completeness Report</title>
181<style>table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:8px;text-align:left}th{background:#f4f4f4}h1{color:#333}</style>
182</head><body>
183<h1>Model Completeness Report</h1>
184<h2>Overview</h2>
185<table><tbody>
186<tr><td>Files</td><td>{{files}}</td></tr>
187<tr><td>Elements</td><td>{{elements}}</td></tr>
188<tr><td>Relationships</td><td>{{relationships}}</td></tr>
189<tr><td>Completeness Score</td><td>{{completeness_score}}</td></tr>
190<tr><td>Total Findings</td><td>{{total_findings}}</td></tr>
191</tbody></table>
192<h2>Check Results</h2>
193<table><thead><tr><th>Check</th><th>Findings</th></tr></thead><tbody>
194{{#each checks}}
195<tr><td>{{name}}</td><td>{{count}}</td></tr>
196{{/each}}
197</tbody></table>
198<h2>Type Breakdown</h2>
199<table><thead><tr><th>Kind</th><th>Count</th></tr></thead><tbody>
200{{#each type_breakdown}}
201<tr><td>{{kind}}</td><td>{{count}}</td></tr>
202{{/each}}
203</tbody></table>
204</body></html>"#;
205
206const COMPLETENESS_REPORT_CSV: &str = r#"Metric,Value
207Files,{{files}}
208Elements,{{elements}}
209Relationships,{{relationships}}
210Completeness Score,{{completeness_score}}
211Total Findings,{{total_findings}}
212
213Check,Findings
214{{#each checks}}
215{{name}},{{count}}
216{{/each}}
217
218Kind,Count
219{{#each type_breakdown}}
220{{kind}},{{count}}
221{{/each}}"#;
222
223fn get_template(builtin: BuiltinTemplate, format: RenderFormat) -> &'static str {
224    match (builtin, format) {
225        (BuiltinTemplate::TraceabilityMatrix, RenderFormat::Markdown) => TRACEABILITY_MATRIX_MD,
226        (BuiltinTemplate::TraceabilityMatrix, RenderFormat::Html) => TRACEABILITY_MATRIX_HTML,
227        (BuiltinTemplate::TraceabilityMatrix, RenderFormat::Csv) => TRACEABILITY_MATRIX_CSV,
228        (BuiltinTemplate::RequirementsTable, RenderFormat::Markdown) => REQUIREMENTS_TABLE_MD,
229        (BuiltinTemplate::RequirementsTable, RenderFormat::Html) => REQUIREMENTS_TABLE_HTML,
230        (BuiltinTemplate::RequirementsTable, RenderFormat::Csv) => REQUIREMENTS_TABLE_CSV,
231        (BuiltinTemplate::CompletenessReport, RenderFormat::Markdown) => COMPLETENESS_REPORT_MD,
232        (BuiltinTemplate::CompletenessReport, RenderFormat::Html) => COMPLETENESS_REPORT_HTML,
233        (BuiltinTemplate::CompletenessReport, RenderFormat::Csv) => COMPLETENESS_REPORT_CSV,
234    }
235}
236
237fn short_name(qualified: &str) -> &str {
238    qualified.rsplit("::").next().unwrap_or(qualified)
239}
240
241fn short_path(p: &Path) -> String {
242    p.file_name()
243        .and_then(|n| n.to_str())
244        .unwrap_or_default()
245        .to_string()
246}
247
248fn is_requirement(elem: &SysmlElement) -> bool {
249    elem.kind.to_lowercase().contains("requirement")
250}
251
252fn build_satisfy_map(rels: &[SysmlRelationship]) -> HashMap<String, Vec<String>> {
253    let mut map: HashMap<String, Vec<String>> = HashMap::new();
254    for rel in rels {
255        if rel.kind.eq_ignore_ascii_case("satisfy") {
256            map.entry(rel.target.to_lowercase())
257                .or_default()
258                .push(short_name(&rel.source).to_string());
259            let short = short_name(&rel.target).to_lowercase();
260            if short != rel.target.to_lowercase() {
261                map.entry(short)
262                    .or_default()
263                    .push(short_name(&rel.source).to_string());
264            }
265        }
266    }
267    map
268}
269
270fn build_verify_map(rels: &[SysmlRelationship]) -> HashMap<String, Vec<String>> {
271    let mut map: HashMap<String, Vec<String>> = HashMap::new();
272    for rel in rels {
273        if rel.kind.eq_ignore_ascii_case("verify") {
274            map.entry(rel.target.to_lowercase())
275                .or_default()
276                .push(short_name(&rel.source).to_string());
277            let short = short_name(&rel.target).to_lowercase();
278            if short != rel.target.to_lowercase() {
279                map.entry(short)
280                    .or_default()
281                    .push(short_name(&rel.source).to_string());
282            }
283        }
284    }
285    map
286}
287
288fn build_traceability_context(graph: &SysmlGraph) -> TraceabilityContext {
289    let satisfy_map = build_satisfy_map(graph.relationships());
290    let verify_map = build_verify_map(graph.relationships());
291
292    let mut rows = Vec::new();
293    let mut satisfied_count = 0;
294    let mut verified_count = 0;
295
296    for elem in graph.elements() {
297        if !is_requirement(elem) {
298            continue;
299        }
300
301        let qname_lower = elem.qualified_name.to_lowercase();
302        let short_lower = short_name(&elem.qualified_name).to_lowercase();
303
304        let mut satisfied_by: Vec<String> = Vec::new();
305        if let Some(v) = satisfy_map.get(&qname_lower) {
306            satisfied_by.extend(v.iter().cloned());
307        }
308        if qname_lower != short_lower {
309            if let Some(v) = satisfy_map.get(&short_lower) {
310                for s in v {
311                    if !satisfied_by.contains(s) {
312                        satisfied_by.push(s.clone());
313                    }
314                }
315            }
316        }
317
318        let mut verified_by: Vec<String> = Vec::new();
319        if let Some(v) = verify_map.get(&qname_lower) {
320            verified_by.extend(v.iter().cloned());
321        }
322        if qname_lower != short_lower {
323            if let Some(v) = verify_map.get(&short_lower) {
324                for s in v {
325                    if !verified_by.contains(s) {
326                        verified_by.push(s.clone());
327                    }
328                }
329            }
330        }
331
332        let has_satisfy = !satisfied_by.is_empty();
333        let has_verify = !verified_by.is_empty();
334
335        if has_satisfy {
336            satisfied_count += 1;
337        }
338        if has_verify {
339            verified_count += 1;
340        }
341
342        let status = if has_satisfy && has_verify {
343            "complete"
344        } else if has_satisfy || has_verify {
345            "partial"
346        } else {
347            "gap"
348        };
349
350        rows.push(TraceabilityRow {
351            requirement: short_name(&elem.qualified_name).to_string(),
352            kind: elem.kind.clone(),
353            file: short_path(&elem.file_path),
354            satisfied_by,
355            verified_by,
356            status: status.to_string(),
357        });
358    }
359
360    let total = rows.len();
361    let coverage_pct = if total > 0 {
362        format!(
363            "{:.0}",
364            (satisfied_count.min(total) + verified_count.min(total)) as f64 / (2 * total) as f64
365                * 100.0
366        )
367    } else {
368        "100".to_string()
369    };
370
371    TraceabilityContext {
372        rows,
373        total_requirements: total,
374        satisfied_count,
375        verified_count,
376        coverage_pct,
377    }
378}
379
380fn build_requirements_context(graph: &SysmlGraph) -> RequirementsContext {
381    let mut rows: Vec<RequirementRow> = graph
382        .elements()
383        .iter()
384        .filter(|e| is_requirement(e))
385        .map(|e| RequirementRow {
386            name: short_name(&e.qualified_name).to_string(),
387            kind: e.kind.clone(),
388            file: short_path(&e.file_path),
389            line: e.span.start_line,
390            doc: e.doc.as_deref().unwrap_or("").to_string(),
391            member_count: e.members.len(),
392        })
393        .collect();
394
395    rows.sort_by(|a, b| a.name.cmp(&b.name));
396    let total = rows.len();
397    RequirementsContext { rows, total }
398}
399
400fn build_completeness_context(graph: &SysmlGraph) -> CompletenessContext {
401    let all_checks = [
402        (CheckType::OrphanRequirements, "Orphan Requirements"),
403        (CheckType::UnverifiedRequirements, "Unverified Requirements"),
404        (CheckType::MissingVerification, "Missing Verification"),
405        (CheckType::UnconnectedPorts, "Unconnected Ports"),
406        (CheckType::DanglingReferences, "Dangling References"),
407    ];
408
409    let mut checks = Vec::new();
410    let mut total_findings = 0;
411    let mut orphan_count = 0;
412    let mut unverified_count = 0;
413
414    for (ct, name) in &all_checks {
415        let findings = graph.check(ct.clone());
416        let count = findings.len();
417        total_findings += count;
418        match ct {
419            CheckType::OrphanRequirements => orphan_count = count,
420            CheckType::UnverifiedRequirements => unverified_count = count,
421            _ => {}
422        }
423        checks.push(CheckSummary {
424            name: name.to_string(),
425            count,
426        });
427    }
428
429    let total_requirements = graph
430        .elements()
431        .iter()
432        .filter(|e| is_requirement(e))
433        .count();
434
435    let completeness_score = if total_requirements > 0 {
436        let gap = (orphan_count + unverified_count).min(total_requirements);
437        1.0 - (gap as f64 / total_requirements as f64)
438    } else {
439        1.0
440    };
441
442    let mut type_map: HashMap<&str, usize> = HashMap::new();
443    for elem in graph.elements() {
444        *type_map.entry(&elem.kind).or_insert(0) += 1;
445    }
446    let mut type_breakdown: Vec<TypeCount> = type_map
447        .into_iter()
448        .map(|(kind, count)| TypeCount {
449            kind: kind.to_string(),
450            count,
451        })
452        .collect();
453    type_breakdown.sort_by(|a, b| b.count.cmp(&a.count));
454
455    CompletenessContext {
456        files: graph.file_count(),
457        elements: graph.element_count(),
458        relationships: graph.relationship_count(),
459        completeness_score: format!("{:.3}", completeness_score),
460        checks,
461        total_findings,
462        type_breakdown,
463    }
464}
465
466pub fn render_builtin(
467    graph: &SysmlGraph,
468    template: BuiltinTemplate,
469    format: RenderFormat,
470) -> Result<String, RenderError> {
471    let tmpl_str = get_template(template, format);
472    let mut hbs = Handlebars::new();
473    hbs.set_strict_mode(false);
474    hbs.register_template_string("t", tmpl_str)
475        .map_err(|e| RenderError::TemplateParse(Box::new(e)))?;
476
477    match template {
478        BuiltinTemplate::TraceabilityMatrix => {
479            let ctx = build_traceability_context(graph);
480            Ok(hbs.render("t", &ctx)?)
481        }
482        BuiltinTemplate::RequirementsTable => {
483            let ctx = build_requirements_context(graph);
484            Ok(hbs.render("t", &ctx)?)
485        }
486        BuiltinTemplate::CompletenessReport => {
487            let ctx = build_completeness_context(graph);
488            Ok(hbs.render("t", &ctx)?)
489        }
490    }
491}
492
493pub fn render_custom(graph: &SysmlGraph, template_path: &Path) -> Result<String, RenderError> {
494    let tmpl_str = std::fs::read_to_string(template_path)?;
495    let mut hbs = Handlebars::new();
496    hbs.set_strict_mode(false);
497    hbs.register_template_string("t", &tmpl_str)
498        .map_err(|e| RenderError::TemplateParse(Box::new(e)))?;
499
500    let ctx = build_full_context(graph);
501    Ok(hbs.render("t", &ctx)?)
502}
503
504#[derive(Serialize)]
505struct FullContext {
506    files: usize,
507    elements: usize,
508    relationships: usize,
509    traceability: TraceabilityContext,
510    requirements: RequirementsContext,
511    completeness: CompletenessContext,
512}
513
514fn build_full_context(graph: &SysmlGraph) -> FullContext {
515    FullContext {
516        files: graph.file_count(),
517        elements: graph.element_count(),
518        relationships: graph.relationship_count(),
519        traceability: build_traceability_context(graph),
520        requirements: build_requirements_context(graph),
521        completeness: build_completeness_context(graph),
522    }
523}
524
525pub fn parse_builtin_template(name: &str) -> Option<BuiltinTemplate> {
526    match name.to_lowercase().replace('-', "_").as_str() {
527        "traceability_matrix" | "traceability" => Some(BuiltinTemplate::TraceabilityMatrix),
528        "requirements_table" | "requirements" => Some(BuiltinTemplate::RequirementsTable),
529        "completeness_report" | "completeness" => Some(BuiltinTemplate::CompletenessReport),
530        _ => None,
531    }
532}
533
534pub fn parse_render_format(name: &str) -> Option<RenderFormat> {
535    match name.to_lowercase().as_str() {
536        "markdown" | "md" => Some(RenderFormat::Markdown),
537        "html" => Some(RenderFormat::Html),
538        "csv" => Some(RenderFormat::Csv),
539        _ => None,
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use crate::parser::SysmlParser;
547    use nomograph_core::traits::Parser as NomographParser;
548    use std::path::PathBuf;
549
550    fn fixture_dir() -> PathBuf {
551        Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/eve")
552    }
553
554    fn walkdir(dir: PathBuf) -> Vec<PathBuf> {
555        let mut files = Vec::new();
556        if let Ok(entries) = std::fs::read_dir(&dir) {
557            for entry in entries.flatten() {
558                let path = entry.path();
559                if path.is_dir() {
560                    files.extend(walkdir(path));
561                } else {
562                    files.push(path);
563                }
564            }
565        }
566        files
567    }
568
569    fn build_eve_graph() -> SysmlGraph {
570        let parser = SysmlParser::new();
571        let mut results = Vec::new();
572        for entry in walkdir(fixture_dir()) {
573            if entry.extension().and_then(|e| e.to_str()) == Some("sysml") {
574                let source = std::fs::read_to_string(&entry).expect("read fixture");
575                let result = parser.parse(&source, &entry).expect("parse fixture");
576                results.push(result);
577            }
578        }
579        let mut graph = SysmlGraph::new();
580        graph.index(results).expect("index");
581        graph
582    }
583
584    #[test]
585    fn test_render_traceability_matrix_md() {
586        let graph = build_eve_graph();
587        let output = render_builtin(
588            &graph,
589            BuiltinTemplate::TraceabilityMatrix,
590            RenderFormat::Markdown,
591        )
592        .expect("render should succeed");
593        assert!(output.contains("Traceability Matrix"));
594        assert!(output.contains("Requirement"));
595        assert!(output.contains("Summary"));
596    }
597
598    #[test]
599    fn test_render_traceability_matrix_html() {
600        let graph = build_eve_graph();
601        let output = render_builtin(
602            &graph,
603            BuiltinTemplate::TraceabilityMatrix,
604            RenderFormat::Html,
605        )
606        .expect("render should succeed");
607        assert!(output.contains("<html>"));
608        assert!(output.contains("Traceability Matrix"));
609        assert!(output.contains("<table>"));
610    }
611
612    #[test]
613    fn test_render_traceability_matrix_csv() {
614        let graph = build_eve_graph();
615        let output = render_builtin(
616            &graph,
617            BuiltinTemplate::TraceabilityMatrix,
618            RenderFormat::Csv,
619        )
620        .expect("render should succeed");
621        assert!(output.contains("Requirement,Kind,Satisfied By,Verified By,Status"));
622    }
623
624    #[test]
625    fn test_render_requirements_table_md() {
626        let graph = build_eve_graph();
627        let output = render_builtin(
628            &graph,
629            BuiltinTemplate::RequirementsTable,
630            RenderFormat::Markdown,
631        )
632        .expect("render should succeed");
633        assert!(output.contains("Requirements Table"));
634        assert!(output.contains("Total"));
635    }
636
637    #[test]
638    fn test_render_completeness_report_md() {
639        let graph = build_eve_graph();
640        let output = render_builtin(
641            &graph,
642            BuiltinTemplate::CompletenessReport,
643            RenderFormat::Markdown,
644        )
645        .expect("render should succeed");
646        assert!(output.contains("Model Completeness Report"));
647        assert!(output.contains("Files"));
648        assert!(output.contains("Completeness Score"));
649        assert!(output.contains("Orphan Requirements"));
650    }
651
652    #[test]
653    fn test_render_completeness_report_html() {
654        let graph = build_eve_graph();
655        let output = render_builtin(
656            &graph,
657            BuiltinTemplate::CompletenessReport,
658            RenderFormat::Html,
659        )
660        .expect("render should succeed");
661        assert!(output.contains("<html>"));
662        assert!(output.contains("Model Completeness Report"));
663    }
664
665    #[test]
666    fn test_parse_builtin_template() {
667        assert_eq!(
668            parse_builtin_template("traceability-matrix"),
669            Some(BuiltinTemplate::TraceabilityMatrix)
670        );
671        assert_eq!(
672            parse_builtin_template("requirements-table"),
673            Some(BuiltinTemplate::RequirementsTable)
674        );
675        assert_eq!(
676            parse_builtin_template("completeness-report"),
677            Some(BuiltinTemplate::CompletenessReport)
678        );
679        assert_eq!(
680            parse_builtin_template("traceability"),
681            Some(BuiltinTemplate::TraceabilityMatrix)
682        );
683        assert!(parse_builtin_template("unknown").is_none());
684    }
685
686    #[test]
687    fn test_parse_render_format() {
688        assert_eq!(
689            parse_render_format("markdown"),
690            Some(RenderFormat::Markdown)
691        );
692        assert_eq!(parse_render_format("md"), Some(RenderFormat::Markdown));
693        assert_eq!(parse_render_format("html"), Some(RenderFormat::Html));
694        assert_eq!(parse_render_format("csv"), Some(RenderFormat::Csv));
695        assert!(parse_render_format("xml").is_none());
696    }
697}