1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use harn_lint::LintSeverity;
6use serde::Serialize;
7
8use crate::cli::PersonaDoctorArgs;
9use crate::package::{self, PersonaManifestEntry, ResolvedPersonaManifest};
10use crate::test_runner;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
13#[serde(rename_all = "lowercase")]
14pub enum DoctorStatus {
15 Green,
16 Yellow,
17 Red,
18}
19
20impl DoctorStatus {
21 fn label(self) -> &'static str {
22 match self {
23 Self::Green => "green",
24 Self::Yellow => "yellow",
25 Self::Red => "red",
26 }
27 }
28}
29
30#[derive(Debug, Serialize)]
31pub struct DoctorCheck {
32 pub name: String,
33 pub status: DoctorStatus,
34 pub message: String,
35}
36
37#[derive(Debug, Serialize)]
38pub struct PersonaDoctorReport {
39 pub persona: String,
40 pub manifest_path: PathBuf,
41 pub checks: Vec<DoctorCheck>,
42}
43
44impl PersonaDoctorReport {
45 fn has_red(&self) -> bool {
46 self.checks
47 .iter()
48 .any(|check| check.status == DoctorStatus::Red)
49 }
50}
51
52pub(crate) async fn run_doctor(
53 manifest_arg: Option<&Path>,
54 args: &PersonaDoctorArgs,
55) -> Result<(), String> {
56 let report = doctor_report(manifest_arg, args).await;
57 match report {
58 Ok(report) => {
59 if args.json {
60 println!(
61 "{}",
62 serde_json::to_string_pretty(&report)
63 .map_err(|error| format!("failed to serialize doctor report: {error}"))?
64 );
65 } else {
66 print_report(&report);
67 }
68 Ok(())
69 }
70 Err(error_report) => {
71 if args.json {
72 println!(
73 "{}",
74 serde_json::to_string_pretty(&error_report)
75 .map_err(|error| format!("failed to serialize doctor report: {error}"))?
76 );
77 } else {
78 print_report(&error_report);
79 }
80 Err("persona doctor found red checks".to_string())
81 }
82 }
83}
84
85pub(crate) async fn doctor_report(
86 manifest_arg: Option<&Path>,
87 args: &PersonaDoctorArgs,
88) -> Result<PersonaDoctorReport, PersonaDoctorReport> {
89 let manifest_path = resolve_manifest_path(manifest_arg, &args.name);
90 let mut checks = Vec::new();
91 let catalog = match package::load_personas_from_manifest_path(&manifest_path) {
92 Ok(catalog) => {
93 checks.push(check(
94 "manifest",
95 DoctorStatus::Green,
96 format!("{} validates", catalog.manifest_path.display()),
97 ));
98 catalog
99 }
100 Err(errors) => {
101 checks.push(check(
102 "manifest",
103 DoctorStatus::Red,
104 errors
105 .iter()
106 .map(ToString::to_string)
107 .collect::<Vec<_>>()
108 .join("; "),
109 ));
110 return Err(PersonaDoctorReport {
111 persona: args.name.clone(),
112 manifest_path,
113 checks,
114 });
115 }
116 };
117
118 let Some(persona) = catalog
119 .personas
120 .iter()
121 .find(|persona| persona.name.as_deref() == Some(args.name.as_str()))
122 .or_else(|| {
123 catalog
124 .personas
125 .iter()
126 .find(|persona| persona.name.as_deref() == Some(path_name(&args.name).as_str()))
127 })
128 else {
129 checks.push(check(
130 "manifest-persona",
131 DoctorStatus::Red,
132 format!(
133 "persona '{}' not found in {}",
134 args.name,
135 catalog.manifest_path.display()
136 ),
137 ));
138 return Err(PersonaDoctorReport {
139 persona: args.name.clone(),
140 manifest_path: catalog.manifest_path,
141 checks,
142 });
143 };
144 let persona_name = persona.name.clone().unwrap_or_else(|| args.name.clone());
145
146 let entry_source = resolve_entry_source(&catalog, persona);
147 checks.push(source_shape_check(&entry_source));
148 checks.push(lint_check(&catalog, &entry_source));
149 checks.push(prompt_asset_check(&catalog, &entry_source));
150 checks.push(step_metadata_check(persona));
151 checks.push(cost_check(persona));
152 checks.push(smoke_check(&catalog, &persona_name, args.timeout_ms).await);
153
154 let report = PersonaDoctorReport {
155 persona: persona_name,
156 manifest_path: catalog.manifest_path,
157 checks,
158 };
159 if report.has_red() {
160 Err(report)
161 } else {
162 Ok(report)
163 }
164}
165
166pub async fn doctor_report_for_persona(
167 manifest_arg: Option<&Path>,
168 name: &str,
169 timeout_ms: u64,
170) -> Result<PersonaDoctorReport, PersonaDoctorReport> {
171 let args = PersonaDoctorArgs {
172 name: name.to_string(),
173 json: false,
174 timeout_ms,
175 };
176 doctor_report(manifest_arg, &args).await
177}
178
179fn print_report(report: &PersonaDoctorReport) {
180 println!(
181 "persona doctor: {} ({})",
182 report.persona,
183 report.manifest_path.display()
184 );
185 for check in &report.checks {
186 println!(
187 " {:<6} {:<20} {}",
188 check.status.label(),
189 check.name,
190 check.message
191 );
192 }
193}
194
195fn check(name: &str, status: DoctorStatus, message: impl Into<String>) -> DoctorCheck {
196 DoctorCheck {
197 name: name.to_string(),
198 status,
199 message: message.into(),
200 }
201}
202
203fn resolve_manifest_path(manifest_arg: Option<&Path>, name: &str) -> PathBuf {
204 if let Some(path) = manifest_arg {
205 return path.to_path_buf();
206 }
207 let raw = PathBuf::from(name);
208 if raw.is_dir() {
209 let manifest = raw.join("harn.toml");
210 if manifest.exists() {
211 return manifest;
212 }
213 }
214 if raw.is_file() {
215 return raw;
216 }
217 let normalized = name.replace('-', "_");
218 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
219 for candidate in [
220 cwd.join("personas").join(&normalized).join("harn.toml"),
221 cwd.join(&normalized).join("harn.toml"),
222 ] {
223 if candidate.exists() {
224 return candidate;
225 }
226 }
227 PathBuf::from("harn.toml")
228}
229
230fn path_name(value: &str) -> String {
231 Path::new(value)
232 .file_name()
233 .and_then(|name| name.to_str())
234 .unwrap_or(value)
235 .replace('-', "_")
236}
237
238fn resolve_entry_source(
239 catalog: &ResolvedPersonaManifest,
240 persona: &PersonaManifestEntry,
241) -> Option<PathBuf> {
242 let entry = persona.entry_workflow.as_deref()?;
243 let (path, _) = entry.split_once('#')?;
244 Some(catalog.manifest_dir.join(path))
245}
246
247fn source_shape_check(entry_source: &Option<PathBuf>) -> DoctorCheck {
248 let Some(path) = entry_source else {
249 return check(
250 "entry-source",
251 DoctorStatus::Red,
252 "entry_workflow must point at a .harn file with #run",
253 );
254 };
255 match fs::read_to_string(path) {
256 Ok(source) => {
257 let banned = [
258 "__host_agent",
259 "workflow_stage_agent_loop",
260 "harn_vm::",
261 "RustAgent",
262 ];
263 if let Some(token) = banned.iter().find(|token| source.contains(**token)) {
264 return check(
265 "entry-source",
266 DoctorStatus::Red,
267 format!(
268 "{} references removed/private runtime token {token}",
269 path.display()
270 ),
271 );
272 }
273 if !source.contains("@persona") {
274 return check(
275 "entry-source",
276 DoctorStatus::Red,
277 format!("{} does not declare @persona", path.display()),
278 );
279 }
280 check(
281 "entry-source",
282 DoctorStatus::Green,
283 format!("{} is Harn-first and declares @persona", path.display()),
284 )
285 }
286 Err(error) => check(
287 "entry-source",
288 DoctorStatus::Red,
289 format!("failed to read {}: {error}", path.display()),
290 ),
291 }
292}
293
294fn lint_check(catalog: &ResolvedPersonaManifest, entry_source: &Option<PathBuf>) -> DoctorCheck {
295 let Some(path) = entry_source else {
296 return check("lint", DoctorStatus::Red, "entry source unavailable");
297 };
298 let source = match fs::read_to_string(path) {
299 Ok(source) => source,
300 Err(error) => {
301 return check(
302 "lint",
303 DoctorStatus::Red,
304 format!("failed to read {}: {error}", path.display()),
305 )
306 }
307 };
308 let program = match harn_parser::parse_source(&source) {
309 Ok(program) => program,
310 Err(error) => return check("lint", DoctorStatus::Red, error.to_string()),
311 };
312 let files = collect_package_harn_files(&catalog.manifest_dir);
313 let module_graph = crate::commands::check::build_module_graph(&files);
314 let options = harn_lint::LintOptions {
315 file_path: Some(path),
316 require_file_header: false,
317 complexity_threshold: None,
318 persona_step_allowlist: &[],
319 };
320 let diagnostics = harn_lint::lint_with_module_graph(
321 &program,
322 &[],
323 Some(&source),
324 &HashSet::new(),
325 &module_graph,
326 path,
327 &options,
328 );
329 if diagnostics.is_empty() {
330 return check("lint", DoctorStatus::Green, "no issues found");
331 }
332 let red = diagnostics.iter().any(|diag| {
333 diag.severity == LintSeverity::Error || diag.rule == "persona-body-must-call-steps"
334 });
335 let status = if red {
336 DoctorStatus::Red
337 } else {
338 DoctorStatus::Yellow
339 };
340 let summary = diagnostics
341 .iter()
342 .take(3)
343 .map(|diag| format!("{}: {}", diag.rule, diag.message))
344 .collect::<Vec<_>>()
345 .join("; ");
346 check("lint", status, summary)
347}
348
349fn prompt_asset_check(
350 catalog: &ResolvedPersonaManifest,
351 entry_source: &Option<PathBuf>,
352) -> DoctorCheck {
353 let prompt_dir = catalog.manifest_dir.join("prompts");
354 let prompt_files = collect_prompt_files(&prompt_dir);
355 if prompt_files.is_empty() {
356 return check(
357 "prompt-assets",
358 DoctorStatus::Yellow,
359 "no .harn.prompt assets found",
360 );
361 }
362 for path in &prompt_files {
363 let source = match fs::read_to_string(path) {
364 Ok(source) => source,
365 Err(error) => {
366 return check(
367 "prompt-assets",
368 DoctorStatus::Red,
369 format!("failed to read {}: {error}", path.display()),
370 )
371 }
372 };
373 if let Err(error) = harn_vm::stdlib::template::validate_template_syntax(&source) {
374 return check(
375 "prompt-assets",
376 DoctorStatus::Red,
377 format!("{}: {error}", path.display()),
378 );
379 }
380 }
381 let uses_prompt_asset = entry_source
382 .as_ref()
383 .and_then(|path| fs::read_to_string(path).ok())
384 .is_some_and(|source| source.contains("render_prompt(") || source.contains("render("));
385 let status = if uses_prompt_asset {
386 DoctorStatus::Green
387 } else {
388 DoctorStatus::Yellow
389 };
390 check(
391 "prompt-assets",
392 status,
393 format!("{} prompt asset(s) validate", prompt_files.len()),
394 )
395}
396
397fn step_metadata_check(persona: &PersonaManifestEntry) -> DoctorCheck {
398 if persona.steps.is_empty() {
399 return check(
400 "step-metadata",
401 DoctorStatus::Red,
402 "entry source did not expose typed @step metadata",
403 );
404 }
405 let missing_receipt = persona
406 .steps
407 .iter()
408 .filter(|step| step.receipt.as_deref().unwrap_or_default().is_empty())
409 .count();
410 if missing_receipt > 0 {
411 return check(
412 "step-metadata",
413 DoctorStatus::Yellow,
414 format!(
415 "{} step(s) found, {missing_receipt} without explicit receipt policy",
416 persona.steps.len()
417 ),
418 );
419 }
420 check(
421 "step-metadata",
422 DoctorStatus::Green,
423 format!("{} typed step(s) found", persona.steps.len()),
424 )
425}
426
427fn cost_check(persona: &PersonaManifestEntry) -> DoctorCheck {
428 let step_token_budget: u64 = persona
429 .steps
430 .iter()
431 .filter_map(|step| step.budget.as_ref()?.max_tokens)
432 .sum();
433 let Some(max_tokens) = persona.budget.max_tokens else {
434 return check(
435 "cost-budget",
436 DoctorStatus::Yellow,
437 "manifest has no max_tokens budget",
438 );
439 };
440 if step_token_budget == 0 {
441 return check(
442 "cost-budget",
443 DoctorStatus::Yellow,
444 format!("manifest max_tokens={max_tokens}, no per-step token budgets"),
445 );
446 }
447 if step_token_budget > max_tokens {
448 return check(
449 "cost-budget",
450 DoctorStatus::Red,
451 format!("per-step max_tokens sum {step_token_budget} exceeds manifest max_tokens {max_tokens}"),
452 );
453 }
454 check(
455 "cost-budget",
456 DoctorStatus::Green,
457 format!(
458 "per-step max_tokens sum {step_token_budget} within manifest max_tokens {max_tokens}"
459 ),
460 )
461}
462
463async fn smoke_check(
464 catalog: &ResolvedPersonaManifest,
465 persona_name: &str,
466 timeout_ms: u64,
467) -> DoctorCheck {
468 let test_path = catalog
469 .manifest_dir
470 .join("tests")
471 .join(format!("{persona_name}_smoke.harn"));
472 if !test_path.exists() {
473 return check(
474 "smoke-test",
475 DoctorStatus::Yellow,
476 format!("{} not found", test_path.display()),
477 );
478 }
479 let summary = test_runner::run_tests(&test_path, None, timeout_ms, false).await;
480 if summary.failed > 0 {
481 let first_error = summary
482 .results
483 .iter()
484 .find(|result| !result.passed)
485 .and_then(|result| result.error.as_deref())
486 .unwrap_or("smoke test failed");
487 return check(
488 "smoke-test",
489 DoctorStatus::Red,
490 format!("{first_error} ({} failed)", summary.failed),
491 );
492 }
493 if summary.total == 0 {
494 return check(
495 "smoke-test",
496 DoctorStatus::Yellow,
497 "no test pipelines found",
498 );
499 }
500 check(
501 "smoke-test",
502 DoctorStatus::Green,
503 format!("{} smoke test(s) passed", summary.passed),
504 )
505}
506
507fn collect_package_harn_files(dir: &Path) -> Vec<PathBuf> {
508 let mut files = Vec::new();
509 crate::commands::collect_harn_files(dir, &mut files);
510 files
511}
512
513fn collect_prompt_files(dir: &Path) -> Vec<PathBuf> {
514 let mut files = Vec::new();
515 collect_prompt_files_inner(dir, &mut files);
516 files.sort();
517 files
518}
519
520fn collect_prompt_files_inner(dir: &Path, out: &mut Vec<PathBuf>) {
521 let Ok(entries) = fs::read_dir(dir) else {
522 return;
523 };
524 for entry in entries.filter_map(Result::ok) {
525 let path = entry.path();
526 if path.is_dir() {
527 collect_prompt_files_inner(&path, out);
528 } else if path
529 .file_name()
530 .and_then(|name| name.to_str())
531 .is_some_and(|name| name.ends_with(".harn.prompt"))
532 {
533 out.push(path);
534 }
535 }
536}