1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::lint::{LintConfigSource, LintDefinition, LintLevel, LintPhase, LintSuite};
8use crate::lints::{
9 CoverageLint, JsonSchemaDialectCompatLint, JsonSchemaKeywordCompatLint,
10 MaxStructuredContentBytesLint, MaxToolsLint, McpSchemaMinVersionLint,
11 MissingStructuredContentLint, NoCrashLint, OutputSchemaCompileLint,
12};
13use crate::CoverageRule;
14
15const DEFAULT_TOOLTEST_TOML: &str = include_str!("default_tooltest.toml");
16
17#[derive(Debug, Deserialize)]
18#[serde(deny_unknown_fields)]
19struct LintConfigFile {
20 #[serde(default)]
21 version: Option<u32>,
22 lints: Vec<LintConfigEntry>,
23}
24
25#[derive(Debug, Deserialize)]
26#[serde(deny_unknown_fields)]
27struct LintConfigEntry {
28 id: String,
29 level: LintLevel,
30 #[serde(default)]
31 params: Option<toml::Value>,
32}
33
34#[derive(Debug, Deserialize, Serialize)]
35#[serde(deny_unknown_fields)]
36struct MaxToolsParams {
37 max: usize,
38}
39
40#[derive(Debug, Deserialize, Serialize)]
41#[serde(deny_unknown_fields)]
42struct McpSchemaMinVersionParams {
43 min_version: String,
44}
45
46#[derive(Debug, Deserialize, Serialize)]
47#[serde(deny_unknown_fields)]
48struct JsonSchemaDialectCompatParams {
49 allowlist: Vec<String>,
50}
51
52#[derive(Debug, Deserialize, Serialize)]
53#[serde(deny_unknown_fields)]
54struct MaxStructuredContentBytesParams {
55 max_bytes: usize,
56}
57
58#[derive(Debug, Deserialize, Serialize, Default)]
59#[serde(deny_unknown_fields)]
60struct CoverageParams {
61 #[serde(default)]
62 rules: Vec<CoverageRule>,
63}
64
65pub fn default_tooltest_toml() -> &'static str {
66 DEFAULT_TOOLTEST_TOML
67}
68
69pub fn load_lint_suite() -> Result<LintSuite, String> {
70 load_lint_suite_with_env(std::env::current_dir(), home_config_path())
71}
72
73pub(crate) fn load_lint_suite_from(
74 start_dir: &Path,
75 home_config: Option<&Path>,
76) -> Result<LintSuite, String> {
77 if let Some(path) = find_repo_config(start_dir) {
78 return load_lint_suite_from_path(&path)
79 .map(|suite| suite.with_source(LintConfigSource::Repo));
80 }
81 if let Some(path) = home_config.filter(|path| path.exists()) {
82 return load_lint_suite_from_path(path)
83 .map(|suite| suite.with_source(LintConfigSource::Home));
84 }
85 parse_lint_suite(DEFAULT_TOOLTEST_TOML)
86 .map(|suite| suite.with_source(LintConfigSource::Default))
87}
88
89fn load_lint_suite_with_env(
90 cwd: Result<PathBuf, std::io::Error>,
91 home_config: Option<PathBuf>,
92) -> Result<LintSuite, String> {
93 let cwd = cwd.map_err(|error| format!("failed to read cwd: {error}"))?;
94 load_lint_suite_from(&cwd, home_config.as_deref())
95}
96
97fn load_lint_suite_from_path(path: &Path) -> Result<LintSuite, String> {
98 let contents = fs::read_to_string(path)
99 .map_err(|error| format!("failed to read lint config '{}': {error}", path.display()))?;
100 parse_lint_suite(&contents)
101 .map_err(|error| format!("invalid lint config '{}': {error}", path.display()))
102}
103
104fn parse_lint_suite(contents: &str) -> Result<LintSuite, String> {
105 let config: LintConfigFile = toml::from_str(contents).map_err(|error| format!("{error}"))?;
106 let version = config.version.unwrap_or(1);
107 if version != 1 {
108 return Err(format!("unsupported lint config version {version}"));
109 }
110
111 let mut seen = HashSet::new();
112 let mut rules = Vec::new();
113 for lint in config.lints {
114 if !seen.insert(lint.id.clone()) {
115 return Err(format!("duplicate lint id '{}'", lint.id));
116 }
117 let rule = build_lint_rule(&lint)?;
118 rules.push(rule);
119 }
120 Ok(LintSuite::new(rules))
121}
122
123fn build_lint_rule(entry: &LintConfigEntry) -> Result<std::sync::Arc<dyn crate::LintRule>, String> {
124 match entry.id.as_str() {
125 "max_tools" => {
126 let params: MaxToolsParams = require_params(entry, "max_tools")?;
127 let definition =
128 definition_with_params(entry, LintPhase::List, serde_json::to_value(¶ms).ok());
129 Ok(std::sync::Arc::new(MaxToolsLint::new(
130 definition, params.max,
131 )))
132 }
133 "mcp_schema_min_version" => {
134 let params: McpSchemaMinVersionParams =
135 require_params(entry, "mcp_schema_min_version")?;
136 let definition =
137 definition_with_params(entry, LintPhase::List, serde_json::to_value(¶ms).ok());
138 let lint = McpSchemaMinVersionLint::new(definition, params.min_version)?;
139 Ok(std::sync::Arc::new(lint))
140 }
141 "json_schema_dialect_compat" => {
142 let params: JsonSchemaDialectCompatParams =
143 require_params(entry, "json_schema_dialect_compat")?;
144 let definition =
145 definition_with_params(entry, LintPhase::List, serde_json::to_value(¶ms).ok());
146 Ok(std::sync::Arc::new(JsonSchemaDialectCompatLint::new(
147 definition,
148 params.allowlist,
149 )))
150 }
151 "json_schema_keyword_compat" => {
152 reject_params(entry, "json_schema_keyword_compat")?;
153 let definition = definition_with_params(entry, LintPhase::List, None);
154 Ok(std::sync::Arc::new(JsonSchemaKeywordCompatLint::new(
155 definition,
156 )))
157 }
158 "output_schema_compile" => {
159 reject_params(entry, "output_schema_compile")?;
160 let definition = definition_with_params(entry, LintPhase::List, None);
161 Ok(std::sync::Arc::new(OutputSchemaCompileLint::new(
162 definition,
163 )))
164 }
165 "max_structured_content_bytes" => {
166 let params: MaxStructuredContentBytesParams =
167 require_params(entry, "max_structured_content_bytes")?;
168 let definition = definition_with_params(
169 entry,
170 LintPhase::Response,
171 serde_json::to_value(¶ms).ok(),
172 );
173 Ok(std::sync::Arc::new(MaxStructuredContentBytesLint::new(
174 definition,
175 params.max_bytes,
176 )))
177 }
178 "missing_structured_content" => {
179 reject_params(entry, "missing_structured_content")?;
180 let definition = definition_with_params(entry, LintPhase::Response, None);
181 Ok(std::sync::Arc::new(MissingStructuredContentLint::new(
182 definition,
183 )))
184 }
185 "coverage" => {
186 let params: CoverageParams = optional_params(entry)?;
187 let definition =
188 definition_with_params(entry, LintPhase::Run, serde_json::to_value(¶ms).ok());
189 let lint = CoverageLint::new(definition, params.rules)?;
190 Ok(std::sync::Arc::new(lint))
191 }
192 "no_crash" => {
193 reject_params(entry, "no_crash")?;
194 let definition = definition_with_params(entry, LintPhase::Run, None);
195 let lint = NoCrashLint::new(definition)?;
196 Ok(std::sync::Arc::new(lint))
197 }
198 other => Err(format!("unknown lint id '{other}'")),
199 }
200}
201
202fn definition_with_params(
203 entry: &LintConfigEntry,
204 phase: LintPhase,
205 params: Option<serde_json::Value>,
206) -> LintDefinition {
207 let mut definition = LintDefinition::new(entry.id.clone(), phase, entry.level.clone());
208 if let Some(params) = params {
209 definition = definition.with_params(params);
210 }
211 definition
212}
213
214fn require_params<T: for<'de> Deserialize<'de>>(
215 entry: &LintConfigEntry,
216 lint_id: &str,
217) -> Result<T, String> {
218 let value = entry
219 .params
220 .clone()
221 .ok_or_else(|| format!("lint '{lint_id}' missing params"))?;
222 value
223 .try_into()
224 .map_err(|error| format!("invalid params for lint '{lint_id}': {error}"))
225}
226
227fn optional_params<T: for<'de> Deserialize<'de> + Default>(
228 entry: &LintConfigEntry,
229) -> Result<T, String> {
230 match entry.params.clone() {
231 Some(value) => value
232 .try_into()
233 .map_err(|error| format!("invalid params for lint '{}': {error}", entry.id)),
234 None => Ok(T::default()),
235 }
236}
237
238fn reject_params(entry: &LintConfigEntry, lint_id: &str) -> Result<(), String> {
239 if entry.params.is_some() {
240 return Err(format!("lint '{lint_id}' does not accept params"));
241 }
242 Ok(())
243}
244
245fn find_repo_config(start_dir: &Path) -> Option<PathBuf> {
246 let git_root = find_git_root(start_dir)?;
247 let mut current = Some(start_dir);
248 while let Some(dir) = current {
249 let candidate = dir.join("tooltest.toml");
250 if candidate.is_file() {
251 return Some(candidate);
252 }
253 if dir == git_root {
254 break;
255 }
256 current = dir.parent();
257 }
258 None
259}
260
261fn find_git_root(start_dir: &Path) -> Option<PathBuf> {
262 let mut current = Some(start_dir);
263 while let Some(dir) = current {
264 if dir.join(".git").exists() {
265 return Some(dir.to_path_buf());
266 }
267 current = dir.parent();
268 }
269 None
270}
271
272fn home_config_path() -> Option<PathBuf> {
273 home_config_path_from(std::env::var_os("HOME"))
274}
275
276fn home_config_path_from(home: Option<std::ffi::OsString>) -> Option<PathBuf> {
277 let home = home?;
278 Some(PathBuf::from(home).join(".config").join("tooltest.toml"))
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use std::fs;
285 use tooltest_test_support::temp_path;
286
287 fn write_config(path: &Path, contents: &str) {
288 let parent = path
289 .parent()
290 .filter(|parent| !parent.as_os_str().is_empty())
291 .unwrap_or_else(|| Path::new("."));
292 fs::create_dir_all(parent).expect("create config dir");
293 fs::write(path, contents).expect("write config");
294 }
295
296 fn assert_lint_present(levels: &std::collections::HashMap<&str, LintLevel>, lint: &str) {
297 assert!(levels.contains_key(lint), "missing lint {lint}");
298 }
299
300 fn assert_allowlist_entry(allowlist: &std::collections::HashSet<String>, entry: &str) {
301 assert!(allowlist.contains(entry), "missing allowlist entry {entry}");
302 }
303
304 #[test]
305 fn default_tooltest_toml_exposes_defaults() {
306 let contents = default_tooltest_toml();
307 assert!(contents.contains("no_crash"));
308 }
309
310 #[test]
311 fn repo_config_overrides_home_config() {
312 let repo_root = temp_path("lint-repo-root");
313 let nested = repo_root.join("nested");
314 fs::create_dir_all(repo_root.join(".git")).expect("git dir");
315 fs::create_dir_all(&nested).expect("nested");
316 let repo_config = repo_root.join("tooltest.toml");
317 write_config(
318 &repo_config,
319 r#"
320[[lints]]
321id = "max_tools"
322level = "error"
323[lints.params]
324max = 1
325"#,
326 );
327
328 let home_root = temp_path("lint-home");
329 let home_config = home_root.join(".config").join("tooltest.toml");
330 write_config(
331 &home_config,
332 r#"
333[[lints]]
334id = "missing_structured_content"
335level = "warning"
336"#,
337 );
338
339 let suite = load_lint_suite_from(&nested, Some(&home_config)).expect("suite");
340 assert!(suite.has_enabled("max_tools"));
341 assert!(!suite.has_enabled("missing_structured_content"));
342 assert_eq!(suite.source(), LintConfigSource::Repo);
343
344 let _ = fs::remove_dir_all(repo_root);
345 let _ = fs::remove_dir_all(home_root);
346 }
347
348 #[test]
349 fn home_config_used_when_repo_missing() {
350 let root = temp_path("lint-home-only");
351 fs::create_dir_all(&root).expect("create dir");
352 let home_root = temp_path("lint-home-config");
353 let home_config = home_root.join(".config").join("tooltest.toml");
354 write_config(
355 &home_config,
356 r#"
357[[lints]]
358id = "max_tools"
359level = "error"
360[lints.params]
361max = 1
362"#,
363 );
364
365 let suite = load_lint_suite_from(&root, Some(&home_config)).expect("suite");
366 assert!(suite.has_enabled("max_tools"));
367 assert_eq!(suite.source(), LintConfigSource::Home);
368
369 let _ = fs::remove_dir_all(root);
370 let _ = fs::remove_dir_all(home_root);
371 }
372
373 #[test]
374 fn home_config_ignored_when_missing() {
375 let root = temp_path("lint-home-missing");
376 fs::create_dir_all(&root).expect("create dir");
377 let home_root = temp_path("lint-missing-home-config");
378 let home_config = home_root.join(".config").join("tooltest.toml");
379
380 let suite = load_lint_suite_from(&root, Some(&home_config)).expect("suite");
381 assert!(suite.has_enabled("no_crash"));
382 assert_eq!(suite.source(), LintConfigSource::Default);
383
384 let _ = fs::remove_dir_all(root);
385 let _ = fs::remove_dir_all(home_root);
386 }
387
388 #[test]
389 fn repo_search_stops_at_git_root() {
390 let root = temp_path("lint-git-root");
391 let repo_root = root.join("repo");
392 let nested = repo_root.join("nested");
393 fs::create_dir_all(repo_root.join(".git")).expect("git dir");
394 fs::create_dir_all(&nested).expect("nested");
395 write_config(
396 &root.join("tooltest.toml"),
397 r#"
398[[lints]]
399id = "max_tools"
400level = "error"
401[lints.params]
402max = 1
403"#,
404 );
405 assert!(find_repo_config(&nested).is_none());
406 let _ = fs::remove_dir_all(root);
407 }
408
409 #[test]
410 fn missing_config_uses_default() {
411 let root = temp_path("lint-default");
412 fs::create_dir_all(&root).expect("create dir");
413 let suite = load_lint_suite_from(&root, None).expect("suite");
414 assert!(suite.has_enabled("no_crash"));
415 assert!(suite.has_enabled("mcp_schema_min_version"));
416 assert!(suite.has_enabled("missing_structured_content"));
417 assert_eq!(suite.source(), LintConfigSource::Default);
418 let _ = fs::remove_dir_all(root);
419 }
420
421 #[test]
422 fn repo_config_ignored_without_git_root() {
423 let root = temp_path("lint-no-git-root");
424 let nested = root.join("nested");
425 fs::create_dir_all(&nested).expect("nested");
426 write_config(
427 &root.join("tooltest.toml"),
428 r#"
429[[lints]]
430id = "max_tools"
431level = "error"
432[lints.params]
433max = 1
434"#,
435 );
436
437 let home_root = temp_path("lint-no-git-home");
438 let home_config = home_root.join(".config").join("tooltest.toml");
439 write_config(
440 &home_config,
441 r#"
442[[lints]]
443id = "json_schema_dialect_compat"
444level = "warning"
445[lints.params]
446allowlist = ["http://json-schema.org/draft-04/schema"]
447"#,
448 );
449
450 let suite = load_lint_suite_from(&nested, Some(&home_config)).expect("suite");
451 assert!(suite.has_enabled("json_schema_dialect_compat"));
452 assert!(!suite.has_enabled("max_tools"));
453 assert_eq!(suite.source(), LintConfigSource::Home);
454
455 let _ = fs::remove_dir_all(root);
456 let _ = fs::remove_dir_all(home_root);
457 }
458
459 #[test]
460 fn unknown_lint_id_rejected() {
461 let error = parse_lint_suite(
462 r#"
463[[lints]]
464id = "unknown"
465level = "warning"
466"#,
467 )
468 .err()
469 .expect("error");
470 assert!(error.contains("unknown lint id"));
471 }
472
473 #[test]
474 fn duplicate_lint_id_rejected() {
475 let error = parse_lint_suite(
476 r#"
477[[lints]]
478id = "no_crash"
479level = "error"
480
481[[lints]]
482id = "no_crash"
483level = "error"
484"#,
485 )
486 .err()
487 .expect("error");
488 assert!(error.contains("duplicate lint id"));
489 }
490
491 #[test]
492 fn invalid_level_rejected() {
493 let error = parse_lint_suite(
494 r#"
495[[lints]]
496id = "no_crash"
497level = "nope"
498"#,
499 )
500 .err()
501 .expect("error");
502 let has_unknown = error.contains("unknown variant");
503 let has_invalid = error.contains("invalid");
504 assert!(has_unknown | has_invalid);
505 }
506
507 #[test]
508 fn unsupported_version_rejected() {
509 let error = parse_lint_suite(
510 r#"
511version = 2
512[[lints]]
513id = "no_crash"
514level = "error"
515"#,
516 )
517 .err()
518 .expect("error");
519 assert!(error.contains("unsupported lint config version"));
520 }
521
522 #[test]
523 fn missing_version_defaults_to_one() {
524 let suite = parse_lint_suite(
525 r#"
526[[lints]]
527id = "no_crash"
528level = "error"
529"#,
530 )
531 .expect("suite");
532 assert!(suite.has_enabled("no_crash"));
533 }
534
535 #[test]
536 fn load_lint_suite_reports_missing_cwd() {
537 let error = load_lint_suite_with_env(
538 Err(std::io::Error::new(
539 std::io::ErrorKind::NotFound,
540 "missing cwd",
541 )),
542 None,
543 )
544 .err()
545 .expect("error");
546 assert!(error.contains("failed to read cwd"));
547 }
548
549 #[test]
550 fn home_config_path_reads_home() {
551 let temp = temp_path("lint-home-env");
552 fs::create_dir_all(&temp).expect("create dir");
553 let path = home_config_path_from(Some(temp.clone().into())).expect("home path");
554 assert!(path.ends_with(".config/tooltest.toml"));
555 let _ = fs::remove_dir_all(&temp);
556 }
557
558 #[test]
559 fn home_config_path_handles_missing_home() {
560 assert!(home_config_path_from(None).is_none());
561 }
562
563 #[test]
564 fn write_config_creates_parent_directory() {
565 let root = temp_path("lint-write-config");
566 let config_path = root.join("nested").join("tooltest.toml");
567 write_config(
568 &config_path,
569 r#"
570[[lints]]
571id = "no_crash"
572level = "error"
573"#,
574 );
575 assert!(config_path.exists());
576 let _ = fs::remove_dir_all(root);
577 }
578
579 #[test]
580 fn write_config_handles_simple_path() {
581 let config_path = PathBuf::from("tooltest.toml");
582 let root = temp_path("lint-write-relative");
583 fs::create_dir_all(&root).expect("create dir");
584 let full_path = root.join(&config_path);
585 write_config(
586 &full_path,
587 r#"
588[[lints]]
589id = "no_crash"
590level = "error"
591"#,
592 );
593 assert!(full_path.exists());
594 let _ = fs::remove_dir_all(root);
595 }
596
597 #[test]
598 fn write_config_handles_path_without_parent() {
599 let config_path = Path::new("tooltest-temp-config.toml");
600 write_config(
601 config_path,
602 r#"
603[[lints]]
604id = "no_crash"
605level = "error"
606"#,
607 );
608 assert!(config_path.exists());
609 let _ = fs::remove_file(config_path);
610 }
611
612 #[test]
613 fn load_lint_suite_from_path_reports_missing_file() {
614 let root = temp_path("lint-missing-file");
615 fs::create_dir_all(&root).expect("create dir");
616 let missing = root.join("tooltest.toml");
617 let error = load_lint_suite_from_path(&missing).err().expect("error");
618 assert!(error.contains("failed to read lint config"));
619 let _ = fs::remove_dir_all(root);
620 }
621
622 #[test]
623 fn load_lint_suite_from_path_reports_invalid_config() {
624 let root = temp_path("lint-invalid-config");
625 fs::create_dir_all(&root).expect("create dir");
626 let config_path = root.join("tooltest.toml");
627 write_config(
628 &config_path,
629 r#"
630[[lints]]
631id = "unknown"
632level = "warning"
633"#,
634 );
635 let error = load_lint_suite_from_path(&config_path)
636 .err()
637 .expect("error");
638 assert!(error.contains("invalid lint config"));
639 let _ = fs::remove_dir_all(root);
640 }
641
642 #[test]
643 fn parse_lint_suite_accepts_all_lints() {
644 let suite = parse_lint_suite(
645 r#"
646[[lints]]
647id = "max_tools"
648level = "error"
649[lints.params]
650max = 1
651
652[[lints]]
653id = "mcp_schema_min_version"
654level = "warning"
655[lints.params]
656min_version = "2024-01-01"
657
658[[lints]]
659id = "json_schema_dialect_compat"
660level = "warning"
661[lints.params]
662allowlist = ["http://json-schema.org/draft-04/schema"]
663
664[[lints]]
665id = "json_schema_keyword_compat"
666level = "warning"
667
668[[lints]]
669id = "max_structured_content_bytes"
670level = "warning"
671[lints.params]
672max_bytes = 64
673
674[[lints]]
675id = "missing_structured_content"
676level = "warning"
677
678[[lints]]
679id = "output_schema_compile"
680level = "warning"
681
682[[lints]]
683id = "coverage"
684level = "error"
685[lints.params]
686rules = [{ rule = "percent_called", min_percent = 0.0 }]
687
688[[lints]]
689id = "no_crash"
690level = "error"
691"#,
692 )
693 .expect("suite");
694 assert!(suite.has_enabled("max_tools"));
695 assert!(suite.has_enabled("mcp_schema_min_version"));
696 assert!(suite.has_enabled("json_schema_dialect_compat"));
697 assert!(suite.has_enabled("json_schema_keyword_compat"));
698 assert!(suite.has_enabled("max_structured_content_bytes"));
699 assert!(suite.has_enabled("missing_structured_content"));
700 assert!(suite.has_enabled("output_schema_compile"));
701 assert!(suite.has_enabled("coverage"));
702 assert!(suite.has_enabled("no_crash"));
703 }
704
705 #[test]
706 fn default_config_includes_required_lints_and_defaults() {
707 let suite = parse_lint_suite(DEFAULT_TOOLTEST_TOML).expect("suite");
708 let mut levels = std::collections::HashMap::new();
709 let mut params_by_id = std::collections::HashMap::new();
710 for rule in suite.rules() {
711 let definition = rule.definition();
712 levels.insert(definition.id.as_str(), definition.level.clone());
713 if let Some(params) = definition.params.clone() {
714 params_by_id.insert(definition.id.as_str(), params);
715 }
716 }
717
718 let expected_lints = [
719 "no_crash",
720 "mcp_schema_min_version",
721 "missing_structured_content",
722 "output_schema_compile",
723 "max_tools",
724 "json_schema_dialect_compat",
725 "json_schema_keyword_compat",
726 "max_structured_content_bytes",
727 "coverage",
728 ];
729 for lint in expected_lints {
730 assert_lint_present(&levels, lint);
731 }
732
733 assert_eq!(levels["no_crash"], LintLevel::Error);
734 assert_eq!(levels["mcp_schema_min_version"], LintLevel::Warning);
735 assert_eq!(levels["missing_structured_content"], LintLevel::Warning);
736 assert_eq!(levels["output_schema_compile"], LintLevel::Warning);
737 assert_eq!(levels["max_tools"], LintLevel::Disabled);
738 assert_eq!(levels["json_schema_dialect_compat"], LintLevel::Disabled);
739 assert_eq!(levels["json_schema_keyword_compat"], LintLevel::Warning);
740 assert_eq!(levels["max_structured_content_bytes"], LintLevel::Disabled);
741 assert_eq!(levels["coverage"], LintLevel::Disabled);
742
743 let allowlist = params_by_id
744 .get("json_schema_dialect_compat")
745 .and_then(|params| params.get("allowlist"))
746 .and_then(|value| value.as_array())
747 .expect("allowlist");
748 let allowlist: std::collections::HashSet<_> = allowlist
749 .iter()
750 .filter_map(|value| value.as_str().map(|entry| entry.to_string()))
751 .collect();
752 let required = [
753 "https://json-schema.org/draft/2020-12/schema",
754 "https://json-schema.org/draft/2019-09/schema",
755 "http://json-schema.org/draft-07/schema",
756 "http://json-schema.org/draft-06/schema",
757 "http://json-schema.org/draft-04/schema",
758 ];
759 for entry in required {
760 assert_allowlist_entry(&allowlist, entry);
761 }
762 }
763
764 #[test]
765 #[should_panic(expected = "missing lint missing-lint")]
766 fn assert_lint_present_panics_when_missing() {
767 let levels = std::collections::HashMap::new();
768 assert_lint_present(&levels, "missing-lint");
769 }
770
771 #[test]
772 #[should_panic(expected = "missing allowlist entry missing-schema")]
773 fn assert_allowlist_entry_panics_when_missing() {
774 let allowlist = std::collections::HashSet::new();
775 assert_allowlist_entry(&allowlist, "missing-schema");
776 }
777
778 #[test]
779 fn parse_lint_suite_rejects_invalid_min_version() {
780 let error = parse_lint_suite(
781 r#"
782[[lints]]
783id = "mcp_schema_min_version"
784level = "warning"
785[lints.params]
786min_version = "not-a-date"
787"#,
788 )
789 .err()
790 .expect("error");
791 assert!(error.contains("invalid minimum protocol version"));
792 }
793
794 #[test]
795 fn parse_lint_suite_rejects_invalid_coverage_rules() {
796 let error = parse_lint_suite(
797 r#"
798[[lints]]
799id = "coverage"
800level = "error"
801[lints.params]
802rules = [{ rule = "percent_called", min_percent = 101.0 }]
803"#,
804 )
805 .err()
806 .expect("error");
807 assert!(error.contains("min_percent"));
808 }
809
810 #[test]
811 fn parse_lint_suite_rejects_missing_params() {
812 let error = parse_lint_suite(
813 r#"
814[[lints]]
815id = "max_tools"
816level = "error"
817"#,
818 )
819 .err()
820 .expect("error");
821 assert!(error.contains("missing params"));
822 }
823
824 #[test]
825 fn parse_lint_suite_rejects_missing_params_for_min_version() {
826 let error = parse_lint_suite(
827 r#"
828[[lints]]
829id = "mcp_schema_min_version"
830level = "warning"
831"#,
832 )
833 .err()
834 .expect("error");
835 assert!(error.contains("missing params"));
836 }
837
838 #[test]
839 fn parse_lint_suite_rejects_missing_params_for_schema_allowlist() {
840 let error = parse_lint_suite(
841 r#"
842[[lints]]
843id = "json_schema_dialect_compat"
844level = "warning"
845"#,
846 )
847 .err()
848 .expect("error");
849 assert!(error.contains("missing params"));
850 }
851
852 #[test]
853 fn parse_lint_suite_rejects_missing_params_for_structured_bytes() {
854 let error = parse_lint_suite(
855 r#"
856[[lints]]
857id = "max_structured_content_bytes"
858level = "warning"
859"#,
860 )
861 .err()
862 .expect("error");
863 assert!(error.contains("missing params"));
864 }
865
866 #[test]
867 fn parse_lint_suite_rejects_params_for_missing_structured_content() {
868 let error = parse_lint_suite(
869 r#"
870[[lints]]
871id = "missing_structured_content"
872level = "warning"
873[lints.params]
874max = 1
875"#,
876 )
877 .err()
878 .expect("error");
879 assert!(error.contains("does not accept params"));
880 }
881
882 #[test]
883 fn parse_lint_suite_rejects_params_for_schema_keyword_compat() {
884 let error = parse_lint_suite(
885 r#"
886[[lints]]
887id = "json_schema_keyword_compat"
888level = "warning"
889[lints.params]
890extra = 1
891"#,
892 )
893 .err()
894 .expect("error");
895 assert!(error.contains("does not accept params"));
896 }
897
898 #[test]
899 fn parse_lint_suite_rejects_params_for_output_schema_compile() {
900 let error = parse_lint_suite(
901 r#"
902[[lints]]
903id = "output_schema_compile"
904level = "warning"
905[lints.params]
906extra = 1
907"#,
908 )
909 .err()
910 .expect("error");
911 assert!(error.contains("does not accept params"));
912 }
913
914 #[test]
915 fn parse_lint_suite_rejects_invalid_params() {
916 let error = parse_lint_suite(
917 r#"
918[[lints]]
919id = "max_tools"
920level = "error"
921[lints.params]
922max = "nope"
923"#,
924 )
925 .err()
926 .expect("error");
927 assert!(error.contains("invalid params"));
928 }
929
930 #[test]
931 fn parse_lint_suite_rejects_invalid_optional_params() {
932 let error = parse_lint_suite(
933 r#"
934[[lints]]
935id = "coverage"
936level = "error"
937[lints.params]
938rules = "nope"
939"#,
940 )
941 .err()
942 .expect("error");
943 assert!(error.contains("invalid params"));
944 }
945
946 #[test]
947 fn coverage_params_optional() {
948 let suite = parse_lint_suite(
949 r#"
950[[lints]]
951id = "coverage"
952level = "error"
953"#,
954 )
955 .expect("suite");
956 assert!(suite.has_enabled("coverage"));
957 }
958
959 #[test]
960 fn reject_params_for_no_crash() {
961 let error = parse_lint_suite(
962 r#"
963[[lints]]
964id = "no_crash"
965level = "error"
966[lints.params]
967max = 1
968"#,
969 )
970 .err()
971 .expect("error");
972 assert!(error.contains("does not accept params"));
973 }
974
975 #[test]
976 fn fixed_severity_lint_rejected_when_not_error() {
977 let error = parse_lint_suite(
978 r#"
979[[lints]]
980id = "no_crash"
981level = "warning"
982"#,
983 )
984 .err()
985 .expect("error");
986 assert!(error.contains("no_crash lint must be configured at error level"));
987 }
988
989 #[test]
990 fn coverage_params_default_when_missing() {
991 let suite = parse_lint_suite(
992 r#"
993[[lints]]
994id = "coverage"
995level = "warning"
996"#,
997 )
998 .expect("suite");
999 assert!(suite.has_enabled("coverage"));
1000 }
1001
1002 #[test]
1003 fn reject_params_for_fixed_severity_lint() {
1004 let error = parse_lint_suite(
1005 r#"
1006[[lints]]
1007id = "no_crash"
1008level = "error"
1009[lints.params]
1010foo = 1
1011"#,
1012 )
1013 .err()
1014 .expect("error");
1015 assert!(error.contains("does not accept params"));
1016 }
1017}