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}