Skip to main content

uncurses/ansi/
program_status.rs

1//! Program status reports and feature detection through OSC 7501.
2//!
3//! Each report replaces one terminal record. An absent ID selects the root.
4//! A clear report removes its record and descendants; clearing the root
5//! removes all records. Screen switches leave records intact. Done and error
6//! records survive process exit and the next shell prompt.
7//!
8//! Writers use `ESC ] 7501 ; pairs ESC \`. Messages and titles are standard
9//! base64 of UTF-8. Unsupported terminals ignore these sequences.
10//!
11//! See the [protocol specification](https://www.superlogical.com/rex/docs/build/program-status).
12
13use std::io::{self, Write};
14
15use super::clipboard::base64_encode;
16
17/// Query OSC 7501 support. The reply starts with `?` and may list accepted
18/// states and kinds.
19pub const REQUEST_PROGRAM_STATUS: &[u8] = b"\x1b]7501;?\x1b\\";
20
21/// State of a program or one of its tasks.
22///
23/// Standard names are associated constants. Use [`Self::new`] for a name
24/// advertised by a protocol extension. The writer validates names before
25/// output; construction does not establish terminal support.
26///
27/// State and kind names are distinct types:
28///
29/// ```compile_fail
30/// use uncurses::ansi::program_status::{ProgramStatus, ProgramStatusKind};
31///
32/// let report = ProgramStatus::new(ProgramStatusKind::AUTH);
33/// ```
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub struct ProgramStatusState<'a>(&'a str);
36
37impl<'a> ProgramStatusState<'a> {
38    /// At rest, ready for the user's next instruction.
39    pub const IDLE: Self = Self("idle");
40    /// Work is in progress.
41    pub const WORKING: Self = Self("working");
42    /// Work finished and its result is ready.
43    pub const DONE: Self = Self("done");
44    /// Work requires user input before it can continue.
45    pub const BLOCKED: Self = Self("blocked");
46    /// Work failed and stopped.
47    pub const ERROR: Self = Self("error");
48    /// Remove the addressed record and all descendants.
49    pub const CLEAR: Self = Self("clear");
50
51    /// Borrow a state name without validation.
52    ///
53    /// Check the terminal's accepted states before use. The writer accepts
54    /// nonempty values from `[A-Za-z0-9_.,+/=-]` within the sequence limit.
55    pub const fn new(name: &'a str) -> Self {
56        Self(name)
57    }
58
59    /// Return the state name used on the wire.
60    pub const fn as_str(self) -> &'a str {
61        self.0
62    }
63}
64
65/// What a blocked program needs from the user.
66///
67/// Standard names are associated constants. Use [`Self::new`] for a name
68/// advertised by a protocol extension. The writer validates names before
69/// output; construction does not establish terminal support.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71pub struct ProgramStatusKind<'a>(&'a str);
72
73impl<'a> ProgramStatusKind<'a> {
74    /// Approval for an action.
75    pub const PERMISSION: Self = Self("permission");
76    /// An answer to a question.
77    pub const QUESTION: Self = Self("question");
78    /// Authentication, such as a login or credential.
79    pub const AUTH: Self = Self("auth");
80
81    /// Borrow a kind name without validation.
82    ///
83    /// Check the terminal's accepted kinds before use. The writer accepts
84    /// nonempty values from `[A-Za-z0-9_.,+/=-]` within the sequence limit.
85    pub const fn new(name: &'a str) -> Self {
86        Self(name)
87    }
88
89    /// Return the kind name used on the wire.
90    pub const fn as_str(self) -> &'a str {
91        self.0
92    }
93}
94
95/// A complete OSC 7501 report, with borrowed text.
96///
97/// Each report replaces its record, so include every field that should
98/// remain present. An absent `app` inherits from the nearest ancestor with
99/// one. Text and identifiers are validated by [`write_program_status`]
100/// before any output. Callers supply plain UTF-8, not base64.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ProgramStatus<'a> {
103    /// Required record state.
104    pub state: ProgramStatusState<'a>,
105    /// Optional record path. At most 128 bytes and eight slash-separated
106    /// segments, each 1-32 bytes from `[A-Za-z0-9_.+-]`. `None` selects root.
107    pub id: Option<&'a str>,
108    /// Reason for blocking. Terminals use this only with state `blocked`.
109    pub kind: Option<ProgramStatusKind<'a>>,
110    /// Percentage, from 0 to 100. Terminals use this only with state `working`
111    /// or `blocked`. `None` means indeterminate progress.
112    pub progress: Option<u8>,
113    /// Program name: 1-32 bytes from `[A-Za-z0-9_.+-]`.
114    pub app: Option<&'a str>,
115    /// Human-readable record label, at most 192 UTF-8 bytes.
116    /// C0, DEL, and C1 control characters are rejected.
117    pub title: Option<&'a str>,
118    /// Human-readable status message, at most 2048 UTF-8 bytes.
119    /// C0, DEL, and C1 control characters are rejected.
120    pub message: Option<&'a str>,
121}
122
123impl<'a> ProgramStatus<'a> {
124    /// Create a root report with the specified state and no optional fields.
125    pub fn new(state: ProgramStatusState<'a>) -> Self {
126        Self {
127            state,
128            id: None,
129            kind: None,
130            progress: None,
131            app: None,
132            title: None,
133            message: None,
134        }
135    }
136}
137
138pub(crate) fn valid_value(value: &str) -> bool {
139    value
140        .bytes()
141        .all(|b| b.is_ascii_alphanumeric() || b"_.,+/=-".contains(&b))
142}
143
144fn valid_segment(value: &str) -> bool {
145    (1..=32).contains(&value.len())
146        && value
147            .bytes()
148            .all(|b| b.is_ascii_alphanumeric() || b"_.+-".contains(&b))
149}
150
151fn validate_text(value: Option<&str>, limit: usize, field: &str) -> io::Result<()> {
152    if value.is_some_and(|s| s.len() > limit || s.chars().any(char::is_control)) {
153        return Err(io::Error::new(
154            io::ErrorKind::InvalidInput,
155            format!(
156                "program status {field} exceeds {limit} UTF-8 bytes or contains a control character"
157            ),
158        ));
159    }
160    Ok(())
161}
162
163/// Write one complete program status report without flushing.
164///
165/// All fields are validated before any bytes are written. Invalid IDs,
166/// state names, kind names, app names, text, or percentages return
167/// [`io::ErrorKind::InvalidInput`]. State and kind names must be nonempty
168/// values from `[A-Za-z0-9_.,+/=-]`.
169/// Text limits count UTF-8 bytes before base64 encoding. The complete
170/// encoded sequence, including its framing, must fit within 4096 bytes.
171///
172/// A terminal may apply lower limits or ignore OSC 7501 entirely. This
173/// function reports write errors, not terminal acceptance.
174///
175/// ```
176/// use uncurses::ansi::program_status::{
177///     ProgramStatus, ProgramStatusState, write_program_status,
178/// };
179///
180/// let mut report = ProgramStatus::new(ProgramStatusState::WORKING);
181/// report.app = Some("cargo");
182/// report.message = Some("Build");
183/// let mut bytes = Vec::new();
184/// write_program_status(&mut bytes, &report).unwrap();
185/// assert_eq!(bytes, b"\x1b]7501;state=working:app=cargo:msg=QnVpbGQ=\x1b\\");
186/// ```
187pub fn write_program_status<W: Write>(w: &mut W, report: &ProgramStatus<'_>) -> io::Result<()> {
188    for (field, value) in [
189        ("state", Some(report.state.as_str())),
190        ("kind", report.kind.map(ProgramStatusKind::as_str)),
191    ] {
192        if value.is_some_and(|name| name.is_empty() || name.len() > 4096 || !valid_value(name)) {
193            return Err(io::Error::new(
194                io::ErrorKind::InvalidInput,
195                format!("invalid program status {field} name"),
196            ));
197        }
198    }
199    if report.id.is_some_and(|id| {
200        id.len() > 128 || id.split('/').count() > 8 || !id.split('/').all(valid_segment)
201    }) {
202        return Err(io::Error::new(
203            io::ErrorKind::InvalidInput,
204            "invalid program status id",
205        ));
206    }
207    if report.app.is_some_and(|app| !valid_segment(app)) {
208        return Err(io::Error::new(
209            io::ErrorKind::InvalidInput,
210            "invalid program status app",
211        ));
212    }
213    if report.progress.is_some_and(|progress| progress > 100) {
214        return Err(io::Error::new(
215            io::ErrorKind::InvalidInput,
216            "program status progress exceeds 100",
217        ));
218    }
219    validate_text(report.title, 192, "title")?;
220    validate_text(report.message, 2048, "message")?;
221
222    let mut sequence = String::from("\x1b]7501;state=");
223    sequence.push_str(report.state.as_str());
224    if let Some(id) = report.id {
225        sequence.push_str(":id=");
226        sequence.push_str(id);
227    }
228    if let Some(kind) = report.kind {
229        sequence.push_str(":kind=");
230        sequence.push_str(kind.as_str());
231    }
232    if let Some(progress) = report.progress {
233        sequence.push_str(":progress=");
234        sequence.push_str(&progress.to_string());
235    }
236    if let Some(app) = report.app {
237        sequence.push_str(":app=");
238        sequence.push_str(app);
239    }
240    if let Some(title) = report.title {
241        sequence.push_str(":title=");
242        sequence.push_str(&base64_encode(title.as_bytes()));
243    }
244    if let Some(message) = report.message {
245        sequence.push_str(":msg=");
246        sequence.push_str(&base64_encode(message.as_bytes()));
247    }
248    sequence.push_str("\x1b\\");
249    if sequence.len() > 4096 {
250        return Err(io::Error::new(
251            io::ErrorKind::InvalidInput,
252            "program status sequence exceeds 4096 bytes",
253        ));
254    }
255    w.write_all(sequence.as_bytes())
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn program_status_names_have_value_equality() {
264        use std::collections::HashSet;
265
266        for state in [
267            ProgramStatusState::IDLE,
268            ProgramStatusState::WORKING,
269            ProgramStatusState::DONE,
270            ProgramStatusState::BLOCKED,
271            ProgramStatusState::ERROR,
272            ProgramStatusState::CLEAR,
273        ] {
274            let name = state.as_str().to_owned();
275            let constructed = ProgramStatusState::new(&name);
276            assert_eq!(state, constructed);
277            assert_eq!(HashSet::from([state, constructed]).len(), 1);
278        }
279        for kind in [
280            ProgramStatusKind::PERMISSION,
281            ProgramStatusKind::QUESTION,
282            ProgramStatusKind::AUTH,
283        ] {
284            let name = kind.as_str().to_owned();
285            let constructed = ProgramStatusKind::new(&name);
286            assert_eq!(kind, constructed);
287            assert_eq!(HashSet::from([kind, constructed]).len(), 1);
288        }
289        assert_ne!(
290            ProgramStatusState::new("paused"),
291            ProgramStatusState::WORKING
292        );
293        assert_ne!(ProgramStatusKind::new("confirm"), ProgramStatusKind::AUTH);
294    }
295
296    #[test]
297    fn program_status_states_and_clear_records() {
298        for (state, value) in [
299            (ProgramStatusState::IDLE, "idle"),
300            (ProgramStatusState::WORKING, "working"),
301            (ProgramStatusState::DONE, "done"),
302            (ProgramStatusState::BLOCKED, "blocked"),
303            (ProgramStatusState::ERROR, "error"),
304            (ProgramStatusState::CLEAR, "clear"),
305        ] {
306            let mut report = ProgramStatus::new(state);
307            for id in [None, Some("build/test")] {
308                report.id = id;
309                let mut bytes = Vec::new();
310                write_program_status(&mut bytes, &report).unwrap();
311                let suffix = if id.is_some() { ":id=build/test" } else { "" };
312                assert_eq!(
313                    bytes,
314                    format!("\x1b]7501;state={value}{suffix}\x1b\\").as_bytes()
315                );
316            }
317        }
318    }
319
320    #[test]
321    fn program_status_encodes_all_fields_and_blocked_kinds() {
322        for (kind, value) in [
323            (ProgramStatusKind::PERMISSION, "permission"),
324            (ProgramStatusKind::QUESTION, "question"),
325            (ProgramStatusKind::AUTH, "auth"),
326        ] {
327            let report = ProgramStatus {
328                state: ProgramStatusState::BLOCKED,
329                id: Some("build/test"),
330                kind: Some(kind),
331                progress: Some(42),
332                app: Some("cargo"),
333                title: Some("Test"),
334                message: Some("Ready \u{2713}: yes?"),
335            };
336            let original = report.clone();
337            let mut bytes = Vec::new();
338            write_program_status(&mut bytes, &report).unwrap();
339            assert_eq!(
340                bytes,
341                format!("\x1b]7501;state=blocked:id=build/test:kind={value}:progress=42:app=cargo:title=VGVzdA==:msg=UmVhZHkg4pyTOiB5ZXM/\x1b\\").as_bytes(),
342            );
343            assert_eq!(report, original);
344        }
345    }
346
347    fn invalid(report: &ProgramStatus<'_>) {
348        let mut bytes = Vec::new();
349        let error = write_program_status(&mut bytes, report).unwrap_err();
350        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
351        assert!(bytes.is_empty(), "invalid reports must emit nothing");
352    }
353
354    #[test]
355    fn program_status_accepts_borrowed_extension_names() {
356        let state_name = String::from("paused");
357        let kind_name = String::from("confirm");
358        let mut report = ProgramStatus::new(ProgramStatusState::new(&state_name));
359        let mut bytes = Vec::new();
360        write_program_status(&mut bytes, &report).unwrap();
361        assert_eq!(bytes, b"\x1b]7501;state=paused\x1b\\");
362        assert_eq!(report.state.as_str(), state_name);
363
364        report.state = ProgramStatusState::BLOCKED;
365        report.kind = Some(ProgramStatusKind::new(&kind_name));
366        let original = report.clone();
367        bytes.clear();
368        write_program_status(&mut bytes, &report).unwrap();
369        assert_eq!(bytes, b"\x1b]7501;state=blocked:kind=confirm\x1b\\");
370        assert_eq!(report.kind.unwrap().as_str(), kind_name);
371        assert_eq!(report, original);
372
373        report.state = ProgramStatusState::new("AZaz09_.,+/=-");
374        report.kind = Some(ProgramStatusKind::new("AZaz09_.,+/=-"));
375        bytes.clear();
376        write_program_status(&mut bytes, &report).unwrap();
377        assert_eq!(
378            bytes,
379            b"\x1b]7501;state=AZaz09_.,+/=-:kind=AZaz09_.,+/=-\x1b\\"
380        );
381    }
382
383    #[test]
384    fn program_status_preserves_commas_in_report_values() {
385        for name in [",", ",paused", "paused,", "paused,done"] {
386            let mut report = ProgramStatus::new(ProgramStatusState::new(name));
387            let mut bytes = Vec::new();
388            write_program_status(&mut bytes, &report).unwrap();
389            assert_eq!(bytes, format!("\x1b]7501;state={name}\x1b\\").as_bytes());
390
391            report.state = ProgramStatusState::BLOCKED;
392            report.kind = Some(ProgramStatusKind::new(name));
393            bytes.clear();
394            write_program_status(&mut bytes, &report).unwrap();
395            assert_eq!(
396                bytes,
397                format!("\x1b]7501;state=blocked:kind={name}\x1b\\").as_bytes()
398            );
399        }
400    }
401
402    #[test]
403    fn program_status_rejects_invalid_extension_names_before_output() {
404        for name in [
405            "", "a:b", "a;b", "a b", "a\tb", "a\nb", "\x1b\\", "\u{e9}", "\u{9c}",
406        ] {
407            invalid(&ProgramStatus::new(ProgramStatusState::new(name)));
408            let mut report = ProgramStatus::new(ProgramStatusState::BLOCKED);
409            report.kind = Some(ProgramStatusKind::new(name));
410            invalid(&report);
411        }
412        for code in (0..=0x1f).chain(0x7f..=0x9f) {
413            let name = format!("a{}b", char::from_u32(code).unwrap());
414            invalid(&ProgramStatus::new(ProgramStatusState::new(&name)));
415            let mut report = ProgramStatus::new(ProgramStatusState::BLOCKED);
416            report.kind = Some(ProgramStatusKind::new(&name));
417            invalid(&report);
418        }
419    }
420
421    #[test]
422    fn program_status_extension_names_respect_complete_sequence_limit() {
423        for (kind, overhead) in [
424            (false, b"\x1b]7501;state=\x1b\\".len()),
425            (true, b"\x1b]7501;state=blocked:kind=\x1b\\".len()),
426        ] {
427            for extra in [0, 1, overhead + 1] {
428                let name = "a".repeat(4096 - overhead + extra);
429                let mut report = ProgramStatus::new(if kind {
430                    ProgramStatusState::BLOCKED
431                } else {
432                    ProgramStatusState::new(&name)
433                });
434                if kind {
435                    report.kind = Some(ProgramStatusKind::new(&name));
436                }
437                if extra == 0 {
438                    let mut bytes = Vec::new();
439                    write_program_status(&mut bytes, &report).unwrap();
440                    assert_eq!(bytes.len(), 4096);
441                    report.title = Some("x");
442                    invalid(&report);
443                } else {
444                    invalid(&report);
445                }
446            }
447        }
448
449        let name = "a".repeat(2100);
450        let mut report = ProgramStatus::new(ProgramStatusState::new(&name));
451        report.kind = Some(ProgramStatusKind::new(&name));
452        invalid(&report);
453    }
454
455    #[test]
456    fn program_status_rejects_invalid_ids_apps_and_progress() {
457        let mut report = ProgramStatus::new(ProgramStatusState::WORKING);
458        for id in ["", "/a", "a/", "a//b", "a:b", "a;b", "a=b", "a b", "\u{e9}"] {
459            report.id = Some(id);
460            invalid(&report);
461        }
462        let segment = "a".repeat(33);
463        let levels = ["a"; 9].join("/");
464        let long_id = vec!["a".repeat(25); 5].join("/");
465        for id in [&segment, &levels, &long_id] {
466            report.id = Some(id);
467            invalid(&report);
468        }
469        report.id = None;
470        for app in ["", "a/b", "a b", "a:b", "a=b", "\u{e9}", &segment] {
471            report.app = Some(app);
472            invalid(&report);
473        }
474        report.app = None;
475        for progress in [101, 255] {
476            report.progress = Some(progress);
477            invalid(&report);
478        }
479    }
480
481    #[test]
482    fn program_status_rejects_controls_and_text_over_limits() {
483        for code in (0..=0x1f).chain(0x7f..=0x9f) {
484            let text = format!("before{}after", char::from_u32(code).unwrap());
485            let mut report = ProgramStatus::new(ProgramStatusState::DONE);
486            report.title = Some(&text);
487            invalid(&report);
488            report.title = None;
489            report.message = Some(&text);
490            invalid(&report);
491            report.message = None;
492        }
493        for title in ["a".repeat(193), "\u{e9}".repeat(97)] {
494            let mut report = ProgramStatus::new(ProgramStatusState::DONE);
495            report.title = Some(&title);
496            invalid(&report);
497        }
498        for message in ["a".repeat(2049), "\u{e9}".repeat(1025)] {
499            let mut report = ProgramStatus::new(ProgramStatusState::DONE);
500            report.message = Some(&message);
501            invalid(&report);
502        }
503    }
504
505    #[test]
506    fn program_status_accepts_limits_and_empty_text() {
507        let id = format!(
508            "{}/{}/{}/{}",
509            "a".repeat(32),
510            "b".repeat(32),
511            "c".repeat(32),
512            "d".repeat(29)
513        );
514        assert_eq!(id.len(), 128);
515        let app = "a".repeat(32);
516        let title = "\u{e9}".repeat(96);
517        let message = "\u{e9}".repeat(1024);
518        let mut report = ProgramStatus {
519            state: ProgramStatusState::BLOCKED,
520            id: Some(&id),
521            kind: Some(ProgramStatusKind::PERMISSION),
522            progress: Some(100),
523            app: Some(&app),
524            title: Some(&title),
525            message: Some(&message),
526        };
527        let mut bytes = Vec::new();
528        write_program_status(&mut bytes, &report).unwrap();
529        assert!(bytes.len() < 4096);
530        let sequence = String::from_utf8(bytes).unwrap();
531        assert_eq!(
532            sequence
533                .split(":title=")
534                .nth(1)
535                .unwrap()
536                .split(':')
537                .next()
538                .unwrap()
539                .len(),
540            256
541        );
542        assert_eq!(
543            sequence
544                .split(":msg=")
545                .nth(1)
546                .unwrap()
547                .trim_end_matches("\x1b\\")
548                .len(),
549            2732
550        );
551
552        report.id = Some("a/b/c/d/e/f/g/h");
553        report.app = Some("AZaz09_.+-");
554        report.title = Some("");
555        report.message = Some("");
556        report.progress = Some(0);
557        let mut bytes = Vec::new();
558        write_program_status(&mut bytes, &report).unwrap();
559        assert!(bytes.ends_with(b":title=:msg=\x1b\\"));
560    }
561
562    #[test]
563    fn program_status_propagates_write_errors() {
564        let mut empty = [];
565        let error = write_program_status(
566            &mut empty.as_mut_slice(),
567            &ProgramStatus::new(ProgramStatusState::IDLE),
568        )
569        .unwrap_err();
570        assert_eq!(error.kind(), io::ErrorKind::WriteZero);
571    }
572}