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