Skip to main content

cli/
colors.rs

1use owo_colors::{OwoColorize, Stream};
2
3use crate::path_display;
4
5fn symbol_for_stream(s: &str, stream: Stream) -> String {
6    match s {
7        "✓" => s.if_supports_color(stream, |t| t.green()).to_string(),
8        "↑" => s.if_supports_color(stream, |t| t.cyan()).to_string(),
9        "~" => s.if_supports_color(stream, |t| t.yellow()).to_string(),
10        "!" => s.if_supports_color(stream, |t| t.magenta()).to_string(),
11        "✗" => s.if_supports_color(stream, |t| t.red()).to_string(),
12        other => other.to_string(),
13    }
14}
15
16pub fn symbol(s: &str) -> String {
17    symbol_for_stream(s, Stream::Stdout)
18}
19
20/// Like [`symbol`], but checks color support against stderr instead of
21/// stdout. Use this when the result is printed with `eprintln!` — checking
22/// the wrong stream means color escapes can be wrongly included or omitted
23/// when stdout and stderr have different redirection (e.g. `cmd > log.txt`
24/// with a real terminal still attached to stderr).
25pub fn symbol_stderr(s: &str) -> String {
26    symbol_for_stream(s, Stream::Stderr)
27}
28
29pub fn green(s: &str) -> String {
30    s.if_supports_color(Stream::Stdout, |t| t.green())
31        .to_string()
32}
33
34pub fn yellow(s: &str) -> String {
35    s.if_supports_color(Stream::Stdout, |t| t.yellow())
36        .to_string()
37}
38
39/// Like [`yellow`], but checks color support against stderr. See
40/// [`symbol_stderr`] for why this matters.
41pub fn yellow_stderr(s: &str) -> String {
42    s.if_supports_color(Stream::Stderr, |t| t.yellow())
43        .to_string()
44}
45
46/// Bold warning emphasis for sensitive actions rendered on stderr.
47pub fn bold_yellow_stderr(s: &str) -> String {
48    use owo_colors::Style;
49    s.if_supports_color(Stream::Stderr, |t| t.style(Style::new().bold().yellow()))
50        .to_string()
51}
52
53pub fn cyan_stderr(s: &str) -> String {
54    s.if_supports_color(Stream::Stderr, |t| t.cyan())
55        .to_string()
56}
57
58pub fn dim_stderr(s: &str) -> String {
59    s.if_supports_color(Stream::Stderr, |t| t.dimmed())
60        .to_string()
61}
62
63pub fn red(s: &str) -> String {
64    s.if_supports_color(Stream::Stdout, |t| t.red()).to_string()
65}
66
67pub fn bold(s: &str) -> String {
68    s.if_supports_color(Stream::Stdout, |t| t.bold())
69        .to_string()
70}
71
72pub fn bold_cyan(s: &str) -> String {
73    use owo_colors::Style;
74    s.if_supports_color(Stream::Stdout, |t| t.style(Style::new().bold().cyan()))
75        .to_string()
76}
77
78pub fn dim(s: &str) -> String {
79    s.if_supports_color(Stream::Stdout, |t| t.dimmed())
80        .to_string()
81}
82
83pub fn cyan(s: &str) -> String {
84    s.if_supports_color(Stream::Stdout, |t| t.cyan())
85        .to_string()
86}
87
88pub fn status_label(s: &str, sym: &str) -> String {
89    match sym {
90        "✓" => s
91            .if_supports_color(Stream::Stdout, |t| t.green())
92            .to_string(),
93        "↑" => s
94            .if_supports_color(Stream::Stdout, |t| t.cyan())
95            .to_string(),
96        "~" | "!" => s
97            .if_supports_color(Stream::Stdout, |t| t.yellow())
98            .to_string(),
99        "✗" => s.if_supports_color(Stream::Stdout, |t| t.red()).to_string(),
100        _ => s.to_string(),
101    }
102}
103
104/// Shared column width for the presets-note labels below, so their values line up.
105const PRESETS_NOTE_LABEL_WIDTH: usize = 18; // "◈ Shell Deployment".chars().count()
106
107fn presets_note_value(plain_label: &str, styled_label: &str, value: &str) -> String {
108    let pad = " ".repeat(PRESETS_NOTE_LABEL_WIDTH.saturating_sub(plain_label.chars().count()) + 2);
109    format!("{styled_label}{pad}{value}")
110}
111
112fn presets_note(plain_label: &str, styled_label: &str, dir: &std::path::Path) -> String {
113    presets_note_value(plain_label, styled_label, &path_display::format(dir))
114}
115
116/// Returns a formatted note naming the effective base preset source.
117pub fn presets_source_note(value: &str) -> String {
118    use owo_colors::Style;
119    let label = "◈ Preset Source";
120    let styled = label
121        .if_supports_color(Stream::Stdout, |t| t.style(Style::new().bold().cyan()))
122        .to_string();
123    presets_note_value(label, &styled, value)
124}
125
126/// Returns a formatted note indicating the active external presets directory.
127pub fn external_presets_note(dir: &std::path::Path) -> String {
128    presets_source_note(&format!("external · {}", path_display::format(dir)))
129}
130
131/// Returns a formatted note indicating the active presets overlay directory.
132pub fn presets_overlay_note(dir: &std::path::Path) -> String {
133    use owo_colors::Style;
134    let label = "◈ Presets Overlay";
135    let styled = label
136        .if_supports_color(Stream::Stdout, |t| t.style(Style::new().bold().yellow()))
137        .to_string();
138    presets_note(label, &styled, dir)
139}
140
141/// Returns a formatted note describing how external shell presets are deployed.
142pub fn shell_deployment_note(value: &str) -> String {
143    let label = "◈ Shell Deployment";
144    presets_note_value(label, label, value)
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use std::path::Path;
151
152    #[test]
153    fn external_presets_note_contains_path() {
154        let path = Path::new("/home/user/.custom/presets");
155        let note = external_presets_note(path);
156        assert!(
157            note.contains("/home/user/.custom/presets"),
158            "note should include the path: {note:?}"
159        );
160    }
161
162    #[test]
163    fn external_presets_note_uses_shared_source_label() {
164        let path = Path::new("/some/dir");
165        let note = external_presets_note(path);
166        assert!(
167            note.contains("Preset Source") && note.contains("external"),
168            "note should use the shared preset-source vocabulary: {note:?}"
169        );
170    }
171
172    #[test]
173    fn presets_note_values_use_the_same_column() {
174        let source = presets_source_note("built-in");
175        let external = external_presets_note(Path::new("/external"));
176        let overlay = presets_overlay_note(Path::new("/overlay"));
177        let deployment = shell_deployment_note("snapshot");
178
179        assert_eq!(source.find("built-in"), external.find("external"));
180        assert_eq!(source.find("built-in"), overlay.find("/overlay"));
181        assert_eq!(source.find("built-in"), deployment.find("snapshot"));
182    }
183}