1pub const METADATA_HEADING: &str = "* GDOC_METADATA";
15
16fn is_metadata_heading(line: &str) -> bool {
19 line == METADATA_HEADING || line.starts_with(concat!("* GDOC_METADATA", " "))
20}
21
22fn is_keyword_line(line: &str) -> bool {
24 line.trim_start().starts_with("#+")
25}
26
27fn is_heading_line(line: &str) -> bool {
29 let stars = line.chars().take_while(|&c| c == '*').count();
30 stars > 0 && line[stars..].starts_with(' ')
31}
32
33#[must_use]
35pub fn metadata_boundary(text: &str) -> Option<usize> {
36 let mut offset = 0;
37 for line in text.split_inclusive('\n') {
38 if is_metadata_heading(line.trim_end_matches(['\n', '\r'])) {
39 return Some(offset);
40 }
41 offset += line.len();
42 }
43 None
44}
45
46#[must_use]
49pub fn split(text: &str) -> (&str, Option<&str>) {
50 metadata_boundary(text).map_or((text, None), |offset| {
51 (&text[..offset], Some(&text[offset..]))
52 })
53}
54
55#[must_use]
57pub fn body(text: &str) -> &str {
58 split(text).0
59}
60
61#[must_use]
63pub fn read_keyword(text: &str, key: &str) -> Option<String> {
64 body(text).lines().find_map(|line| keyword_value(line, key))
65}
66
67fn keyword_value(line: &str, key: &str) -> Option<String> {
69 let rest = line.trim_start().strip_prefix("#+")?;
70 let (found_key, value) = rest.split_once(':')?;
71 found_key
72 .eq_ignore_ascii_case(key)
73 .then(|| value.trim().to_owned())
74}
75
76#[must_use]
82pub fn upsert_keyword(text: &str, key: &str, value: &str) -> String {
83 let rendered = format!("#+{key}: {value}");
84 let lines: Vec<&str> = text.split_inclusive('\n').collect();
85
86 if let Some(index) = lines
87 .iter()
88 .position(|line| keyword_value(line, key).is_some())
89 {
90 return rebuild_replacing(&lines, index, &rendered);
91 }
92
93 let insert_at = keyword_insertion_index(&lines);
94 rebuild_inserting(&lines, insert_at, &rendered)
95}
96
97fn keyword_insertion_index(lines: &[&str]) -> usize {
100 let mut insert_at = 0;
101 for (index, line) in lines.iter().enumerate() {
102 let trimmed = line.trim_end_matches(['\n', '\r']);
103 if is_heading_line(trimmed) {
104 break;
105 }
106 if is_keyword_line(trimmed) {
107 insert_at = index + 1;
108 }
109 }
110 insert_at
111}
112
113fn rebuild_replacing(lines: &[&str], index: usize, rendered: &str) -> String {
116 let mut out = String::with_capacity(text_len(lines));
117 for (current, line) in lines.iter().enumerate() {
118 if current == index {
119 out.push_str(rendered);
120 if line.ends_with('\n') {
121 out.push('\n');
122 }
123 } else {
124 out.push_str(line);
125 }
126 }
127 out
128}
129
130fn rebuild_inserting(lines: &[&str], index: usize, rendered: &str) -> String {
133 let mut out = String::with_capacity(text_len(lines) + rendered.len() + 1);
134 for (current, line) in lines.iter().enumerate() {
135 if current == index {
136 out.push_str(rendered);
137 out.push('\n');
138 }
139 out.push_str(line);
140 }
141 if index >= lines.len() {
142 out.push_str(rendered);
143 out.push('\n');
144 }
145 out
146}
147
148fn text_len(lines: &[&str]) -> usize {
150 lines.iter().map(|line| line.len()).sum()
151}
152
153#[must_use]
159pub fn replace_metadata(text: &str, region: &str) -> String {
160 metadata_boundary(text).map_or_else(
161 || {
162 let mut out = String::with_capacity(text.len() + region.len() + 2);
163 out.push_str(text);
164 if !out.is_empty() && !out.ends_with('\n') {
165 out.push('\n');
166 }
167 if !out.ends_with("\n\n") {
168 out.push('\n');
169 }
170 out.push_str(region);
171 out
172 },
173 |offset| {
174 let mut out = String::with_capacity(offset + region.len());
175 out.push_str(&text[..offset]);
176 out.push_str(region);
177 out
178 },
179 )
180}
181
182#[cfg(test)]
183mod tests {
184 use super::{body, metadata_boundary, read_keyword, replace_metadata, split, upsert_keyword};
185
186 const SAMPLE: &str = "#+TITLE: Doc\n#+GDOC_ID: abc123\n\n* Intro\nbody text\n\n* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src json\n{}\n#+end_src\n";
187
188 #[test]
189 fn boundary_found_at_metadata_heading() {
190 let offset = metadata_boundary(SAMPLE).expect("has metadata");
191 assert!(SAMPLE[offset..].starts_with("* GDOC_METADATA :noexport:"));
192 }
193
194 #[test]
195 fn split_preserves_concatenation() {
196 let (b, meta) = split(SAMPLE);
197 let mut joined = String::from(b);
198 joined.push_str(meta.expect("has metadata"));
199 assert_eq!(joined, SAMPLE);
200 assert!(b.ends_with("body text\n\n"));
201 }
202
203 #[test]
204 fn no_metadata_yields_whole_body() {
205 let text = "* Intro\nbody\n";
206 assert_eq!(metadata_boundary(text), None);
207 assert_eq!(body(text), text);
208 assert_eq!(split(text), (text, None));
209 }
210
211 #[test]
212 fn read_keyword_is_case_insensitive_and_body_scoped() {
213 assert_eq!(read_keyword(SAMPLE, "GDOC_ID").as_deref(), Some("abc123"));
214 assert_eq!(read_keyword(SAMPLE, "gdoc_id").as_deref(), Some("abc123"));
215 assert_eq!(read_keyword(SAMPLE, "GDOC_URL"), None);
216 }
217
218 #[test]
219 fn upsert_existing_keyword_changes_only_that_line() {
220 let updated = upsert_keyword(SAMPLE, "GDOC_ID", "xyz789");
221 assert_eq!(read_keyword(&updated, "GDOC_ID").as_deref(), Some("xyz789"));
222 assert_eq!(
224 updated.replace("#+GDOC_ID: xyz789", "#+GDOC_ID: abc123"),
225 SAMPLE
226 );
227 }
228
229 #[test]
230 fn upsert_new_keyword_inserts_after_leading_keywords() {
231 let updated = upsert_keyword(SAMPLE, "GDOC_URL", "https://x");
232 assert_eq!(
233 read_keyword(&updated, "GDOC_URL").as_deref(),
234 Some("https://x")
235 );
236 let url_pos = updated.find("#+GDOC_URL:").expect("present");
238 let intro_pos = updated.find("* Intro").expect("present");
239 assert!(url_pos < intro_pos);
240 assert!(updated.contains("\n* Intro\nbody text\n"));
242 }
243
244 #[test]
245 fn upsert_into_keywordless_file_prepends() {
246 let text = "* Intro\nbody\n";
247 let updated = upsert_keyword(text, "GDOC_ID", "abc");
248 assert_eq!(updated, "#+GDOC_ID: abc\n* Intro\nbody\n");
249 }
250
251 #[test]
252 fn replace_metadata_preserves_body_bytes() {
253 let new_region =
254 "* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src json\n{\"v\":1}\n#+end_src\n";
255 let updated = replace_metadata(SAMPLE, new_region);
256 let (original_body, _) = split(SAMPLE);
257 let (new_body, new_meta) = split(&updated);
258 assert_eq!(new_body, original_body);
259 assert_eq!(new_meta, Some(new_region));
260 }
261
262 #[test]
263 fn replace_metadata_appends_when_absent() {
264 let text = "#+TITLE: Doc\n\n* Intro\nbody\n";
265 let region = "* GDOC_METADATA :noexport:\n** Sync State\n";
266 let updated = replace_metadata(text, region);
267 assert!(updated.starts_with("#+TITLE: Doc\n\n* Intro\nbody\n"));
268 assert!(updated.ends_with(region));
269 assert_eq!(
270 metadata_boundary(&updated).map(|o| &updated[o..]),
271 Some(region)
272 );
273 }
274
275 #[test]
276 fn noop_writeback_round_trips_body() {
277 let id = read_keyword(SAMPLE, "GDOC_ID").expect("id");
280 let once = upsert_keyword(SAMPLE, "GDOC_ID", &id);
281 let (_, meta) = split(&once);
282 let twice = replace_metadata(&once, meta.expect("meta"));
283 assert_eq!(twice, SAMPLE);
284 }
285}