Skip to main content

scientific_workflow/study/
phase.rs

1//! First-class phase and task declarations owned by a study.
2
3use std::collections::{BTreeMap, HashSet};
4use std::fmt;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use serde::de::DeserializeOwned;
9use serde_json::Value;
10use sha2::{Digest, Sha256};
11
12use super::error::StudyError;
13use super::task::{TaskContext, TaskResult, Workload};
14use crate::configuration::{ProjectPaths, ResolvedConfiguration};
15
16/// Stable numeric identity of one execution phase.
17#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
18pub struct PhaseId(u64);
19
20impl PhaseId {
21    /// Creates a phase identity suitable for command-line selections such as
22    /// `[2, 4, 5]`.
23    pub const fn new(value: u64) -> Self {
24        Self(value)
25    }
26
27    /// Returns the exact numeric phase identity.
28    pub const fn get(self) -> u64 {
29        self.0
30    }
31}
32
33impl From<u64> for PhaseId {
34    fn from(value: u64) -> Self {
35        Self::new(value)
36    }
37}
38
39impl fmt::Display for PhaseId {
40    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41        self.0.fmt(formatter)
42    }
43}
44
45/// Stable task identity scoped to one phase.
46#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
47pub struct TaskId(Arc<str>);
48
49impl TaskId {
50    /// Creates a task ID. Empty or whitespace-only IDs are rejected when the
51    /// owning phase is built, keeping task construction infallible.
52    pub fn new(value: impl Into<String>) -> Self {
53        Self(value.into().into())
54    }
55
56    /// Borrows the exact ID text.
57    pub fn as_str(&self) -> &str {
58        &self.0
59    }
60}
61
62impl fmt::Display for TaskId {
63    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64        self.0.fmt(formatter)
65    }
66}
67
68/// Exact phase-qualified task lookup key.
69#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
70pub struct TaskKey {
71    phase: PhaseId,
72    task: TaskId,
73}
74
75impl TaskKey {
76    /// Creates one phase-qualified task key.
77    pub fn new(phase: impl Into<PhaseId>, task: TaskId) -> Self {
78        Self {
79            phase: phase.into(),
80            task,
81        }
82    }
83
84    /// Returns the owning phase ID.
85    pub const fn phase_id(&self) -> PhaseId {
86        self.phase
87    }
88
89    /// Borrows the phase-local task ID.
90    pub fn task_id(&self) -> &TaskId {
91        &self.task
92    }
93}
94
95impl fmt::Display for TaskKey {
96    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97        write!(formatter, "{}/{}", self.phase, self.task)
98    }
99}
100
101/// Reporting shape of one task.
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103#[non_exhaustive]
104pub enum TaskMode {
105    /// Work that may report iterative progress.
106    Progress,
107    /// One-shot work with lifecycle status only.
108    OneShot,
109}
110
111/// First-class immutable task declaration owned by exactly one [`Phase`].
112pub struct Task {
113    key: TaskKey,
114    category: Arc<str>,
115    mode: TaskMode,
116    label: Arc<str>,
117    metadata: Arc<BTreeMap<String, Value>>,
118    workload: Option<Workload>,
119    completed: bool,
120}
121
122impl Task {
123    /// Creates a one-shot task.
124    pub fn one_shot<W>(id: impl Into<String>, label: impl Into<String>, workload: W) -> Self
125    where
126        W: FnOnce(&TaskContext) -> TaskResult + Send + 'static,
127    {
128        Self::new(
129            id,
130            label,
131            TaskMode::OneShot,
132            Some(Box::new(workload)),
133            false,
134        )
135    }
136
137    /// Creates a task that may report progress through its context.
138    pub fn progress<W>(id: impl Into<String>, label: impl Into<String>, workload: W) -> Self
139    where
140        W: FnOnce(&TaskContext) -> TaskResult + Send + 'static,
141    {
142        Self::new(
143            id,
144            label,
145            TaskMode::Progress,
146            Some(Box::new(workload)),
147            false,
148        )
149    }
150
151    /// Creates a one-shot task using the conventional configuration identity.
152    pub fn one_shot_for_configuration<W>(
153        category: impl Into<String>,
154        configuration: &ResolvedConfiguration,
155        workload: W,
156    ) -> Self
157    where
158        W: FnOnce(&TaskContext) -> TaskResult + Send + 'static,
159    {
160        Self::for_configuration(
161            category,
162            configuration,
163            TaskMode::OneShot,
164            Box::new(workload),
165        )
166    }
167
168    /// Creates a progress task using the conventional configuration identity.
169    pub fn progress_for_configuration<W>(
170        category: impl Into<String>,
171        configuration: &ResolvedConfiguration,
172        workload: W,
173    ) -> Self
174    where
175        W: FnOnce(&TaskContext) -> TaskResult + Send + 'static,
176    {
177        Self::for_configuration(
178            category,
179            configuration,
180            TaskMode::Progress,
181            Box::new(workload),
182        )
183    }
184
185    /// Creates an application-verified task that is already satisfied.
186    pub fn completed(id: impl Into<String>, label: impl Into<String>) -> Self {
187        Self::new(id, label, TaskMode::OneShot, None, true)
188    }
189
190    fn new(
191        id: impl Into<String>,
192        label: impl Into<String>,
193        mode: TaskMode,
194        workload: Option<Workload>,
195        completed: bool,
196    ) -> Self {
197        Self {
198            key: TaskKey::new(0, TaskId::new(id)),
199            category: "task".into(),
200            mode,
201            label: label.into().into(),
202            metadata: Arc::new(BTreeMap::new()),
203            workload,
204            completed,
205        }
206    }
207
208    fn for_configuration(
209        category: impl Into<String>,
210        configuration: &ResolvedConfiguration,
211        mode: TaskMode,
212        workload: Workload,
213    ) -> Self {
214        let category = category.into();
215        let ordinal = configuration.ordinal();
216        Self::new(
217            format!("{category}-{ordinal}"),
218            format!("{category} {ordinal}"),
219            mode,
220            Some(workload),
221            false,
222        )
223        .category(category)
224        .with_configuration(configuration)
225    }
226
227    /// Sets the optional application-defined task category.
228    #[must_use]
229    pub fn category(mut self, category: impl Into<String>) -> Self {
230        self.category = category.into().into();
231        self
232    }
233
234    /// Adds application-defined metadata used for inspection and selection.
235    #[must_use]
236    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
237        Arc::make_mut(&mut self.metadata).insert(key.into(), value.into());
238        self
239    }
240
241    /// Attaches a complete resolved scientific configuration to task provenance.
242    #[must_use]
243    pub fn with_configuration(mut self, configuration: &ResolvedConfiguration) -> Self {
244        let metadata = Arc::make_mut(&mut self.metadata);
245        metadata.insert(
246            "configuration_ordinal".to_owned(),
247            Value::from(configuration.ordinal()),
248        );
249        metadata.insert(
250            "configuration_identity".to_owned(),
251            serde_json::json!({
252                "component": configuration.component(),
253                "workload": configuration.workload(),
254                "global_ordinal": configuration.global_ordinal(),
255                "component_ordinal": configuration.component_ordinal(),
256                "workload_ordinal": configuration.workload_ordinal(),
257            }),
258        );
259        metadata.insert("configuration".to_owned(), configuration.to_json_value());
260        self
261    }
262
263    /// Attaches the complete named project path table to task provenance.
264    #[must_use]
265    pub fn with_project_paths(mut self, paths: &ProjectPaths) -> Self {
266        Arc::make_mut(&mut self.metadata).insert("project_paths".to_owned(), paths.to_json_value());
267        self
268    }
269
270    /// Returns the exact phase-qualified task key.
271    pub fn key(&self) -> &TaskKey {
272        &self.key
273    }
274
275    /// Returns the phase-local task ID.
276    pub fn id(&self) -> &TaskId {
277        self.key.task_id()
278    }
279
280    /// Returns the application-defined task category.
281    pub fn category_name(&self) -> &str {
282        &self.category
283    }
284
285    /// Returns the automatically generated display label.
286    pub fn label(&self) -> &str {
287        &self.label
288    }
289
290    /// Returns whether the task reports iterative progress or one-shot status.
291    pub const fn mode(&self) -> TaskMode {
292        self.mode
293    }
294
295    /// Borrows one application-defined metadata value.
296    pub fn metadata_value(&self, key: &str) -> Option<&Value> {
297        self.metadata.get(key)
298    }
299
300    /// Borrows one required task metadata value.
301    pub fn require_metadata(&self, key: &str) -> Result<&Value, StudyError> {
302        self.metadata_value(key)
303            .ok_or_else(|| StudyError::UnknownTaskMetadata {
304                task: self.key.to_string(),
305                key: key.to_owned(),
306            })
307    }
308
309    /// Decodes one required task metadata value without first cloning its JSON tree.
310    pub fn decode_metadata<T>(&self, key: &str) -> Result<T, StudyError>
311    where
312        T: DeserializeOwned,
313    {
314        T::deserialize(self.require_metadata(key)?).map_err(|source| {
315            StudyError::DecodeTaskMetadata {
316                task: self.key.to_string(),
317                key: key.to_owned(),
318                source,
319            }
320        })
321    }
322
323    /// Iterates application-defined metadata.
324    pub fn metadata_iter(&self) -> impl Iterator<Item = (&str, &Value)> + '_ {
325        self.metadata
326            .iter()
327            .map(|(key, value)| (key.as_str(), value))
328    }
329
330    pub(crate) fn metadata_map(&self) -> Arc<BTreeMap<String, Value>> {
331        Arc::clone(&self.metadata)
332    }
333
334    pub(crate) fn take_workload(&mut self) -> Option<Workload> {
335        self.workload.take()
336    }
337
338    pub(crate) fn has_workload(&self) -> bool {
339        self.workload.is_some()
340    }
341
342    pub(crate) const fn is_completed(&self) -> bool {
343        self.completed
344    }
345
346    fn attach_to_phase(&mut self, phase: PhaseId) {
347        self.key.phase = phase;
348    }
349}
350
351impl fmt::Debug for Task {
352    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
353        formatter
354            .debug_struct("Task")
355            .field("key", &self.key)
356            .field("category", &self.category)
357            .field("label", &self.label)
358            .field("mode", &self.mode)
359            .field("metadata", &self.metadata.len())
360            .finish_non_exhaustive()
361    }
362}
363
364/// Immutable nonempty execution phase and renderer section.
365pub struct Phase {
366    id: PhaseId,
367    label: Arc<str>,
368    tasks: Vec<Task>,
369    max_active_tasks: usize,
370    prepared_task_queue_capacity: usize,
371    delay_per_task: Option<Duration>,
372    task_timeout: Option<Duration>,
373    deadline_after: Option<Duration>,
374    dependencies: Vec<PhaseId>,
375    failure_policy: PhaseFailurePolicy,
376    require_confirm: bool,
377}
378
379/// Scheduling behavior after the first workload failure in a phase.
380#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
381#[non_exhaustive]
382pub enum PhaseFailurePolicy {
383    /// Stop new work and cooperatively cancel active workloads.
384    #[default]
385    FailFast,
386    /// Stop new work but allow already active workloads to finish.
387    FinishActive,
388}
389
390impl PhaseFailurePolicy {
391    /// Returns the stable uncolored policy name used by plain output.
392    pub const fn as_str(self) -> &'static str {
393        match self {
394            Self::FailFast => "fail-fast",
395            Self::FinishActive => "finish-active",
396        }
397    }
398}
399
400impl Phase {
401    /// Begins declaring a phase. Tasks must be added before [`PhaseBuilder::build`].
402    pub fn builder(id: impl Into<PhaseId>, label: impl Into<String>) -> PhaseBuilder {
403        PhaseBuilder {
404            id: id.into(),
405            label: label.into(),
406            tasks: Vec::new(),
407            max_active_tasks: 1,
408            prepared_task_queue_capacity: 1,
409            delay_per_task: None,
410            task_timeout: None,
411            deadline_after: None,
412            dependencies: Vec::new(),
413            failure_policy: PhaseFailurePolicy::FailFast,
414            require_confirm: false,
415        }
416    }
417
418    /// Returns the stable phase identity.
419    pub const fn id(&self) -> PhaseId {
420        self.id
421    }
422
423    /// Returns the human-facing phase heading.
424    pub fn label(&self) -> &str {
425        &self.label
426    }
427
428    /// Returns every task in deterministic display/execution order.
429    pub fn tasks(&self) -> &[Task] {
430        &self.tasks
431    }
432
433    /// Returns the phase-local concurrent workload ceiling.
434    pub const fn max_active_tasks(&self) -> usize {
435        self.max_active_tasks
436    }
437
438    /// Returns the prepared-but-not-running workload ceiling.
439    pub const fn prepared_task_queue_capacity(&self) -> usize {
440        self.prepared_task_queue_capacity
441    }
442
443    /// Returns the optional minimum interval between consecutive task starts.
444    pub const fn delay_per_task(&self) -> Option<Duration> {
445        self.delay_per_task
446    }
447
448    /// Returns the optional elapsed-time limit for each running task.
449    pub const fn task_timeout(&self) -> Option<Duration> {
450        self.task_timeout
451    }
452
453    /// Returns the optional phase deadline measured from phase execution start.
454    pub const fn deadline_after(&self) -> Option<Duration> {
455        self.deadline_after
456    }
457
458    /// Returns declared predecessor phases in declaration order.
459    pub fn dependencies(&self) -> &[PhaseId] {
460        &self.dependencies
461    }
462
463    /// Returns the behavior selected for the first workload failure.
464    pub const fn failure_policy(&self) -> PhaseFailurePolicy {
465        self.failure_policy
466    }
467
468    /// Reports whether successful completion requires confirmation before the
469    /// next selected phase may start.
470    pub const fn requires_confirmation(&self) -> bool {
471        self.require_confirm
472    }
473
474    pub(crate) fn into_tasks(self) -> Vec<Task> {
475        self.tasks
476    }
477
478    /// Returns one exact phase-local task by ID.
479    pub fn task(&self, id: &TaskId) -> Option<&Task> {
480        self.tasks.iter().find(|task| task.key.task_id() == id)
481    }
482
483    /// Returns the sole task matching an exact partial selector.
484    pub fn unique_task_matching(&self, selector: &TaskSelector) -> Result<&Task, StudyError> {
485        let mut matches = self.tasks.iter().filter(|task| selector.matches(task));
486        let first = matches.next().ok_or_else(|| StudyError::TaskNotFound {
487            selector: selector.to_string(),
488        })?;
489        if let Some(second) = matches.next() {
490            return Err(StudyError::TaskSelectorAmbiguous {
491                selector: selector.to_string(),
492                first: first.key.to_string(),
493                second: second.key.to_string(),
494            });
495        }
496        Ok(first)
497    }
498}
499
500/// Builder for one nonempty first-class phase.
501pub struct PhaseBuilder {
502    id: PhaseId,
503    label: String,
504    tasks: Vec<Task>,
505    max_active_tasks: usize,
506    prepared_task_queue_capacity: usize,
507    delay_per_task: Option<Duration>,
508    task_timeout: Option<Duration>,
509    deadline_after: Option<Duration>,
510    dependencies: Vec<PhaseId>,
511    failure_policy: PhaseFailurePolicy,
512    require_confirm: bool,
513}
514
515impl PhaseBuilder {
516    /// Registers one task with this phase.
517    pub fn task(mut self, task: Task) -> Self {
518        self.tasks.push(task);
519        self
520    }
521
522    /// Registers tasks in deterministic declaration order.
523    pub fn tasks<I>(mut self, tasks: I) -> Self
524    where
525        I: IntoIterator<Item = Task>,
526    {
527        self.tasks.extend(tasks);
528        self
529    }
530
531    /// Sets the later scheduler's phase-local active workload ceiling.
532    pub fn max_active_tasks(mut self, maximum: usize) -> Self {
533        self.max_active_tasks = maximum;
534        self
535    }
536
537    /// Sets the later scheduler's prepared-work queue capacity.
538    pub fn prepared_task_queue_capacity(mut self, capacity: usize) -> Self {
539        self.prepared_task_queue_capacity = capacity;
540        self
541    }
542
543    /// Sets a minimum start-to-start interval for executable tasks.
544    ///
545    /// This policy is optional. Without this call, tasks are admitted exactly
546    /// as before. Completed tasks do not consume a delay rank.
547    pub fn delay_per_task(mut self, delay: Duration) -> Self {
548        self.delay_per_task = Some(delay);
549        self
550    }
551
552    /// Sets the maximum elapsed time for each task after it starts.
553    ///
554    /// Expiration requests cooperative cancellation; Rust workloads cannot be
555    /// forcibly terminated while they are blocked in user or system code.
556    pub fn task_timeout(mut self, timeout: Duration) -> Self {
557        self.task_timeout = Some(timeout);
558        self
559    }
560
561    /// Sets a phase-wide deadline relative to the phase execution start.
562    ///
563    /// Once reached, no additional tasks start and active tasks receive a
564    /// cooperative cancellation request.
565    pub fn deadline_after(mut self, deadline: Duration) -> Self {
566        self.deadline_after = Some(deadline);
567        self
568    }
569
570    /// Declares one phase that must be satisfied before this phase starts.
571    pub fn depends_on(mut self, dependency: impl Into<PhaseId>) -> Self {
572        self.dependencies.push(dependency.into());
573        self
574    }
575
576    /// Selects behavior after the first workload failure.
577    pub fn failure_policy(mut self, policy: PhaseFailurePolicy) -> Self {
578        self.failure_policy = policy;
579        self
580    }
581
582    /// Requires the user to type `yes` before advancing to the next phase.
583    ///
584    /// The default is `false`. This setting has no effect when this phase is
585    /// the final selected phase because there is no transition to confirm.
586    pub fn require_confirm(mut self, require: bool) -> Self {
587        self.require_confirm = require;
588        self
589    }
590
591    /// Validates and creates one immutable nonempty phase.
592    pub fn build(mut self) -> Result<Phase, StudyError> {
593        if self.label.trim().is_empty() {
594            return Err(StudyError::InvalidPhaseLabel { phase: self.id.0 });
595        }
596        if self.tasks.is_empty() {
597            return Err(StudyError::EmptyPhase { phase: self.id.0 });
598        }
599        if self.max_active_tasks == 0 {
600            return Err(StudyError::InvalidPhaseWorkloadLimit { phase: self.id.0 });
601        }
602        if self.prepared_task_queue_capacity == 0 {
603            return Err(StudyError::InvalidPhaseQueueCapacity { phase: self.id.0 });
604        }
605        for (setting, duration) in [
606            ("delay_per_task", self.delay_per_task),
607            ("task_timeout", self.task_timeout),
608            ("deadline_after", self.deadline_after),
609        ] {
610            if duration.is_some_and(|duration| {
611                duration.is_zero() || Instant::now().checked_add(duration).is_none()
612            }) {
613                return Err(StudyError::InvalidPhaseTiming {
614                    phase: self.id.0,
615                    setting,
616                });
617            }
618        }
619
620        let mut ids = HashSet::with_capacity(self.tasks.len());
621        for task in &mut self.tasks {
622            if task.key.task_id().as_str().trim().is_empty() {
623                return Err(StudyError::InvalidTaskId { phase: self.id.0 });
624            }
625            if task.category.trim().is_empty() {
626                return Err(StudyError::InvalidTaskCategory {
627                    task: task.key.task_id().to_string(),
628                });
629            }
630            if !ids.insert(task.key.task_id().clone()) {
631                return Err(StudyError::DuplicateTaskId {
632                    phase: self.id.0,
633                    task: task.key.task_id().to_string(),
634                });
635            }
636            task.attach_to_phase(self.id);
637        }
638
639        let mut dependencies = HashSet::with_capacity(self.dependencies.len());
640        for dependency in &self.dependencies {
641            if !dependencies.insert(*dependency) || *dependency == self.id {
642                return Err(StudyError::PhaseDependencyCycle { phase: self.id.0 });
643            }
644        }
645
646        Ok(Phase {
647            id: self.id,
648            label: self.label.into(),
649            tasks: self.tasks,
650            max_active_tasks: self.max_active_tasks,
651            prepared_task_queue_capacity: self.prepared_task_queue_capacity,
652            delay_per_task: self.delay_per_task,
653            task_timeout: self.task_timeout,
654            deadline_after: self.deadline_after,
655            dependencies: self.dependencies,
656            failure_policy: self.failure_policy,
657            require_confirm: self.require_confirm,
658        })
659    }
660}
661
662impl fmt::Debug for Phase {
663    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
664        formatter
665            .debug_struct("Phase")
666            .field("id", &self.id)
667            .field("label", &self.label)
668            .field("tasks", &self.tasks.len())
669            .field("max_active_tasks", &self.max_active_tasks)
670            .field(
671                "prepared_task_queue_capacity",
672                &self.prepared_task_queue_capacity,
673            )
674            .field("delay_per_task", &self.delay_per_task)
675            .field("task_timeout", &self.task_timeout)
676            .field("deadline_after", &self.deadline_after)
677            .field("dependencies", &self.dependencies)
678            .field("failure_policy", &self.failure_policy)
679            .field("require_confirm", &self.require_confirm)
680            .finish()
681    }
682}
683
684impl fmt::Debug for PhaseBuilder {
685    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
686        formatter
687            .debug_struct("PhaseBuilder")
688            .field("id", &self.id)
689            .field("label", &self.label)
690            .field("tasks", &self.tasks.len())
691            .field("max_active_tasks", &self.max_active_tasks)
692            .field(
693                "prepared_task_queue_capacity",
694                &self.prepared_task_queue_capacity,
695            )
696            .field("delay_per_task", &self.delay_per_task)
697            .field("task_timeout", &self.task_timeout)
698            .field("deadline_after", &self.deadline_after)
699            .field("dependencies", &self.dependencies)
700            .field("require_confirm", &self.require_confirm)
701            .finish_non_exhaustive()
702    }
703}
704
705/// Exact partial selector over phase, task category, and metadata.
706#[derive(Clone, Debug, Default)]
707pub struct TaskSelector {
708    phase: Option<PhaseId>,
709    category: Option<Arc<str>>,
710    metadata: Vec<(Box<str>, Value)>,
711}
712
713impl TaskSelector {
714    /// Creates an unconstrained selector.
715    pub fn new() -> Self {
716        Self::default()
717    }
718
719    /// Constrains selection to one phase.
720    pub fn phase(mut self, phase: impl Into<PhaseId>) -> Self {
721        self.phase = Some(phase.into());
722        self
723    }
724
725    /// Constrains selection to one task category.
726    pub fn category(mut self, category: impl Into<String>) -> Self {
727        self.category = Some(category.into().into());
728        self
729    }
730
731    /// Adds or replaces one exact metadata constraint.
732    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
733        let key = key.into();
734        if let Some((_, current)) = self
735            .metadata
736            .iter_mut()
737            .find(|(candidate, _)| candidate.as_ref() == key)
738        {
739            *current = value.into();
740        } else {
741            self.metadata.push((key.into_boxed_str(), value.into()));
742        }
743        self
744    }
745
746    pub(crate) fn matches(&self, task: &Task) -> bool {
747        self.phase.is_none_or(|phase| task.key.phase == phase)
748            && self
749                .category
750                .as_deref()
751                .is_none_or(|category| task.category_name() == category)
752            && self
753                .metadata
754                .iter()
755                .all(|(key, value)| task.metadata_value(key) == Some(value))
756    }
757}
758
759impl fmt::Display for TaskSelector {
760    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
761        let mut fields = Vec::new();
762        if let Some(phase) = self.phase {
763            fields.push(format!("phase={phase}"));
764        }
765        if let Some(category) = &self.category {
766            fields.push(format!("category={category}"));
767        }
768        fields.extend(
769            self.metadata
770                .iter()
771                .map(|(key, value)| format!("{key}={}", compact_value(value))),
772        );
773        formatter.write_str(&fields.join(", "))
774    }
775}
776
777fn compact_value(value: &Value) -> String {
778    match value {
779        Value::Array(values) => format!("<array:{}:{}>", values.len(), short_hash(value)),
780        Value::Object(values) => format!("<object:{}:{}>", values.len(), short_hash(value)),
781        _ => serde_json::to_string(value)
782            .expect("serde_json::Value always serializes to valid compact JSON"),
783    }
784}
785
786fn short_hash(value: &Value) -> String {
787    let bytes = serde_json::to_vec(value)
788        .expect("serde_json::Value always serializes to valid compact JSON");
789    let digest = Sha256::digest(bytes);
790    digest[..4]
791        .iter()
792        .map(|byte| format!("{byte:02x}"))
793        .collect::<Vec<_>>()
794        .join("")
795}