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}