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 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}