1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
//! `StatusBuilder` is the chainable builder for a single Status line.
//!
//! Commits on Drop. **Style rule (NOT compile-enforced):** never put `?`
//! inside a `.detail(some_op()?)` chain — early return drops the builder
//! with partial fields and emits a half-built Status before the error
//! propagates. Build the inputs first, then construct the builder.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use super::Role;
use super::component::StatusLabel;
use super::renderer::{Renderer, StatusFields, Writer, finalize_subject};
/// Builder for one Status line. Commits on Drop.
///
/// **Gotcha:** never use `?` to compute a field inside the chain
/// (e.g., `.detail(some_op()?)`). If `some_op()` returns Err, the
/// half-built builder drops, committing a partial Status, then `?`
/// propagates. Build the inputs first, then construct the builder.
pub struct StatusBuilder<'p> {
pub(crate) renderer: Arc<Renderer>,
pub(crate) sink: Arc<dyn Writer>,
pub(crate) depth: usize,
pub(crate) role: Role,
pub(crate) subject: String,
pub(crate) detail: Option<String>,
pub(crate) duration: Option<Duration>,
pub(crate) target: Option<PathBuf>,
pub(crate) label: Option<StatusLabel>,
/// Lifetime parameter binding to either Printer or SectionGuard.
pub(crate) _phantom: std::marker::PhantomData<&'p ()>,
}
impl<'p> StatusBuilder<'p> {
/// Crate-private constructor used by both `Printer::status` and
/// `SectionGuard::status` to avoid duplicating the field list.
pub(crate) fn new(
renderer: Arc<Renderer>,
sink: Arc<dyn Writer>,
depth: usize,
role: Role,
subject: impl Into<String>,
) -> Self {
Self {
renderer,
sink,
depth,
role,
subject: subject.into(),
detail: None,
duration: None,
target: None,
label: None,
_phantom: std::marker::PhantomData,
}
}
pub fn detail(mut self, text: impl Into<String>) -> Self {
self.detail = Some(text.into());
self
}
pub fn detail_opt(mut self, text: Option<&str>) -> Self {
self.detail = text.map(|s| s.to_string());
self
}
pub fn duration(mut self, d: Duration) -> Self {
self.duration = Some(d);
self
}
pub fn target(mut self, path: &Path) -> Self {
self.target = Some(path.to_path_buf());
self
}
/// Append a styled label (e.g. `[source-name]`) at the end of the subject.
/// Auto-prefixes a single space so callers pass just the label content
/// (`"[source-name]"`, not `" [source-name]"`).
///
/// The label always renders at end-of-subject — the API cannot embed
/// styled segments mid-subject, which would break the outer role color
/// via the inner SGR reset.
pub fn label(mut self, role: Role, text: impl Into<String>) -> Self {
self.label = Some(StatusLabel {
role,
text: text.into(),
});
self
}
}
impl Drop for StatusBuilder<'_> {
fn drop(&mut self) {
// Sanitize caller-supplied subject ANSI BEFORE composing the
// renderer-owned label SGR (foreign `\x1b[0m` in a captured error
// would otherwise prematurely close the role styling at the inner
// reset). The label SGR is appended after sanitation so it survives.
self.subject = finalize_subject(&self.renderer.theme, &self.subject, self.label.as_ref());
let detail = self.detail.as_deref();
let target = self.target.as_deref();
self.renderer.render_status(
self.sink.as_ref(),
self.depth,
&StatusFields {
role: self.role,
subject: &self.subject,
detail,
duration: self.duration,
target,
},
);
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use super::super::renderer::{Renderer, StringSink};
use super::super::{Theme, Verbosity};
use super::*;
use crate::output::strip_ansi;
use serial_test::serial;
fn build() -> (Arc<Renderer>, Arc<Mutex<String>>) {
let buf = Arc::new(Mutex::new(String::new()));
(
Arc::new(Renderer::new(Theme::default(), Verbosity::Normal)),
buf,
)
}
fn sink_for(buf: &Arc<Mutex<String>>) -> Arc<dyn Writer> {
Arc::new(StringSink(buf.clone()))
}
#[test]
fn unbound_builder_commits_immediately_on_drop() {
let (r, buf) = build();
let sink = sink_for(&buf);
StatusBuilder::new(r, sink, 0, Role::Ok, "done"); // drops here
let s = strip_ansi(&buf.lock().unwrap());
assert!(s.contains("✓ done"), "got: {s:?}");
}
#[test]
fn chained_detail_and_duration_render() {
let (r, buf) = build();
let sink = sink_for(&buf);
let b = StatusBuilder::new(r, sink, 0, Role::Fail, "/tmp/foo")
.detail("permission denied")
.duration(std::time::Duration::from_millis(2500));
drop(b);
let s = strip_ansi(&buf.lock().unwrap());
assert!(s.contains("✗ /tmp/foo — permission denied"), "got: {s:?}");
assert!(s.contains("(2.5s)"), "got: {s:?}");
}
/// API-contract test for `StatusBuilder::label`. The label is appended at
/// the END of the subject (auto-prefixed by a space), so the inner SGR
/// reset closing the label's color cannot be followed by any further
/// outer-role-styled text. Visible composition: "<glyph> <subject> <label>".
#[test]
#[serial]
fn label_appends_at_end_of_subject() {
let _restore_no_color = std::env::var("NO_COLOR").ok();
unsafe {
std::env::remove_var("NO_COLOR");
}
let _guard = crate::output::test_support::ColorsEnabledGuard::set(true);
let (r, buf) = build();
let sink = sink_for(&buf);
let b = StatusBuilder::new(r, sink, 0, Role::Warn, "subject text")
.label(Role::Secondary, "[meta]");
drop(b);
let raw = buf.lock().unwrap().clone();
let s = strip_ansi(&raw);
assert!(
s.contains("⚠ subject text [meta]"),
"visible composition wrong; got: {s:?}"
);
// Contract: the inner reset (\x1b[0m) introduced by the label's styled
// segment must NOT be followed by another role-styled run before the
// end of the line. Specifically, after the last \x1b[0m on the status
// line, only whitespace or line-terminator may follow on the subject
// portion (the renderer may append its own trailing SGR sequences, but
// they must close the line, not re-open a colored run for outer text).
let line = raw.lines().find(|l| l.contains("subject text")).unwrap();
let last_reset = line.rfind("\x1b[0m").expect("label adds a reset");
let tail = &line[last_reset + "\x1b[0m".len()..];
// Tail can only be: empty, whitespace, or further SGR resets — never
// a styled run with role color codes for trailing visible content.
// Strip ANSI from the tail; what remains must be visible whitespace
// only (no payload chars). The label is the last visible payload.
let tail_visible = strip_ansi(tail);
assert!(
tail_visible.trim().is_empty(),
"no visible content may follow the label's inner reset; tail_visible={tail_visible:?}, line={line:?}"
);
unsafe {
if let Some(v) = _restore_no_color {
std::env::set_var("NO_COLOR", v);
}
}
}
/// Foreign ANSI carried in a caller-supplied subject (e.g. a captured
/// error formatted via `format!("sync failed for {url}: {e}")`) must be
/// stripped at the renderer boundary, so a stray `\x1b[0m` mid-subject
/// cannot prematurely terminate the role styling and foreign color
/// escapes cannot paint trailing characters.
#[cfg(feature = "test-helpers")]
#[test]
#[serial]
fn subject_strips_foreign_ansi_before_role_styling() {
use crate::output::Printer;
let (p, cap) = Printer::for_test_doc();
p.status(Role::Fail, "subject \x1b[31mforeign red\x1b[0m text")
.detail("plain detail");
p.flush();
let raw = cap.human();
assert!(
!raw.contains("\x1b[31m"),
"foreign red SGR must be stripped from subject; raw={raw:?}"
);
let visible = strip_ansi(&raw);
assert!(
visible.contains("subject foreign red text"),
"got: {visible:?}"
);
}
/// Mirror of the streaming-path test for the buffered `Doc` path through
/// `render_doc::render_component` (Status arm). Both call sites compose
/// the subject via the shared `finalize_subject` helper so the byte
/// shape must match.
#[cfg(feature = "test-helpers")]
#[test]
#[serial]
fn doc_subject_strips_foreign_ansi_before_role_styling() {
use crate::output::{Doc, Printer};
let (p, cap) = Printer::for_test_doc();
let doc = Doc::new().status(Role::Fail, "subject with \x1b[31mfoo\x1b[0m");
p.emit(doc);
p.flush();
let raw = cap.human();
assert!(
!raw.contains("\x1b[31m"),
"foreign red SGR must be stripped from Doc subject; raw={raw:?}"
);
let visible = strip_ansi(&raw);
assert!(visible.contains("subject with foo"), "got: {visible:?}");
}
}