spec_kit_mcp/tools/
analyze.rs

1//! Spec-Kit Analyze Tool
2//!
3//! Analyzes cross-artifact consistency and coverage.
4
5use 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/// Parameters for the speckit_analyze tool
16#[derive(Debug, Deserialize, Serialize)]
17pub struct AnalyzeParams {
18    /// Path to project directory
19    project_path: PathBuf,
20
21    /// Check consistency across artifacts
22    #[serde(default = "default_true")]
23    check_consistency: bool,
24
25    /// Check coverage of requirements
26    #[serde(default = "default_true")]
27    check_coverage: bool,
28
29    /// Output path for analysis report
30    #[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
42/// Tool for analyzing spec-kit artifacts
43pub struct AnalyzeTool {
44    #[allow(dead_code)]
45    cli: SpecKitCli,
46}
47
48impl AnalyzeTool {
49    /// Create a new analyze tool
50    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        // Check for artifacts
102        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        // Consistency checks
123        if params.check_consistency && found_artifacts.len() >= 2 {
124            analysis.push_str("\n## Consistency Analysis\n\n");
125
126            // Read all artifacts
127            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            // Check for keywords mentioned in spec but missing in plan
136            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                    // Extract key terms from spec
148                    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        // Coverage checks
174        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        // Recommendations
200        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        // Write analysis
210        tokio::fs::write(&params.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        // Create some artifacts
260        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}