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
46pub fn red(s: &str) -> String {
47    s.if_supports_color(Stream::Stdout, |t| t.red()).to_string()
48}
49
50pub fn bold(s: &str) -> String {
51    s.if_supports_color(Stream::Stdout, |t| t.bold())
52        .to_string()
53}
54
55pub fn dim(s: &str) -> String {
56    s.if_supports_color(Stream::Stdout, |t| t.dimmed())
57        .to_string()
58}
59
60pub fn cyan(s: &str) -> String {
61    s.if_supports_color(Stream::Stdout, |t| t.cyan())
62        .to_string()
63}
64
65pub fn status_label(s: &str, sym: &str) -> String {
66    match sym {
67        "✓" => s
68            .if_supports_color(Stream::Stdout, |t| t.green())
69            .to_string(),
70        "↑" => s
71            .if_supports_color(Stream::Stdout, |t| t.cyan())
72            .to_string(),
73        "~" | "!" => s
74            .if_supports_color(Stream::Stdout, |t| t.yellow())
75            .to_string(),
76        "✗" => s.if_supports_color(Stream::Stdout, |t| t.red()).to_string(),
77        _ => s.to_string(),
78    }
79}
80
81/// Shared column width for the presets-note labels below, so their values line up.
82const PRESETS_NOTE_LABEL_WIDTH: usize = 18; // "◈ External Presets".chars().count()
83
84fn presets_note_value(plain_label: &str, styled_label: &str, value: &str) -> String {
85    let pad = " ".repeat(PRESETS_NOTE_LABEL_WIDTH.saturating_sub(plain_label.chars().count()) + 2);
86    format!("{styled_label}{pad}{value}")
87}
88
89fn presets_note(plain_label: &str, styled_label: &str, dir: &std::path::Path) -> String {
90    presets_note_value(plain_label, styled_label, &path_display::format(dir))
91}
92
93/// Returns a formatted note indicating the active external presets directory.
94pub fn external_presets_note(dir: &std::path::Path) -> String {
95    use owo_colors::Style;
96    let label = "◈ External Presets";
97    let styled = label
98        .if_supports_color(Stream::Stdout, |t| t.style(Style::new().bold().cyan()))
99        .to_string();
100    presets_note(label, &styled, dir)
101}
102
103/// Returns a formatted note indicating the active presets overlay directory.
104pub fn presets_overlay_note(dir: &std::path::Path) -> String {
105    use owo_colors::Style;
106    let label = "◈ Presets Overlay";
107    let styled = label
108        .if_supports_color(Stream::Stdout, |t| t.style(Style::new().bold().yellow()))
109        .to_string();
110    presets_note(label, &styled, dir)
111}
112
113/// Returns a formatted note describing how external shell presets are deployed.
114pub fn shell_deployment_note(value: &str) -> String {
115    let label = "◈ Shell Deployment";
116    presets_note_value(label, label, value)
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use std::path::Path;
123
124    #[test]
125    fn external_presets_note_contains_path() {
126        let path = Path::new("/home/user/.custom/presets");
127        let note = external_presets_note(path);
128        assert!(
129            note.contains("/home/user/.custom/presets"),
130            "note should include the path: {note:?}"
131        );
132    }
133
134    #[test]
135    fn external_presets_note_contains_presets_label() {
136        let path = Path::new("/some/dir");
137        let note = external_presets_note(path);
138        assert!(
139            note.contains("External Presets"),
140            "note should include the 'External Presets' label: {note:?}"
141        );
142    }
143
144    #[test]
145    fn presets_note_values_use_the_same_column() {
146        let external = external_presets_note(Path::new("/external"));
147        let overlay = presets_overlay_note(Path::new("/overlay"));
148        let deployment = shell_deployment_note("snapshot");
149
150        assert_eq!(external.find("/external"), overlay.find("/overlay"));
151        assert_eq!(external.find("/external"), deployment.find("snapshot"));
152    }
153}