organizational_intelligence_plugin/
pmat.rs

1// pmat integration module
2// Toyota Way: Integrate existing quality tools rather than reinventing
3// Phase 1.5: Add TDG, SATD, and complexity metrics to defect analysis
4
5use anyhow::{anyhow, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::Path;
9use std::process::Command;
10use tracing::{debug, info, warn};
11
12/// TDG analysis result for a file
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct FileTdgScore {
15    pub path: String,
16    pub score: f32,
17    pub grade: String,
18}
19
20/// Aggregated TDG results for a repository
21#[derive(Debug, Clone)]
22pub struct TdgAnalysis {
23    /// Map of file path to TDG score
24    pub file_scores: HashMap<String, f32>,
25    /// Average TDG score across all files
26    pub average_score: f32,
27    /// Maximum TDG score (worst file)
28    pub max_score: f32,
29}
30
31/// pmat integration wrapper
32pub struct PmatIntegration;
33
34impl PmatIntegration {
35    /// Run pmat TDG analysis on a repository
36    ///
37    /// # Arguments
38    /// * `repo_path` - Path to the repository to analyze
39    ///
40    /// # Returns
41    /// * `Ok(TdgAnalysis)` with TDG scores
42    /// * `Err` if pmat is not available or analysis fails
43    ///
44    /// # Examples
45    /// ```no_run
46    /// use organizational_intelligence_plugin::pmat::PmatIntegration;
47    /// use std::path::PathBuf;
48    ///
49    /// let analysis = PmatIntegration::analyze_tdg(&PathBuf::from("/tmp/repo")).unwrap();
50    /// println!("Average TDG: {}", analysis.average_score);
51    /// ```
52    pub fn analyze_tdg<P: AsRef<Path>>(repo_path: P) -> Result<TdgAnalysis> {
53        let path = repo_path.as_ref();
54        info!("Running pmat TDG analysis on {:?}", path);
55
56        // Check if pmat is available
57        if !Self::is_pmat_available() {
58            warn!("pmat command not found - TDG analysis unavailable");
59            return Err(anyhow!("pmat command not available in PATH"));
60        }
61
62        // Run pmat analyze tdg --path {repo_path} --format json
63        let output = Command::new("pmat")
64            .args(["analyze", "tdg", "--path"])
65            .arg(path)
66            .args(["--format", "json"])
67            .output()
68            .map_err(|e| anyhow!("Failed to execute pmat: {}", e))?;
69
70        if !output.status.success() {
71            let stderr = String::from_utf8_lossy(&output.stderr);
72            return Err(anyhow!("pmat tdg failed: {}", stderr));
73        }
74
75        // Parse JSON output
76        let stdout = String::from_utf8_lossy(&output.stdout);
77        debug!("pmat output: {}", stdout);
78
79        Self::parse_tdg_output(&stdout)
80    }
81
82    /// Check if pmat command is available
83    fn is_pmat_available() -> bool {
84        Command::new("pmat")
85            .arg("--version")
86            .output()
87            .map(|output| output.status.success())
88            .unwrap_or(false)
89    }
90
91    /// Parse pmat TDG JSON output
92    fn parse_tdg_output(json_output: &str) -> Result<TdgAnalysis> {
93        // pmat outputs JSON with file scores
94        // Actual format: {"files": [{"file_path": "src/main.rs", "total": 95.0, "grade": "APLus"}]}
95
96        #[derive(Deserialize)]
97        struct PmatFile {
98            file_path: String,
99            total: f32,
100            #[allow(dead_code)]
101            #[serde(default)]
102            grade: String,
103        }
104
105        #[derive(Deserialize)]
106        struct PmatOutput {
107            files: Vec<PmatFile>,
108        }
109
110        let parsed: PmatOutput = serde_json::from_str(json_output)
111            .map_err(|e| anyhow!("Failed to parse pmat JSON: {}", e))?;
112
113        let mut file_scores = HashMap::new();
114        let mut total_score = 0.0_f32;
115        let mut max_score = 0.0_f32;
116
117        for file in &parsed.files {
118            file_scores.insert(file.file_path.clone(), file.total);
119            total_score += file.total;
120            max_score = max_score.max(file.total);
121        }
122
123        let average_score = if parsed.files.is_empty() {
124            0.0
125        } else {
126            total_score / parsed.files.len() as f32
127        };
128
129        Ok(TdgAnalysis {
130            file_scores,
131            average_score,
132            max_score,
133        })
134    }
135
136    /// Get TDG score for a specific file
137    ///
138    /// # Arguments
139    /// * `analysis` - TDG analysis result
140    /// * `file_path` - Path to look up
141    ///
142    /// # Returns
143    /// * `Some(score)` if file was analyzed
144    /// * `None` if file not found
145    pub fn get_file_score(analysis: &TdgAnalysis, file_path: &str) -> Option<f32> {
146        analysis.file_scores.get(file_path).copied()
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_parse_tdg_output() {
156        let json = r#"{
157            "files": [
158                {"file_path": "src/main.rs", "total": 95.0, "grade": "APLus"},
159                {"file_path": "src/lib.rs", "total": 88.0, "grade": "A"}
160            ]
161        }"#;
162
163        let result = PmatIntegration::parse_tdg_output(json).unwrap();
164
165        assert_eq!(result.average_score, 91.5);
166        assert_eq!(result.max_score, 95.0);
167        assert_eq!(result.file_scores.len(), 2);
168        assert_eq!(result.file_scores.get("src/main.rs"), Some(&95.0));
169    }
170
171    #[test]
172    fn test_parse_empty_tdg_output() {
173        let json = r#"{
174            "files": []
175        }"#;
176
177        let result = PmatIntegration::parse_tdg_output(json).unwrap();
178
179        assert_eq!(result.average_score, 0.0);
180        assert_eq!(result.max_score, 0.0);
181        assert_eq!(result.file_scores.len(), 0);
182    }
183
184    #[test]
185    fn test_get_file_score() {
186        let mut file_scores = HashMap::new();
187        file_scores.insert("src/main.rs".to_string(), 95.0);
188        file_scores.insert("src/lib.rs".to_string(), 88.0);
189
190        let analysis = TdgAnalysis {
191            file_scores,
192            average_score: 91.5,
193            max_score: 95.0,
194        };
195
196        assert_eq!(
197            PmatIntegration::get_file_score(&analysis, "src/main.rs"),
198            Some(95.0)
199        );
200        assert_eq!(
201            PmatIntegration::get_file_score(&analysis, "nonexistent.rs"),
202            None
203        );
204    }
205
206    // Integration test requiring pmat to be installed
207    #[test]
208    #[ignore]
209    fn test_analyze_tdg_integration() {
210        // This test requires pmat to be installed
211        let temp_dir = tempfile::TempDir::new().unwrap();
212
213        // Create a simple Rust file
214        std::fs::write(
215            temp_dir.path().join("test.rs"),
216            "fn main() { println!(\"Hello\"); }",
217        )
218        .unwrap();
219
220        let result = PmatIntegration::analyze_tdg(temp_dir.path());
221
222        // Should either succeed or fail gracefully if pmat not available
223        match result {
224            Ok(analysis) => {
225                assert!(analysis.average_score >= 0.0);
226                assert!(analysis.average_score <= 100.0);
227            }
228            Err(e) => {
229                // Expected if pmat not installed
230                assert!(e.to_string().contains("pmat"));
231            }
232        }
233    }
234
235    #[test]
236    fn test_parse_tdg_invalid_json() {
237        let invalid_json = "not valid json";
238
239        let result = PmatIntegration::parse_tdg_output(invalid_json);
240        assert!(result.is_err());
241        assert!(result.unwrap_err().to_string().contains("parse"));
242    }
243
244    #[test]
245    fn test_parse_tdg_single_file() {
246        let json = r#"{
247            "files": [
248                {"file_path": "src/single.rs", "total": 100.0, "grade": "APlusPlus"}
249            ]
250        }"#;
251
252        let result = PmatIntegration::parse_tdg_output(json).unwrap();
253
254        assert_eq!(result.average_score, 100.0);
255        assert_eq!(result.max_score, 100.0);
256        assert_eq!(result.file_scores.len(), 1);
257    }
258
259    #[test]
260    fn test_parse_tdg_multiple_files() {
261        let json = r#"{
262            "files": [
263                {"file_path": "file1.rs", "total": 90.0, "grade": "A"},
264                {"file_path": "file2.rs", "total": 85.0, "grade": "B"},
265                {"file_path": "file3.rs", "total": 95.0, "grade": "APLus"}
266            ]
267        }"#;
268
269        let result = PmatIntegration::parse_tdg_output(json).unwrap();
270
271        assert_eq!(result.average_score, 90.0);
272        assert_eq!(result.max_score, 95.0);
273        assert_eq!(result.file_scores.len(), 3);
274    }
275
276    #[test]
277    fn test_parse_tdg_with_zero_scores() {
278        let json = r#"{
279            "files": [
280                {"file_path": "bad1.rs", "total": 0.0, "grade": "F"},
281                {"file_path": "bad2.rs", "total": 0.0, "grade": "F"}
282            ]
283        }"#;
284
285        let result = PmatIntegration::parse_tdg_output(json).unwrap();
286
287        assert_eq!(result.average_score, 0.0);
288        assert_eq!(result.max_score, 0.0);
289    }
290
291    #[test]
292    fn test_parse_tdg_without_grade_field() {
293        let json = r#"{
294            "files": [
295                {"file_path": "src/main.rs", "total": 88.5}
296            ]
297        }"#;
298
299        let result = PmatIntegration::parse_tdg_output(json).unwrap();
300
301        assert_eq!(result.average_score, 88.5);
302        assert_eq!(result.max_score, 88.5);
303    }
304
305    #[test]
306    fn test_file_tdg_score_structure() {
307        let score = FileTdgScore {
308            path: "src/test.rs".to_string(),
309            score: 92.5,
310            grade: "A".to_string(),
311        };
312
313        assert_eq!(score.path, "src/test.rs");
314        assert_eq!(score.score, 92.5);
315        assert_eq!(score.grade, "A");
316    }
317
318    #[test]
319    fn test_file_tdg_score_clone() {
320        let original = FileTdgScore {
321            path: "src/test.rs".to_string(),
322            score: 92.5,
323            grade: "A".to_string(),
324        };
325
326        let cloned = original.clone();
327
328        assert_eq!(original.path, cloned.path);
329        assert_eq!(original.score, cloned.score);
330        assert_eq!(original.grade, cloned.grade);
331    }
332
333    #[test]
334    fn test_file_tdg_score_debug() {
335        let score = FileTdgScore {
336            path: "src/test.rs".to_string(),
337            score: 92.5,
338            grade: "A".to_string(),
339        };
340
341        let debug_str = format!("{:?}", score);
342        assert!(debug_str.contains("src/test.rs"));
343        assert!(debug_str.contains("92.5"));
344        assert!(debug_str.contains("A"));
345    }
346
347    #[test]
348    fn test_tdg_analysis_clone() {
349        let mut file_scores = HashMap::new();
350        file_scores.insert("file.rs".to_string(), 85.0);
351
352        let original = TdgAnalysis {
353            file_scores: file_scores.clone(),
354            average_score: 85.0,
355            max_score: 85.0,
356        };
357
358        let cloned = original.clone();
359
360        assert_eq!(original.average_score, cloned.average_score);
361        assert_eq!(original.max_score, cloned.max_score);
362        assert_eq!(original.file_scores.len(), cloned.file_scores.len());
363    }
364
365    #[test]
366    fn test_tdg_analysis_debug() {
367        let mut file_scores = HashMap::new();
368        file_scores.insert("file.rs".to_string(), 85.0);
369
370        let analysis = TdgAnalysis {
371            file_scores,
372            average_score: 85.0,
373            max_score: 85.0,
374        };
375
376        let debug_str = format!("{:?}", analysis);
377        assert!(debug_str.contains("85"));
378    }
379
380    #[test]
381    fn test_get_file_score_nonexistent() {
382        let analysis = TdgAnalysis {
383            file_scores: HashMap::new(),
384            average_score: 0.0,
385            max_score: 0.0,
386        };
387
388        assert_eq!(
389            PmatIntegration::get_file_score(&analysis, "missing.rs"),
390            None
391        );
392    }
393
394    #[test]
395    fn test_get_file_score_empty_analysis() {
396        let analysis = TdgAnalysis {
397            file_scores: HashMap::new(),
398            average_score: 0.0,
399            max_score: 0.0,
400        };
401
402        assert_eq!(PmatIntegration::get_file_score(&analysis, "any.rs"), None);
403    }
404
405    #[test]
406    fn test_parse_tdg_with_various_scores() {
407        let json = r#"{
408            "files": [
409                {"file_path": "low.rs", "total": 10.5, "grade": "F"},
410                {"file_path": "medium.rs", "total": 55.0, "grade": "C"},
411                {"file_path": "high.rs", "total": 99.9, "grade": "APlusPlus"}
412            ]
413        }"#;
414
415        let result = PmatIntegration::parse_tdg_output(json).unwrap();
416
417        assert!(result.average_score > 50.0 && result.average_score < 60.0);
418        assert_eq!(result.max_score, 99.9);
419        assert_eq!(
420            PmatIntegration::get_file_score(&result, "low.rs"),
421            Some(10.5)
422        );
423    }
424
425    #[test]
426    fn test_file_tdg_score_serialization() {
427        let score = FileTdgScore {
428            path: "src/test.rs".to_string(),
429            score: 92.5,
430            grade: "A".to_string(),
431        };
432
433        let json = serde_json::to_string(&score).unwrap();
434        let deserialized: FileTdgScore = serde_json::from_str(&json).unwrap();
435
436        assert_eq!(score.path, deserialized.path);
437        assert_eq!(score.score, deserialized.score);
438        assert_eq!(score.grade, deserialized.grade);
439    }
440
441    #[test]
442    fn test_parse_tdg_fractional_average() {
443        let json = r#"{
444            "files": [
445                {"file_path": "file1.rs", "total": 33.3, "grade": "D"},
446                {"file_path": "file2.rs", "total": 66.6, "grade": "B"},
447                {"file_path": "file3.rs", "total": 99.9, "grade": "APlusPlus"}
448            ]
449        }"#;
450
451        let result = PmatIntegration::parse_tdg_output(json).unwrap();
452
453        // Average should be (33.3 + 66.6 + 99.9) / 3 = 66.6
454        assert!((result.average_score - 66.6).abs() < 0.1);
455    }
456
457    #[test]
458    fn test_parse_tdg_with_long_file_paths() {
459        let long_path = "a/very/long/path/to/some/deeply/nested/directory/structure/file.rs";
460        let json = format!(
461            r#"{{
462            "files": [
463                {{"file_path": "{}", "total": 85.0, "grade": "A"}}
464            ]
465        }}"#,
466            long_path
467        );
468
469        let result = PmatIntegration::parse_tdg_output(&json).unwrap();
470
471        assert_eq!(
472            PmatIntegration::get_file_score(&result, long_path),
473            Some(85.0)
474        );
475    }
476
477    #[test]
478    fn test_is_pmat_available() {
479        // This test will pass or fail depending on whether pmat is installed
480        // But it exercises the code path
481        let _available = PmatIntegration::is_pmat_available();
482        // Just verify it returns without panicking
483    }
484}