Skip to main content

osc7501/
lib.rs

1#![doc = include_str!("../README.md")]
2
3mod capabilities;
4mod constants;
5mod context;
6
7pub use capabilities::CapabilityReply;
8pub use context::Context;
9
10use std::io::{self, Write};
11
12use constants::ALPHABET;
13
14/// Parse status reports and terminal capability replies.
15pub use osc7501_parser as parser;
16pub use parser::{AppId, Kind, Progress, RecordId, State};
17
18/// Current terminal records, available with `store`; `store-alloc` adds owned storage.
19#[cfg(feature = "store")]
20pub use osc7501_store as store;
21
22use parser::{Action, MAX_MESSAGE, MAX_SEQUENCE, MAX_TITLE};
23
24/// Plain-text report to encode. `None` means absent, not "keep prior value".
25#[derive(Clone, Copy, Debug)]
26pub struct Status<'a> {
27    action: Action,
28    id: Option<RecordId<'a>>,
29    app: Option<AppId<'a>>,
30    title: Option<&'a str>,
31    message: Option<&'a str>,
32    kind: Option<Kind>,
33    progress: Option<Progress>,
34}
35
36impl<'a> Status<'a> {
37    /// Begins a complete replacement for the root record.
38    #[must_use]
39    pub const fn new(state: State) -> Self {
40        Self::action(Action::Set(state))
41    }
42
43    /// Removes the addressed subtree, or all records when no id is supplied.
44    #[must_use]
45    pub const fn clear() -> Self {
46        Self::action(Action::Clear)
47    }
48
49    const fn action(action: Action) -> Self {
50        Self {
51            action,
52            id: None,
53            app: None,
54            title: None,
55            message: None,
56            kind: None,
57            progress: None,
58        }
59    }
60
61    /// Addresses a validated child record.
62    #[must_use]
63    pub const fn id(mut self, id: RecordId<'a>) -> Self {
64        self.id = Some(id);
65        self
66    }
67
68    /// Adds a validated application identifier; repeat it on every root update.
69    #[must_use]
70    pub const fn app(mut self, app: AppId<'a>) -> Self {
71        self.app = Some(app);
72        self
73    }
74
75    /// Adds a single-line label, validated when sent.
76    #[must_use]
77    pub const fn title(mut self, title: &'a str) -> Self {
78        self.title = Some(title);
79        self
80    }
81
82    /// Adds a single-line message, validated when sent.
83    #[must_use]
84    pub const fn message(mut self, message: &'a str) -> Self {
85        self.message = Some(message);
86        self
87    }
88
89    /// Adds the blocking reason. Sending this on another state is an error.
90    #[must_use]
91    pub const fn kind(mut self, kind: Kind) -> Self {
92        self.kind = Some(kind);
93        self
94    }
95
96    /// Adds a percentage. Sending this outside working/blocked is an error.
97    #[must_use]
98    pub const fn progress(mut self, progress: Progress) -> Self {
99        self.progress = Some(progress);
100        self
101    }
102
103    /// Encodes a complete report and writes it through the caller's writer.
104    /// This method does not flush the writer.
105    ///
106    /// # Errors
107    /// Returns a validation error before writing if text is invalid or fields
108    /// are incompatible with the state. Returns an I/O error if writing fails;
109    /// I/O failures can leave a partial frame, as with any `write_all` operation.
110    pub fn write_to<W: Write + ?Sized>(self, output: &mut W) -> Result<(), Error> {
111        self.validate()?;
112        let mut frame = [0; MAX_SEQUENCE];
113        let mut remaining = frame.as_mut_slice();
114        remaining.write_all(b"\x1b]7501;state=")?;
115        remaining.write_all(self.action.as_str().as_bytes())?;
116        for (key, value) in [
117            ("id", self.id.map(RecordId::as_str)),
118            ("app", self.app.map(AppId::as_str)),
119            ("kind", self.kind.map(Kind::as_str)),
120        ] {
121            if let Some(value) = value {
122                write!(remaining, ":{key}={value}")?;
123            }
124        }
125        if let Some(progress) = self.progress {
126            write!(remaining, ":progress={}", progress.get())?;
127        }
128        if let Some(title) = self.title {
129            remaining.write_all(b":title=")?;
130            encode(title.as_bytes(), &mut remaining)?;
131        }
132        if let Some(message) = self.message {
133            remaining.write_all(b":msg=")?;
134            encode(message.as_bytes(), &mut remaining)?;
135        }
136        remaining.write_all(b"\x1b\\")?;
137        let length = MAX_SEQUENCE - remaining.len();
138        output.write_all(&frame[..length])?;
139        Ok(())
140    }
141
142    fn validate(self) -> Result<(), Error> {
143        if self.kind.is_some() && self.action != Action::Set(State::Blocked) {
144            return Err(Error::IncompatibleField);
145        }
146        if self.progress.is_some()
147            && !matches!(self.action, Action::Set(State::Working | State::Blocked))
148        {
149            return Err(Error::IncompatibleField);
150        }
151        for (text, cap) in [(self.title, MAX_TITLE), (self.message, MAX_MESSAGE)] {
152            if let Some(text) = text {
153                if text.len() > cap {
154                    return Err(Error::TextTooLong);
155                }
156                if text.chars().any(char::is_control) {
157                    return Err(Error::ControlCharacter);
158                }
159            }
160        }
161        Ok(())
162    }
163}
164
165fn encode(input: &[u8], output: &mut impl Write) -> io::Result<()> {
166    for chunk in input.chunks(3) {
167        let a = chunk[0];
168        let b = chunk.get(1).copied().unwrap_or(0);
169        let c = chunk.get(2).copied().unwrap_or(0);
170        let encoded = [
171            ALPHABET[usize::from(a >> 2)],
172            ALPHABET[usize::from((a & 3) << 4 | b >> 4)],
173            if chunk.len() > 1 {
174                ALPHABET[usize::from((b & 15) << 2 | c >> 6)]
175            } else {
176                b'='
177            },
178            if chunk.len() > 2 {
179                ALPHABET[usize::from(c & 63)]
180            } else {
181                b'='
182            },
183        ];
184        output.write_all(&encoded)?;
185    }
186    Ok(())
187}
188
189/// Writes an optional feature query without reading input or imposing a timeout.
190/// This function does not flush the writer.
191///
192/// Route replies through the TUI's existing input reader and [`parser::Decoder`].
193/// A bare query and a default reply have identical bytes; use stream direction
194/// and a pending-query flag to distinguish them.
195///
196/// # Errors
197/// Returns the writer's I/O error.
198pub fn write_query<W: Write + ?Sized>(output: &mut W) -> io::Result<()> {
199    output.write_all(b"\x1b]7501;?\x1b\\")
200}
201
202/// Validation, parsing, or transport failure.
203#[derive(Debug, thiserror::Error)]
204#[non_exhaustive]
205pub enum Error {
206    /// A title or message exceeds the decoded byte cap.
207    #[error("status text exceeds its byte cap")]
208    TextTooLong,
209    /// Text contains a forbidden control character.
210    #[error("status text contains a control character")]
211    ControlCharacter,
212    /// Kind or progress is incompatible with this state.
213    #[error("status field is incompatible with its state")]
214    IncompatibleField,
215    /// A value constructor, parser, text decoder, or frame limit failed.
216    #[error("{0}")]
217    Parser(#[from] parser::Error),
218    /// The caller's output writer failed.
219    #[error("{0}")]
220    Io(#[from] io::Error),
221}