Skip to main content

foundry_validation/
lib.rs

1use foundry_types::CheckStatus;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::path::Path;
5use std::process::Command;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8pub struct CheckResult {
9    pub name: String,
10    pub status: CheckStatus,
11    pub detail: String,
12}
13
14pub fn has_failures(checks: &[CheckResult]) -> bool {
15    checks
16        .iter()
17        .any(|c| matches!(c.status, CheckStatus::Failed))
18}
19
20pub fn skipped_checks(checks: &[CheckResult]) -> Vec<String> {
21    checks
22        .iter()
23        .filter(|c| matches!(c.status, CheckStatus::Skipped))
24        .map(|c| c.name.clone())
25        .collect()
26}
27
28fn is_available(cmd: &str) -> bool {
29    Command::new(cmd)
30        .arg("--version")
31        .output()
32        .map(|o| o.status.success())
33        .unwrap_or(false)
34}
35
36fn cargo_subcommand_available(sub: &str) -> bool {
37    Command::new("cargo")
38        .arg(sub)
39        .arg("--version")
40        .output()
41        .map(|o| o.status.success())
42        .unwrap_or(false)
43}
44
45pub fn doctor_checks() -> Vec<CheckResult> {
46    let mut checks = vec![
47        CheckResult {
48            name: "cargo-available".into(),
49            status: if is_available("cargo") {
50                CheckStatus::Passed
51            } else {
52                CheckStatus::Failed
53            },
54            detail: "checked availability of cargo".into(),
55        },
56        CheckResult {
57            name: "rustc-available".into(),
58            status: if is_available("rustc") {
59                CheckStatus::Passed
60            } else {
61                CheckStatus::Failed
62            },
63            detail: "checked availability of rustc".into(),
64        },
65    ];
66
67    checks.push(CheckResult {
68        name: "cargo-fmt-capability".into(),
69        status: if cargo_subcommand_available("fmt") {
70            CheckStatus::Passed
71        } else {
72            CheckStatus::Skipped
73        },
74        detail: "checked cargo fmt capability".into(),
75    });
76
77    checks.push(CheckResult {
78        name: "cargo-clippy-capability".into(),
79        status: if cargo_subcommand_available("clippy") {
80            CheckStatus::Passed
81        } else {
82            CheckStatus::Skipped
83        },
84        detail: "checked cargo clippy capability".into(),
85    });
86
87    checks
88}
89
90pub fn post_gen_checks(project_dir: &Path, enabled: bool) -> Vec<CheckResult> {
91    if !enabled {
92        return vec![CheckResult {
93            name: "post-gen-checks".into(),
94            status: CheckStatus::Skipped,
95            detail: "disabled by config".into(),
96        }];
97    }
98
99    vec![
100        run_cargo_check(project_dir),
101        run_cargo_fmt_check(project_dir),
102        run_cargo_clippy(project_dir),
103    ]
104}
105
106pub fn schema_checks(project_dir: &Path) -> Vec<CheckResult> {
107    vec![
108        validate_schema_file(
109            &project_dir.join("schemas/foundry.schema.json"),
110            "foundry-schema-json",
111        ),
112        validate_schema_file(
113            &project_dir.join("schemas/companion.schema.json"),
114            "companion-schema-json",
115        ),
116    ]
117}
118
119fn validate_schema_file(path: &Path, name: &str) -> CheckResult {
120    if !path.exists() {
121        return CheckResult {
122            name: name.to_string(),
123            status: CheckStatus::Skipped,
124            detail: format!("schema file not found: {}", path.display()),
125        };
126    }
127
128    match std::fs::read_to_string(path)
129        .ok()
130        .and_then(|s| serde_json::from_str::<Value>(&s).ok())
131    {
132        Some(Value::Object(_)) => CheckResult {
133            name: name.to_string(),
134            status: CheckStatus::Passed,
135            detail: format!("validated schema JSON file: {}", path.display()),
136        },
137        Some(_) => CheckResult {
138            name: name.to_string(),
139            status: CheckStatus::Failed,
140            detail: format!("schema root is not a JSON object: {}", path.display()),
141        },
142        None => CheckResult {
143            name: name.to_string(),
144            status: CheckStatus::Failed,
145            detail: format!("invalid schema JSON file: {}", path.display()),
146        },
147    }
148}
149
150fn run_cargo_check(project_dir: &Path) -> CheckResult {
151    if !is_available("cargo") {
152        return CheckResult {
153            name: "cargo-check".into(),
154            status: CheckStatus::Skipped,
155            detail: "cargo is not available".into(),
156        };
157    }
158
159    run_cargo(project_dir, "cargo-check", &["check", "--quiet"])
160}
161
162fn run_cargo_fmt_check(project_dir: &Path) -> CheckResult {
163    if !cargo_subcommand_available("fmt") {
164        return CheckResult {
165            name: "cargo-fmt-check".into(),
166            status: CheckStatus::Skipped,
167            detail: "cargo fmt component is not available".into(),
168        };
169    }
170
171    run_cargo(project_dir, "cargo-fmt-check", &["fmt", "--all", "--check"])
172}
173
174fn run_cargo_clippy(project_dir: &Path) -> CheckResult {
175    if !cargo_subcommand_available("clippy") {
176        return CheckResult {
177            name: "cargo-clippy".into(),
178            status: CheckStatus::Skipped,
179            detail: "cargo clippy component is not available".into(),
180        };
181    }
182
183    run_cargo(project_dir, "cargo-clippy", &["clippy", "--all-targets"])
184}
185
186fn run_cargo(project_dir: &Path, check_name: &str, args: &[&str]) -> CheckResult {
187    let output = Command::new("cargo")
188        .args(args)
189        .current_dir(project_dir)
190        .output();
191
192    match output {
193        Ok(out) if out.status.success() => CheckResult {
194            name: check_name.into(),
195            status: CheckStatus::Passed,
196            detail: format!("executed successfully: cargo {}", args.join(" ")),
197        },
198        Ok(out) => {
199            let stderr = String::from_utf8_lossy(&out.stderr);
200            let first = stderr.lines().next().unwrap_or("command failed");
201            CheckResult {
202                name: check_name.into(),
203                status: CheckStatus::Failed,
204                detail: format!("cargo {} failed: {}", args.join(" "), first),
205            }
206        }
207        Err(err) => CheckResult {
208            name: check_name.into(),
209            status: CheckStatus::Failed,
210            detail: format!("failed to execute cargo {}: {}", args.join(" "), err),
211        },
212    }
213}