1use crate::{CLI_VERSION, VERSION, read};
2use serde_json::{Value, json};
3use std::io::{BufRead, BufReader, Write};
4use std::path::Path;
5use std::process::{Command, Stdio};
6use std::sync::mpsc;
7use std::time::Duration;
8use toml_edit::DocumentMut;
9
10pub(crate) fn probe(command: &str, args: &[String], root: &Path) -> Result<(), String> {
12 let mut child = Command::new(command)
13 .args(args)
14 .current_dir(root)
15 .stdin(Stdio::piped())
16 .stdout(Stdio::piped())
17 .stderr(Stdio::inherit())
18 .spawn()
19 .map_err(|e| format!("MCP {command}: {e}"))?;
20 let result = (|| {
21 let mut stdin = child.stdin.take().ok_or("MCP stdin ausente")?;
22 let stdout = child.stdout.take().ok_or("MCP stdout ausente")?;
23 let (tx, rx) = mpsc::channel();
24 std::thread::spawn(move || {
25 for line in BufReader::new(stdout).lines() {
26 if tx.send(line).is_err() {
27 break;
28 }
29 }
30 });
31 let send = |stdin: &mut std::process::ChildStdin, value: Value| -> Result<(), String> {
32 writeln!(stdin, "{value}").and_then(|()| stdin.flush()).map_err(|e| e.to_string())
33 };
34 let receive = |id: u64| -> Result<Value, String> {
35 let deadline = std::time::Instant::now() + Duration::from_secs(20);
36 loop {
37 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
38 let line = rx
39 .recv_timeout(remaining)
40 .map_err(|e| format!("MCP não respondeu em 20s: {e}"))?
41 .map_err(|e| e.to_string())?;
42 let reply: Value = serde_json::from_str(&line).map_err(|e| format!("MCP: {e}"))?;
43 if reply["id"] == id {
44 if !reply["error"].is_null() {
45 return Err(format!("MCP: {}", reply["error"]));
46 }
47 return Ok(reply["result"].clone());
48 }
49 }
50 };
51 send(
52 &mut stdin,
53 json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"cargo-ravenrs-doctor","version":CLI_VERSION}}}),
54 )?;
55 let initialized = receive(1)?;
56 if initialized["serverInfo"]["version"] != VERSION {
57 return Err(format!(
58 "MCP versão {}, framework esperado {VERSION}",
59 initialized["serverInfo"]["version"]
60 ));
61 }
62 send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}))?;
63 send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}))?;
64 let tools = receive(2)?;
65 if !tools["tools"].as_array().is_some_and(|t| t.iter().any(|t| t["name"] == "component_api")) {
66 return Err("MCP não retornou component_api".into());
67 }
68 Ok(())
69 })();
70 let _ = child.kill();
71 let _ = child.wait();
72 result
73}
74
75pub fn doctor(root: &Path) -> Result<(), String> {
77 let manifest: DocumentMut =
78 read(&root.join("Cargo.toml"))?.parse().map_err(|e| format!("Cargo.toml: {e}"))?;
79 let mut versions = Vec::new();
80 collect_versions(manifest.as_item(), &mut versions);
81 if versions.is_empty() {
82 return Err("Nenhuma dependência RavenRS com versão explícita; doctor requer um Cargo.toml de projeto independente.".into());
83 }
84 for (name, version) in versions {
85 if version.trim_start_matches(['=', '^', '~']) != VERSION {
86 return Err(format!(
87 "{name} usa {version}, mas cargo-ravenrs usa {VERSION}. Instale a tag correspondente."
88 ));
89 }
90 }
91 println!("✓ CLI: {CLI_VERSION}; crates e MCP: {VERSION}");
92 if root.join(".ravenrs-project.toml").is_file() && !root.join(".ravenrs-ai.toml").is_file() {
93 let state: DocumentMut =
94 read(&root.join(".ravenrs-project.toml"))?.parse().map_err(|e| format!("Projeto: {e}"))?;
95 if state["ai"].as_bool() == Some(false) {
96 probe("cargo", &["ravenrs".into(), "mcp".into()], root)?;
97 println!("✓ MCP; integração com assistentes desativada por --no-ai.");
98 return Ok(());
99 }
100 }
101 let state_path = root.join(".ravenrs-ai.toml");
102 if !state_path.is_file() {
103 return Err("Arquivos de IA ausentes. Execute cargo ravenrs ai.".into());
104 }
105 let state: DocumentMut = read(&state_path)?.parse().map_err(|e| format!("Estado de IA: {e}"))?;
106 if state["version"].as_str() != Some(VERSION) {
107 return Err("Arquivos de IA desatualizados. Execute cargo ravenrs ai.".into());
108 }
109 let plugin = state["plugin"].as_bool().unwrap_or(false);
110 let expected = crate::ai::ai_files(root, plugin)?;
111 for (path, contents) in expected {
112 if read(&root.join(&path))? != contents {
113 return Err(format!("{path} desatualizado. Execute cargo ravenrs ai."));
114 }
115 }
116 println!("✓ Arquivos de IA e skill");
117 for (path, key) in
118 [(".mcp.json", "mcpServers"), (".cursor/mcp.json", "mcpServers"), (".vscode/mcp.json", "servers")]
119 {
120 let config: Value =
121 serde_json::from_str(&read(&root.join(path))?).map_err(|e| format!("{path}: {e}"))?;
122 let server = &config[key]["ravenrs"];
123 let command = server["command"].as_str().ok_or("MCP command ausente")?;
124 let args: Vec<String> = server["args"]
125 .as_array()
126 .ok_or("MCP args ausentes")?
127 .iter()
128 .map(|v| v.as_str().map(str::to_owned).ok_or("MCP argumento inválido".to_owned()))
129 .collect::<Result<_, _>>()?;
130 probe(command, &args, root)?;
131 println!("✓ MCP {path}");
132 }
133 let codex: DocumentMut =
134 read(&root.join(".codex/config.toml"))?.parse().map_err(|e| format!("Codex: {e}"))?;
135 let server = &codex["mcp_servers"]["ravenrs"];
136 let args = server["args"]
137 .as_array()
138 .ok_or("Codex args ausentes")?
139 .iter()
140 .map(|v| v.as_str().map(str::to_owned).ok_or("Codex argumento inválido".into()))
141 .collect::<Result<Vec<String>, String>>()?;
142 probe(server["command"].as_str().ok_or("Codex command ausente")?, &args, root)?;
143 println!("✓ MCP .codex/config.toml\nDoctor: tudo certo.");
144 Ok(())
145}
146
147fn collect_versions(item: &toml_edit::Item, versions: &mut Vec<(String, String)>) {
148 if let Some(table) = item.as_table_like() {
149 for (key, item) in table.iter() {
150 if key.starts_with("ravenrs") {
151 if let Some(version) =
152 item.as_str().or_else(|| item.get("version").and_then(toml_edit::Item::as_str))
153 {
154 versions.push((key.into(), version.into()));
155 }
156 } else {
157 collect_versions(item, versions);
158 }
159 }
160 }
161}