1#![allow(clippy::disallowed_methods)] use crate::tools::args::{self, try_arg};
8use crate::tools::subprocess::run_apr;
9use crate::types::{InputSchema, ToolCallResult, ToolDefinition};
10
11pub const NAME: &str = "apr.qa";
13
14#[must_use]
23pub fn qa_tool_definition() -> ToolDefinition {
24 let input_schema: InputSchema = serde_json::from_str(crate::schemas::APR_QA_SCHEMA).expect(
25 "FALSIFY-MCP-008: apr.qa codegen constant must parse as InputSchema; \
26 regenerate by editing contracts/apr-mcp-tool-schemas-v1.yaml and rebuilding",
27 );
28 ToolDefinition {
29 name: NAME.to_string(),
30 description: crate::schemas::APR_QA_DESCRIPTION.to_string(),
31 input_schema,
32 }
33}
34
35pub fn build_argv(args: &serde_json::Value) -> Result<Vec<String>, String> {
44 let model_path = args::required_str(args, "model_path")?;
45
46 let mut owned: Vec<String> = vec![
47 "qa".to_string(),
48 model_path.to_string(),
49 "--json".to_string(),
50 ];
51
52 if let Some(tps) = args::opt_f64(args, "assert_tps")? {
55 owned.push("--assert-tps".to_string());
56 owned.push(tps.to_string());
57 }
58 if let Some(n) = args::opt_u64(args, "max_tokens")? {
59 owned.push("--max-tokens".to_string());
60 owned.push(n.to_string());
61 }
62 if let Some(n) = args::opt_u64(args, "iterations")? {
63 owned.push("--iterations".to_string());
64 owned.push(n.to_string());
65 }
66 Ok(owned)
67}
68
69#[must_use]
71pub fn call(args: &serde_json::Value) -> ToolCallResult {
72 let owned = try_arg!(build_argv(args));
73 let argv: Vec<&str> = owned.iter().map(String::as_str).collect();
74 run_apr(&argv)
75}
76
77pub fn dispatch(
79 args: &serde_json::Value,
80 _cancel: &std::sync::mpsc::Receiver<()>,
81 _sink: Option<&crate::server::NotificationSink>,
82 _token: Option<serde_json::Value>,
83) -> ToolCallResult {
84 call(args)
85}
86
87crate::register_mcp_tool!(
88 name: NAME,
89 definition: qa_tool_definition,
90 dispatch: dispatch,
91);
92
93#[cfg(test)]
94#[allow(clippy::disallowed_methods)]
95mod tests {
96 use super::*;
97
98 #[test]
99 fn definition_has_correct_name_and_required_field() {
100 let def = qa_tool_definition();
101 assert_eq!(def.name, "apr.qa");
102 assert_eq!(def.input_schema.schema_type, "object");
103 assert_eq!(def.input_schema.required, vec!["model_path".to_string()]);
104 for field in ["model_path", "assert_tps", "max_tokens", "iterations"] {
105 assert!(
106 def.input_schema.properties.contains_key(field),
107 "property {field} present"
108 );
109 }
110 }
111
112 #[test]
113 fn missing_model_path_returns_error() {
114 let result = call(&serde_json::json!({}));
115 assert_eq!(result.is_error, Some(true));
116 assert!(result.content[0].text.contains("model_path"));
117 }
118
119 #[test]
123 fn assert_tps_as_a_json_string_still_reaches_the_gate() {
124 let argv = build_argv(&serde_json::json!({
125 "model_path": "m.gguf",
126 "assert_tps": "100000",
127 "iterations": 1
128 }))
129 .expect("string 100000 is a usable number");
130 assert!(
131 argv.contains(&"--assert-tps".to_string()),
132 "throughput gate disarmed: {argv:?}"
133 );
134 let idx = argv
135 .iter()
136 .position(|a| a == "--assert-tps")
137 .expect("flag present");
138 assert_eq!(argv[idx + 1], "100000");
139 }
140
141 #[test]
144 fn assert_tps_as_a_number_reaches_the_gate() {
145 let argv =
146 build_argv(&serde_json::json!({ "model_path": "m.gguf", "assert_tps": 100_000 }))
147 .expect("number is usable");
148 assert!(argv.contains(&"--assert-tps".to_string()), "{argv:?}");
149 }
150
151 #[test]
154 fn unusable_assert_tps_is_an_error_not_a_dropped_flag() {
155 let result = call(&serde_json::json!({ "model_path": "m.gguf", "assert_tps": "fast" }));
156 assert_eq!(result.is_error, Some(true));
157 assert!(result.content[0].text.contains("assert_tps"));
158 }
159
160 #[test]
161 fn omitted_assert_tps_stays_omitted() {
162 let argv = build_argv(&serde_json::json!({ "model_path": "m.gguf" })).expect("valid");
163 assert_eq!(argv, vec!["qa", "m.gguf", "--json"]);
164 }
165}