Skip to main content

org_gdocs/
orgfile.rs

1//! Two-region model of a synced org file: the user's **body** and the
2//! tool-owned **machine region**, split at the `* GDOC_METADATA` heading.
3//!
4//! Every function here is a pure, total transformation of file text. The body
5//! (everything before the boundary) is preserved **byte-for-byte** by
6//! construction (domain invariant DI-2): writeback only ever rewrites a single
7//! `#+KEYWORD:` line or the metadata tail. The only sanctioned body mutation
8//! — inserting a `:CUSTOM_ID:` property — lives in [`crate::project`] (P2), not
9//! here.
10//!
11//! `#+KEYWORD:` matching is case-insensitive, as org keywords are.
12
13/// Level-1 heading, sans tags, that begins the tool-owned machine region.
14pub const METADATA_HEADING: &str = "* GDOC_METADATA";
15
16/// Whether a newline-stripped line is the `GDOC_METADATA` heading (with or
17/// without trailing tags, e.g. `* GDOC_METADATA :noexport:`).
18fn is_metadata_heading(line: &str) -> bool {
19    line == METADATA_HEADING || line.starts_with(concat!("* GDOC_METADATA", " "))
20}
21
22/// Whether a line is a top-of-file org keyword line (`#+KEY: ...`).
23fn is_keyword_line(line: &str) -> bool {
24    line.trim_start().starts_with("#+")
25}
26
27/// Whether a newline-stripped line begins a heading (`* `, `** `, ...).
28fn 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/// Byte offset where the `GDOC_METADATA` heading begins, if the file has one.
34#[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/// Split into `(body, Some(machine_region))`, or `(whole_text, None)` when the
47/// file has no `GDOC_METADATA` section yet.
48#[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/// The user-owned body region (everything before the machine region).
56#[must_use]
57pub fn body(text: &str) -> &str {
58    split(text).0
59}
60
61/// Extract the value of a `#+KEY:` keyword from the body region, if present.
62#[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
67/// If `line` is `#+KEY: value` (case-insensitive key), return the trimmed value.
68fn 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/// Set `#+KEY: value` in the body region.
77///
78/// An existing keyword line is rewritten in place; otherwise the keyword is
79/// inserted after the last top-of-file keyword line (or prepended when the file
80/// has none). Only the affected line changes; all other bytes are preserved.
81#[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
97/// Index (into `split_inclusive('\n')` lines) at which to insert a new keyword:
98/// just after the last leading keyword line that precedes the first heading.
99fn 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
113/// Rebuild text replacing the content of `lines[index]` with `rendered`,
114/// preserving that line's original trailing newline (or lack thereof).
115fn 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
130/// Rebuild text inserting `rendered` (with a trailing newline) before
131/// `lines[index]`.
132fn 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
148/// Total byte length of the collected lines (== original text length).
149fn text_len(lines: &[&str]) -> usize {
150    lines.iter().map(|line| line.len()).sum()
151}
152
153/// Replace the machine region with `region`, preserving every body byte.
154///
155/// The machine region spans the `* GDOC_METADATA` heading to end of file. When
156/// the file has none yet, `region` is appended, separated from the body by a
157/// blank line. `region` is expected to start with the `* GDOC_METADATA` heading.
158#[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        // Everything except the one line is byte-identical.
223        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        // Inserted within the top keyword block, before the first heading.
237        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        // Body content untouched.
241        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        // Simulate a no-op sync: re-set an existing keyword to its current value
278        // and replace the metadata with the identical region.
279        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}