Skip to main content

wsb_github/
output.rs

1//! What the command line says, for a person or for a program.
2//!
3//! A person reads a board as columns of titles; a program needs the ids the
4//! next call takes. Both are served by the same run: every board, field, card
5//! and sync outcome is a record, and the prose a terminal shows is written
6//! only when the run is not `--jsonl` — the records already say all of it.
7//!
8//! Every JSONL line carries the same envelope: a monotonic `seq`, an RFC 3339
9//! UTC timestamp `ts`, and the `type` that says which record it is. A gap in
10//! `seq` means a line was lost.
11
12use std::io::Write;
13use std::sync::atomic::{AtomicU64, Ordering};
14
15use serde::Serialize;
16
17/// One option of a single-select board field.
18#[derive(Debug, Clone, PartialEq, Serialize)]
19pub struct ProjectOption {
20    pub id: String,
21    pub name: String,
22}
23
24/// One field value on one card.
25#[derive(Debug, Clone, PartialEq, Serialize)]
26pub struct ProjectValue {
27    /// The field's name.
28    pub field: String,
29    /// `single_select` | `text` | `number` | `date` | `iteration`.
30    pub kind: String,
31    /// The value as a person reads it: an option's name, the text, the number,
32    /// the date as YYYY-MM-DD, an iteration's title.
33    pub value: String,
34    /// The option's id, for a single-select.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub option: Option<String>,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize)]
40#[serde(tag = "type", rename_all = "snake_case")]
41pub enum Record {
42    /// One Projects board. `board` is its node id, which every later call
43    /// may name it by; `number` is what a person calls it.
44    ProjectBoard {
45        board: String,
46        number: i64,
47        title: String,
48        owner: String,
49        url: String,
50        closed: bool,
51    },
52    /// One field of a board, with the options of a single-select.
53    ProjectField {
54        board: String,
55        field: String,
56        name: String,
57        kind: String,
58        options: Vec<ProjectOption>,
59    },
60    /// One card, as GitHub last returned it. `fields` is by field NAME, in
61    /// the board's field order, and carries only what the card has a value
62    /// for.
63    ProjectItem {
64        board: String,
65        item: String,
66        /// `ISSUE` | `PULL_REQUEST` | `DRAFT_ISSUE` | `REDACTED`.
67        kind: String,
68        title: String,
69        #[serde(skip_serializing_if = "Option::is_none")]
70        url: Option<String>,
71        #[serde(skip_serializing_if = "Option::is_none")]
72        repository: Option<String>,
73        #[serde(skip_serializing_if = "Option::is_none")]
74        number: Option<i64>,
75        #[serde(skip_serializing_if = "Option::is_none")]
76        state: Option<String>,
77        archived: bool,
78        /// The description, as markdown. Empty is a fact.
79        body: String,
80        fields: Vec<ProjectValue>,
81    },
82    /// A card was taken off a board.
83    ProjectItemRemoved { board: String, item: String },
84    /// A board one project references.
85    BoardReference { project: String, board: String },
86    /// One issue, as the mirror holds it.
87    Issue {
88        repo: String,
89        number: i64,
90        title: String,
91        state: String,
92        url: String,
93        updated_at: String,
94    },
95    /// What a sync did with one scope.
96    SyncScope {
97        scope: String,
98        /// `changed` | `unchanged` | `failed`.
99        outcome: String,
100        #[serde(skip_serializing_if = "Option::is_none")]
101        error: Option<String>,
102    },
103    /// Something about what to mirror that could not be found out.
104    SyncProblem { problem: String },
105    /// One whole sync.
106    SyncSummary {
107        changed: usize,
108        unchanged: usize,
109        failed: usize,
110        no_token: bool,
111        problems: usize,
112    },
113    /// Where the token and the mirror are.
114    Status {
115        mirror: String,
116        token: Option<String>,
117        scopes: usize,
118        failing: usize,
119        never_synced: usize,
120    },
121    /// Something said that is not data.
122    Message { level: String, text: String },
123    /// Why the command did not do what was asked.
124    Failure { message: String },
125}
126
127/// Which of the terminal's two streams a line of prose belongs on.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum Stream {
130    Out,
131    Err,
132}
133
134/// Where prose goes when it is not written to the terminal.
135type Host = Box<dyn Fn(Stream, &str) + Send + Sync>;
136
137#[derive(Serialize)]
138struct Line<'a> {
139    seq: u64,
140    ts: String,
141    #[serde(flatten)]
142    record: &'a Record,
143}
144
145/// One run's voice.
146pub struct Output {
147    jsonl: bool,
148    seq: AtomicU64,
149    /// Set when another command line hosts this one and packages what it says
150    /// into its own stream; the terminal otherwise.
151    host: Option<Host>,
152}
153
154impl Output {
155    pub fn new(jsonl: bool) -> Self {
156        Self {
157            jsonl,
158            seq: AtomicU64::new(0),
159            host: None,
160        }
161    }
162
163    /// Prose handed to `host`, line by line, instead of written to the
164    /// terminal.
165    ///
166    /// For `wsb github`: wsb owns its stream, and under its `--jsonl` a line
167    /// written past it would be the one line in the run that is not a record.
168    pub fn through(host: impl Fn(Stream, &str) + Send + Sync + 'static) -> Self {
169        Self {
170            jsonl: false,
171            seq: AtomicU64::new(0),
172            host: Some(Box::new(host)),
173        }
174    }
175
176    fn write(&self, stream: Stream, line: &str) {
177        match (&self.host, stream) {
178            (Some(host), _) => host(stream, line),
179            (None, Stream::Out) => {
180                let mut stdout = std::io::stdout().lock();
181                let _ = writeln!(stdout, "{line}");
182            }
183            (None, Stream::Err) => eprintln!("{line}"),
184        }
185    }
186
187    pub fn is_jsonl(&self) -> bool {
188        self.jsonl
189    }
190
191    /// A record: one line under `--jsonl`, nothing otherwise — the prose says
192    /// it for a person.
193    pub fn emit(&self, record: Record) {
194        if !self.jsonl {
195            return;
196        }
197        self.line(&record);
198    }
199
200    fn line(&self, record: &Record) {
201        let line = Line {
202            seq: self.seq.fetch_add(1, Ordering::SeqCst) + 1,
203            ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
204            record,
205        };
206        let text = serde_json::to_string(&line).expect("a record always serialises");
207        let mut stdout = std::io::stdout().lock();
208        let _ = writeln!(stdout, "{text}");
209        let _ = stdout.flush();
210    }
211
212    /// Prose for a person; nothing under `--jsonl`.
213    pub fn say(&self, lines: impl IntoIterator<Item = String>) {
214        if self.jsonl {
215            return;
216        }
217        for line in lines {
218            self.write(Stream::Out, &line);
219        }
220    }
221
222    /// Prose that goes with a refusal, on the error stream; nothing under
223    /// `--jsonl`, where the failure record already says it.
224    pub fn explain(&self, text: &str) {
225        if self.jsonl {
226            return;
227        }
228        for line in text.lines() {
229            self.write(Stream::Err, line);
230        }
231    }
232
233    /// A warning: the run goes on, and somebody should know.
234    pub fn warn(&self, text: &str) {
235        if self.jsonl {
236            self.line(&Record::Message {
237                level: "warning".to_string(),
238                text: text.to_string(),
239            });
240        } else {
241            self.write(Stream::Err, &format!("wsb github: {text}"));
242        }
243    }
244
245    /// Why the run stopped.
246    pub fn fail(&self, message: &str) {
247        if self.jsonl {
248            self.line(&Record::Failure {
249                message: message.to_string(),
250            });
251        } else {
252            self.write(Stream::Err, &format!("wsb github: {message}"));
253        }
254    }
255}