Skip to main content

tooltest_core/
lint.rs

1use std::sync::Arc;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value as JsonValue;
6
7use crate::{CallToolResult, CorpusReport, CoverageReport, RunOutcome, Tool, ToolInvocation};
8/// Severity levels for lint findings.
9#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
10#[serde(rename_all = "snake_case")]
11pub enum LintLevel {
12    Error,
13    Warning,
14    Disabled,
15}
16
17impl LintLevel {
18    pub fn is_disabled(&self) -> bool {
19        matches!(self, Self::Disabled)
20    }
21}
22
23/// Phases in which lints are evaluated.
24#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
25#[serde(rename_all = "snake_case")]
26pub enum LintPhase {
27    List,
28    Response,
29    Run,
30}
31
32/// Source of the lint configuration for the run.
33#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
34#[serde(rename_all = "snake_case")]
35pub enum LintConfigSource {
36    Repo,
37    Home,
38    #[default]
39    Default,
40}
41
42/// Definition of a lint instance.
43#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
44pub struct LintDefinition {
45    pub id: String,
46    pub phase: LintPhase,
47    pub level: LintLevel,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub params: Option<JsonValue>,
50}
51
52impl LintDefinition {
53    pub fn new(id: impl Into<String>, phase: LintPhase, level: LintLevel) -> Self {
54        Self {
55            id: id.into(),
56            phase,
57            level,
58            params: None,
59        }
60    }
61
62    pub fn with_params(mut self, params: JsonValue) -> Self {
63        self.params = Some(params);
64        self
65    }
66}
67
68/// A lint finding emitted during evaluation.
69#[derive(Clone, Debug)]
70pub struct LintFinding {
71    pub message: String,
72    pub details: Option<JsonValue>,
73    pub code: Option<String>,
74}
75
76impl LintFinding {
77    pub fn new(message: impl Into<String>) -> Self {
78        Self {
79            message: message.into(),
80            details: None,
81            code: None,
82        }
83    }
84
85    pub fn with_details(mut self, details: JsonValue) -> Self {
86        self.details = Some(details);
87        self
88    }
89
90    pub fn with_code(mut self, code: impl Into<String>) -> Self {
91        self.code = Some(code.into());
92        self
93    }
94}
95
96/// Context for list-phase lint evaluation.
97#[derive(Clone, Debug)]
98#[non_exhaustive]
99pub struct ListLintContext<'a> {
100    /// Raw tool count from the tools/list response.
101    pub raw_tool_count: usize,
102    /// Server-reported MCP protocol version from initialize response.
103    pub protocol_version: Option<&'a str>,
104    pub tools: &'a [Tool],
105}
106
107/// Context for response-phase lint evaluation.
108#[derive(Clone, Debug)]
109#[non_exhaustive]
110pub struct ResponseLintContext<'a> {
111    pub tool: &'a Tool,
112    pub invocation: &'a ToolInvocation,
113    pub response: &'a CallToolResult,
114}
115
116/// Context for run-phase lint evaluation.
117#[derive(Clone, Debug)]
118#[non_exhaustive]
119pub struct RunLintContext<'a> {
120    pub coverage: Option<&'a CoverageReport>,
121    pub corpus: Option<&'a CorpusReport>,
122    pub coverage_allowlist: Option<&'a [String]>,
123    pub coverage_blocklist: Option<&'a [String]>,
124    pub outcome: &'a RunOutcome,
125}
126
127/// Trait for implementing lint checks.
128pub trait LintRule: Send + Sync {
129    fn definition(&self) -> &LintDefinition;
130
131    fn check_list(&self, _context: &ListLintContext<'_>) -> Vec<LintFinding> {
132        Vec::new()
133    }
134
135    fn check_response(&self, _context: &ResponseLintContext<'_>) -> Vec<LintFinding> {
136        Vec::new()
137    }
138
139    fn check_run(&self, _context: &RunLintContext<'_>) -> Vec<LintFinding> {
140        Vec::new()
141    }
142}
143
144/// Collection of configured lint rules.
145#[derive(Clone, Default)]
146pub struct LintSuite {
147    rules: Vec<Arc<dyn LintRule>>,
148    source: LintConfigSource,
149}
150
151impl LintSuite {
152    pub fn new(rules: Vec<Arc<dyn LintRule>>) -> Self {
153        Self {
154            rules,
155            source: LintConfigSource::Default,
156        }
157    }
158
159    pub fn with_source(mut self, source: LintConfigSource) -> Self {
160        self.source = source;
161        self
162    }
163
164    pub fn source(&self) -> LintConfigSource {
165        self.source
166    }
167
168    pub fn len(&self) -> usize {
169        self.rules.len()
170    }
171
172    pub fn is_empty(&self) -> bool {
173        self.rules.is_empty()
174    }
175
176    pub(crate) fn rules(&self) -> &[Arc<dyn LintRule>] {
177        &self.rules
178    }
179
180    pub fn has_enabled(&self, id: &str) -> bool {
181        self.rules.iter().any(|rule| {
182            let definition = rule.definition();
183            definition.id == id && definition.level != LintLevel::Disabled
184        })
185    }
186}
187
188pub(crate) struct LintPhases {
189    pub(crate) list: Vec<Arc<dyn LintRule>>,
190    pub(crate) response: Vec<Arc<dyn LintRule>>,
191    pub(crate) run: Vec<Arc<dyn LintRule>>,
192}
193
194impl LintPhases {
195    pub(crate) fn from_suite(suite: &LintSuite) -> Self {
196        let mut list = Vec::new();
197        let mut response = Vec::new();
198        let mut run = Vec::new();
199        for rule in suite.rules() {
200            if rule.definition().level.is_disabled() {
201                continue;
202            }
203            match rule.definition().phase {
204                LintPhase::List => list.push(Arc::clone(rule)),
205                LintPhase::Response => response.push(Arc::clone(rule)),
206                LintPhase::Run => run.push(Arc::clone(rule)),
207            }
208        }
209        Self {
210            list,
211            response,
212            run,
213        }
214    }
215}