Skip to main content

merman_analysis/
options_json.rs

1use crate::{
2    AnalysisOptions, AnalysisRuleConfig, AnalysisRuleProfile, DiagnosticSeverity,
3    configurable_rule_descriptor,
4};
5use chrono::NaiveDate;
6use merman_core::MermaidConfig;
7use serde::{Deserialize, Serialize};
8use serde_json::Map;
9use serde_json::Value;
10use std::error::Error as StdError;
11use std::fmt::{Display, Formatter};
12
13#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
14pub struct AnalysisOptionsJson {
15    pub fixed_today: Option<String>,
16    pub fixed_local_offset_minutes: Option<i32>,
17    pub site_config: Option<Value>,
18    pub parse: Option<ParseOptionsJson>,
19    pub resources: Option<ResourceOptionsJson>,
20    pub lint: Option<LintOptionsJson>,
21}
22
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct ParseOptionsJson {
25    pub suppress_errors: Option<bool>,
26}
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ResourceOptionsJson {
30    pub profile: Option<String>,
31    pub max_source_bytes: Option<usize>,
32    pub max_svg_bytes: Option<usize>,
33    pub max_flowchart_nodes: Option<usize>,
34    pub max_flowchart_edges: Option<usize>,
35    pub max_flowchart_subgraphs: Option<usize>,
36    pub max_class_nodes: Option<usize>,
37    pub max_class_edges: Option<usize>,
38    pub max_class_namespaces: Option<usize>,
39    pub max_label_bytes: Option<usize>,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43pub struct LintOptionsJson {
44    pub profile: Option<String>,
45    #[serde(default)]
46    pub enable_rules: Vec<String>,
47    #[serde(default)]
48    pub disable_rules: Vec<String>,
49    #[serde(default)]
50    pub rule_severities: Vec<LintRuleSeverityOverrideJson>,
51}
52
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54pub struct LintRuleSeverityOverrideJson {
55    pub rule_id: String,
56    pub severity: String,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct AnalysisOptionsJsonError {
61    message: String,
62}
63
64impl AnalysisOptionsJsonError {
65    fn new(message: impl Into<String>) -> Self {
66        Self {
67            message: message.into(),
68        }
69    }
70}
71
72impl Display for AnalysisOptionsJsonError {
73    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
74        f.write_str(&self.message)
75    }
76}
77
78impl StdError for AnalysisOptionsJsonError {}
79
80pub fn analysis_options_from_json_value(
81    value: &Value,
82) -> Result<AnalysisOptions, AnalysisOptionsJsonError> {
83    analysis_options_json_from_json_value(value)?.to_analysis_options()
84}
85
86pub fn analysis_options_json_from_json_value(
87    value: &Value,
88) -> Result<AnalysisOptionsJson, AnalysisOptionsJsonError> {
89    let options_value = analysis_options_root_value(value)?;
90    serde_json::from_value(options_value.clone()).map_err(|err| {
91        AnalysisOptionsJsonError::new(format!("invalid analysis options JSON: {err}"))
92    })
93}
94
95fn analysis_options_root_value(value: &Value) -> Result<&Value, AnalysisOptionsJsonError> {
96    let Value::Object(map) = value else {
97        return Ok(value);
98    };
99
100    if analysis_option_keys_present(map) {
101        if ["merman", "analysis"]
102            .iter()
103            .any(|key| map.get(*key).is_some_and(Value::is_object))
104        {
105            return Err(AnalysisOptionsJsonError::new(
106                "options JSON must not mix top-level analysis options with `analysis` or `merman` wrappers",
107            ));
108        }
109        return Ok(value);
110    }
111
112    let mut wrapped_keys = ["merman", "analysis"].into_iter().filter(|key| {
113        map.get(*key)
114            .and_then(Value::as_object)
115            .is_some_and(analysis_option_keys_present)
116    });
117    if let Some(key) = wrapped_keys.next() {
118        if wrapped_keys.next().is_some() {
119            return Err(AnalysisOptionsJsonError::new(
120                "options JSON must not contain both `merman` and `analysis` wrappers with analysis options",
121            ));
122        }
123        return Ok(map
124            .get(key)
125            .expect("checked key existence and object shape"));
126    }
127
128    Ok(value)
129}
130
131fn analysis_option_keys_present(map: &Map<String, Value>) -> bool {
132    [
133        "fixed_today",
134        "fixed_local_offset_minutes",
135        "site_config",
136        "parse",
137        "resources",
138        "lint",
139    ]
140    .iter()
141    .any(|key| map.contains_key(*key))
142}
143
144impl AnalysisOptionsJson {
145    pub fn to_analysis_options(&self) -> Result<AnalysisOptions, AnalysisOptionsJsonError> {
146        let mut analysis = AnalysisOptions::default()
147            .with_parse_options(self.parse_options())
148            .with_max_source_bytes(self.max_source_bytes()?);
149
150        if let Some(site_config) = self.site_config()? {
151            analysis = analysis.with_site_config(site_config);
152        }
153        if let Some(today) = self.fixed_today()? {
154            analysis = analysis.with_fixed_today(Some(today));
155        }
156        if let Some(offset_minutes) = self.fixed_local_offset_minutes()? {
157            analysis = analysis.with_fixed_local_offset_minutes(Some(offset_minutes));
158        }
159
160        analysis = analysis.with_rule_config(self.rule_config()?);
161        Ok(analysis)
162    }
163
164    pub fn parse_options(&self) -> merman_core::ParseOptions {
165        if self
166            .parse
167            .as_ref()
168            .and_then(|parse| parse.suppress_errors)
169            .unwrap_or(false)
170        {
171            merman_core::ParseOptions::lenient()
172        } else {
173            merman_core::ParseOptions::strict()
174        }
175    }
176
177    pub fn max_source_bytes(&self) -> Result<Option<usize>, AnalysisOptionsJsonError> {
178        let max_source_bytes = self
179            .resources
180            .as_ref()
181            .and_then(|resources| resources.max_source_bytes)
182            .filter(|max_source_bytes| *max_source_bytes > 0);
183        Ok(max_source_bytes)
184    }
185
186    pub fn rule_config(&self) -> Result<AnalysisRuleConfig, AnalysisOptionsJsonError> {
187        let Some(lint) = self.lint.as_ref() else {
188            return Ok(AnalysisRuleConfig::default());
189        };
190
191        let mut config = AnalysisRuleConfig::default();
192        if let Some(profile) = lint.profile.as_deref() {
193            config.set_profile(parse_lint_profile(profile)?);
194        }
195
196        for rule_id in &lint.enable_rules {
197            if rule_id.trim().is_empty() {
198                return Err(AnalysisOptionsJsonError::new(
199                    "lint.enable_rules entries must not be empty",
200                ));
201            }
202            validate_configurable_rule_id(rule_id, "lint.enable_rules")?;
203            config.enable_rule(rule_id.clone());
204        }
205
206        for rule_id in &lint.disable_rules {
207            if rule_id.trim().is_empty() {
208                return Err(AnalysisOptionsJsonError::new(
209                    "lint.disable_rules entries must not be empty",
210                ));
211            }
212            validate_configurable_rule_id(rule_id, "lint.disable_rules")?;
213            config.disable_rule(rule_id.clone());
214        }
215
216        for override_ in &lint.rule_severities {
217            if override_.rule_id.trim().is_empty() {
218                return Err(AnalysisOptionsJsonError::new(
219                    "lint.rule_severities.rule_id must not be empty",
220                ));
221            }
222            validate_configurable_rule_id(&override_.rule_id, "lint.rule_severities.rule_id")?;
223            config.set_rule_severity(
224                override_.rule_id.clone(),
225                parse_lint_severity(&override_.severity)?,
226            );
227        }
228
229        Ok(config)
230    }
231
232    pub fn fixed_today(&self) -> Result<Option<NaiveDate>, AnalysisOptionsJsonError> {
233        let Some(today) = self.fixed_today.as_deref() else {
234            return Ok(None);
235        };
236        NaiveDate::parse_from_str(today, "%Y-%m-%d")
237            .map(Some)
238            .map_err(|_| {
239                AnalysisOptionsJsonError::new("fixed_today must be a date in YYYY-MM-DD format")
240            })
241    }
242
243    pub fn fixed_local_offset_minutes(&self) -> Result<Option<i32>, AnalysisOptionsJsonError> {
244        let Some(offset_minutes) = self.fixed_local_offset_minutes else {
245            return Ok(None);
246        };
247        let valid = offset_minutes
248            .checked_mul(60)
249            .and_then(chrono::FixedOffset::east_opt)
250            .is_some();
251        if !valid {
252            return Err(AnalysisOptionsJsonError::new(
253                "fixed_local_offset_minutes must be between -1439 and 1439",
254            ));
255        }
256        Ok(Some(offset_minutes))
257    }
258
259    pub fn site_config(&self) -> Result<Option<MermaidConfig>, AnalysisOptionsJsonError> {
260        let Some(site_config) = self.site_config.as_ref() else {
261            return Ok(None);
262        };
263        if !site_config.is_object() {
264            return Err(AnalysisOptionsJsonError::new(
265                "site_config must be a JSON object",
266            ));
267        }
268        Ok(Some(MermaidConfig::from_value(site_config.clone())))
269    }
270}
271
272fn parse_lint_profile(value: &str) -> Result<AnalysisRuleProfile, AnalysisOptionsJsonError> {
273    match value.trim().to_ascii_lowercase().as_str() {
274        "core" => Ok(AnalysisRuleProfile::Core),
275        "recommended" => Ok(AnalysisRuleProfile::Recommended),
276        "strict" => Ok(AnalysisRuleProfile::Strict),
277        _ => Err(AnalysisOptionsJsonError::new(
278            "lint.profile must be core, recommended, or strict",
279        )),
280    }
281}
282
283fn parse_lint_severity(value: &str) -> Result<DiagnosticSeverity, AnalysisOptionsJsonError> {
284    match value.trim().to_ascii_lowercase().as_str() {
285        "error" => Ok(DiagnosticSeverity::Error),
286        "warning" | "warn" => Ok(DiagnosticSeverity::Warning),
287        "info" => Ok(DiagnosticSeverity::Info),
288        "hint" => Ok(DiagnosticSeverity::Hint),
289        _ => Err(AnalysisOptionsJsonError::new(
290            "lint.rule_severities.severity must be error, warning, info, or hint",
291        )),
292    }
293}
294
295fn validate_configurable_rule_id(
296    rule_id: &str,
297    field: &str,
298) -> Result<(), AnalysisOptionsJsonError> {
299    if configurable_rule_descriptor(rule_id).is_none() {
300        return Err(AnalysisOptionsJsonError::new(format!(
301            "{field} entry `{rule_id}` must reference a configurable analysis rule id",
302        )));
303    }
304    Ok(())
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::{rule_descriptors, rules::RESOURCE_LIMIT_RULE_ID};
311
312    #[test]
313    fn shared_analysis_options_json_honors_lint_configuration() {
314        let options = AnalysisOptionsJson {
315            lint: Some(LintOptionsJson {
316                disable_rules: vec!["merman.git_graph.duplicate_commit_id".to_string()],
317                rule_severities: vec![LintRuleSeverityOverrideJson {
318                    rule_id: "merman.authoring.config.prefer_init_directive".to_string(),
319                    severity: "hint".to_string(),
320                }],
321                ..Default::default()
322            }),
323            ..Default::default()
324        };
325        let analysis = options.to_analysis_options().unwrap();
326        let descriptors = rule_descriptors();
327        let duplicate_commit = descriptors
328            .iter()
329            .find(|descriptor| descriptor.id == "merman.git_graph.duplicate_commit_id")
330            .unwrap();
331        let prefer_init = descriptors
332            .iter()
333            .find(|descriptor| descriptor.id == "merman.authoring.config.prefer_init_directive")
334            .unwrap();
335        let prefer_frontmatter = descriptors
336            .iter()
337            .find(|descriptor| descriptor.id == "merman.authoring.config.prefer_frontmatter_config")
338            .unwrap();
339
340        assert_eq!(analysis.rule_config.profile(), AnalysisRuleProfile::Core);
341        assert!(!analysis.rule_config.is_rule_enabled(*duplicate_commit));
342        assert!(!analysis.rule_config.is_rule_enabled(*prefer_init));
343        assert!(!analysis.rule_config.is_rule_enabled(*prefer_frontmatter));
344        assert_eq!(
345            analysis.rule_config.severity_for(*prefer_init),
346            DiagnosticSeverity::Hint
347        );
348    }
349
350    #[test]
351    fn shared_analysis_options_json_accepts_lint_profiles_and_explicit_enablement() {
352        let wrapped = serde_json::json!({
353            "lint": {
354                "profile": "recommended"
355            }
356        });
357        let analysis = analysis_options_from_json_value(&wrapped).unwrap();
358        let prefer_init = rule_descriptors()
359            .iter()
360            .find(|descriptor| descriptor.id == "merman.authoring.config.prefer_init_directive")
361            .unwrap();
362        let prefer_frontmatter = rule_descriptors()
363            .iter()
364            .find(|descriptor| descriptor.id == "merman.authoring.config.prefer_frontmatter_config")
365            .unwrap();
366
367        assert_eq!(
368            analysis.rule_config.profile(),
369            AnalysisRuleProfile::Recommended
370        );
371        assert!(analysis.rule_config.is_rule_enabled(*prefer_init));
372        assert!(analysis.rule_config.is_rule_enabled(*prefer_frontmatter));
373
374        let wrapped = serde_json::json!({
375            "lint": {
376                "enable_rules": [
377                    "merman.authoring.config.prefer_init_directive",
378                    "merman.authoring.config.prefer_frontmatter_config"
379                ]
380            }
381        });
382        let analysis = analysis_options_from_json_value(&wrapped).unwrap();
383
384        assert_eq!(analysis.rule_config.profile(), AnalysisRuleProfile::Core);
385        assert!(analysis.rule_config.is_rule_enabled(*prefer_init));
386        assert!(analysis.rule_config.is_rule_enabled(*prefer_frontmatter));
387    }
388
389    #[test]
390    fn shared_analysis_options_json_rejects_unknown_lint_rule_ids() {
391        let options = AnalysisOptionsJson {
392            lint: Some(LintOptionsJson {
393                disable_rules: vec!["merman.unknown.rule".to_string()],
394                ..Default::default()
395            }),
396            ..Default::default()
397        };
398
399        let err = options.to_analysis_options().unwrap_err();
400        assert!(
401            err.to_string().contains("configurable analysis rule id"),
402            "unexpected error: {err}"
403        );
404    }
405
406    #[test]
407    fn shared_analysis_options_json_rejects_external_lint_rule_ids() {
408        let cases = [
409            serde_json::json!({
410                "lint": {
411                    "enable_rules": ["require-direction"]
412                }
413            }),
414            serde_json::json!({
415                "lint": {
416                    "disable_rules": ["mermaid-lint/no-empty-labels"]
417                }
418            }),
419            serde_json::json!({
420                "lint": {
421                    "rule_severities": [
422                        {
423                            "rule_id": "duplicate-ids",
424                            "severity": "warning"
425                        }
426                    ]
427                }
428            }),
429        ];
430
431        for options in cases {
432            let err = analysis_options_from_json_value(&options).unwrap_err();
433            assert!(
434                err.to_string().contains("configurable analysis rule id"),
435                "unexpected error for {options}: {err}"
436            );
437        }
438    }
439
440    #[test]
441    fn shared_analysis_options_json_rejects_internal_lint_rule_ids() {
442        let wrapped = serde_json::json!({
443            "lint": {
444                "rule_severities": [
445                    {
446                        "rule_id": "merman.internal.panic",
447                        "severity": "warning"
448                    }
449                ]
450            }
451        });
452
453        let err = analysis_options_from_json_value(&wrapped).unwrap_err();
454        assert!(
455            err.to_string().contains("configurable analysis rule id"),
456            "unexpected error: {err}"
457        );
458    }
459
460    #[test]
461    fn shared_analysis_options_json_rejects_resource_lint_rule_ids() {
462        let cases = [
463            serde_json::json!({
464                "lint": {
465                    "enable_rules": [RESOURCE_LIMIT_RULE_ID]
466                }
467            }),
468            serde_json::json!({
469                "lint": {
470                    "disable_rules": [RESOURCE_LIMIT_RULE_ID]
471                }
472            }),
473            serde_json::json!({
474                "lint": {
475                    "rule_severities": [
476                        {
477                            "rule_id": RESOURCE_LIMIT_RULE_ID,
478                            "severity": "hint"
479                        }
480                    ]
481                }
482            }),
483        ];
484
485        for options in cases {
486            let err = analysis_options_from_json_value(&options).unwrap_err();
487            assert!(
488                err.to_string().contains("configurable analysis rule id"),
489                "unexpected error for {options}: {err}"
490            );
491        }
492    }
493
494    #[test]
495    fn shared_analysis_options_json_accepts_namespaced_wrappers() {
496        let wrapped = serde_json::json!({
497            "merman": {
498                "lint": {
499                    "disable_rules": ["merman.git_graph.duplicate_commit_id"]
500                }
501            }
502        });
503        let analysis = analysis_options_from_json_value(&wrapped).unwrap();
504        let duplicate_commit = rule_descriptors()
505            .iter()
506            .find(|descriptor| descriptor.id == "merman.git_graph.duplicate_commit_id")
507            .unwrap();
508
509        assert!(!analysis.rule_config.is_rule_enabled(*duplicate_commit));
510
511        let wrapped = serde_json::json!({
512            "analysis": {
513                "lint": {
514                    "profile": "recommended",
515                    "rule_severities": [
516                        {
517                            "rule_id": "merman.authoring.config.prefer_init_directive",
518                            "severity": "warning"
519                        }
520                    ]
521                }
522            }
523        });
524        let analysis = analysis_options_from_json_value(&wrapped).unwrap();
525        let prefer_init = rule_descriptors()
526            .iter()
527            .find(|descriptor| descriptor.id == "merman.authoring.config.prefer_init_directive")
528            .unwrap();
529        let prefer_frontmatter = rule_descriptors()
530            .iter()
531            .find(|descriptor| descriptor.id == "merman.authoring.config.prefer_frontmatter_config")
532            .unwrap();
533
534        assert_eq!(
535            analysis.rule_config.profile(),
536            AnalysisRuleProfile::Recommended
537        );
538        assert_eq!(
539            analysis.rule_config.severity_for(*prefer_init),
540            DiagnosticSeverity::Warning
541        );
542        assert!(analysis.rule_config.is_rule_enabled(*prefer_init));
543        assert!(analysis.rule_config.is_rule_enabled(*prefer_frontmatter));
544    }
545
546    #[test]
547    fn shared_analysis_options_json_treats_zero_source_limit_as_default() {
548        let zero = serde_json::json!({
549            "analysis": {
550                "resources": {
551                    "max_source_bytes": 0
552                }
553            }
554        });
555        let positive = serde_json::json!({
556            "analysis": {
557                "resources": {
558                    "max_source_bytes": 1024
559                }
560            }
561        });
562
563        assert_eq!(
564            analysis_options_from_json_value(&zero)
565                .unwrap()
566                .max_source_bytes,
567            None
568        );
569        assert_eq!(
570            analysis_options_from_json_value(&positive)
571                .unwrap()
572                .max_source_bytes,
573            Some(1024)
574        );
575    }
576
577    #[test]
578    fn shared_analysis_options_json_rejects_two_namespaced_analysis_wrappers() {
579        let mixed = serde_json::json!({
580            "merman": {
581                "lint": {
582                    "profile": "recommended"
583                }
584            },
585            "analysis": {
586                "resources": {
587                    "max_source_bytes": 1024
588                }
589            }
590        });
591
592        let err = analysis_options_from_json_value(&mixed).unwrap_err();
593
594        assert!(
595            err.to_string()
596                .contains("must not contain both `merman` and `analysis` wrappers"),
597            "unexpected error: {err}"
598        );
599    }
600
601    #[test]
602    fn shared_analysis_options_json_rejects_mixed_direct_and_namespaced_options() {
603        let mixed = serde_json::json!({
604            "resources": {
605                "max_source_bytes": 1024
606            },
607            "analysis": {
608                "lint": {
609                    "profile": "recommended"
610                }
611            }
612        });
613
614        let err = analysis_options_from_json_value(&mixed).unwrap_err();
615
616        assert!(
617            err.to_string()
618                .contains("must not mix top-level analysis options with `analysis` or `merman`"),
619            "unexpected error: {err}"
620        );
621    }
622}