1use crate::core::{ForgeGuardError, ProjectConfig};
4use anyhow::{Context, Result};
5use colored::*;
6use serde::{Deserialize, Serialize};
7
8#[allow(dead_code)]
10pub struct Doctor {
11 config: ProjectConfig,
12 verbose: bool,
13 issues: Vec<DoctorIssue>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct DoctorIssue {
19 pub category: String,
20 pub severity: String,
21 pub message: String,
22 pub recommendation: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct DoctorReport {
28 pub healthy: bool,
29 pub issues: Vec<DoctorIssue>,
30 pub foundry_version: Option<String>,
31 pub solidity_version: Option<String>,
32 pub chain: String,
33 pub project_path: String,
34}
35
36impl Doctor {
37 pub fn new(config: &ProjectConfig, verbose: bool) -> Result<Self, ForgeGuardError> {
39 Ok(Self {
40 config: config.clone(),
41 verbose,
42 issues: Vec::new(),
43 })
44 }
45
46 pub fn check_foundry_version(&mut self) -> Result<String, ForgeGuardError> {
48 let output = std::process::Command::new("forge")
49 .arg("--version")
50 .output()
51 .map_err(|_| ForgeGuardError::Command("forge not found. Install Foundry: https://book.getfoundry.sh/getting-started/installation".into()))?;
52
53 if !output.status.success() {
54 return Err(ForgeGuardError::Command("forge not found. Install Foundry: https://book.getfoundry.sh/getting-started/installation".into()));
55 }
56
57 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
58 Ok(version)
59 }
60
61 pub fn check_solidity_version(&mut self) -> Result<String, ForgeGuardError> {
63 let output = std::process::Command::new("forge")
64 .args(["config", "--json"])
65 .output()
66 .map_err(|_| ForgeGuardError::Command("forge not found".into()))?;
67
68 let stdout = String::from_utf8_lossy(&output.stdout);
70 if let Ok(config) = serde_json::from_str::<serde_json::Value>(&stdout) {
71 if let Some(solc) = config.get("solc").and_then(|s| s.as_str()) {
72 return Ok(solc.to_string());
73 }
74 }
75
76 Ok("0.8.20+ (default)".into())
77 }
78
79 pub fn check_project_structure(&mut self) -> Result<Vec<String>, ForgeGuardError> {
81 let mut issues = Vec::new();
82 let root = &self.config.project_root;
83
84 if !root.join("foundry.toml").exists() && !root.join("foundry.toml").exists() {
86 issues.push("No foundry.toml found — run `forge init` to create one".into());
87 }
88
89 let src_dirs = &self.config.src_dirs;
91 for dir in src_dirs {
92 let dir_path = if dir.is_absolute() {
93 dir.clone()
94 } else {
95 root.join(dir)
96 };
97 if !dir_path.exists() {
98 issues.push(format!(
99 "Source directory not found: {}",
100 dir_path.display()
101 ));
102 }
103 }
104
105 Ok(issues)
106 }
107
108 pub fn check_dependencies(&mut self) -> Result<Vec<String>, ForgeGuardError> {
110 let mut issues = Vec::new();
111
112 let root = &self.config.project_root;
114 let foundry_toml = root.join("foundry.toml");
115 let remappings = root.join("remappings.txt");
116
117 if foundry_toml.exists() {
118 let content = std::fs::read_to_string(&foundry_toml)?;
119 if content.contains("openzeppelin-contracts@4.9") {
121 issues
122 .push("OpenZeppelin 4.9.x has known vulnerabilities — upgrade to 5.0+".into());
123 }
124 }
125
126 if !remappings.exists() {
127 issues.push(
128 "No remappings.txt found — consider adding one for dependency management".into(),
129 );
130 }
131
132 Ok(issues)
133 }
134
135 pub fn check_compiler_settings(&mut self) -> Result<Vec<String>, ForgeGuardError> {
137 let mut issues = Vec::new();
138 let root = &self.config.project_root;
139 let foundry_toml = root.join("foundry.toml");
140
141 if foundry_toml.exists() {
142 let content = std::fs::read_to_string(&foundry_toml)?;
143 if !content.contains("optimizer") {
145 issues.push(
146 "Compiler optimizer not configured — consider enabling for production".into(),
147 );
148 }
149 }
150
151 Ok(issues)
152 }
153
154 pub fn check_rpc_connectivity(&mut self, chain: &str) -> Result<(), ForgeGuardError> {
156 let rpc_url = std::env::var("ETH_RPC_URL")
157 .unwrap_or_else(|_| format!("https://{}.llamarpc.com", chain));
158
159 let output = std::process::Command::new("curl")
161 .args(["-s", "-o", "/dev/null", "-w", "%{http_code}", &rpc_url])
162 .output()
163 .map_err(|_| ForgeGuardError::Rpc("curl not available for RPC check".into()))?;
164
165 let status = String::from_utf8_lossy(&output.stdout);
166 match status.trim() {
167 "200" | "401" | "403" => Ok(()), _ => Err(ForgeGuardError::Rpc(format!(
169 "RPC endpoint unreachable: {} (HTTP {})",
170 rpc_url, status
171 ))),
172 }
173 }
174
175 pub fn check_security_config(&mut self) -> Result<Vec<String>, ForgeGuardError> {
177 let mut issues = Vec::new();
178 if self.config.strict {
179 issues.push("Strict mode enabled — deployment will fail on any finding".into());
180 }
181 Ok(issues)
182 }
183
184 pub fn generate_report(&self, healthy: bool) -> DoctorReport {
186 DoctorReport {
187 healthy,
188 issues: self.issues.clone(),
189 foundry_version: None,
190 solidity_version: None,
191 chain: self.config.chain.clone(),
192 project_path: self.config.project_root.to_string_lossy().to_string(),
193 }
194 }
195}
196
197pub fn sync_foundry_config(config: &ProjectConfig, dry_run: bool, show_diff: bool) -> Result<()> {
202 let root = &config.project_root;
203 let foundry_toml = root.join("foundry.toml");
204 let forge_guard_toml = root.join("forge-guard.toml");
205
206 if !foundry_toml.exists() {
207 anyhow::bail!("No foundry.toml found at {}", foundry_toml.display());
208 }
209
210 eprintln!("{} Syncing settings from foundry.toml...\n", "🔄".bold());
211
212 let foundry_content = std::fs::read_to_string(&foundry_toml)?;
214 let foundry_config: toml::Value = toml::from_str(&foundry_content)?;
215
216 let profile = foundry_config.get("profile").and_then(|p| p.get("default"));
218
219 let src_dirs = profile
220 .and_then(|p| p.get("src").and_then(|v| v.as_str()))
221 .unwrap_or("src");
222 let test_dirs = profile
223 .and_then(|p| p.get("test").and_then(|v| v.as_str()))
224 .unwrap_or("test");
225 let lib_dirs: Vec<String> = profile
226 .and_then(|p| p.get("libs").and_then(|v| v.as_array()))
227 .map(|arr| {
228 arr.iter()
229 .filter_map(|v| v.as_str().map(String::from))
230 .collect()
231 })
232 .unwrap_or_else(|| vec!["lib".into()]);
233 let solc_version = foundry_config
234 .get("solc")
235 .or_else(|| profile.and_then(|p| p.get("solc")))
236 .and_then(|v| v.as_str());
237 let remappings: Vec<String> = foundry_config
238 .get("remappings")
239 .and_then(|v| v.as_array())
240 .map(|arr| {
241 arr.iter()
242 .filter_map(|v| v.as_str().map(String::from))
243 .collect()
244 })
245 .unwrap_or_default();
246
247 let mut cfg: toml::Value = if forge_guard_toml.exists() {
251 std::fs::read_to_string(&forge_guard_toml)
252 .ok()
253 .and_then(|c| toml::from_str(&c).ok())
254 .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()))
255 } else {
256 toml::Value::Table(toml::map::Map::new())
257 };
258
259 let table = cfg.as_table_mut().expect("toml table");
260 if let Some(version) = solc_version {
261 table.insert(
262 "solc_version".into(),
263 toml::Value::String(version.to_string()),
264 );
265 }
266 table.insert(
267 "src_dirs".into(),
268 toml::Value::Array(vec![toml::Value::String(src_dirs.to_string())]),
269 );
270 if test_dirs != "test" {
271 table.insert(
272 "test_dirs".into(),
273 toml::Value::Array(vec![toml::Value::String(test_dirs.to_string())]),
274 );
275 }
276 if !lib_dirs.is_empty() && lib_dirs != ["lib"] {
277 table.insert(
278 "lib_dirs".into(),
279 toml::Value::Array(
280 lib_dirs
281 .iter()
282 .map(|l| toml::Value::String(l.clone()))
283 .collect(),
284 ),
285 );
286 }
287 if !remappings.is_empty() {
288 table.insert(
289 "remappings".into(),
290 toml::Value::Array(
291 remappings
292 .iter()
293 .map(|r| toml::Value::String(r.clone()))
294 .collect(),
295 ),
296 );
297 }
298
299 let mut new_config =
300 String::from("# Auto-synced from foundry.toml by forge-guard doctor --sync\n\n");
301 new_config
302 .push_str(&toml::to_string_pretty(&cfg).context("Failed to serialize synced config")?);
303
304 eprintln!(" Detected settings to sync:");
306 eprintln!("{} src_dirs: {src_dirs}", " •".cyan());
307 eprintln!("{} test_dirs: {test_dirs}", " •".cyan());
308 if let Some(version) = solc_version {
309 eprintln!("{} solc_version: {version}", " •".cyan());
310 }
311 if !remappings.is_empty() {
312 eprintln!("{} remappings: {} entries", " •".cyan(), remappings.len());
313 }
314
315 if show_diff {
316 let current = if forge_guard_toml.exists() {
318 std::fs::read_to_string(&forge_guard_toml)?
319 } else {
320 String::new()
321 };
322
323 eprintln!("\n{} Current forge-guard.toml:", "──".dimmed());
324 if current.is_empty() {
325 eprintln!(" (file does not exist)");
326 } else {
327 for line in current.lines() {
328 eprintln!("{}{}", " ".dimmed(), line);
329 }
330 }
331 eprintln!("\n{} New forge-guard.toml (after sync):", "──".dimmed());
332 for line in new_config.lines() {
333 eprintln!("{}{}", " +".green(), line);
334 }
335 }
336
337 if dry_run {
338 eprintln!(
339 "\n{} Dry run — no changes written to forge-guard.toml",
340 "ℹ️".yellow()
341 );
342 return Ok(());
343 }
344
345 std::fs::write(&forge_guard_toml, &new_config)
346 .with_context(|| format!("Failed to write {}", forge_guard_toml.display()))?;
347
348 eprintln!(
349 "\n{} forge-guard.toml synced from foundry.toml",
350 "✅".green().bold()
351 );
352
353 Ok(())
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use crate::core::ProjectConfig;
360
361 #[test]
362 fn test_doctor_creation() {
363 let config = ProjectConfig::default();
364 let doctor = Doctor::new(&config, false).unwrap();
365 assert!(!doctor.verbose);
366 assert!(doctor.issues.is_empty());
367 }
368
369 #[test]
370 fn test_doctor_verbose() {
371 let config = ProjectConfig::default();
372 let doctor = Doctor::new(&config, true).unwrap();
373 assert!(doctor.verbose);
374 }
375
376 #[test]
377 fn test_check_project_structure_no_foundry_toml() {
378 let config = ProjectConfig::default();
379 let mut doctor = Doctor::new(&config, false).unwrap();
380 let issues = doctor.check_project_structure().unwrap();
382 assert!(issues.is_empty() || issues.iter().any(|i| i.contains("foundry.toml")));
384 }
385
386 #[test]
387 fn test_generate_report() {
388 let config = ProjectConfig::default();
389 let doctor = Doctor::new(&config, false).unwrap();
390
391 let report = doctor.generate_report(true);
392 assert!(report.healthy);
393 assert_eq!(report.chain, "ethereum");
394 }
395
396 #[test]
397 fn test_generate_report_unhealthy() {
398 let config = ProjectConfig::default();
399 let doctor = Doctor::new(&config, false).unwrap();
400
401 let report = doctor.generate_report(false);
402 assert!(!report.healthy);
403 }
404
405 #[test]
406 fn test_doctor_issue_serialization() {
407 let issue = DoctorIssue {
408 category: "test".into(),
409 severity: "high".into(),
410 message: "Test issue".into(),
411 recommendation: Some("Fix it".into()),
412 };
413
414 let json = serde_json::to_string(&issue).unwrap();
415 assert!(json.contains("Test issue"));
416 assert!(json.contains("high"));
417
418 let deserialized: DoctorIssue = serde_json::from_str(&json).unwrap();
419 assert_eq!(deserialized.message, "Test issue");
420 }
421
422 #[test]
423 fn test_sync_foundry_config_no_toml_fails() {
424 let config = ProjectConfig::default();
425 let result = sync_foundry_config(&config, false, false);
426 assert!(result.is_err());
427 assert!(result.unwrap_err().to_string().contains("No foundry.toml"));
428 }
429
430 #[test]
431 fn test_sync_foundry_config_dry_run_without_toml_fails() {
432 let config = ProjectConfig::default();
433 let result = sync_foundry_config(&config, true, false);
434 assert!(result.is_err());
435 assert!(result.unwrap_err().to_string().contains("No foundry.toml"));
437 }
438
439 #[test]
440 fn test_sync_foundry_config_diff_without_toml_fails() {
441 let config = ProjectConfig::default();
442 let result = sync_foundry_config(&config, false, true);
443 assert!(result.is_err());
444 }
445
446 #[test]
447 fn test_sync_foundry_config_writes_real_values() {
448 let dir = tempfile::tempdir().unwrap();
449 std::fs::write(
450 dir.path().join("foundry.toml"),
451 "[profile.default]\n\
452 src = 'contracts'\n\
453 test = 'tests'\n\
454 libs = ['lib', 'vendor']\n\
455 solc = '0.8.23'\n",
456 )
457 .unwrap();
458
459 let config = ProjectConfig {
460 project_root: dir.path().to_path_buf(),
461 ..ProjectConfig::default()
462 };
463
464 sync_foundry_config(&config, false, false).unwrap();
465
466 let written = std::fs::read_to_string(dir.path().join("forge-guard.toml")).unwrap();
467 let reparsed: toml::Value = toml::from_str(&written).unwrap();
468 assert_eq!(reparsed["solc_version"].as_str(), Some("0.8.23"));
469 assert_eq!(reparsed["src_dirs"][0].as_str(), Some("contracts"));
470 assert_eq!(reparsed["test_dirs"][0].as_str(), Some("tests"));
471 let libs: Vec<&str> = reparsed["lib_dirs"]
472 .as_array()
473 .unwrap()
474 .iter()
475 .map(|v| v.as_str().unwrap())
476 .collect();
477 assert_eq!(libs, vec!["lib", "vendor"]);
478 }
479
480 #[test]
481 fn test_sync_foundry_config_dry_run_does_not_write() {
482 let dir = tempfile::tempdir().unwrap();
483 std::fs::write(
484 dir.path().join("foundry.toml"),
485 "[profile.default]\nsrc = 'contracts'\n",
486 )
487 .unwrap();
488
489 let config = ProjectConfig {
490 project_root: dir.path().to_path_buf(),
491 ..ProjectConfig::default()
492 };
493
494 sync_foundry_config(&config, true, false).unwrap();
495 assert!(
496 !dir.path().join("forge-guard.toml").exists(),
497 "Dry run must not write the config file"
498 );
499 }
500
501 #[test]
502 fn test_sync_foundry_config_syncs_remappings() {
503 let dir = tempfile::tempdir().unwrap();
504 std::fs::write(
505 dir.path().join("foundry.toml"),
506 "remappings = ['@oz/=lib/openzeppelin/', '@forge-std/=lib/forge-std/']\n",
507 )
508 .unwrap();
509
510 let config = ProjectConfig {
511 project_root: dir.path().to_path_buf(),
512 ..ProjectConfig::default()
513 };
514
515 sync_foundry_config(&config, false, false).unwrap();
516
517 let written = std::fs::read_to_string(dir.path().join("forge-guard.toml")).unwrap();
518 assert!(written.contains("@oz/=lib/openzeppelin/"));
519 assert!(written.contains("@forge-std/=lib/forge-std/"));
520 let reparsed: toml::Value = toml::from_str(&written).unwrap();
522 let remappings = reparsed["remappings"].as_array().unwrap();
523 assert_eq!(remappings.len(), 2);
524 }
525
526 #[test]
527 fn test_sync_foundry_config_preserves_existing_settings() {
528 let dir = tempfile::tempdir().unwrap();
529 std::fs::write(
530 dir.path().join("foundry.toml"),
531 "[profile.default]\nsrc = 'contracts'\n",
532 )
533 .unwrap();
534 std::fs::write(
536 dir.path().join("forge-guard.toml"),
537 "[security]\nenable_high = true\n\n[report]\ninclude_snippets = true\n",
538 )
539 .unwrap();
540
541 let config = ProjectConfig {
542 project_root: dir.path().to_path_buf(),
543 ..ProjectConfig::default()
544 };
545
546 sync_foundry_config(&config, false, false).unwrap();
547
548 let written = std::fs::read_to_string(dir.path().join("forge-guard.toml")).unwrap();
549 assert!(written.contains("[security]"));
551 assert!(written.contains("enable_high = true"));
552 assert!(written.contains("[report]"));
553 assert!(written.contains("src_dirs = [\"contracts\"]"));
555 assert!(toml::from_str::<toml::Value>(&written).is_ok());
557 }
558
559 #[test]
560 fn test_sync_foundry_config_defaults_when_keys_missing() {
561 let dir = tempfile::tempdir().unwrap();
562 std::fs::write(dir.path().join("foundry.toml"), "[profile.default]\n").unwrap();
563
564 let config = ProjectConfig {
565 project_root: dir.path().to_path_buf(),
566 ..ProjectConfig::default()
567 };
568
569 sync_foundry_config(&config, false, false).unwrap();
570
571 let written = std::fs::read_to_string(dir.path().join("forge-guard.toml")).unwrap();
572 let reparsed: toml::Value = toml::from_str(&written).unwrap();
573 assert_eq!(reparsed["src_dirs"][0].as_str(), Some("src"));
575 assert!(reparsed.get("test_dirs").is_none());
576 assert!(reparsed.get("lib_dirs").is_none());
577 }
578
579 #[test]
580 fn test_doctor_report_serialization() {
581 let issue = DoctorIssue {
582 category: "sec".into(),
583 severity: "medium".into(),
584 message: "Issue".into(),
585 recommendation: None,
586 };
587 let report = DoctorReport {
588 healthy: false,
589 issues: vec![issue],
590 foundry_version: Some("nightly".into()),
591 solidity_version: Some("0.8.20".into()),
592 chain: "base".into(),
593 project_path: "/project".into(),
594 };
595
596 let json = serde_json::to_string(&report).unwrap();
597 assert!(json.contains("nightly"));
598 assert!(json.contains("base"));
599
600 let deserialized: DoctorReport = serde_json::from_str(&json).unwrap();
601 assert!(!deserialized.healthy);
602 assert_eq!(deserialized.chain, "base");
603 }
604}