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. An empty payload has zero lines.
67///
68/// # Examples
69///
70/// ```
71/// use coding_tools::payload::to_lines;
72///
73/// assert_eq!(to_lines("foo\n"), vec!["foo"]); // final newline ends the line
74/// assert_eq!(to_lines("a\nb"), vec!["a", "b"]);
75/// assert_eq!(to_lines("a\n\n"), vec!["a", ""]); // an intentional blank line stays
76/// assert!(to_lines("").is_empty()); // empty payload: zero lines
77/// ```
78pub fn to_lines(payload: &str) -> Vec<String> {
79 if payload.is_empty() {
80 return Vec::new();
81 }
82 let body = payload.strip_suffix('\n').unwrap_or(payload);
83 body.split('\n').map(str::to_string).collect()
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn unprefixed_values_pass_through_verbatim() {
92 for raw in ["plain", "http://x/y", "std::fmt", "a file: in the middle"] {
93 let r = resolve(raw).unwrap();
94 assert_eq!(r.text, raw);
95 assert!(!r.from_file);
96 }
97 }
98
99 #[test]
100 fn text_prefix_strips_once_and_only_once() {
101 assert_eq!(resolve("text:text:x").unwrap().text, "text:x");
102 assert_eq!(resolve("text:").unwrap().text, "");
103 }
104
105 #[test]
106 fn file_prefix_reads_exact_bytes() {
107 let dir = std::env::temp_dir().join("ct-payload-test");
108 std::fs::create_dir_all(&dir).unwrap();
109 let p = dir.join("payload.block");
110 std::fs::write(&p, " indented(line),\nnext\n").unwrap();
111 let r = resolve(&format!("file:{}", p.display())).unwrap();
112 assert!(r.from_file);
113 assert_eq!(r.text, " indented(line),\nnext\n");
114 }
115
116 #[test]
117 fn missing_payload_file_is_an_error() {
118 assert!(resolve("file:/no/such/payload").is_err());
119 }
120}