Skip to main content

batuta/stack/
quality_checker.rs

1//! Quality Checker
2//!
3//! Runs quality assessments on PAIML stack components using PMAT tools.
4
5use anyhow::{anyhow, Result};
6use std::path::{Path, PathBuf};
7
8use super::hero_image::HeroImageResult;
9use super::quality::{ComponentQuality, QualityGrade, Score, StackQualityReport};
10
11/// README sections to check and their point values.
12const SECTION_CHECKS: &[(&str, u32)] =
13    &[("installation", 3), ("usage", 3), ("license", 3), ("contributing", 3)];
14
15/// Award `points` if `path.join(file)` exists, otherwise 0.
16fn score_if_exists(path: &Path, file: &str, points: u32) -> u32 {
17    if path.join(file).exists() {
18        points
19    } else {
20        0
21    }
22}
23
24/// Check whether a README section header exists (e.g. `## installation` or `# installation`).
25fn check_section_exists(content_lower: &str, section: &str) -> bool {
26    content_lower.contains(&format!("## {}", section))
27        || content_lower.contains(&format!("# {}", section))
28}
29
30/// Extract an `f64` from a [`serde_json::Value`], returning `default` on failure.
31fn extract_json_f64(value: &serde_json::Value, default: f64) -> f64 {
32    value.as_f64().unwrap_or(default)
33}
34
35/// Run `cargo <subcommand>` in `dir` and return `points` if the command succeeds.
36fn run_command_score(dir: &Path, args: &[&str], points: u32) -> u32 {
37    use std::process::Command;
38
39    let result = Command::new("cargo").args(args).current_dir(dir).output();
40    if result.map(|o| o.status.success()).unwrap_or(false) {
41        points
42    } else {
43        0
44    }
45}
46
47/// Quality matrix checker for PAIML stack components
48pub struct QualityChecker {
49    /// Workspace root path
50    workspace_root: PathBuf,
51    /// Minimum required grade
52    min_grade: QualityGrade,
53    /// Whether to require strict A+ for all
54    strict: bool,
55}
56
57impl QualityChecker {
58    /// Create a new quality checker
59    pub fn new(workspace_root: PathBuf) -> Self {
60        Self { workspace_root, min_grade: QualityGrade::AMinus, strict: false }
61    }
62
63    /// Set minimum required grade
64    pub fn with_min_grade(mut self, grade: QualityGrade) -> Self {
65        self.min_grade = grade;
66        self
67    }
68
69    /// Enable strict A+ mode
70    pub fn strict(mut self, strict: bool) -> Self {
71        self.strict = strict;
72        self
73    }
74
75    /// Check quality for a single component
76    pub async fn check_component(&self, name: &str) -> Result<ComponentQuality> {
77        let path = self.find_component_path(name)?;
78
79        // Run pmat rust-project-score
80        let rust_score = self.run_rust_project_score(&path).await?;
81
82        // Run pmat repo-score
83        let (repo_score, readme_score) = self.run_repo_score(&path).await?;
84
85        // Detect hero image
86        let hero_image = HeroImageResult::detect(&path);
87
88        Ok(ComponentQuality::new(name, path, rust_score, repo_score, readme_score, hero_image))
89    }
90
91    /// Check quality for all stack components
92    pub async fn check_all(&self) -> Result<StackQualityReport> {
93        use crate::stack::PAIML_CRATES;
94
95        let mut components = Vec::new();
96
97        for crate_name in PAIML_CRATES {
98            match self.check_component(crate_name).await {
99                Ok(quality) => components.push(quality),
100                Err(e) => {
101                    // Log error but continue with other components
102                    tracing::warn!("Failed to check {}: {}", crate_name, e);
103                }
104            }
105        }
106
107        Ok(StackQualityReport::from_components(components))
108    }
109
110    /// Find path to component repository
111    fn find_component_path(&self, name: &str) -> Result<PathBuf> {
112        // Check if it's the current workspace
113        let cargo_toml = self.workspace_root.join("Cargo.toml");
114        if cargo_toml.exists() {
115            if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
116                if content.contains(&format!("name = \"{}\"", name)) {
117                    return Ok(self.workspace_root.clone());
118                }
119            }
120        }
121
122        // Check parent directory for sibling projects
123        if let Some(parent) = self.workspace_root.parent() {
124            let sibling = parent.join(name);
125            if sibling.exists() && sibling.join("Cargo.toml").exists() {
126                return Ok(sibling);
127            }
128        }
129
130        Err(anyhow!("Could not find component: {}", name))
131    }
132
133    /// Run pmat rust-project-score on a path
134    async fn run_rust_project_score(&self, path: &Path) -> Result<Score> {
135        use std::process::Command;
136
137        let output = Command::new("pmat")
138            .args(["rust-project-score", "--path"])
139            .arg(path)
140            .args(["--format", "json"])
141            .output();
142
143        match output {
144            Ok(output) if output.status.success() => {
145                // Parse JSON output from pmat
146                if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&output.stdout) {
147                    // pmat uses total_earned/total_possible (scale varies, normalize to 114)
148                    let earned = extract_json_f64(&json["total_earned"], 0.0);
149                    let possible = extract_json_f64(&json["total_possible"], 134.0);
150                    let percentage = extract_json_f64(&json["percentage"], 0.0);
151
152                    // Normalize to 0-114 scale for consistent grading
153                    let normalized_score = ((percentage / 100.0) * 114.0).round() as u32;
154                    let grade = QualityGrade::from_rust_project_score(normalized_score);
155
156                    // Store actual earned/possible but use normalized for grade
157                    return Ok(Score {
158                        value: earned.round() as u32,
159                        max: possible.round() as u32,
160                        grade,
161                    });
162                }
163            }
164            Ok(output) => {
165                // Log stderr for debugging
166                let stderr = String::from_utf8_lossy(&output.stderr);
167                if !stderr.is_empty() {
168                    tracing::debug!("pmat stderr: {}", stderr);
169                }
170            }
171            Err(e) => {
172                tracing::debug!("pmat not available: {}", e);
173            }
174        }
175
176        // Fallback: estimate score from cargo test and clippy
177        self.estimate_rust_score(path).await
178    }
179
180    /// Estimate rust project score when pmat is not available
181    async fn estimate_rust_score(&self, path: &Path) -> Result<Score> {
182        let mut score = 50u32; // Base score
183
184        // Check if tests pass (+20)
185        score += run_command_score(path, &["test", "--quiet"], 20);
186
187        // Check if clippy passes (+15)
188        score += run_command_score(path, &["clippy", "--quiet", "--", "-D", "warnings"], 15);
189
190        // Check for documentation (+10)
191        score += score_if_exists(path, "README.md", 10);
192
193        // Check for Cargo.toml metadata (+5)
194        let cargo_toml = path.join("Cargo.toml");
195        if cargo_toml.exists() {
196            if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
197                if content.contains("[package.metadata") || content.contains("documentation =") {
198                    score += 5;
199                }
200            }
201        }
202
203        let grade = QualityGrade::from_rust_project_score(score);
204        Ok(Score::new(score, 114, grade))
205    }
206
207    /// Run pmat repo-score on a path
208    async fn run_repo_score(&self, path: &Path) -> Result<(Score, Score)> {
209        use std::process::Command;
210
211        let output = Command::new("pmat")
212            .args(["repo-score", "--path"])
213            .arg(path)
214            .args(["--format", "json"])
215            .output();
216
217        match output {
218            Ok(output) if output.status.success() => {
219                if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&output.stdout) {
220                    // total_score is a float in pmat output
221                    let total = extract_json_f64(&json["total_score"], 0.0).round() as u32;
222
223                    // Extract documentation score from categories
224                    let readme = extract_json_f64(
225                        &json["categories"]["documentation"]["score"],
226                        0.0,
227                    )
228                    .round() as u32;
229
230                    let repo_grade = QualityGrade::from_repo_score(total);
231                    let readme_grade = QualityGrade::from_readme_score(readme);
232
233                    return Ok((
234                        Score::new(total, 110, repo_grade),
235                        Score::new(readme, 20, readme_grade),
236                    ));
237                }
238            }
239            Ok(output) => {
240                let stderr = String::from_utf8_lossy(&output.stderr);
241                if !stderr.is_empty() {
242                    tracing::debug!("pmat repo-score stderr: {}", stderr);
243                }
244            }
245            Err(e) => {
246                tracing::debug!("pmat repo-score not available: {}", e);
247            }
248        }
249
250        // Fallback: estimate scores
251        self.estimate_repo_scores(path).await
252    }
253
254    /// Estimate repo and readme scores when pmat is not available
255    async fn estimate_repo_scores(&self, path: &Path) -> Result<(Score, Score)> {
256        let mut repo_score = 40u32; // Base score
257        let mut readme_score = 0u32;
258
259        // Check README.md (+10 base, +points per section)
260        let readme_path = path.join("README.md");
261        if readme_path.exists() {
262            repo_score += 10;
263            readme_score += 5; // Base for existing
264
265            if let Ok(content) = std::fs::read_to_string(&readme_path) {
266                let content_lower = content.to_lowercase();
267
268                // Check for required sections via table-driven lookup
269                for &(section, points) in SECTION_CHECKS {
270                    if check_section_exists(&content_lower, section) {
271                        readme_score += points;
272                    }
273                }
274                if content.len() > 500 {
275                    readme_score += 3; // Substantial content
276                }
277            }
278        }
279
280        // Check for Makefile (+15)
281        repo_score += score_if_exists(path, "Makefile", 15);
282
283        // Check for CI (+15)
284        repo_score += score_if_exists(path, ".github/workflows", 15);
285
286        // Check for pre-commit hooks (+10)
287        if path.join(".pre-commit-config.yaml").exists()
288            || path.join(".git/hooks/pre-commit").exists()
289        {
290            repo_score += 10;
291        }
292
293        readme_score = readme_score.min(20);
294        let repo_grade = QualityGrade::from_repo_score(repo_score);
295        let readme_grade = QualityGrade::from_readme_score(readme_score);
296
297        Ok((Score::new(repo_score, 110, repo_grade), Score::new(readme_score, 20, readme_grade)))
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use std::path::PathBuf;
305
306    /// Create a fresh temp directory, removing any stale leftover first.
307    fn setup_test_dir(name: &str) -> PathBuf {
308        let dir = std::env::temp_dir().join(format!("{}_{}", name, std::process::id()));
309        let _ = std::fs::remove_dir_all(&dir);
310        std::fs::create_dir_all(&dir).expect("mkdir failed");
311        dir
312    }
313
314    /// Best-effort cleanup of a temp directory.
315    fn cleanup_test_dir(dir: &Path) {
316        let _ = std::fs::remove_dir_all(dir);
317    }
318
319    // ===== Free function tests =====
320
321    #[test]
322    fn test_score_if_exists_present() {
323        let dir = setup_test_dir("test_qc_score_exists");
324        std::fs::write(dir.join("README.md"), "hello").expect("fs write failed");
325        assert_eq!(score_if_exists(&dir, "README.md", 10), 10);
326        cleanup_test_dir(&dir);
327    }
328
329    #[test]
330    fn test_score_if_exists_absent() {
331        let dir = setup_test_dir("test_qc_score_absent");
332        assert_eq!(score_if_exists(&dir, "README.md", 10), 0);
333        cleanup_test_dir(&dir);
334    }
335
336    #[test]
337    fn test_check_section_exists_h2() {
338        assert!(check_section_exists("## installation\nsome text", "installation"));
339    }
340
341    #[test]
342    fn test_check_section_exists_h1() {
343        assert!(check_section_exists("# usage\nsome text", "usage"));
344    }
345
346    #[test]
347    fn test_check_section_exists_missing() {
348        assert!(!check_section_exists("some random text", "installation"));
349    }
350
351    #[test]
352    fn test_extract_json_f64_present() {
353        let val = serde_json::json!(42.5);
354        assert_eq!(extract_json_f64(&val, 0.0), 42.5);
355    }
356
357    #[test]
358    fn test_extract_json_f64_null() {
359        let val = serde_json::json!(null);
360        assert_eq!(extract_json_f64(&val, 99.0), 99.0);
361    }
362
363    #[test]
364    fn test_extract_json_f64_string() {
365        let val = serde_json::json!("not a number");
366        assert_eq!(extract_json_f64(&val, 7.0), 7.0);
367    }
368
369    #[test]
370    fn test_extract_json_f64_integer() {
371        let val = serde_json::json!(100);
372        assert_eq!(extract_json_f64(&val, 0.0), 100.0);
373    }
374
375    #[test]
376    fn test_run_command_score_success() {
377        // run_command_score runs `cargo <args>`, so use a cargo subcommand
378        let dir = setup_test_dir("test_qc_cmd_score");
379        assert_eq!(run_command_score(&dir, &["--version"], 20), 20);
380        cleanup_test_dir(&dir);
381    }
382
383    #[test]
384    fn test_run_command_score_failure() {
385        let dir = setup_test_dir("test_qc_cmd_fail");
386        assert_eq!(run_command_score(&dir, &["false"], 20), 0);
387        cleanup_test_dir(&dir);
388    }
389
390    #[test]
391    fn test_run_command_score_not_found() {
392        let dir = setup_test_dir("test_qc_cmd_notfound");
393        assert_eq!(run_command_score(&dir, &["nonexistent_tool_xyz_abc"], 20), 0);
394        cleanup_test_dir(&dir);
395    }
396
397    // ===== QualityChecker construction =====
398
399    #[test]
400    fn test_quality_checker_creation() {
401        let checker = QualityChecker::new(PathBuf::from("/tmp"));
402        assert_eq!(checker.min_grade, QualityGrade::AMinus);
403        assert!(!checker.strict);
404    }
405
406    #[test]
407    fn test_quality_checker_with_min_grade() {
408        let checker =
409            QualityChecker::new(PathBuf::from("/tmp")).with_min_grade(QualityGrade::APlus);
410        assert_eq!(checker.min_grade, QualityGrade::APlus);
411    }
412
413    #[test]
414    fn test_quality_checker_strict() {
415        let checker = QualityChecker::new(PathBuf::from("/tmp")).strict(true);
416        assert!(checker.strict);
417    }
418
419    #[test]
420    fn test_quality_checker_chaining() {
421        let checker = QualityChecker::new(PathBuf::from("/tmp"))
422            .with_min_grade(QualityGrade::AMinus)
423            .strict(true);
424        assert_eq!(checker.min_grade, QualityGrade::AMinus);
425        assert!(checker.strict);
426    }
427
428    // ===== find_component_path =====
429
430    #[test]
431    fn test_find_component_path_current_workspace() {
432        let temp_dir = setup_test_dir("test_quality_checker_workspace");
433        std::fs::write(
434            temp_dir.join("Cargo.toml"),
435            "[package]\nname = \"test-crate\"\nversion = \"1.0.0\"\n",
436        )
437        .expect("unexpected failure");
438        let checker = QualityChecker::new(temp_dir.clone());
439        let path = checker.find_component_path("test-crate").expect("unexpected failure");
440        assert_eq!(path, temp_dir);
441        cleanup_test_dir(&temp_dir);
442    }
443
444    #[test]
445    fn test_find_component_path_sibling() {
446        let temp_dir = setup_test_dir("test_quality_siblings");
447        let project_a = temp_dir.join("project-a");
448        let project_b = temp_dir.join("project-b");
449        std::fs::create_dir_all(&project_a).expect("mkdir failed");
450        std::fs::create_dir_all(&project_b).expect("mkdir failed");
451        std::fs::write(
452            project_a.join("Cargo.toml"),
453            "[package]\nname = \"project-a\"\nversion = \"1.0.0\"\n",
454        )
455        .expect("unexpected failure");
456        std::fs::write(
457            project_b.join("Cargo.toml"),
458            "[package]\nname = \"project-b\"\nversion = \"1.0.0\"\n",
459        )
460        .expect("unexpected failure");
461        let checker = QualityChecker::new(project_a.clone());
462        let path = checker.find_component_path("project-b").expect("unexpected failure");
463        assert_eq!(path, project_b);
464        cleanup_test_dir(&temp_dir);
465    }
466
467    #[test]
468    fn test_find_component_path_not_found() {
469        let temp_dir = setup_test_dir("test_quality_not_found");
470        let checker = QualityChecker::new(temp_dir.clone());
471        assert!(checker.find_component_path("nonexistent-crate").is_err());
472        cleanup_test_dir(&temp_dir);
473    }
474
475    #[test]
476    fn test_find_component_no_cargo_toml() {
477        let temp_dir = setup_test_dir("test_quality_no_cargo");
478        let checker = QualityChecker::new(temp_dir.clone());
479        assert!(checker.find_component_path("any-crate").is_err());
480        cleanup_test_dir(&temp_dir);
481    }
482
483    #[test]
484    fn test_find_component_cargo_toml_no_match() {
485        let temp_dir = setup_test_dir("test_quality_no_match");
486        std::fs::write(
487            temp_dir.join("Cargo.toml"),
488            "[package]\nname = \"other-crate\"\nversion = \"1.0.0\"\n",
489        )
490        .expect("unexpected failure");
491        let checker = QualityChecker::new(temp_dir.clone());
492        // Name doesn't match and no sibling → error
493        assert!(checker.find_component_path("wanted-crate").is_err());
494        cleanup_test_dir(&temp_dir);
495    }
496
497    // ===== estimate_repo_scores =====
498
499    #[tokio::test]
500    async fn test_estimate_repo_scores_empty_dir() {
501        let dir = setup_test_dir("test_qc_repo_empty");
502        let checker = QualityChecker::new(dir.clone());
503        let (repo, readme) =
504            checker.estimate_repo_scores(&dir).await.expect("async operation failed");
505        assert_eq!(repo.value, 40); // base only
506        assert_eq!(readme.value, 0);
507        cleanup_test_dir(&dir);
508    }
509
510    #[tokio::test]
511    async fn test_estimate_repo_scores_with_readme() {
512        let dir = setup_test_dir("test_qc_repo_readme");
513        // 5 (base) + 3 (installation) + 3 (usage) + 3 (license) + 3 (contributing) = 17
514        std::fs::write(
515            dir.join("README.md"),
516            "# My Project\n\n## Installation\n\nRun cargo install.\n\n## Usage\n\nJust run it.\n\n## License\n\nMIT\n\n## Contributing\n\nPRs welcome.\n",
517        )
518        .expect("unexpected failure");
519        let checker = QualityChecker::new(dir.clone());
520        let (repo, readme) =
521            checker.estimate_repo_scores(&dir).await.expect("async operation failed");
522        assert!(repo.value > 40); // base + README
523        assert_eq!(readme.value, 17);
524        cleanup_test_dir(&dir);
525    }
526
527    #[tokio::test]
528    async fn test_estimate_repo_scores_with_makefile_and_ci() {
529        let dir = setup_test_dir("test_qc_repo_mk_ci");
530        std::fs::write(dir.join("Makefile"), "all:\n\ttrue\n").expect("fs write failed");
531        std::fs::create_dir_all(dir.join(".github/workflows")).expect("mkdir failed");
532        let checker = QualityChecker::new(dir.clone());
533        let (repo, _) = checker.estimate_repo_scores(&dir).await.expect("async operation failed");
534        assert_eq!(repo.value, 40 + 15 + 15); // base + Makefile + CI
535        cleanup_test_dir(&dir);
536    }
537
538    #[tokio::test]
539    async fn test_estimate_repo_scores_with_precommit() {
540        let dir = setup_test_dir("test_qc_repo_precommit");
541        std::fs::write(dir.join(".pre-commit-config.yaml"), "repos: []\n")
542            .expect("fs write failed");
543        let checker = QualityChecker::new(dir.clone());
544        let (repo, _) = checker.estimate_repo_scores(&dir).await.expect("async operation failed");
545        assert_eq!(repo.value, 40 + 10); // base + pre-commit
546        cleanup_test_dir(&dir);
547    }
548
549    #[tokio::test]
550    async fn test_estimate_repo_scores_readme_partial_sections() {
551        let dir = setup_test_dir("test_qc_repo_partial");
552        std::fs::write(dir.join("README.md"), "# Proj\n\n## Installation\nstuff\n")
553            .expect("fs write failed");
554        let checker = QualityChecker::new(dir.clone());
555        let (_, readme) = checker.estimate_repo_scores(&dir).await.expect("async operation failed");
556        // 5 base + 3 installation = 8
557        assert_eq!(readme.value, 8);
558        cleanup_test_dir(&dir);
559    }
560
561    // ===== estimate_rust_score =====
562
563    #[tokio::test]
564    async fn test_estimate_rust_score_empty_dir() {
565        let dir = setup_test_dir("test_qc_rust_empty");
566        let checker = QualityChecker::new(dir.clone());
567        let score = checker.estimate_rust_score(&dir).await.expect("async operation failed");
568        // Base 50 + cargo test/clippy succeed on empty dirs (no code = no errors)
569        assert!(score.value >= 50, "score should be at least base: {}", score.value);
570        cleanup_test_dir(&dir);
571    }
572
573    #[tokio::test]
574    async fn test_estimate_rust_score_with_readme() {
575        let dir = setup_test_dir("test_qc_rust_readme");
576        std::fs::write(dir.join("README.md"), "# Hello").expect("fs write failed");
577        let checker = QualityChecker::new(dir.clone());
578        let score = checker.estimate_rust_score(&dir).await.expect("async operation failed");
579        // README adds 10 to whatever the base+cargo score is
580        assert!(score.value >= 60, "score with README should be >= 60: {}", score.value);
581        cleanup_test_dir(&dir);
582    }
583
584    #[tokio::test]
585    async fn test_estimate_rust_score_with_metadata() {
586        let dir = setup_test_dir("test_qc_rust_meta");
587        std::fs::write(
588            dir.join("Cargo.toml"),
589            "[package]\nname = \"x\"\ndocumentation = \"https://docs.rs/x\"\n",
590        )
591        .expect("unexpected failure");
592        let checker = QualityChecker::new(dir.clone());
593        let score = checker.estimate_rust_score(&dir).await.expect("async operation failed");
594        assert_eq!(score.value, 55); // 50 base + 5 metadata
595        cleanup_test_dir(&dir);
596    }
597
598    #[tokio::test]
599    async fn test_estimate_rust_score_with_package_metadata() {
600        let dir = setup_test_dir("test_qc_rust_pkgmeta");
601        std::fs::write(
602            dir.join("Cargo.toml"),
603            "[package]\nname = \"x\"\n[package.metadata.foo]\nbar = true\n",
604        )
605        .expect("unexpected failure");
606        let checker = QualityChecker::new(dir.clone());
607        let score = checker.estimate_rust_score(&dir).await.expect("async operation failed");
608        assert_eq!(score.value, 55); // 50 base + 5 metadata
609        cleanup_test_dir(&dir);
610    }
611
612    #[tokio::test]
613    async fn test_estimate_rust_score_no_cargo_toml() {
614        let dir = setup_test_dir("test_qc_rust_nocargo");
615        let checker = QualityChecker::new(dir.clone());
616        let score = checker.estimate_rust_score(&dir).await.expect("async operation failed");
617        // No Cargo.toml but cargo commands may still succeed (parent workspace)
618        assert!(score.value >= 50, "score should be at least base: {}", score.value);
619        cleanup_test_dir(&dir);
620    }
621
622    #[tokio::test]
623    async fn test_estimate_rust_score_with_readme_and_metadata() {
624        let dir = setup_test_dir("test_qc_rust_both");
625        std::fs::write(dir.join("README.md"), "# Project\n").expect("fs write failed");
626        std::fs::write(dir.join("Cargo.toml"), "[package]\nname = \"x\"\ndocumentation = \"y\"\n")
627            .expect("unexpected failure");
628        let checker = QualityChecker::new(dir.clone());
629        let score = checker.estimate_rust_score(&dir).await.expect("async operation failed");
630        assert_eq!(score.value, 65); // 50 + 10 README + 5 metadata
631        cleanup_test_dir(&dir);
632    }
633
634    // ===== run_rust_project_score / run_repo_score (pmat fallback) =====
635
636    #[tokio::test]
637    async fn test_run_rust_project_score_returns_valid() {
638        // pmat may or may not be installed; just verify we get a valid score
639        let dir = setup_test_dir("test_qc_pmat_fallback");
640        std::fs::write(dir.join("README.md"), "# Hi").expect("fs write failed");
641        let checker = QualityChecker::new(dir.clone());
642        let score = checker.run_rust_project_score(&dir).await.expect("async operation failed");
643        assert!(score.value > 0);
644        assert!(score.max > 0);
645        cleanup_test_dir(&dir);
646    }
647
648    #[tokio::test]
649    async fn test_run_repo_score_returns_valid() {
650        // pmat may or may not be installed; just verify we get valid scores
651        let dir = setup_test_dir("test_qc_repo_fallback");
652        std::fs::write(dir.join("README.md"), "# Project\n## Installation\nstuff\n")
653            .expect("fs write failed");
654        std::fs::write(dir.join("Makefile"), "all:\n").expect("fs write failed");
655        let checker = QualityChecker::new(dir.clone());
656        let (repo, readme) = checker.run_repo_score(&dir).await.expect("async operation failed");
657        assert!(repo.value > 0);
658        assert!(repo.max > 0);
659        assert!(readme.max > 0);
660        cleanup_test_dir(&dir);
661    }
662
663    // ===== check_component =====
664
665    #[tokio::test]
666    async fn test_check_component_self() {
667        let dir = setup_test_dir("test_qc_check_self");
668        std::fs::write(
669            dir.join("Cargo.toml"),
670            "[package]\nname = \"my-crate\"\nversion = \"0.1.0\"\n",
671        )
672        .expect("unexpected failure");
673        std::fs::write(dir.join("README.md"), "# My Crate\n## Usage\nstuff\n")
674            .expect("fs write failed");
675        let checker = QualityChecker::new(dir.clone());
676        let result = checker.check_component("my-crate").await.expect("async operation failed");
677        assert_eq!(result.name, "my-crate");
678        cleanup_test_dir(&dir);
679    }
680
681    #[tokio::test]
682    async fn test_check_component_not_found() {
683        let dir = setup_test_dir("test_qc_check_notfound");
684        let checker = QualityChecker::new(dir.clone());
685        let result = checker.check_component("nonexistent-xyz").await;
686        assert!(result.is_err());
687        cleanup_test_dir(&dir);
688    }
689
690    // ===== readme length bonus =====
691
692    #[tokio::test]
693    async fn test_estimate_repo_scores_readme_length_bonus() {
694        let dir = setup_test_dir("test_qc_readme_len");
695        let long_content = format!("# Project\n\n## Installation\n\n{}\n", "x".repeat(600));
696        std::fs::write(dir.join("README.md"), &long_content).expect("fs write failed");
697        let checker = QualityChecker::new(dir.clone());
698        let (_, readme) = checker.estimate_repo_scores(&dir).await.expect("async operation failed");
699        // 5 base + 3 installation + 3 length bonus = 11
700        assert_eq!(readme.value, 11);
701        cleanup_test_dir(&dir);
702    }
703
704    // ===== section checks edge cases =====
705
706    #[test]
707    fn test_check_section_exists_lowered_input() {
708        // The function expects content already lowercased by the caller
709        assert!(check_section_exists("## installation\n", "installation"));
710    }
711
712    #[test]
713    fn test_check_section_exists_no_hash() {
714        assert!(!check_section_exists("installation\n", "installation"));
715    }
716
717    // ===== pre-commit git hook detection =====
718
719    #[tokio::test]
720    async fn test_estimate_repo_scores_git_hook() {
721        let dir = setup_test_dir("test_qc_githook");
722        std::fs::create_dir_all(dir.join(".git/hooks")).expect("mkdir failed");
723        std::fs::write(dir.join(".git/hooks/pre-commit"), "#!/bin/sh\n").expect("fs write failed");
724        let checker = QualityChecker::new(dir.clone());
725        let (repo, _) = checker.estimate_repo_scores(&dir).await.expect("async operation failed");
726        assert_eq!(repo.value, 40 + 10); // base + pre-commit
727        cleanup_test_dir(&dir);
728    }
729
730    // ===== Coverage Gap: readme_score clamping =====
731
732    #[tokio::test]
733    async fn test_estimate_repo_scores_readme_score_capped_at_20() {
734        let dir = setup_test_dir("test_qc_readme_cap");
735        // Create README with all sections + long content to try to exceed 20
736        let long_content = format!(
737            "# Project\n\n## Installation\nstuff\n## Usage\nstuff\n## License\nMIT\n## Contributing\nYes\n\n{}\n",
738            "x".repeat(600)
739        );
740        std::fs::write(dir.join("README.md"), &long_content).expect("fs write failed");
741        let checker = QualityChecker::new(dir.clone());
742        let (_, readme) = checker.estimate_repo_scores(&dir).await.expect("async operation failed");
743        // 5 base + 3*4 sections + 3 length = 20, capped at 20
744        assert!(readme.value <= 20);
745        cleanup_test_dir(&dir);
746    }
747
748    // ===== Coverage Gap: find_component_path sibling without Cargo.toml =====
749
750    #[test]
751    fn test_find_component_sibling_dir_no_cargo_toml() {
752        let temp_dir = setup_test_dir("test_quality_sibling_no_cargo");
753        let project_a = temp_dir.join("project-a");
754        let project_b = temp_dir.join("project-b");
755        std::fs::create_dir_all(&project_a).expect("mkdir failed");
756        std::fs::create_dir_all(&project_b).expect("mkdir failed");
757        std::fs::write(
758            project_a.join("Cargo.toml"),
759            "[package]\nname = \"project-a\"\nversion = \"1.0.0\"\n",
760        )
761        .expect("unexpected failure");
762        // project-b exists but has NO Cargo.toml
763        let checker = QualityChecker::new(project_a.clone());
764        assert!(checker.find_component_path("project-b").is_err());
765        cleanup_test_dir(&temp_dir);
766    }
767
768    // ===== Coverage Gap: estimate_repo_scores all infrastructure =====
769
770    #[tokio::test]
771    async fn test_estimate_repo_scores_full_infrastructure() {
772        let dir = setup_test_dir("test_qc_full_infra");
773        // README with everything
774        let long_content = format!(
775            "# Project\n\n## Installation\nstuff\n## Usage\nstuff\n## License\nMIT\n## Contributing\nYes\n\n{}\n",
776            "x".repeat(600)
777        );
778        std::fs::write(dir.join("README.md"), &long_content).expect("fs write failed");
779        std::fs::write(dir.join("Makefile"), "all:\n\ttrue\n").expect("fs write failed");
780        std::fs::create_dir_all(dir.join(".github/workflows")).expect("mkdir failed");
781        std::fs::write(dir.join(".pre-commit-config.yaml"), "repos: []\n")
782            .expect("fs write failed");
783        let checker = QualityChecker::new(dir.clone());
784        let (repo, readme) =
785            checker.estimate_repo_scores(&dir).await.expect("async operation failed");
786        // base(40) + readme(10) + makefile(15) + ci(15) + precommit(10) = 90
787        assert_eq!(repo.value, 90);
788        assert_eq!(readme.value, 20); // capped
789        cleanup_test_dir(&dir);
790    }
791
792    // ===== Coverage Gap: check_component with sibling =====
793
794    #[tokio::test]
795    async fn test_check_component_sibling() {
796        let temp_dir = setup_test_dir("test_qc_check_sibling");
797        let project_a = temp_dir.join("project-a");
798        let project_b = temp_dir.join("project-b");
799        std::fs::create_dir_all(&project_a).expect("mkdir failed");
800        std::fs::create_dir_all(&project_b).expect("mkdir failed");
801        std::fs::write(
802            project_a.join("Cargo.toml"),
803            "[package]\nname = \"project-a\"\nversion = \"1.0.0\"\n",
804        )
805        .expect("unexpected failure");
806        std::fs::write(
807            project_b.join("Cargo.toml"),
808            "[package]\nname = \"project-b\"\nversion = \"1.0.0\"\n",
809        )
810        .expect("unexpected failure");
811        std::fs::write(project_b.join("README.md"), "# Project B\n").expect("fs write failed");
812        let checker = QualityChecker::new(project_a.clone());
813        let result = checker.check_component("project-b").await.expect("async operation failed");
814        assert_eq!(result.name, "project-b");
815        cleanup_test_dir(&temp_dir);
816    }
817
818    // ===== Coverage Gap: estimate_rust_score Cargo.toml without relevant keys =====
819
820    #[tokio::test]
821    async fn test_estimate_rust_score_cargo_toml_no_metadata() {
822        let dir = setup_test_dir("test_qc_rust_nometadata");
823        std::fs::write(dir.join("Cargo.toml"), "[package]\nname = \"x\"\nversion = \"0.1.0\"\n")
824            .expect("unexpected failure");
825        let checker = QualityChecker::new(dir.clone());
826        let score = checker.estimate_rust_score(&dir).await.expect("async operation failed");
827        // 50 base, no documentation or metadata match
828        assert_eq!(score.value, 50);
829        cleanup_test_dir(&dir);
830    }
831
832    // ===== Coverage Gap: Score construction =====
833
834    #[test]
835    fn test_score_grade_assignment() {
836        let grade = QualityGrade::from_rust_project_score(100);
837        let score = Score::new(100, 114, grade);
838        assert_eq!(score.value, 100);
839        assert_eq!(score.max, 114);
840    }
841}