Skip to main content

pbox_core/
metadata.rs

1use crate::PboxId;
2use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6const MARKER_PREFIX: &str = "<!-- pbox-meta-v1";
7const MARKER_SUFFIX: &str = "-->";
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct PboxMetadata {
11    pub id: PboxId,
12    pub vmid: u64,
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub image: Option<String>,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub snapshot: Option<String>,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub node: Option<String>,
19    #[serde(default, skip_serializing_if = "Vec::is_empty")]
20    pub recipes: Vec<PboxRecipeProvenance>,
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    pub capabilities: Vec<String>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct PboxRecipeProvenance {
27    pub id: String,
28    pub repository: String,
29    pub revision: String,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub applied_at: Option<String>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub result: Option<String>,
34}
35
36impl PboxMetadata {
37    pub fn new(id: PboxId, vmid: u64) -> Self {
38        Self {
39            id,
40            vmid,
41            image: None,
42            snapshot: None,
43            node: None,
44            recipes: Vec::new(),
45            capabilities: Vec::new(),
46        }
47    }
48
49    pub fn with_node(mut self, node: impl Into<String>) -> Self {
50        self.node = Some(node.into());
51        self
52    }
53}
54
55#[derive(Debug, Error)]
56pub enum MetadataError {
57    #[error("metadata marker is missing its closing -->")]
58    UnterminatedMarker,
59    #[error("metadata marker must be unique")]
60    MultipleMarkers,
61    #[error("invalid pbox-meta-v1 JSON: {0}")]
62    InvalidJson(#[from] serde_json::Error),
63}
64
65pub fn encode_metadata(metadata: &PboxMetadata) -> Result<String, MetadataError> {
66    let json = serde_json::to_vec(metadata)?;
67    let payload = URL_SAFE_NO_PAD.encode(json);
68    Ok(format!("{MARKER_PREFIX} {payload} {MARKER_SUFFIX}"))
69}
70
71pub fn parse_metadata(markdown: &str) -> Result<Option<PboxMetadata>, MetadataError> {
72    let spans = marker_spans(markdown)?;
73    match spans.as_slice() {
74        [] => Ok(None),
75        [span] => parse_span(markdown, *span).map(Some),
76        _ => Err(MetadataError::MultipleMarkers),
77    }
78}
79
80pub fn preserve_metadata(markdown: &str, metadata: &PboxMetadata) -> Result<String, MetadataError> {
81    let marker = encode_metadata(metadata)?;
82    let spans = marker_spans(markdown)?;
83    if spans.is_empty() {
84        let mut output = markdown.to_owned();
85        if !output.is_empty() {
86            if !output.ends_with('\n') {
87                output.push_str("\n\n");
88            } else {
89                output.push('\n');
90            }
91        }
92        output.push_str(&marker);
93        return Ok(output);
94    }
95
96    // Replace the first block in place and remove any stale duplicates. Text outside
97    // marker spans is copied byte-for-byte, so user-authored Markdown is preserved.
98    let mut output = String::with_capacity(markdown.len() + marker.len());
99    let mut cursor = 0;
100    for (index, (start, end)) in spans.iter().copied().enumerate() {
101        output.push_str(&markdown[cursor..start]);
102        if index == 0 {
103            output.push_str(&marker);
104        }
105        cursor = end;
106    }
107    output.push_str(&markdown[cursor..]);
108    Ok(output)
109}
110
111fn marker_spans(markdown: &str) -> Result<Vec<(usize, usize)>, MetadataError> {
112    let mut spans = Vec::new();
113    let mut search_from = 0;
114    while let Some(relative_start) = markdown[search_from..].find(MARKER_PREFIX) {
115        let start = search_from + relative_start;
116        let relative_end = markdown[start..]
117            .find(MARKER_SUFFIX)
118            .ok_or(MetadataError::UnterminatedMarker)?;
119        let end = start + relative_end + MARKER_SUFFIX.len();
120        spans.push((start, end));
121        search_from = end;
122    }
123    Ok(spans)
124}
125
126fn parse_span(markdown: &str, (start, end): (usize, usize)) -> Result<PboxMetadata, MetadataError> {
127    let marker = &markdown[start..end];
128    let payload = marker
129        .strip_prefix(MARKER_PREFIX)
130        .and_then(|value| value.strip_suffix(MARKER_SUFFIX))
131        .map(str::trim)
132        .unwrap_or_default();
133    if let Ok(decoded) = URL_SAFE_NO_PAD.decode(payload) {
134        return Ok(serde_json::from_slice(&decoded)?);
135    }
136    Ok(serde_json::from_str(payload)?)
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn metadata_round_trips_and_preserves_notes() {
145        let mut metadata =
146            PboxMetadata::new(PboxId::parse("pbx_t3yzd9y3").unwrap(), 9000).with_node("pve-a");
147        metadata.recipes.push(PboxRecipeProvenance {
148            id: "desktop/xfce".to_owned(),
149            repository: "https://github.com/kierandrewett/pbox-recipes.git".to_owned(),
150            revision: "abc123".to_owned(),
151            applied_at: None,
152            result: Some("failed".to_owned()),
153        });
154        metadata.image = Some("docker.io/library/fedora:44".to_owned());
155        metadata.snapshot = Some("llm-ready (psn_12345678)".to_owned());
156        metadata.capabilities.push("desktop".to_owned());
157        metadata.capabilities.push("contains --> safely".to_owned());
158        let markdown = "User note\n\nKeep this text.";
159        let encoded = preserve_metadata(markdown, &metadata).unwrap();
160        assert_eq!(encoded.matches(MARKER_SUFFIX).count(), 1);
161        assert!(encoded.contains("User note"));
162        assert_eq!(parse_metadata(&encoded).unwrap(), Some(metadata));
163    }
164
165    #[test]
166    fn stale_duplicate_markers_are_collapsed() {
167        let first = PboxMetadata::new(PboxId::parse("pbx_t3yzd9y3").unwrap(), 9000);
168        let second = PboxMetadata::new(PboxId::parse("pbx_12345678").unwrap(), 9001);
169        let source = format!(
170            "note\n{}\n{}",
171            encode_metadata(&first).unwrap(),
172            encode_metadata(&first).unwrap()
173        );
174        let result = preserve_metadata(&source, &second).unwrap();
175        assert_eq!(result.matches(MARKER_PREFIX).count(), 1);
176        assert_eq!(parse_metadata(&result).unwrap(), Some(second));
177    }
178}