Skip to main content

harn_cli/commands/
persona_doctor.rs

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}