Skip to main content

tooltest_core/
lint_config.rs

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(&params).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(&params).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(&params).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(&params).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(&params).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}