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 dim(s: &str) -> String {
73    s.if_supports_color(Stream::Stdout, |t| t.dimmed())
74        .to_string()
75}
76
77pub fn cyan(s: &str) -> String {
78    s.if_supports_color(Stream::Stdout, |t| t.cyan())
79        .to_string()
80}
81
82pub fn status_label(s: &str, sym: &str) -> String {
83    match sym {
84        "✓" => s
85            .if_supports_color(Stream::Stdout, |t| t.green())
86            .to_string(),
87        "↑" => s
88            .if_supports_color(Stream::Stdout, |t| t.cyan())
89            .to_string(),
90        "~" | "!" => s
91            .if_supports_color(Stream::Stdout, |t| t.yellow())
92            .to_string(),
93        "✗" => s.if_supports_color(Stream::Stdout, |t| t.red()).to_string(),
94        _ => s.to_string(),
95    }
96}
97
98/// Shared column width for the presets-note labels below, so their values line up.
99const PRESETS_NOTE_LABEL_WIDTH: usize = 18; // "◈ Shell Deployment".chars().count()
100
101fn presets_note_value(plain_label: &str, styled_label: &str, value: &str) -> String {
102    let pad = " ".repeat(PRESETS_NOTE_LABEL_WIDTH.saturating_sub(plain_label.chars().count()) + 2);
103    format!("{styled_label}{pad}{value}")
104}
105
106fn presets_note(plain_label: &str, styled_label: &str, dir: &std::path::Path) -> String {
107    presets_note_value(plain_label, styled_label, &path_display::format(dir))
108}
109
110/// Returns a formatted note naming the effective base preset source.
111pub fn presets_source_note(value: &str) -> String {
112    use owo_colors::Style;
113    let label = "◈ Preset Source";
114    let styled = label
115        .if_supports_color(Stream::Stdout, |t| t.style(Style::new().bold().cyan()))
116        .to_string();
117    presets_note_value(label, &styled, value)
118}
119
120/// Returns a formatted note indicating the active external presets directory.
121pub fn external_presets_note(dir: &std::path::Path) -> String {
122    presets_source_note(&format!("external · {}", path_display::format(dir)))
123}
124
125/// Returns a formatted note indicating the active presets overlay directory.
126pub fn presets_overlay_note(dir: &std::path::Path) -> String {
127    use owo_colors::Style;
128    let label = "◈ Presets Overlay";
129    let styled = label
130        .if_supports_color(Stream::Stdout, |t| t.style(Style::new().bold().yellow()))
131        .to_string();
132    presets_note(label, &styled, dir)
133}
134
135/// Returns a formatted note describing how external shell presets are deployed.
136pub fn shell_deployment_note(value: &str) -> String {
137    let label = "◈ Shell Deployment";
138    presets_note_value(label, label, value)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::path::Path;
145
146    #[test]
147    fn external_presets_note_contains_path() {
148        let path = Path::new("/home/user/.custom/presets");
149        let note = external_presets_note(path);
150        assert!(
151            note.contains("/home/user/.custom/presets"),
152            "note should include the path: {note:?}"
153        );
154    }
155
156    #[test]
157    fn external_presets_note_uses_shared_source_label() {
158        let path = Path::new("/some/dir");
159        let note = external_presets_note(path);
160        assert!(
161            note.contains("Preset Source") && note.contains("external"),
162            "note should use the shared preset-source vocabulary: {note:?}"
163        );
164    }
165
166    #[test]
167    fn presets_note_values_use_the_same_column() {
168        let source = presets_source_note("built-in");
169        let external = external_presets_note(Path::new("/external"));
170        let overlay = presets_overlay_note(Path::new("/overlay"));
171        let deployment = shell_deployment_note("snapshot");
172
173        assert_eq!(source.find("built-in"), external.find("external"));
174        assert_eq!(source.find("built-in"), overlay.find("/overlay"));
175        assert_eq!(source.find("built-in"), deployment.find("snapshot"));
176    }
177}