spec_kit_mcp/tools/
analyze.rs1use anyhow::{Context, Result};
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use std::path::PathBuf;
10
11use crate::mcp::types::{ContentBlock, ToolDefinition, ToolResult};
12use crate::speckit::SpecKitCli;
13use crate::tools::Tool;
14
15#[derive(Debug, Deserialize, Serialize)]
17pub struct AnalyzeParams {
18 project_path: PathBuf,
20
21 #[serde(default = "default_true")]
23 check_consistency: bool,
24
25 #[serde(default = "default_true")]
27 check_coverage: bool,
28
29 #[serde(default = "default_analyze_path")]
31 output_path: PathBuf,
32}
33
34fn default_true() -> bool {
35 true
36}
37
38fn default_analyze_path() -> PathBuf {
39 PathBuf::from("./speckit.analyze")
40}
41
42pub struct AnalyzeTool {
44 #[allow(dead_code)]
45 cli: SpecKitCli,
46}
47
48impl AnalyzeTool {
49 pub fn new(cli: SpecKitCli) -> Self {
51 Self { cli }
52 }
53}
54
55#[async_trait]
56impl Tool for AnalyzeTool {
57 fn definition(&self) -> ToolDefinition {
58 ToolDefinition {
59 name: "speckit_analyze".to_string(),
60 description: "Analyze cross-artifact consistency and coverage - ensures constitution, specs, plans, and tasks are aligned".to_string(),
61 input_schema: json!({
62 "type": "object",
63 "properties": {
64 "project_path": {
65 "type": "string",
66 "description": "Path to the project directory containing spec-kit artifacts"
67 },
68 "check_consistency": {
69 "type": "boolean",
70 "default": true,
71 "description": "Check if artifacts are consistent with each other"
72 },
73 "check_coverage": {
74 "type": "boolean",
75 "default": true,
76 "description": "Check if all requirements are covered in plan/tasks"
77 },
78 "output_path": {
79 "type": "string",
80 "description": "Path where analysis report will be written",
81 "default": "./speckit.analyze"
82 }
83 },
84 "required": ["project_path"]
85 })
86 }
87 }
88
89 async fn execute(&self, params: Value) -> Result<ToolResult> {
90 let params: AnalyzeParams =
91 serde_json::from_value(params).context("Failed to parse analyze parameters")?;
92
93 tracing::info!(
94 project_path = %params.project_path.display(),
95 "Analyzing project artifacts"
96 );
97
98 let mut analysis = String::from("# Spec-Kit Analysis Report\n\n");
99 analysis.push_str(&format!("Project: {}\n\n", params.project_path.display()));
100
101 let artifacts = vec![
103 ("Constitution", "speckit.constitution"),
104 ("Specification", "speckit.specify"),
105 ("Plan", "speckit.plan"),
106 ("Tasks", "speckit.tasks"),
107 ];
108
109 analysis.push_str("## Artifact Status\n\n");
110
111 let mut found_artifacts = Vec::new();
112 for (name, filename) in &artifacts {
113 let path = params.project_path.join(filename);
114 if path.exists() {
115 analysis.push_str(&format!("✓ {} found\n", name));
116 found_artifacts.push((*name, path));
117 } else {
118 analysis.push_str(&format!("✗ {} missing\n", name));
119 }
120 }
121
122 if params.check_consistency && found_artifacts.len() >= 2 {
124 analysis.push_str("\n## Consistency Analysis\n\n");
125
126 let mut contents = Vec::new();
128 for (name, path) in &found_artifacts {
129 match tokio::fs::read_to_string(path).await {
130 Ok(content) => contents.push((*name, content)),
131 Err(e) => analysis.push_str(&format!("⚠ Failed to read {}: {}\n", name, e)),
132 }
133 }
134
135 if contents.len() >= 2 {
137 let spec_content = contents
138 .iter()
139 .find(|(n, _)| n.contains("Spec"))
140 .map(|(_, c)| c);
141 let plan_content = contents
142 .iter()
143 .find(|(n, _)| n.contains("Plan"))
144 .map(|(_, c)| c);
145
146 if let (Some(spec), Some(plan)) = (spec_content, plan_content) {
147 let important_terms = spec
149 .split_whitespace()
150 .filter(|w| w.len() > 5 && w.chars().next().unwrap().is_uppercase())
151 .take(10)
152 .collect::<Vec<_>>();
153
154 let mut missing = Vec::new();
155 for term in important_terms {
156 if !plan.contains(term) {
157 missing.push(term);
158 }
159 }
160
161 if missing.is_empty() {
162 analysis.push_str("✓ Key terms from specification are addressed in plan\n");
163 } else {
164 analysis.push_str("⚠ Terms in spec but not in plan:\n");
165 for term in missing {
166 analysis.push_str(&format!(" - {}\n", term));
167 }
168 }
169 }
170 }
171 }
172
173 if params.check_coverage {
175 analysis.push_str("\n## Coverage Analysis\n\n");
176
177 if found_artifacts
178 .iter()
179 .any(|(n, _)| n.contains("Specification"))
180 {
181 analysis.push_str("✓ Requirements are specified\n");
182 } else {
183 analysis.push_str("✗ Missing specification\n");
184 }
185
186 if found_artifacts.iter().any(|(n, _)| n.contains("Plan")) {
187 analysis.push_str("✓ Technical plan exists\n");
188 } else {
189 analysis.push_str("✗ Missing technical plan\n");
190 }
191
192 if found_artifacts.iter().any(|(n, _)| n.contains("Tasks")) {
193 analysis.push_str("✓ Tasks are defined\n");
194 } else {
195 analysis.push_str("✗ Missing task breakdown\n");
196 }
197 }
198
199 analysis.push_str("\n## Recommendations\n\n");
201
202 if found_artifacts.len() < 4 {
203 analysis.push_str("1. Complete missing artifacts\n");
204 }
205 analysis.push_str("2. Review consistency issues (if any)\n");
206 analysis.push_str("3. Ensure all requirements are covered in tasks\n");
207 analysis.push_str("4. Keep artifacts updated as project evolves\n");
208
209 tokio::fs::write(¶ms.output_path, &analysis)
211 .await
212 .context("Failed to write analysis")?;
213
214 let message = format!(
215 "Analysis complete!\n\n\
216 Artifacts found: {}/{}\n\
217 Report: {}\n\n\
218 {}",
219 found_artifacts.len(),
220 artifacts.len(),
221 params.output_path.display(),
222 if found_artifacts.len() == artifacts.len() {
223 "✓ All artifacts present"
224 } else {
225 "⚠ Some artifacts are missing"
226 }
227 );
228
229 Ok(ToolResult {
230 content: vec![ContentBlock::text(message)],
231 is_error: None,
232 })
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use tempfile::tempdir;
240 use tokio::fs;
241
242 #[tokio::test]
243 async fn test_analyze_tool_definition() {
244 let cli = SpecKitCli::new();
245 let tool = AnalyzeTool::new(cli);
246 let def = tool.definition();
247
248 assert_eq!(def.name, "speckit_analyze");
249 assert!(!def.description.is_empty());
250 }
251
252 #[tokio::test]
253 async fn test_analyze_tool_execute() {
254 let cli = SpecKitCli::new_test_mode();
255 let tool = AnalyzeTool::new(cli);
256
257 let dir = tempdir().unwrap();
258
259 fs::write(dir.path().join("speckit.constitution"), "Principles")
261 .await
262 .unwrap();
263 fs::write(dir.path().join("speckit.specify"), "Requirements")
264 .await
265 .unwrap();
266
267 let params = json!({
268 "project_path": dir.path().to_str().unwrap(),
269 "check_consistency": true,
270 "check_coverage": true
271 });
272
273 let result = tool.execute(params).await.unwrap();
274 assert!(result.is_error.is_none() || !result.is_error.unwrap());
275 }
276}