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}