Skip to main content

git_meta_lib/
list_value.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use sha1::{Digest, Sha1};
4
5use crate::error::{Error, Result};
6
7/// A single timestamped entry in a metadata list value.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct ListEntry {
10    pub value: String,
11    pub timestamp: i64,
12}
13
14/// Parse a stored list JSON blob into timestamped entries.
15/// Legacy string arrays are assigned deterministic timestamps based on order.
16pub fn parse_entries(raw: &str) -> Result<Vec<ListEntry>> {
17    let values: Vec<Value> = serde_json::from_str(raw)?;
18    let mut entries = Vec::with_capacity(values.len());
19
20    for (idx, value) in values.into_iter().enumerate() {
21        match value {
22            Value::String(s) => {
23                entries.push(ListEntry {
24                    value: s,
25                    timestamp: legacy_timestamp(idx),
26                });
27            }
28            Value::Object(mut map) => {
29                let val_field = map.remove("value").ok_or_else(|| {
30                    Error::InvalidValue("list entry missing 'value' field".into())
31                })?;
32                let value = val_field
33                    .as_str()
34                    .ok_or_else(|| Error::InvalidValue("list entry 'value' must be string".into()))?
35                    .to_string();
36                let timestamp = match map.remove("timestamp") {
37                    Some(Value::Number(num)) => num.as_i64().ok_or_else(|| {
38                        Error::InvalidValue("list entry 'timestamp' must be integer".into())
39                    })?,
40                    Some(Value::String(s)) => s.parse::<i64>().map_err(|_| {
41                        Error::InvalidValue("list entry 'timestamp' must be integer".into())
42                    })?,
43                    None => legacy_timestamp(idx),
44                    Some(other) => {
45                        return Err(Error::InvalidValue(format!(
46                            "list entry 'timestamp' must be integer, got {other:?}"
47                        )))
48                    }
49                };
50                entries.push(ListEntry { value, timestamp });
51            }
52            other => {
53                return Err(Error::InvalidValue(format!(
54                    "invalid list entry type: expected string or object, got {other:?}"
55                )));
56            }
57        }
58    }
59
60    Ok(entries)
61}
62
63/// Serialize list entries back to JSON objects.
64pub fn encode_entries(entries: &[ListEntry]) -> Result<String> {
65    Ok(serde_json::to_string(entries)?)
66}
67
68/// Extract just the string values from a stored list JSON blob.
69#[cfg_attr(not(feature = "internal"), allow(dead_code))]
70pub fn list_values_from_json(raw: &str) -> Result<Vec<String>> {
71    Ok(parse_entries(raw)?
72        .into_iter()
73        .map(|entry| entry.value)
74        .collect())
75}
76
77/// Ensure the proposed timestamp is strictly greater than any existing entry.
78pub(crate) fn ensure_unique_timestamp(mut timestamp: i64, entries: &[ListEntry]) -> i64 {
79    if let Some(last) = entries.last() {
80        if timestamp <= last.timestamp {
81            timestamp = last.timestamp + 1;
82        }
83    }
84    timestamp
85}
86
87/// Build a deterministic entry name used for Git tree serialization.
88pub fn make_entry_name(entry: &ListEntry) -> String {
89    make_entry_name_from_parts(entry.timestamp, &entry.value)
90}
91
92/// Build a deterministic entry name from a timestamp and value content hash.
93pub(crate) fn make_entry_name_from_parts(timestamp: i64, value: &str) -> String {
94    let mut hasher = Sha1::new();
95    hasher.update(value.as_bytes());
96    let hash = format!("{:x}", hasher.finalize());
97    format!("{}-{}", timestamp, &hash[..5])
98}
99
100/// Extract the timestamp from a list entry name (format: `<timestamp>-<hash>`).
101pub fn parse_timestamp_from_entry_name(name: &str) -> Option<i64> {
102    let idx = name.find('-')?;
103    name[..idx].parse().ok()
104}
105
106fn legacy_timestamp(idx: usize) -> i64 {
107    idx as i64
108}