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. 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}