pub const METADATA_HEADING: &str = "* GDOC_METADATA";
fn is_metadata_heading(line: &str) -> bool {
line == METADATA_HEADING || line.starts_with(concat!("* GDOC_METADATA", " "))
}
fn is_keyword_line(line: &str) -> bool {
line.trim_start().starts_with("#+")
}
fn is_heading_line(line: &str) -> bool {
let stars = line.chars().take_while(|&c| c == '*').count();
stars > 0 && line[stars..].starts_with(' ')
}
#[must_use]
pub fn metadata_boundary(text: &str) -> Option<usize> {
let mut offset = 0;
for line in text.split_inclusive('\n') {
if is_metadata_heading(line.trim_end_matches(['\n', '\r'])) {
return Some(offset);
}
offset += line.len();
}
None
}
#[must_use]
pub fn split(text: &str) -> (&str, Option<&str>) {
metadata_boundary(text).map_or((text, None), |offset| {
(&text[..offset], Some(&text[offset..]))
})
}
#[must_use]
pub fn body(text: &str) -> &str {
split(text).0
}
#[must_use]
pub fn read_keyword(text: &str, key: &str) -> Option<String> {
body(text).lines().find_map(|line| keyword_value(line, key))
}
fn keyword_value(line: &str, key: &str) -> Option<String> {
let rest = line.trim_start().strip_prefix("#+")?;
let (found_key, value) = rest.split_once(':')?;
found_key
.eq_ignore_ascii_case(key)
.then(|| value.trim().to_owned())
}
#[must_use]
pub fn upsert_keyword(text: &str, key: &str, value: &str) -> String {
let rendered = format!("#+{key}: {value}");
let lines: Vec<&str> = text.split_inclusive('\n').collect();
if let Some(index) = lines
.iter()
.position(|line| keyword_value(line, key).is_some())
{
return rebuild_replacing(&lines, index, &rendered);
}
let insert_at = keyword_insertion_index(&lines);
rebuild_inserting(&lines, insert_at, &rendered)
}
fn keyword_insertion_index(lines: &[&str]) -> usize {
let mut insert_at = 0;
for (index, line) in lines.iter().enumerate() {
let trimmed = line.trim_end_matches(['\n', '\r']);
if is_heading_line(trimmed) {
break;
}
if is_keyword_line(trimmed) {
insert_at = index + 1;
}
}
insert_at
}
fn rebuild_replacing(lines: &[&str], index: usize, rendered: &str) -> String {
let mut out = String::with_capacity(text_len(lines));
for (current, line) in lines.iter().enumerate() {
if current == index {
out.push_str(rendered);
if line.ends_with('\n') {
out.push('\n');
}
} else {
out.push_str(line);
}
}
out
}
fn rebuild_inserting(lines: &[&str], index: usize, rendered: &str) -> String {
let mut out = String::with_capacity(text_len(lines) + rendered.len() + 1);
for (current, line) in lines.iter().enumerate() {
if current == index {
out.push_str(rendered);
out.push('\n');
}
out.push_str(line);
}
if index >= lines.len() {
out.push_str(rendered);
out.push('\n');
}
out
}
fn text_len(lines: &[&str]) -> usize {
lines.iter().map(|line| line.len()).sum()
}
#[must_use]
pub fn replace_metadata(text: &str, region: &str) -> String {
metadata_boundary(text).map_or_else(
|| {
let mut out = String::with_capacity(text.len() + region.len() + 2);
out.push_str(text);
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(region);
out
},
|offset| {
let mut out = String::with_capacity(offset + region.len());
out.push_str(&text[..offset]);
out.push_str(region);
out
},
)
}
#[cfg(test)]
mod tests {
use super::{body, metadata_boundary, read_keyword, replace_metadata, split, upsert_keyword};
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";
#[test]
fn boundary_found_at_metadata_heading() {
let offset = metadata_boundary(SAMPLE).expect("has metadata");
assert!(SAMPLE[offset..].starts_with("* GDOC_METADATA :noexport:"));
}
#[test]
fn split_preserves_concatenation() {
let (b, meta) = split(SAMPLE);
let mut joined = String::from(b);
joined.push_str(meta.expect("has metadata"));
assert_eq!(joined, SAMPLE);
assert!(b.ends_with("body text\n\n"));
}
#[test]
fn no_metadata_yields_whole_body() {
let text = "* Intro\nbody\n";
assert_eq!(metadata_boundary(text), None);
assert_eq!(body(text), text);
assert_eq!(split(text), (text, None));
}
#[test]
fn read_keyword_is_case_insensitive_and_body_scoped() {
assert_eq!(read_keyword(SAMPLE, "GDOC_ID").as_deref(), Some("abc123"));
assert_eq!(read_keyword(SAMPLE, "gdoc_id").as_deref(), Some("abc123"));
assert_eq!(read_keyword(SAMPLE, "GDOC_URL"), None);
}
#[test]
fn upsert_existing_keyword_changes_only_that_line() {
let updated = upsert_keyword(SAMPLE, "GDOC_ID", "xyz789");
assert_eq!(read_keyword(&updated, "GDOC_ID").as_deref(), Some("xyz789"));
assert_eq!(
updated.replace("#+GDOC_ID: xyz789", "#+GDOC_ID: abc123"),
SAMPLE
);
}
#[test]
fn upsert_new_keyword_inserts_after_leading_keywords() {
let updated = upsert_keyword(SAMPLE, "GDOC_URL", "https://x");
assert_eq!(
read_keyword(&updated, "GDOC_URL").as_deref(),
Some("https://x")
);
let url_pos = updated.find("#+GDOC_URL:").expect("present");
let intro_pos = updated.find("* Intro").expect("present");
assert!(url_pos < intro_pos);
assert!(updated.contains("\n* Intro\nbody text\n"));
}
#[test]
fn upsert_into_keywordless_file_prepends() {
let text = "* Intro\nbody\n";
let updated = upsert_keyword(text, "GDOC_ID", "abc");
assert_eq!(updated, "#+GDOC_ID: abc\n* Intro\nbody\n");
}
#[test]
fn replace_metadata_preserves_body_bytes() {
let new_region =
"* GDOC_METADATA :noexport:\n** Sync State\n#+begin_src json\n{\"v\":1}\n#+end_src\n";
let updated = replace_metadata(SAMPLE, new_region);
let (original_body, _) = split(SAMPLE);
let (new_body, new_meta) = split(&updated);
assert_eq!(new_body, original_body);
assert_eq!(new_meta, Some(new_region));
}
#[test]
fn replace_metadata_appends_when_absent() {
let text = "#+TITLE: Doc\n\n* Intro\nbody\n";
let region = "* GDOC_METADATA :noexport:\n** Sync State\n";
let updated = replace_metadata(text, region);
assert!(updated.starts_with("#+TITLE: Doc\n\n* Intro\nbody\n"));
assert!(updated.ends_with(region));
assert_eq!(
metadata_boundary(&updated).map(|o| &updated[o..]),
Some(region)
);
}
#[test]
fn noop_writeback_round_trips_body() {
let id = read_keyword(SAMPLE, "GDOC_ID").expect("id");
let once = upsert_keyword(SAMPLE, "GDOC_ID", &id);
let (_, meta) = split(&once);
let twice = replace_metadata(&once, meta.expect("meta"));
assert_eq!(twice, SAMPLE);
}
}