Skip to main content

coding_tools/
payload.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! The `file:` / `text:` value schemes shared by every payload-typed option.
5//!
6//! Payload-typed options (patterns, replacements, structured values, stdin
7//! text, prose) accept a scheme prefix that says where the value comes from:
8//!
9//! * `file:PATH` — the value is the file's contents, read verbatim (exact
10//!   bytes, UTF-8). A `file:`-sourced pattern is never promoted: its match
11//!   mode defaults to literal.
12//! * `text:VALUE` — the remainder is the literal value; the escape hatch for
13//!   a payload that genuinely begins with `file:` or `text:`.
14//!
15//! Only these two exact prefixes are recognised. Everything else is literal
16//! as-is — there is no general `scheme:` reservation, so values like
17//! `http://…` and `std::fmt` are unaffected.
18
19/// A payload value with its origin, after scheme resolution.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Resolved {
22    /// The resolved value text.
23    pub text: String,
24    /// True when the value was read from a `file:` source — such payloads
25    /// are verbatim: never promoted, matched literally by default.
26    pub from_file: bool,
27}
28
29/// Resolve a raw option value through the `file:` / `text:` schemes.
30///
31/// # Examples
32///
33/// ```
34/// use coding_tools::payload::resolve;
35///
36/// // No recognised prefix: the value is literal as-is.
37/// assert_eq!(resolve("http://example.com").unwrap().text, "http://example.com");
38/// assert!(!resolve("std::fmt").unwrap().from_file);
39///
40/// // text: strips the prefix and nothing else.
41/// assert_eq!(resolve("text:file:not-a-path").unwrap().text, "file:not-a-path");
42/// ```
43pub fn resolve(raw: &str) -> Result<Resolved, String> {
44    if let Some(path) = raw.strip_prefix("file:") {
45        let text = std::fs::read_to_string(path)
46            .map_err(|e| format!("reading payload file '{path}': {e}"))?;
47        Ok(Resolved {
48            text,
49            from_file: true,
50        })
51    } else if let Some(rest) = raw.strip_prefix("text:") {
52        Ok(Resolved {
53            text: rest.to_string(),
54            from_file: false,
55        })
56    } else {
57        Ok(Resolved {
58            text: raw.to_string(),
59            from_file: false,
60        })
61    }
62}
63
64/// Split a payload into its lines for line-anchored matching. A single final
65/// terminating newline ends the last line — it does not add an empty trailing
66/// line. A trailing `\r` on each line is dropped, so a CRLF-terminated payload
67/// (e.g. an anchor file saved by a Windows editor) matches LF source. An empty
68/// payload has zero lines.
69///
70/// # Examples
71///
72/// ```
73/// use coding_tools::payload::to_lines;
74///
75/// assert_eq!(to_lines("foo\n"), vec!["foo"]);          // final newline ends the line
76/// assert_eq!(to_lines("a\nb"), vec!["a", "b"]);
77/// assert_eq!(to_lines("a\n\n"), vec!["a", ""]);        // an intentional blank line stays
78/// assert_eq!(to_lines("a\r\nb\r\n"), vec!["a", "b"]);  // CRLF normalized to LF
79/// assert!(to_lines("").is_empty());                     // empty payload: zero lines
80/// ```
81pub fn to_lines(payload: &str) -> Vec<String> {
82    if payload.is_empty() {
83        return Vec::new();
84    }
85    let body = payload.strip_suffix('\n').unwrap_or(payload);
86    body.split('\n')
87        .map(|l| l.strip_suffix('\r').unwrap_or(l).to_string())
88        .collect()
89}
90
91/// Split a `find`/anchor payload into the lines a block match anchors on:
92/// [`to_lines`], then drop any trailing empty lines.
93///
94/// Editors and file-writers terminate files with a newline and frequently leave
95/// a final blank line; without trimming, that becomes a phantom empty line at
96/// the tail of the anchor and a K-line block fails to match as a K+1-line one.
97/// Interior blank lines and whitespace-only lines are preserved (whitespace is
98/// significant in a block match); only exactly-empty (`""`) trailing lines are
99/// removed. An anchor that is entirely blank therefore reduces to zero lines.
100///
101/// # Examples
102///
103/// ```
104/// use coding_tools::payload::to_find_lines;
105///
106/// // A trailing blank line (e.g. a file ending in two newlines) is dropped.
107/// assert_eq!(to_find_lines("a\nb\n\n"), vec!["a", "b"]);
108/// // A single terminator already collapses, same as `to_lines`.
109/// assert_eq!(to_find_lines("a\nb\n"), vec!["a", "b"]);
110/// // An interior blank line is significant and kept.
111/// assert_eq!(to_find_lines("a\n\nb\n"), vec!["a", "", "b"]);
112/// // An all-blank anchor reduces to nothing.
113/// assert!(to_find_lines("\n\n").is_empty());
114/// ```
115pub fn to_find_lines(payload: &str) -> Vec<String> {
116    let mut lines = to_lines(payload);
117    while lines.last().is_some_and(String::is_empty) {
118        lines.pop();
119    }
120    lines
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn unprefixed_values_pass_through_verbatim() {
129        for raw in ["plain", "http://x/y", "std::fmt", "a file: in the middle"] {
130            let r = resolve(raw).unwrap();
131            assert_eq!(r.text, raw);
132            assert!(!r.from_file);
133        }
134    }
135
136    #[test]
137    fn text_prefix_strips_once_and_only_once() {
138        assert_eq!(resolve("text:text:x").unwrap().text, "text:x");
139        assert_eq!(resolve("text:").unwrap().text, "");
140    }
141
142    #[test]
143    fn file_prefix_reads_exact_bytes() {
144        let dir = std::env::temp_dir().join("ct-payload-test");
145        std::fs::create_dir_all(&dir).unwrap();
146        let p = dir.join("payload.block");
147        std::fs::write(&p, "  indented(line),\nnext\n").unwrap();
148        let r = resolve(&format!("file:{}", p.display())).unwrap();
149        assert!(r.from_file);
150        assert_eq!(r.text, "  indented(line),\nnext\n");
151    }
152
153    #[test]
154    fn missing_payload_file_is_an_error() {
155        assert!(resolve("file:/no/such/payload").is_err());
156    }
157
158    #[test]
159    fn to_lines_treats_one_trailing_newline_as_a_terminator() {
160        assert_eq!(to_lines("foo\n"), vec!["foo"]);
161        assert_eq!(to_lines("a\nb"), vec!["a", "b"]);
162        // A second trailing newline is an intentional blank line, kept here.
163        assert_eq!(to_lines("a\n\n"), vec!["a", ""]);
164        assert!(to_lines("").is_empty());
165    }
166
167    #[test]
168    fn to_lines_normalizes_crlf_to_lf() {
169        assert_eq!(to_lines("a\r\nb\r\n"), vec!["a", "b"]);
170        assert_eq!(to_lines("solo\r\n"), vec!["solo"]);
171        // A lone CR that is not a line terminator is left untouched.
172        assert_eq!(to_lines("a\rb\n"), vec!["a\rb"]);
173        // CRLF plus a trailing blank line: terminators stripped, blank kept.
174        assert_eq!(to_lines("a\r\n\r\n"), vec!["a", ""]);
175    }
176
177    #[test]
178    fn to_find_lines_drops_trailing_blank_lines() {
179        // The phantom-trailing-line case: a 2-line anchor + an extra blank line.
180        assert_eq!(to_find_lines("a\nb\n\n"), vec!["a", "b"]);
181        // Several trailing blanks all go.
182        assert_eq!(to_find_lines("a\nb\n\n\n"), vec!["a", "b"]);
183        // CRLF anchor with a trailing blank line: normalized and trimmed.
184        assert_eq!(to_find_lines("a\r\nb\r\n\r\n"), vec!["a", "b"]);
185        // A single terminator behaves exactly like `to_lines`.
186        assert_eq!(to_find_lines("a\nb\n"), vec!["a", "b"]);
187        // Interior blanks and whitespace-only lines are significant, so kept.
188        assert_eq!(to_find_lines("a\n\nb\n"), vec!["a", "", "b"]);
189        assert_eq!(to_find_lines("a\n   \n"), vec!["a", "   "]);
190        // An all-blank anchor reduces to nothing.
191        assert!(to_find_lines("\n\n").is_empty());
192        assert!(to_find_lines("").is_empty());
193    }
194}