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 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}