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