Skip to main content

edict/commands/
doctor.rs

1use std::io::IsTerminal;
2use std::path::PathBuf;
3
4use anyhow::Context;
5use clap::Args;
6use serde::{Deserialize, Serialize};
7
8use crate::config::Config;
9use crate::subprocess::Tool;
10
11#[derive(Debug, Args)]
12pub struct DoctorArgs {
13    /// Project root directory
14    #[arg(long)]
15    pub project_root: Option<PathBuf>,
16    /// Strict mode: also verify companion tool versions
17    #[arg(long)]
18    pub strict: bool,
19    /// Output format
20    #[arg(long, value_enum)]
21    pub format: Option<OutputFormat>,
22}
23
24#[derive(Debug, Clone, Copy, clap::ValueEnum)]
25pub enum OutputFormat {
26    Pretty,
27    Text,
28    Json,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32pub struct DoctorReport {
33    pub config: ConfigStatus,
34    pub tools: Vec<ToolStatus>,
35    pub project_files: Vec<FileStatus>,
36    pub issues: Vec<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub advice: Option<Vec<String>>,
39}
40
41#[derive(Debug, Serialize, Deserialize)]
42pub struct ConfigStatus {
43    pub project: String,
44    pub version: String,
45    pub agent: String,
46    pub channel: String,
47}
48
49#[derive(Debug, Serialize, Deserialize)]
50pub struct ToolStatus {
51    pub name: String,
52    pub enabled: bool,
53    pub version: Option<String>,
54    pub present: bool,
55}
56
57#[derive(Debug, Serialize, Deserialize)]
58pub struct FileStatus {
59    pub path: String,
60    pub exists: bool,
61}
62
63impl DoctorArgs {
64    pub fn execute(&self) -> anyhow::Result<()> {
65        let project_root = match self.project_root.clone() {
66            Some(p) => p,
67            None => std::env::current_dir().context("could not determine current directory")?,
68        };
69
70        // Check config at root, then ws/default/ (maw v2 bare repo)
71        let (config_path, config_dir) = crate::config::find_config_in_project(&project_root)
72            .map_err(|_| anyhow::anyhow!(
73                "no .edict.toml or .botbox.toml found at {} or ws/default/ — is this an edict project?",
74                project_root.display()
75            ))?;
76        let project_root = config_dir;
77        let config = Config::load(&config_path)?;
78
79        let format = self.format.unwrap_or_else(|| {
80            if std::io::stdout().is_terminal() {
81                OutputFormat::Pretty
82            } else {
83                OutputFormat::Text
84            }
85        });
86
87        let mut report = DoctorReport {
88            config: ConfigStatus {
89                project: config.project.name.clone(),
90                version: config.version.clone(),
91                agent: config.default_agent(),
92                channel: config.channel(),
93            },
94            tools: vec![],
95            project_files: vec![],
96            issues: vec![],
97            advice: None,
98        };
99
100        // Check tools
101        // Always check for Pi (default agent runtime)
102        let pi_output = Tool::new("pi").arg("--version").run();
103        if let Ok(output) = pi_output {
104            report.tools.push(ToolStatus {
105                name: "pi (default runtime)".to_string(),
106                enabled: true,
107                version: Some(output.stdout.trim().to_string()),
108                present: true,
109            });
110        } else {
111            report.tools.push(ToolStatus {
112                name: "pi (default runtime)".to_string(),
113                enabled: true,
114                version: None,
115                present: false,
116            });
117            report
118                .issues
119                .push("Tool not found: pi (default agent runtime)".to_string());
120        }
121
122        let required_tools = vec![
123            ("bones (bn)", config.tools.bones, "bn"),
124            ("maw", config.tools.maw, "maw"),
125            ("crit", config.tools.crit, "crit"),
126            ("botbus (bus)", config.tools.botbus, "bus"),
127            ("vessel", config.tools.vessel, "vessel"),
128        ];
129
130        for (label, enabled, binary) in required_tools {
131            if enabled {
132                let version_output = Tool::new(binary).arg("--version").run();
133                if let Ok(output) = version_output {
134                    report.tools.push(ToolStatus {
135                        name: label.to_string(),
136                        enabled: true,
137                        version: Some(output.stdout.trim().to_string()),
138                        present: true,
139                    });
140                } else {
141                    report.tools.push(ToolStatus {
142                        name: label.to_string(),
143                        enabled: true,
144                        version: None,
145                        present: false,
146                    });
147                    report.issues.push(format!("Tool not found: {}", binary));
148                }
149            } else {
150                report.tools.push(ToolStatus {
151                    name: label.to_string(),
152                    enabled: false,
153                    version: None,
154                    present: false,
155                });
156            }
157        }
158
159        // Check project files
160        let agents_dir = project_root.join(".agents/edict");
161        let agents_exists = agents_dir.exists();
162        report.project_files.push(FileStatus {
163            path: ".agents/edict".to_string(),
164            exists: agents_exists,
165        });
166
167        if !agents_exists {
168            report
169                .issues
170                .push(".agents/edict/ directory not found".to_string());
171        }
172
173        let agents_md = project_root.join("AGENTS.md");
174        let agents_md_exists = agents_md.exists();
175        report.project_files.push(FileStatus {
176            path: "AGENTS.md".to_string(),
177            exists: agents_md_exists,
178        });
179
180        if !agents_md_exists {
181            report.issues.push("AGENTS.md not found".to_string());
182        }
183
184        let claude_md = project_root.join("CLAUDE.md");
185        let claude_md_exists = claude_md.exists();
186        report.project_files.push(FileStatus {
187            path: "CLAUDE.md".to_string(),
188            exists: claude_md_exists,
189        });
190
191        if !claude_md_exists {
192            report
193                .issues
194                .push("CLAUDE.md symlink not found".to_string());
195        }
196
197        // Strict mode: version compatibility check (simplified)
198        if self.strict && !report.issues.is_empty() {
199            report
200                .issues
201                .insert(0, "strict mode: found issues".to_string());
202        }
203
204        let issue_count = report.issues.len();
205
206        // Format output
207        match format {
208            OutputFormat::Pretty => {
209                self.print_pretty(&report);
210            }
211            OutputFormat::Text => {
212                self.print_text(&report);
213            }
214            OutputFormat::Json => {
215                println!("{}", serde_json::to_string_pretty(&report)?);
216            }
217        }
218
219        // Return error with issue count for proper exit code handling
220        if issue_count > 0 {
221            return Err(crate::error::ExitError::new(
222                std::cmp::min(issue_count, 125) as u8,
223                format!("{} issue(s) found", issue_count),
224            )
225            .into());
226        }
227
228        Ok(())
229    }
230
231    fn print_pretty(&self, report: &DoctorReport) {
232        println!("=== Botbox Doctor ===\n");
233        println!("Project: {}", report.config.project);
234        println!("Version: {}", report.config.version);
235        println!("Agent:   {}", report.config.agent);
236        println!("Channel: {}", report.config.channel);
237        println!();
238
239        println!("Tools:");
240        for tool in &report.tools {
241            if tool.enabled {
242                if tool.present {
243                    println!(
244                        "  ✓ {}: {}",
245                        tool.name,
246                        tool.version.as_ref().unwrap_or(&"OK".to_string())
247                    );
248                } else {
249                    println!("  ✗ {}: NOT FOUND", tool.name);
250                }
251            } else {
252                println!("  - {}: disabled", tool.name);
253            }
254        }
255
256        if !report.project_files.is_empty() {
257            println!("\nProject Files:");
258            for file in &report.project_files {
259                if file.exists {
260                    println!("  ✓ {}", file.path);
261                } else {
262                    println!("  ✗ {}", file.path);
263                }
264            }
265        }
266
267        if !report.issues.is_empty() {
268            println!("\nIssues ({}):", report.issues.len());
269            for issue in &report.issues {
270                println!("  • {}", issue);
271            }
272        } else {
273            println!("\n✓ No issues found");
274        }
275    }
276
277    fn print_text(&self, report: &DoctorReport) {
278        println!(
279            "edict-doctor  project={}  version={}  agent={}  channel={}",
280            report.config.project,
281            report.config.version,
282            report.config.agent,
283            report.config.channel
284        );
285
286        for tool in &report.tools {
287            let status = if !tool.enabled {
288                "disabled".to_string()
289            } else if tool.present {
290                format!("ok  {}", tool.version.as_ref().unwrap_or(&String::new()))
291            } else {
292                "missing".to_string()
293            };
294            println!("tool  {}  {}", tool.name, status);
295        }
296
297        for file in &report.project_files {
298            let status = if file.exists { "ok" } else { "missing" };
299            println!("file  {}  {}", file.path, status);
300        }
301
302        if !report.issues.is_empty() {
303            println!("issues  count={}", report.issues.len());
304            for issue in &report.issues {
305                println!("issue  {}", issue);
306            }
307        }
308    }
309}