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("configuration".to_owned(), configuration.to_json_value());
250        self
251    }
252
253    /// Attaches the complete named project path table to task provenance.
254    #[must_use]
255    pub fn with_project_paths(mut self, paths: &ProjectPaths) -> Self {
256        Arc::make_mut(&mut self.metadata).insert("project_paths".to_owned(), paths.to_json_value());
257        self
258    }
259
260    /// Returns the exact phase-qualified task key.
261    pub fn key(&self) -> &TaskKey {
262        &self.key
263    }
264
265    /// Returns the phase-local task ID.
266    pub fn id(&self) -> &TaskId {
267        self.key.task_id()
268    }
269
270    /// Returns the application-defined task category.
271    pub fn category_name(&self) -> &str {
272        &self.category
273    }
274
275    /// Returns the automatically generated display label.
276    pub fn label(&self) -> &str {
277        &self.label
278    }
279
280    /// Returns whether the task reports iterative progress or one-shot status.
281    pub const fn mode(&self) -> TaskMode {
282        self.mode
283    }
284
285    /// Borrows one application-defined metadata value.
286    pub fn metadata_value(&self, key: &str) -> Option<&Value> {
287        self.metadata.get(key)
288    }
289
290    /// Borrows one required task metadata value.
291    pub fn require_metadata(&self, key: &str) -> Result<&Value, StudyError> {
292        self.metadata_value(key)
293            .ok_or_else(|| StudyError::UnknownTaskMetadata {
294                task: self.key.to_string(),
295                key: key.to_owned(),
296            })
297    }
298
299    /// Decodes one required task metadata value without first cloning its JSON tree.
300    pub fn decode_metadata<T>(&self, key: &str) -> Result<T, StudyError>
301    where
302        T: DeserializeOwned,
303    {
304        T::deserialize(self.require_metadata(key)?).map_err(|source| {
305            StudyError::DecodeTaskMetadata {
306                task: self.key.to_string(),
307                key: key.to_owned(),
308                source,
309            }
310        })
311    }
312
313    /// Iterates application-defined metadata.
314    pub fn metadata_iter(&self) -> impl Iterator<Item = (&str, &Value)> + '_ {
315        self.metadata
316            .iter()
317            .map(|(key, value)| (key.as_str(), value))
318    }
319
320    pub(crate) fn metadata_map(&self) -> Arc<BTreeMap<String, Value>> {
321        Arc::clone(&self.metadata)
322    }
323
324    pub(crate) fn take_workload(&mut self) -> Option<Workload> {
325        self.workload.take()
326    }
327
328    pub(crate) fn has_workload(&self) -> bool {
329        self.workload.is_some()
330    }
331
332    pub(crate) const fn is_completed(&self) -> bool {
333        self.completed
334    }
335
336    fn attach_to_phase(&mut self, phase: PhaseId) {
337        self.key.phase = phase;
338    }
339}
340
341impl fmt::Debug for Task {
342    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
343        formatter
344            .debug_struct("Task")
345            .field("key", &self.key)
346            .field("category", &self.category)
347            .field("label", &self.label)
348            .field("mode", &self.mode)
349            .field("metadata", &self.metadata.len())
350            .finish_non_exhaustive()
351    }
352}
353
354/// Immutable nonempty execution phase and renderer section.
355pub struct Phase {
356    id: PhaseId,
357    label: Arc<str>,
358    tasks: Vec<Task>,
359    max_active_tasks: usize,
360    prepared_task_queue_capacity: usize,
361    delay_per_task: Option<Duration>,
362    task_timeout: Option<Duration>,
363    deadline_after: Option<Duration>,
364    dependencies: Vec<PhaseId>,
365    failure_policy: PhaseFailurePolicy,
366    require_confirm: bool,
367}
368
369/// Scheduling behavior after the first workload failure in a phase.
370#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
371#[non_exhaustive]
372pub enum PhaseFailurePolicy {
373    /// Stop new work and cooperatively cancel active workloads.
374    #[default]
375    FailFast,
376    /// Stop new work but allow already active workloads to finish.
377    FinishActive,
378}
379
380impl PhaseFailurePolicy {
381    /// Returns the stable uncolored policy name used by plain output.
382    pub const fn as_str(self) -> &'static str {
383        match self {
384            Self::FailFast => "fail-fast",
385            Self::FinishActive => "finish-active",
386        }
387    }
388}
389
390impl Phase {
391    /// Begins declaring a phase. Tasks must be added before [`PhaseBuilder::build`].
392    pub fn builder(id: impl Into<PhaseId>, label: impl Into<String>) -> PhaseBuilder {
393        PhaseBuilder {
394            id: id.into(),
395            label: label.into(),
396            tasks: Vec::new(),
397            max_active_tasks: 1,
398            prepared_task_queue_capacity: 1,
399            delay_per_task: None,
400            task_timeout: None,
401            deadline_after: None,
402            dependencies: Vec::new(),
403            failure_policy: PhaseFailurePolicy::FailFast,
404            require_confirm: false,
405        }
406    }
407
408    /// Returns the stable phase identity.
409    pub const fn id(&self) -> PhaseId {
410        self.id
411    }
412
413    /// Returns the human-facing phase heading.
414    pub fn label(&self) -> &str {
415        &self.label
416    }
417
418    /// Returns every task in deterministic display/execution order.
419    pub fn tasks(&self) -> &[Task] {
420        &self.tasks
421    }
422
423    /// Returns the phase-local concurrent workload ceiling.
424    pub const fn max_active_tasks(&self) -> usize {
425        self.max_active_tasks
426    }
427
428    /// Returns the prepared-but-not-running workload ceiling.
429    pub const fn prepared_task_queue_capacity(&self) -> usize {
430        self.prepared_task_queue_capacity
431    }
432
433    /// Returns the optional minimum interval between consecutive task starts.
434    pub const fn delay_per_task(&self) -> Option<Duration> {
435        self.delay_per_task
436    }
437
438    /// Returns the optional elapsed-time limit for each running task.
439    pub const fn task_timeout(&self) -> Option<Duration> {
440        self.task_timeout
441    }
442
443    /// Returns the optional phase deadline measured from phase execution start.
444    pub const fn deadline_after(&self) -> Option<Duration> {
445        self.deadline_after
446    }
447
448    /// Returns declared predecessor phases in declaration order.
449    pub fn dependencies(&self) -> &[PhaseId] {
450        &self.dependencies
451    }
452
453    /// Returns the behavior selected for the first workload failure.
454    pub const fn failure_policy(&self) -> PhaseFailurePolicy {
455        self.failure_policy
456    }
457
458    /// Reports whether successful completion requires confirmation before the
459    /// next selected phase may start.
460    pub const fn requires_confirmation(&self) -> bool {
461        self.require_confirm
462    }
463
464    pub(crate) fn into_tasks(self) -> Vec<Task> {
465        self.tasks
466    }
467
468    /// Returns one exact phase-local task by ID.
469    pub fn task(&self, id: &TaskId) -> Option<&Task> {
470        self.tasks.iter().find(|task| task.key.task_id() == id)
471    }
472
473    /// Returns the sole task matching an exact partial selector.
474    pub fn unique_task_matching(&self, selector: &TaskSelector) -> Result<&Task, StudyError> {
475        let mut matches = self.tasks.iter().filter(|task| selector.matches(task));
476        let first = matches.next().ok_or_else(|| StudyError::TaskNotFound {
477            selector: selector.to_string(),
478        })?;
479        if let Some(second) = matches.next() {
480            return Err(StudyError::TaskSelectorAmbiguous {
481                selector: selector.to_string(),
482                first: first.key.to_string(),
483                second: second.key.to_string(),
484            });
485        }
486        Ok(first)
487    }
488}
489
490/// Builder for one nonempty first-class phase.
491pub struct PhaseBuilder {
492    id: PhaseId,
493    label: String,
494    tasks: Vec<Task>,
495    max_active_tasks: usize,
496    prepared_task_queue_capacity: usize,
497    delay_per_task: Option<Duration>,
498    task_timeout: Option<Duration>,
499    deadline_after: Option<Duration>,
500    dependencies: Vec<PhaseId>,
501    failure_policy: PhaseFailurePolicy,
502    require_confirm: bool,
503}
504
505impl PhaseBuilder {
506    /// Registers one task with this phase.
507    pub fn task(mut self, task: Task) -> Self {
508        self.tasks.push(task);
509        self
510    }
511
512    /// Registers tasks in deterministic declaration order.
513    pub fn tasks<I>(mut self, tasks: I) -> Self
514    where
515        I: IntoIterator<Item = Task>,
516    {
517        self.tasks.extend(tasks);
518        self
519    }
520
521    /// Sets the later scheduler's phase-local active workload ceiling.
522    pub fn max_active_tasks(mut self, maximum: usize) -> Self {
523        self.max_active_tasks = maximum;
524        self
525    }
526
527    /// Sets the later scheduler's prepared-work queue capacity.
528    pub fn prepared_task_queue_capacity(mut self, capacity: usize) -> Self {
529        self.prepared_task_queue_capacity = capacity;
530        self
531    }
532
533    /// Sets a minimum start-to-start interval for executable tasks.
534    ///
535    /// This policy is optional. Without this call, tasks are admitted exactly
536    /// as before. Completed tasks do not consume a delay rank.
537    pub fn delay_per_task(mut self, delay: Duration) -> Self {
538        self.delay_per_task = Some(delay);
539        self
540    }
541
542    /// Sets the maximum elapsed time for each task after it starts.
543    ///
544    /// Expiration requests cooperative cancellation; Rust workloads cannot be
545    /// forcibly terminated while they are blocked in user or system code.
546    pub fn task_timeout(mut self, timeout: Duration) -> Self {
547        self.task_timeout = Some(timeout);
548        self
549    }
550
551    /// Sets a phase-wide deadline relative to the phase execution start.
552    ///
553    /// Once reached, no additional tasks start and active tasks receive a
554    /// cooperative cancellation request.
555    pub fn deadline_after(mut self, deadline: Duration) -> Self {
556        self.deadline_after = Some(deadline);
557        self
558    }
559
560    /// Declares one phase that must be satisfied before this phase starts.
561    pub fn depends_on(mut self, dependency: impl Into<PhaseId>) -> Self {
562        self.dependencies.push(dependency.into());
563        self
564    }
565
566    /// Selects behavior after the first workload failure.
567    pub fn failure_policy(mut self, policy: PhaseFailurePolicy) -> Self {
568        self.failure_policy = policy;
569        self
570    }
571
572    /// Requires the user to type `yes` before advancing to the next phase.
573    ///
574    /// The default is `false`. This setting has no effect when this phase is
575    /// the final selected phase because there is no transition to confirm.
576    pub fn require_confirm(mut self, require: bool) -> Self {
577        self.require_confirm = require;
578        self
579    }
580
581    /// Validates and creates one immutable nonempty phase.
582    pub fn build(mut self) -> Result<Phase, StudyError> {
583        if self.label.trim().is_empty() {
584            return Err(StudyError::InvalidPhaseLabel { phase: self.id.0 });
585        }
586        if self.tasks.is_empty() {
587            return Err(StudyError::EmptyPhase { phase: self.id.0 });
588        }
589        if self.max_active_tasks == 0 {
590            return Err(StudyError::InvalidPhaseWorkloadLimit { phase: self.id.0 });
591        }
592        if self.prepared_task_queue_capacity == 0 {
593            return Err(StudyError::InvalidPhaseQueueCapacity { phase: self.id.0 });
594        }
595        for (setting, duration) in [
596            ("delay_per_task", self.delay_per_task),
597            ("task_timeout", self.task_timeout),
598            ("deadline_after", self.deadline_after),
599        ] {
600            if duration.is_some_and(|duration| {
601                duration.is_zero() || Instant::now().checked_add(duration).is_none()
602            }) {
603                return Err(StudyError::InvalidPhaseTiming {
604                    phase: self.id.0,
605                    setting,
606                });
607            }
608        }
609
610        let mut ids = HashSet::with_capacity(self.tasks.len());
611        for task in &mut self.tasks {
612            if task.key.task_id().as_str().trim().is_empty() {
613                return Err(StudyError::InvalidTaskId { phase: self.id.0 });
614            }
615            if task.category.trim().is_empty() {
616                return Err(StudyError::InvalidTaskCategory {
617                    task: task.key.task_id().to_string(),
618                });
619            }
620            if !ids.insert(task.key.task_id().clone()) {
621                return Err(StudyError::DuplicateTaskId {
622                    phase: self.id.0,
623                    task: task.key.task_id().to_string(),
624                });
625            }
626            task.attach_to_phase(self.id);
627        }
628
629        let mut dependencies = HashSet::with_capacity(self.dependencies.len());
630        for dependency in &self.dependencies {
631            if !dependencies.insert(*dependency) || *dependency == self.id {
632                return Err(StudyError::PhaseDependencyCycle { phase: self.id.0 });
633            }
634        }
635
636        Ok(Phase {
637            id: self.id,
638            label: self.label.into(),
639            tasks: self.tasks,
640            max_active_tasks: self.max_active_tasks,
641            prepared_task_queue_capacity: self.prepared_task_queue_capacity,
642            delay_per_task: self.delay_per_task,
643            task_timeout: self.task_timeout,
644            deadline_after: self.deadline_after,
645            dependencies: self.dependencies,
646            failure_policy: self.failure_policy,
647            require_confirm: self.require_confirm,
648        })
649    }
650}
651
652impl fmt::Debug for Phase {
653    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
654        formatter
655            .debug_struct("Phase")
656            .field("id", &self.id)
657            .field("label", &self.label)
658            .field("tasks", &self.tasks.len())
659            .field("max_active_tasks", &self.max_active_tasks)
660            .field(
661                "prepared_task_queue_capacity",
662                &self.prepared_task_queue_capacity,
663            )
664            .field("delay_per_task", &self.delay_per_task)
665            .field("task_timeout", &self.task_timeout)
666            .field("deadline_after", &self.deadline_after)
667            .field("dependencies", &self.dependencies)
668            .field("failure_policy", &self.failure_policy)
669            .field("require_confirm", &self.require_confirm)
670            .finish()
671    }
672}
673
674impl fmt::Debug for PhaseBuilder {
675    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
676        formatter
677            .debug_struct("PhaseBuilder")
678            .field("id", &self.id)
679            .field("label", &self.label)
680            .field("tasks", &self.tasks.len())
681            .field("max_active_tasks", &self.max_active_tasks)
682            .field(
683                "prepared_task_queue_capacity",
684                &self.prepared_task_queue_capacity,
685            )
686            .field("delay_per_task", &self.delay_per_task)
687            .field("task_timeout", &self.task_timeout)
688            .field("deadline_after", &self.deadline_after)
689            .field("dependencies", &self.dependencies)
690            .field("require_confirm", &self.require_confirm)
691            .finish_non_exhaustive()
692    }
693}
694
695/// Exact partial selector over phase, task category, and metadata.
696#[derive(Clone, Debug, Default)]
697pub struct TaskSelector {
698    phase: Option<PhaseId>,
699    category: Option<Arc<str>>,
700    metadata: Vec<(Box<str>, Value)>,
701}
702
703impl TaskSelector {
704    /// Creates an unconstrained selector.
705    pub fn new() -> Self {
706        Self::default()
707    }
708
709    /// Constrains selection to one phase.
710    pub fn phase(mut self, phase: impl Into<PhaseId>) -> Self {
711        self.phase = Some(phase.into());
712        self
713    }
714
715    /// Constrains selection to one task category.
716    pub fn category(mut self, category: impl Into<String>) -> Self {
717        self.category = Some(category.into().into());
718        self
719    }
720
721    /// Adds or replaces one exact metadata constraint.
722    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
723        let key = key.into();
724        if let Some((_, current)) = self
725            .metadata
726            .iter_mut()
727            .find(|(candidate, _)| candidate.as_ref() == key)
728        {
729            *current = value.into();
730        } else {
731            self.metadata.push((key.into_boxed_str(), value.into()));
732        }
733        self
734    }
735
736    pub(crate) fn matches(&self, task: &Task) -> bool {
737        self.phase.is_none_or(|phase| task.key.phase == phase)
738            && self
739                .category
740                .as_deref()
741                .is_none_or(|category| task.category_name() == category)
742            && self
743                .metadata
744                .iter()
745                .all(|(key, value)| task.metadata_value(key) == Some(value))
746    }
747}
748
749impl fmt::Display for TaskSelector {
750    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
751        let mut fields = Vec::new();
752        if let Some(phase) = self.phase {
753            fields.push(format!("phase={phase}"));
754        }
755        if let Some(category) = &self.category {
756            fields.push(format!("category={category}"));
757        }
758        fields.extend(
759            self.metadata
760                .iter()
761                .map(|(key, value)| format!("{key}={}", compact_value(value))),
762        );
763        formatter.write_str(&fields.join(", "))
764    }
765}
766
767fn compact_value(value: &Value) -> String {
768    match value {
769        Value::Array(values) => format!("<array:{}:{}>", values.len(), short_hash(value)),
770        Value::Object(values) => format!("<object:{}:{}>", values.len(), short_hash(value)),
771        _ => serde_json::to_string(value)
772            .expect("serde_json::Value always serializes to valid compact JSON"),
773    }
774}
775
776fn short_hash(value: &Value) -> String {
777    let bytes = serde_json::to_vec(value)
778        .expect("serde_json::Value always serializes to valid compact JSON");
779    let digest = Sha256::digest(bytes);
780    digest[..4]
781        .iter()
782        .map(|byte| format!("{byte:02x}"))
783        .collect::<Vec<_>>()
784        .join("")
785}