1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::process;
4use std::sync::Arc;
5
6use harn_vm::event_log::{AnyEventLog, EventLog};
7
8use crate::cli::{
9 PersonaCheckArgs, PersonaControlArgs, PersonaInspectArgs, PersonaListArgs, PersonaSpendArgs,
10 PersonaStatusArgs, PersonaTickArgs, PersonaTriggerArgs,
11};
12use crate::package::{self, PersonaManifestEntry, PersonaValidationError, ResolvedPersonaManifest};
13
14pub fn list_payload(manifest: Option<&Path>) -> Result<Vec<serde_json::Value>, String> {
18 let catalog = load_catalog_result(manifest)?;
19 Ok(catalog
20 .personas
21 .iter()
22 .map(|persona| persona_to_json(persona, &catalog))
23 .collect())
24}
25
26pub(crate) fn run_list(manifest: Option<&Path>, args: &PersonaListArgs) {
27 if args.json {
28 let personas = list_payload(manifest).unwrap_or_else(|error| fatal(&error));
29 println!(
30 "{}",
31 serde_json::to_string_pretty(&personas)
32 .unwrap_or_else(|error| fatal(&format!("failed to serialize personas: {error}")))
33 );
34 return;
35 }
36
37 let catalog = load_catalog_or_exit(manifest);
38 if catalog.personas.is_empty() {
39 println!(
40 "No personas declared in {}.",
41 catalog.manifest_path.display()
42 );
43 return;
44 }
45
46 println!("Personas in {}:", catalog.manifest_path.display());
47 let name_width = catalog
48 .personas
49 .iter()
50 .filter_map(|persona| persona.name.as_ref())
51 .map(String::len)
52 .max()
53 .unwrap_or(4);
54 for persona in &catalog.personas {
55 let name = persona.name.as_deref().unwrap_or("<unnamed>");
56 let tier = persona
57 .autonomy_tier
58 .map(|tier| tier.as_str())
59 .unwrap_or("<missing>");
60 let receipts = persona
61 .receipt_policy
62 .map(|policy| policy.as_str())
63 .unwrap_or("<missing>");
64 let entry = persona.entry_workflow.as_deref().unwrap_or("<missing>");
65 println!(" {name:<name_width$} tier={tier:<17} receipts={receipts:<8} entry={entry}");
66 }
67}
68
69pub fn check_payload(
73 path: Option<&Path>,
74) -> Result<serde_json::Value, Vec<PersonaValidationError>> {
75 let catalog = load_catalog_validation(path)?;
76 Ok(serde_json::json!({
77 "ok": true,
78 "manifest_path": catalog.manifest_path,
79 "personas": catalog.personas.iter().map(|persona| {
80 serde_json::json!({
81 "name": persona.name.as_deref().unwrap_or_default(),
82 "triggers": &persona.triggers,
83 "tools": &persona.tools,
84 "autonomy": persona.autonomy_tier.map(|tier| tier.as_str()).unwrap_or_default(),
85 "receipts": persona.receipt_policy.map(|policy| policy.as_str()).unwrap_or_default(),
86 })
87 }).collect::<Vec<_>>(),
88 }))
89}
90
91pub(crate) fn run_check(manifest: Option<&Path>, args: &PersonaCheckArgs) {
92 let selected = args.path.as_deref().or(manifest);
93 if args.json {
94 match check_payload(selected) {
95 Ok(payload) => println!(
96 "{}",
97 serde_json::to_string_pretty(&payload).unwrap_or_else(|error| fatal(&format!(
98 "failed to serialize persona check output: {error}"
99 )))
100 ),
101 Err(errors) => {
102 print_validation_errors_json(&errors);
103 process::exit(1);
104 }
105 }
106 return;
107 }
108 let catalog = match load_catalog_validation(selected) {
109 Ok(catalog) => catalog,
110 Err(errors) => fatal(
111 &errors
112 .iter()
113 .map(ToString::to_string)
114 .collect::<Vec<_>>()
115 .join("\n"),
116 ),
117 };
118 println!(
119 "ok: {} persona manifest validates ({} personas)",
120 catalog.manifest_path.display(),
121 catalog.personas.len()
122 );
123}
124
125pub fn inspect_payload(manifest: Option<&Path>, name: &str) -> Result<serde_json::Value, String> {
127 let catalog = load_catalog_result(manifest)?;
128 let persona = catalog
129 .personas
130 .iter()
131 .find(|persona| persona.name.as_deref() == Some(name))
132 .ok_or_else(|| {
133 format!(
134 "persona '{}' not found in {}",
135 name,
136 catalog.manifest_path.display()
137 )
138 })?;
139 Ok(persona_to_json(persona, &catalog))
140}
141
142pub(crate) fn run_inspect(manifest: Option<&Path>, args: &PersonaInspectArgs) {
143 if args.json {
144 let json = inspect_payload(manifest, &args.name).unwrap_or_else(|error| fatal(&error));
145 println!(
146 "{}",
147 serde_json::to_string_pretty(&json)
148 .unwrap_or_else(|error| fatal(&format!("failed to serialize persona: {error}")))
149 );
150 return;
151 }
152
153 let catalog = load_catalog_or_exit(manifest);
154 let Some(persona) = catalog
155 .personas
156 .iter()
157 .find(|persona| persona.name.as_deref() == Some(args.name.as_str()))
158 else {
159 fatal(&format!(
160 "persona '{}' not found in {}",
161 args.name,
162 catalog.manifest_path.display()
163 ));
164 };
165
166 println!(
167 "name: {}",
168 persona.name.as_deref().unwrap_or_default()
169 );
170 if let Some(version) = &persona.version {
171 println!("version: {version}");
172 }
173 println!(
174 "description: {}",
175 persona.description.as_deref().unwrap_or_default()
176 );
177 println!(
178 "entry_workflow: {}",
179 persona.entry_workflow.as_deref().unwrap_or_default()
180 );
181 println!("tools: {}", comma_or_dash(&persona.tools));
182 println!("capabilities: {}", comma_or_dash(&persona.capabilities));
183 println!(
184 "autonomy_tier: {}",
185 persona
186 .autonomy_tier
187 .map(|tier| tier.as_str())
188 .unwrap_or_default()
189 );
190 println!(
191 "receipt_policy: {}",
192 persona
193 .receipt_policy
194 .map(|policy| policy.as_str())
195 .unwrap_or_default()
196 );
197 println!("triggers: {}", comma_or_dash(&persona.triggers));
198 println!("schedules: {}", comma_or_dash(&persona.schedules));
199 println!("handoffs: {}", comma_or_dash(&persona.handoffs));
200 println!("context_packs: {}", comma_or_dash(&persona.context_packs));
201 println!("evals: {}", comma_or_dash(&persona.evals));
202 if !persona.steps.is_empty() {
203 println!("steps:");
204 for step in &persona.steps {
205 let mut detail = format!(" - {} ({})", step.name, step.function);
206 if let Some(model) = step.model.as_deref() {
207 detail.push_str(&format!(" model={model}"));
208 }
209 if let Some(budget) = step.budget.as_ref() {
210 if let Some(max_tokens) = budget.max_tokens {
211 detail.push_str(&format!(" max_tokens={max_tokens}"));
212 }
213 if let Some(max_usd) = budget.max_usd {
214 detail.push_str(&format!(" max_usd={max_usd}"));
215 }
216 }
217 if let Some(boundary) = step.error_boundary.as_deref() {
218 detail.push_str(&format!(" error_boundary={boundary}"));
219 }
220 println!("{detail}");
221 }
222 }
223 if !persona.stages.is_empty() {
224 println!("stages:");
225 for stage in &persona.stages {
226 let tools = stage
227 .allowed_tools
228 .as_deref()
229 .map(comma_or_dash)
230 .unwrap_or_else(|| "inherit".to_string());
231 let mut detail = format!(" - {} tools={tools}", stage.name);
232 if let Some(level) = stage.side_effect_level.as_deref() {
233 detail.push_str(&format!(" side_effect={level}"));
234 }
235 if let Some(max) = stage.max_iterations {
236 detail.push_str(&format!(" max_iterations={max}"));
237 }
238 println!("{detail}");
239 }
240 }
241 if let Some(owner) = &persona.owner {
242 println!("owner: {owner}");
243 }
244 println!("manifest: {}", catalog.manifest_path.display());
245}
246
247pub async fn status_payload(
249 manifest: Option<&Path>,
250 state_dir: &Path,
251 name: &str,
252 at: Option<&str>,
253) -> Result<harn_vm::PersonaStatus, String> {
254 let catalog = load_catalog_result(manifest)?;
255 let binding = runtime_binding_or_err(&catalog, name)?;
256 let log = open_persona_log(state_dir)?;
257 let now_ms = timestamp_arg(at)?;
258 harn_vm::persona_status(&log, &binding, now_ms).await
259}
260
261pub(crate) async fn run_status(
262 manifest: Option<&Path>,
263 state_dir: &Path,
264 args: &PersonaStatusArgs,
265) -> Result<(), String> {
266 let status = status_payload(manifest, state_dir, &args.name, args.at.as_deref()).await?;
267 print_status(&status, args.json);
268 Ok(())
269}
270
271pub async fn pause_payload(
273 manifest: Option<&Path>,
274 state_dir: &Path,
275 name: &str,
276 at: Option<&str>,
277) -> Result<harn_vm::PersonaStatus, String> {
278 let catalog = load_catalog_result(manifest)?;
279 let binding = runtime_binding_or_err(&catalog, name)?;
280 let log = open_persona_log(state_dir)?;
281 let now_ms = timestamp_arg(at)?;
282 harn_vm::pause_persona(&log, &binding, now_ms).await
283}
284
285pub(crate) async fn run_pause(
286 manifest: Option<&Path>,
287 state_dir: &Path,
288 args: &PersonaControlArgs,
289) -> Result<(), String> {
290 let status = pause_payload(manifest, state_dir, &args.name, args.at.as_deref()).await?;
291 print_status(&status, args.json);
292 Ok(())
293}
294
295pub async fn resume_payload(
297 manifest: Option<&Path>,
298 state_dir: &Path,
299 name: &str,
300 at: Option<&str>,
301) -> Result<harn_vm::PersonaStatus, String> {
302 let catalog = load_catalog_result(manifest)?;
303 let binding = runtime_binding_or_err(&catalog, name)?;
304 let log = open_persona_log(state_dir)?;
305 let now_ms = timestamp_arg(at)?;
306 harn_vm::resume_persona(&log, &binding, now_ms).await
307}
308
309pub(crate) async fn run_resume(
310 manifest: Option<&Path>,
311 state_dir: &Path,
312 args: &PersonaControlArgs,
313) -> Result<(), String> {
314 let status = resume_payload(manifest, state_dir, &args.name, args.at.as_deref()).await?;
315 print_status(&status, args.json);
316 Ok(())
317}
318
319pub async fn disable_payload(
321 manifest: Option<&Path>,
322 state_dir: &Path,
323 name: &str,
324 at: Option<&str>,
325) -> Result<harn_vm::PersonaStatus, String> {
326 let catalog = load_catalog_result(manifest)?;
327 let binding = runtime_binding_or_err(&catalog, name)?;
328 let log = open_persona_log(state_dir)?;
329 let now_ms = timestamp_arg(at)?;
330 harn_vm::disable_persona(&log, &binding, now_ms).await
331}
332
333pub(crate) async fn run_disable(
334 manifest: Option<&Path>,
335 state_dir: &Path,
336 args: &PersonaControlArgs,
337) -> Result<(), String> {
338 let status = disable_payload(manifest, state_dir, &args.name, args.at.as_deref()).await?;
339 print_status(&status, args.json);
340 Ok(())
341}
342
343pub async fn tick_payload(
346 manifest: Option<&Path>,
347 state_dir: &Path,
348 name: &str,
349 at: Option<&str>,
350 cost_usd: f64,
351 tokens: u64,
352) -> Result<harn_vm::PersonaRunReceipt, String> {
353 let catalog = load_catalog_result(manifest)?;
354 let binding = runtime_binding_or_err(&catalog, name)?;
355 let log = open_persona_log(state_dir)?;
356 let now_ms = timestamp_arg(at)?;
357 let receipt = harn_vm::fire_persona_schedule(
358 &log,
359 &binding,
360 harn_vm::PersonaRunCost {
361 cost_usd,
362 tokens,
363 ..Default::default()
364 },
365 now_ms,
366 )
367 .await?;
368 log.flush().await.map_err(|error| error.to_string())?;
369 Ok(receipt)
370}
371
372pub(crate) async fn run_tick(
373 manifest: Option<&Path>,
374 state_dir: &Path,
375 args: &PersonaTickArgs,
376) -> Result<(), String> {
377 let receipt = tick_payload(
378 manifest,
379 state_dir,
380 &args.name,
381 args.at.as_deref(),
382 args.cost_usd,
383 args.tokens,
384 )
385 .await?;
386 print_receipt(&receipt, args.json);
387 Ok(())
388}
389
390#[allow(clippy::too_many_arguments)]
393pub async fn trigger_payload(
394 manifest: Option<&Path>,
395 state_dir: &Path,
396 name: &str,
397 provider: &str,
398 kind: &str,
399 metadata_pairs: &[String],
400 at: Option<&str>,
401 cost_usd: f64,
402 tokens: u64,
403) -> Result<harn_vm::PersonaRunReceipt, String> {
404 let catalog = load_catalog_result(manifest)?;
405 let binding = runtime_binding_or_err(&catalog, name)?;
406 let log = open_persona_log(state_dir)?;
407 let now_ms = timestamp_arg(at)?;
408 let metadata = parse_metadata(metadata_pairs)?;
409 let receipt = harn_vm::fire_persona_trigger(
410 &log,
411 &binding,
412 provider,
413 kind,
414 metadata,
415 harn_vm::PersonaRunCost {
416 cost_usd,
417 tokens,
418 ..Default::default()
419 },
420 now_ms,
421 )
422 .await?;
423 log.flush().await.map_err(|error| error.to_string())?;
424 Ok(receipt)
425}
426
427pub(crate) async fn run_trigger(
428 manifest: Option<&Path>,
429 state_dir: &Path,
430 args: &PersonaTriggerArgs,
431) -> Result<(), String> {
432 let receipt = trigger_payload(
433 manifest,
434 state_dir,
435 &args.name,
436 &args.provider,
437 &args.kind,
438 &args.metadata,
439 args.at.as_deref(),
440 args.cost_usd,
441 args.tokens,
442 )
443 .await?;
444 print_receipt(&receipt, args.json);
445 Ok(())
446}
447
448pub async fn spend_payload(
450 manifest: Option<&Path>,
451 state_dir: &Path,
452 name: &str,
453 at: Option<&str>,
454 cost_usd: f64,
455 tokens: u64,
456) -> Result<harn_vm::PersonaBudgetStatus, String> {
457 let catalog = load_catalog_result(manifest)?;
458 let binding = runtime_binding_or_err(&catalog, name)?;
459 let log = open_persona_log(state_dir)?;
460 let now_ms = timestamp_arg(at)?;
461 let budget = harn_vm::record_persona_spend(
462 &log,
463 &binding,
464 harn_vm::PersonaRunCost {
465 cost_usd,
466 tokens,
467 ..Default::default()
468 },
469 now_ms,
470 )
471 .await?;
472 log.flush().await.map_err(|error| error.to_string())?;
473 Ok(budget)
474}
475
476pub(crate) async fn run_spend(
477 manifest: Option<&Path>,
478 state_dir: &Path,
479 args: &PersonaSpendArgs,
480) -> Result<(), String> {
481 let budget = spend_payload(
482 manifest,
483 state_dir,
484 &args.name,
485 args.at.as_deref(),
486 args.cost_usd,
487 args.tokens,
488 )
489 .await?;
490 if args.json {
491 println!(
492 "{}",
493 serde_json::to_string_pretty(&budget)
494 .unwrap_or_else(|error| fatal(&format!("failed to serialize budget: {error}")))
495 );
496 } else {
497 println!(
498 "budget: spent_today=${:.4} tokens_today={} exhausted={}",
499 budget.spent_today_usd, budget.tokens_today, budget.exhausted
500 );
501 }
502 Ok(())
503}
504
505fn load_catalog_or_exit(manifest: Option<&Path>) -> ResolvedPersonaManifest {
506 match load_catalog_result(manifest) {
507 Ok(catalog) => catalog,
508 Err(message) => fatal(&message),
509 }
510}
511
512fn load_catalog_result(manifest: Option<&Path>) -> Result<ResolvedPersonaManifest, String> {
513 load_catalog_validation(manifest).map_err(|errors| validation_errors_to_string(&errors))
514}
515
516fn load_catalog_validation(
517 manifest: Option<&Path>,
518) -> Result<ResolvedPersonaManifest, Vec<PersonaValidationError>> {
519 let result = if let Some(path) = manifest {
520 package::load_personas_from_manifest_path(path).map(Some)
521 } else {
522 package::load_personas_config(None)
523 };
524 match result {
525 Ok(Some(catalog)) => Ok(catalog),
526 Ok(None) => Err(vec![PersonaValidationError {
527 manifest_path: PathBuf::from("harn.toml"),
528 field_path: "harn.toml".to_string(),
529 message: "no harn.toml found; pass --manifest <path> or run inside a Harn project"
530 .to_string(),
531 }]),
532 Err(errors) => Err(errors),
533 }
534}
535
536fn validation_errors_to_string(errors: &[PersonaValidationError]) -> String {
537 errors
538 .iter()
539 .map(ToString::to_string)
540 .collect::<Vec<_>>()
541 .join("\n")
542}
543
544fn runtime_binding_or_err(
545 catalog: &ResolvedPersonaManifest,
546 name: &str,
547) -> Result<harn_vm::PersonaRuntimeBinding, String> {
548 let persona = catalog
549 .personas
550 .iter()
551 .find(|persona| persona.name.as_deref() == Some(name))
552 .ok_or_else(|| {
553 format!(
554 "persona '{}' not found in {}",
555 name,
556 catalog.manifest_path.display()
557 )
558 })?;
559 Ok(crate::package::persona_runtime_binding(
560 persona.name.as_deref().unwrap_or_default(),
561 persona,
562 ))
563}
564
565pub(super) fn open_persona_log(state_dir: &Path) -> Result<Arc<AnyEventLog>, String> {
566 let state_dir = absolutize_from_cwd(state_dir)?;
567 std::fs::create_dir_all(&state_dir).map_err(|error| {
568 format!(
569 "failed to create persona state dir {}: {error}",
570 state_dir.display()
571 )
572 })?;
573 harn_vm::event_log::install_default_for_base_dir(&state_dir)
574 .map_err(|error| format!("failed to open persona event log: {error}"))
575}
576
577fn absolutize_from_cwd(path: &Path) -> Result<PathBuf, String> {
578 if path.is_absolute() {
579 return Ok(path.to_path_buf());
580 }
581 std::env::current_dir()
582 .map(|cwd| cwd.join(path))
583 .map_err(|error| format!("failed to read current directory: {error}"))
584}
585
586pub(super) fn timestamp_arg(value: Option<&str>) -> Result<i64, String> {
587 match value {
588 Some(value) => harn_vm::parse_persona_ms(value),
589 None => Ok(harn_vm::persona_now_ms()),
590 }
591}
592
593fn parse_metadata(values: &[String]) -> Result<BTreeMap<String, String>, String> {
594 let mut metadata = BTreeMap::new();
595 for value in values {
596 let Some((key, raw)) = value.split_once('=') else {
597 return Err(format!("metadata '{value}' must use KEY=VALUE syntax"));
598 };
599 let key = key.trim();
600 if key.is_empty() {
601 return Err(format!("metadata '{value}' has an empty key"));
602 }
603 metadata.insert(key.to_string(), raw.to_string());
604 }
605 Ok(metadata)
606}
607
608fn print_status(status: &harn_vm::PersonaStatus, json: bool) {
609 if json {
610 println!(
611 "{}",
612 serde_json::to_string_pretty(status)
613 .unwrap_or_else(|error| fatal(&format!("failed to serialize status: {error}")))
614 );
615 return;
616 }
617 println!("persona: {}", status.name);
618 println!(
619 "template_ref: {}",
620 status.template_ref.as_deref().unwrap_or("-")
621 );
622 println!("state: {}", status.state.as_str());
623 println!("entry_workflow: {}", status.entry_workflow);
624 println!("role: {}", status.role);
625 println!(
626 "assignment: {}",
627 status
628 .current_assignment
629 .as_ref()
630 .map(|assignment| assignment.work_key.as_str())
631 .unwrap_or("-")
632 );
633 println!(
634 "last_run: {}",
635 status.last_run.as_deref().unwrap_or("-")
636 );
637 println!(
638 "next_run: {}",
639 status.next_scheduled_run.as_deref().unwrap_or("-")
640 );
641 println!("queued_events: {}", status.queued_events);
642 if !status.handoff_inbox.is_empty() {
643 println!("handoffs:");
644 for handoff in &status.handoff_inbox {
645 println!(
646 " - {} kind={} from={} task={}",
647 handoff
648 .handoff_id
649 .as_deref()
650 .unwrap_or(handoff.work_key.as_str()),
651 handoff.handoff_kind.as_deref().unwrap_or("-"),
652 handoff.source_persona.as_deref().unwrap_or("-"),
653 handoff.task.as_deref().unwrap_or("-")
654 );
655 }
656 }
657 println!(
658 "active_lease: {}",
659 status
660 .active_lease
661 .as_ref()
662 .map(|lease| lease.id.as_str())
663 .unwrap_or("-")
664 );
665 println!(
666 "budget: spent_today=${:.4} remaining_today={}",
667 status.budget.spent_today_usd,
668 status
669 .budget
670 .remaining_today_usd
671 .map(|value| format!("${value:.4}"))
672 .unwrap_or_else(|| "-".to_string())
673 );
674 if let Some(receipt) = status.value_receipts.last() {
675 println!(
676 "last_receipt: {} paid=${:.4} avoided=${:.4}",
677 receipt.kind.as_str(),
678 receipt.paid_cost_usd,
679 receipt.avoided_cost_usd
680 );
681 }
682 if let Some(error) = &status.last_error {
683 println!("last_error: {error}");
684 }
685}
686
687fn print_receipt(receipt: &harn_vm::PersonaRunReceipt, json: bool) {
688 if json {
689 println!(
690 "{}",
691 serde_json::to_string_pretty(receipt)
692 .unwrap_or_else(|error| fatal(&format!("failed to serialize receipt: {error}")))
693 );
694 } else {
695 println!(
696 "persona={} status={} work_key={} queued={}",
697 receipt.persona, receipt.status, receipt.work_key, receipt.queued
698 );
699 if let Some(error) = &receipt.error {
700 println!("error={error}");
701 }
702 }
703}
704
705fn print_validation_errors_json(errors: &[PersonaValidationError]) {
706 let payload = serde_json::json!({
707 "ok": false,
708 "errors": errors.iter().map(|error| {
709 serde_json::json!({
710 "manifest_path": &error.manifest_path,
711 "field_path": &error.field_path,
712 "message": &error.message,
713 })
714 }).collect::<Vec<_>>(),
715 });
716 println!(
717 "{}",
718 serde_json::to_string_pretty(&payload).unwrap_or_else(|error| {
719 fatal(&format!(
720 "failed to serialize persona validation errors: {error}"
721 ))
722 })
723 );
724}
725
726fn persona_to_json(
727 persona: &PersonaManifestEntry,
728 catalog: &ResolvedPersonaManifest,
729) -> serde_json::Value {
730 serde_json::json!({
731 "name": persona.name.as_deref().unwrap_or_default(),
732 "version": persona.version.as_deref(),
733 "description": persona.description.as_deref().unwrap_or_default(),
734 "entry_workflow": persona.entry_workflow.as_deref().unwrap_or_default(),
735 "tools": &persona.tools,
736 "capabilities": &persona.capabilities,
737 "autonomy_tier": persona.autonomy_tier.map(|tier| tier.as_str()).unwrap_or_default(),
738 "receipt_policy": persona.receipt_policy.map(|policy| policy.as_str()).unwrap_or_default(),
739 "triggers": &persona.triggers,
740 "schedules": &persona.schedules,
741 "model_policy": {
742 "default_model": persona.model_policy.default_model.as_deref(),
743 "escalation_model": persona.model_policy.escalation_model.as_deref(),
744 "fallback_models": &persona.model_policy.fallback_models,
745 "reasoning_effort": persona.model_policy.reasoning_effort.as_deref(),
746 },
747 "budget": {
748 "daily_usd": persona.budget.daily_usd,
749 "hourly_usd": persona.budget.hourly_usd,
750 "run_usd": persona.budget.run_usd,
751 "frontier_escalations": persona.budget.frontier_escalations,
752 "max_tokens": persona.budget.max_tokens,
753 "max_runtime_seconds": persona.budget.max_runtime_seconds,
754 },
755 "handoffs": &persona.handoffs,
756 "context_packs": &persona.context_packs,
757 "evals": &persona.evals,
758 "steps": &persona.steps,
759 "stages": &persona.stages,
760 "owner": persona.owner.as_deref(),
761 "package_source": {
762 "package": persona.package_source.package.as_deref(),
763 "path": persona.package_source.path.as_deref(),
764 "git": persona.package_source.git.as_deref(),
765 "rev": persona.package_source.rev.as_deref(),
766 },
767 "rollout_policy": {
768 "mode": persona.rollout_policy.mode.as_deref(),
769 "percentage": persona.rollout_policy.percentage,
770 "cohorts": &persona.rollout_policy.cohorts,
771 },
772 "source": {
773 "manifest_path": &catalog.manifest_path,
774 "manifest_dir": &catalog.manifest_dir,
775 },
776 })
777}
778
779fn comma_or_dash(values: &[String]) -> String {
780 if values.is_empty() {
781 "-".to_string()
782 } else {
783 values.join(", ")
784 }
785}
786
787fn fatal(message: &str) -> ! {
788 eprintln!("error: {message}");
789 process::exit(1);
790}