Skip to main content

harn_cli/
format.rs

1//! Shared, lightweight formatting helpers for CLI commands.
2//!
3//! Multiple subcommands had grown private copies of the same RFC3339
4//! timestamp / human-friendly duration formatters; consolidating them
5//! here keeps formatting consistent across the user-facing surface.
6
7use std::path::Path;
8use std::time::Duration as StdDuration;
9
10use time::format_description::well_known::Rfc3339;
11use time::OffsetDateTime;
12
13/// Render a UTC instant as RFC3339, falling back to `Display` if formatting
14/// fails (which `time` only does on internally inconsistent components).
15pub(crate) fn format_timestamp_rfc3339(value: OffsetDateTime) -> String {
16    value.format(&Rfc3339).unwrap_or_else(|_| value.to_string())
17}
18
19/// Render a unix-epoch millisecond timestamp as RFC3339.
20pub(crate) fn format_unix_ms_rfc3339(ms: i64) -> String {
21    let seconds = ms.div_euclid(1000);
22    let value = OffsetDateTime::from_unix_timestamp(seconds).unwrap_or(OffsetDateTime::UNIX_EPOCH);
23    format_timestamp_rfc3339(value)
24}
25
26/// Render a `std::time::Duration` as a coarse "5s / 2m / 3h / 1d / 1w" suffix.
27///
28/// Rounds toward zero on the chosen unit so status output does not overstate
29/// elapsed time.
30pub(crate) fn format_duration_coarse(value: StdDuration) -> String {
31    if value.as_secs() == 0 {
32        return format!("{}ms", value.as_millis());
33    }
34    let seconds = value.as_secs();
35    if seconds < 60 {
36        return format!("{seconds}s");
37    }
38    if seconds < 60 * 60 {
39        return format!("{}m", seconds / 60);
40    }
41    if seconds < 60 * 60 * 24 {
42        return format!("{}h", seconds / (60 * 60));
43    }
44    if seconds < 60 * 60 * 24 * 7 {
45        return format!("{}d", seconds / (60 * 60 * 24));
46    }
47    if seconds.is_multiple_of(60 * 60 * 24 * 7) {
48        return format!("{}w", seconds / (60 * 60 * 24 * 7));
49    }
50    format!("{}d", seconds / (60 * 60 * 24))
51}
52
53/// Quote a path for inclusion in a copy-pasteable shell command line.
54///
55/// Delegates to `shell_words::quote` so every emitted command line shares one
56/// definition of "safe to leave bare"; non-UTF-8 components are rendered
57/// lossily, matching how the path would be displayed elsewhere.
58pub(crate) fn shell_quote_path(path: &Path) -> String {
59    shell_words::quote(&path.to_string_lossy()).into_owned()
60}
61
62/// Render a millisecond duration with a single decimal point of precision
63/// for the ">= 1s" cases (used by portal output).
64pub(crate) fn format_duration_ms(duration_ms: u64) -> String {
65    if duration_ms >= 60_000 {
66        format!("{:.1}m", duration_ms as f64 / 60_000.0)
67    } else if duration_ms >= 1_000 {
68        format!("{:.1}s", duration_ms as f64 / 1_000.0)
69    } else {
70        format!("{duration_ms}ms")
71    }
72}
73
74/// Escape the `|` characters in `value` so it can sit inside a single Markdown
75/// table cell without prematurely ending the column. Several report commands
76/// (eval summaries, provider matrices, diagnostics catalogs) build Markdown
77/// tables and had each grown a private copy of this; keep the escaping uniform.
78pub(crate) fn escape_md(value: &str) -> String {
79    value.replace('|', "\\|")
80}
81
82/// Escape `&`, `<`, `>`, `"`, and `'` for embedding text in HTML or XML
83/// output. Uses `&#39;` for the apostrophe because it is valid in both
84/// (`&apos;` is XML-only). The eval-prompt report, MCP landing page, OAuth
85/// callback page, and JUnit report writer had each grown a private copy.
86pub(crate) fn escape_html(value: &str) -> String {
87    let mut out = String::with_capacity(value.len());
88    for ch in value.chars() {
89        match ch {
90            '&' => out.push_str("&amp;"),
91            '<' => out.push_str("&lt;"),
92            '>' => out.push_str("&gt;"),
93            '"' => out.push_str("&quot;"),
94            '\'' => out.push_str("&#39;"),
95            _ => out.push(ch),
96        }
97    }
98    out
99}
100
101/// Escape a string for a TOML basic (double-quoted) string: backslash,
102/// double quote, and the control characters TOML forbids raw in basic
103/// strings. `harn rules` and `harn connector` scaffolding had divergent
104/// copies — the connector one skipped control characters, so a value with a
105/// newline produced invalid TOML.
106pub(crate) fn escape_toml_basic_string(value: &str) -> String {
107    let mut out = String::with_capacity(value.len());
108    for ch in value.chars() {
109        match ch {
110            '"' => out.push_str("\\\""),
111            '\\' => out.push_str("\\\\"),
112            '\n' => out.push_str("\\n"),
113            '\t' => out.push_str("\\t"),
114            '\r' => out.push_str("\\r"),
115            // Remaining C0 control chars (and DEL) must be escaped in TOML
116            // basic strings.
117            c if (c as u32) < 0x20 || c == '\u{7f}' => {
118                out.push_str(&format!("\\u{:04X}", c as u32));
119            }
120            _ => out.push(ch),
121        }
122    }
123    out
124}
125
126/// Render a full TOML basic string literal.
127pub(crate) fn toml_basic_string_literal(value: &str) -> String {
128    format!("\"{}\"", escape_toml_basic_string(value))
129}
130
131/// Normalize path separators in machine-readable output. Harn package and
132/// bundle artifacts use slash-separated logical paths even on Windows.
133pub(crate) fn slash_separators(value: &str) -> String {
134    value.replace('\\', "/")
135}
136
137/// Render a path with slash separators for deterministic JSON/report output.
138pub(crate) fn slash_path(path: &Path) -> String {
139    slash_separators(&path.to_string_lossy())
140}
141
142/// True when a string starts with Windows drive syntax such as `C:\`, `C:/`,
143/// or drive-relative `C:foo`. URL parsers otherwise treat these as scheme `c`.
144pub(crate) fn looks_like_windows_drive_path(value: &str) -> bool {
145    let bytes = value.as_bytes();
146    bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn timestamp_uses_rfc3339() {
155        let value = OffsetDateTime::UNIX_EPOCH;
156        assert_eq!(format_timestamp_rfc3339(value), "1970-01-01T00:00:00Z");
157    }
158
159    #[test]
160    fn escape_html_handles_all_five_specials() {
161        assert_eq!(
162            escape_html(r#"<a href="x">&'b'</a>"#),
163            "&lt;a href=&quot;x&quot;&gt;&amp;&#39;b&#39;&lt;/a&gt;"
164        );
165    }
166
167    #[test]
168    fn escape_toml_basic_string_escapes_control_characters() {
169        assert_eq!(
170            escape_toml_basic_string("a\"b\\c\nd\te\rf"),
171            "a\\\"b\\\\c\\nd\\te\\rf"
172        );
173        // Other C0 controls (the case the old connector copy missed
174        // entirely) become \uXXXX escapes.
175        assert_eq!(escape_toml_basic_string("bell\u{7}"), "bell\\u0007");
176    }
177
178    #[test]
179    fn toml_basic_string_literal_round_trips_windows_paths() {
180        let raw = r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmpJHS6sR\coding-pack";
181        let parsed: toml::Value =
182            toml::from_str(&format!("path = {}\n", toml_basic_string_literal(raw))).unwrap();
183        assert_eq!(parsed.get("path").and_then(toml::Value::as_str), Some(raw));
184    }
185
186    #[test]
187    fn slash_path_normalizes_windows_separators() {
188        assert_eq!(
189            slash_separators(r"C:\tmp\pkg\lib.harn"),
190            "C:/tmp/pkg/lib.harn"
191        );
192    }
193
194    #[test]
195    fn detects_windows_drive_paths_without_url_parser() {
196        assert!(looks_like_windows_drive_path(r"C:\tmp\registry.toml"));
197        assert!(looks_like_windows_drive_path("D:/tmp/registry.toml"));
198        assert!(looks_like_windows_drive_path("E:relative"));
199        assert!(!looks_like_windows_drive_path(
200            "https://example.com/index.toml"
201        ));
202        assert!(!looks_like_windows_drive_path("/tmp/index.toml"));
203    }
204
205    #[test]
206    fn unix_ms_rounds_to_seconds() {
207        assert_eq!(format_unix_ms_rfc3339(0), "1970-01-01T00:00:00Z");
208        assert_eq!(format_unix_ms_rfc3339(1500), "1970-01-01T00:00:01Z");
209        // Negative ms before the epoch should not panic.
210        assert_eq!(format_unix_ms_rfc3339(-1), "1969-12-31T23:59:59Z");
211    }
212
213    #[test]
214    fn coarse_duration_picks_a_unit() {
215        assert_eq!(format_duration_coarse(StdDuration::from_millis(0)), "0ms");
216        assert_eq!(format_duration_coarse(StdDuration::from_secs(5)), "5s");
217        assert_eq!(format_duration_coarse(StdDuration::from_mins(2)), "2m");
218        assert_eq!(format_duration_coarse(StdDuration::from_hours(2)), "2h");
219        assert_eq!(format_duration_coarse(StdDuration::from_hours(72)), "3d");
220        assert_eq!(format_duration_coarse(StdDuration::from_hours(336)), "2w");
221    }
222
223    #[test]
224    fn ms_duration_uses_one_decimal_above_a_second() {
225        assert_eq!(format_duration_ms(500), "500ms");
226        assert_eq!(format_duration_ms(1_500), "1.5s");
227        assert_eq!(format_duration_ms(90_000), "1.5m");
228    }
229
230    #[test]
231    fn escape_md_escapes_only_pipes() {
232        assert_eq!(escape_md("a|b|c"), "a\\|b\\|c");
233        assert_eq!(escape_md("no pipes here"), "no pipes here");
234        assert_eq!(escape_md(""), "");
235    }
236}