@mbe24/99problems 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/01-feature.yml +65 -0
- package/.github/ISSUE_TEMPLATE/02-bug.yml +122 -0
- package/.github/ISSUE_TEMPLATE/99-custom.yml +33 -0
- package/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/.github/dependabot.yml +20 -0
- package/.github/scripts/publish.js +1 -1
- package/.github/workflows/ci.yml +32 -6
- package/.github/workflows/man-drift.yml +26 -0
- package/.github/workflows/release.yml +7 -7
- package/CONTRIBUTING.md +38 -50
- package/Cargo.lock +150 -107
- package/Cargo.toml +7 -2
- package/README.md +107 -85
- package/artifacts/binary-aarch64-apple-darwin/99problems +0 -0
- package/artifacts/binary-aarch64-unknown-linux-gnu/99problems +0 -0
- package/artifacts/binary-x86_64-apple-darwin/99problems +0 -0
- package/artifacts/binary-x86_64-pc-windows-msvc/99problems.exe +0 -0
- package/artifacts/binary-x86_64-unknown-linux-gnu/99problems +0 -0
- package/docs/man/99problems-completions.1 +31 -0
- package/docs/man/99problems-config.1 +50 -0
- package/docs/man/99problems-get.1 +114 -0
- package/docs/man/99problems-help.1 +25 -0
- package/docs/man/99problems-man.1 +32 -0
- package/docs/man/99problems.1 +52 -0
- package/npm/install.js +90 -3
- package/package.json +7 -7
- package/rust-toolchain.toml +4 -0
- package/src/cmd/config/key.rs +126 -0
- package/src/cmd/config/mod.rs +218 -0
- package/src/cmd/config/render.rs +33 -0
- package/src/cmd/config/store.rs +318 -0
- package/src/cmd/config/write.rs +173 -0
- package/src/cmd/get.rs +658 -0
- package/src/cmd/man.rs +117 -0
- package/src/cmd/mod.rs +3 -0
- package/src/config.rs +618 -118
- package/src/error.rs +254 -0
- package/src/format/json.rs +59 -18
- package/src/format/jsonl.rs +52 -0
- package/src/format/mod.rs +25 -3
- package/src/format/text.rs +73 -0
- package/src/format/yaml.rs +64 -15
- package/src/lib.rs +1 -0
- package/src/logging.rs +54 -0
- package/src/main.rs +225 -138
- package/src/model.rs +9 -1
- package/src/source/bitbucket/cloud/api.rs +67 -0
- package/src/source/bitbucket/cloud/mod.rs +178 -0
- package/src/source/bitbucket/cloud/model.rs +211 -0
- package/src/source/bitbucket/datacenter/api.rs +74 -0
- package/src/source/bitbucket/datacenter/mod.rs +181 -0
- package/src/source/bitbucket/datacenter/model.rs +327 -0
- package/src/source/bitbucket/mod.rs +90 -0
- package/src/source/bitbucket/query.rs +169 -0
- package/src/source/bitbucket/shared/auth.rs +54 -0
- package/src/source/bitbucket/shared/http.rs +59 -0
- package/src/source/bitbucket/shared/mod.rs +5 -0
- package/src/source/github/api.rs +128 -0
- package/src/source/github/mod.rs +191 -0
- package/src/source/github/model.rs +84 -0
- package/src/source/github/query.rs +50 -0
- package/src/source/gitlab/api.rs +282 -0
- package/src/source/gitlab/mod.rs +225 -0
- package/src/source/gitlab/model.rs +102 -0
- package/src/source/gitlab/query.rs +177 -0
- package/src/source/jira/api.rs +222 -0
- package/src/source/jira/mod.rs +161 -0
- package/src/source/jira/model.rs +99 -0
- package/src/source/jira/query.rs +153 -0
- package/src/source/mod.rs +65 -7
- package/tests/integration.rs +404 -33
- package/src/source/github_issues.rs +0 -227
package/src/error.rs
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
use reqwest::StatusCode;
|
|
2
|
+
use serde_json::json;
|
|
3
|
+
use std::fmt::{Display, Formatter};
|
|
4
|
+
|
|
5
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
6
|
+
pub enum ErrorCategory {
|
|
7
|
+
Usage,
|
|
8
|
+
Auth,
|
|
9
|
+
NotFound,
|
|
10
|
+
RateLimited,
|
|
11
|
+
Network,
|
|
12
|
+
Provider,
|
|
13
|
+
Internal,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
impl ErrorCategory {
|
|
17
|
+
#[must_use]
|
|
18
|
+
pub fn code(self) -> &'static str {
|
|
19
|
+
match self {
|
|
20
|
+
Self::Usage => "usage",
|
|
21
|
+
Self::Auth => "auth",
|
|
22
|
+
Self::NotFound => "not_found",
|
|
23
|
+
Self::RateLimited => "rate_limited",
|
|
24
|
+
Self::Network => "network",
|
|
25
|
+
Self::Provider => "provider",
|
|
26
|
+
Self::Internal => "internal",
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#[must_use]
|
|
31
|
+
pub fn exit_code(self) -> i32 {
|
|
32
|
+
match self {
|
|
33
|
+
Self::Usage => 2,
|
|
34
|
+
Self::Auth => 3,
|
|
35
|
+
Self::NotFound => 4,
|
|
36
|
+
Self::RateLimited => 5,
|
|
37
|
+
Self::Network => 6,
|
|
38
|
+
Self::Provider => 7,
|
|
39
|
+
Self::Internal => 1,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#[derive(Debug, Clone)]
|
|
45
|
+
pub struct AppError {
|
|
46
|
+
category: ErrorCategory,
|
|
47
|
+
message: String,
|
|
48
|
+
hint: Option<String>,
|
|
49
|
+
provider: Option<String>,
|
|
50
|
+
http_status: Option<u16>,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
impl AppError {
|
|
54
|
+
#[must_use]
|
|
55
|
+
pub fn usage(message: impl Into<String>) -> Self {
|
|
56
|
+
Self::new(ErrorCategory::Usage, message)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[must_use]
|
|
60
|
+
pub fn auth(message: impl Into<String>) -> Self {
|
|
61
|
+
Self::new(ErrorCategory::Auth, message)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#[must_use]
|
|
65
|
+
pub fn not_found(message: impl Into<String>) -> Self {
|
|
66
|
+
Self::new(ErrorCategory::NotFound, message)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#[must_use]
|
|
70
|
+
pub fn rate_limited(message: impl Into<String>) -> Self {
|
|
71
|
+
Self::new(ErrorCategory::RateLimited, message)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#[must_use]
|
|
75
|
+
pub fn network(message: impl Into<String>) -> Self {
|
|
76
|
+
Self::new(ErrorCategory::Network, message)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#[must_use]
|
|
80
|
+
pub fn provider(message: impl Into<String>) -> Self {
|
|
81
|
+
Self::new(ErrorCategory::Provider, message)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#[must_use]
|
|
85
|
+
pub fn internal(message: impl Into<String>) -> Self {
|
|
86
|
+
Self::new(ErrorCategory::Internal, message)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#[must_use]
|
|
90
|
+
pub fn from_http(provider: &str, operation: &str, status: StatusCode, body: &str) -> Self {
|
|
91
|
+
let mut err = match status.as_u16() {
|
|
92
|
+
401 | 403 => Self::auth(format!(
|
|
93
|
+
"{provider} API {operation} error {status}: {}",
|
|
94
|
+
body_snippet(body)
|
|
95
|
+
)),
|
|
96
|
+
404 => Self::not_found(format!(
|
|
97
|
+
"{provider} API {operation} error {status}: {}",
|
|
98
|
+
body_snippet(body)
|
|
99
|
+
)),
|
|
100
|
+
429 => Self::rate_limited(format!(
|
|
101
|
+
"{provider} API {operation} error {status}: {}",
|
|
102
|
+
body_snippet(body)
|
|
103
|
+
)),
|
|
104
|
+
_ => Self::provider(format!(
|
|
105
|
+
"{provider} API {operation} error {status}: {}",
|
|
106
|
+
body_snippet(body)
|
|
107
|
+
)),
|
|
108
|
+
};
|
|
109
|
+
err.provider = Some(provider.to_string());
|
|
110
|
+
err.http_status = Some(status.as_u16());
|
|
111
|
+
err
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#[must_use]
|
|
115
|
+
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
|
|
116
|
+
self.hint = Some(hint.into());
|
|
117
|
+
self
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#[must_use]
|
|
121
|
+
pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
|
|
122
|
+
self.provider = Some(provider.into());
|
|
123
|
+
self
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#[must_use]
|
|
127
|
+
pub fn with_http_status(mut self, status: StatusCode) -> Self {
|
|
128
|
+
self.http_status = Some(status.as_u16());
|
|
129
|
+
self
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#[must_use]
|
|
133
|
+
pub fn exit_code(&self) -> i32 {
|
|
134
|
+
self.category.exit_code()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#[must_use]
|
|
138
|
+
pub fn render_text(&self) -> String {
|
|
139
|
+
match &self.hint {
|
|
140
|
+
Some(hint) => format!("{}\nHint: {hint}", self.message),
|
|
141
|
+
None => self.message.clone(),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#[must_use]
|
|
146
|
+
pub fn render_json(&self) -> String {
|
|
147
|
+
json!({
|
|
148
|
+
"code": self.category.code(),
|
|
149
|
+
"exit_code": self.exit_code(),
|
|
150
|
+
"message": self.message,
|
|
151
|
+
"hint": self.hint,
|
|
152
|
+
"provider": self.provider,
|
|
153
|
+
"http_status": self.http_status,
|
|
154
|
+
})
|
|
155
|
+
.to_string()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#[must_use]
|
|
159
|
+
pub fn category(&self) -> ErrorCategory {
|
|
160
|
+
self.category
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fn new(category: ErrorCategory, message: impl Into<String>) -> Self {
|
|
164
|
+
Self {
|
|
165
|
+
category,
|
|
166
|
+
message: message.into(),
|
|
167
|
+
hint: None,
|
|
168
|
+
provider: None,
|
|
169
|
+
http_status: None,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
impl Display for AppError {
|
|
175
|
+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
176
|
+
write!(f, "{}", self.message)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
impl std::error::Error for AppError {}
|
|
181
|
+
|
|
182
|
+
#[must_use]
|
|
183
|
+
pub fn classify_anyhow_error(err: &anyhow::Error) -> AppError {
|
|
184
|
+
if let Some(app_err) = err.downcast_ref::<AppError>() {
|
|
185
|
+
return app_err.clone();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if let Some(req_err) = err.downcast_ref::<reqwest::Error>() {
|
|
189
|
+
if req_err.is_timeout() || req_err.is_connect() {
|
|
190
|
+
return AppError::network(format!("Network request failed: {req_err}"));
|
|
191
|
+
}
|
|
192
|
+
return AppError::provider(format!("Remote request failed: {req_err}"));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
|
|
196
|
+
return AppError::internal(format!("I/O error: {io_err}"));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
AppError::internal(err.to_string())
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#[must_use]
|
|
203
|
+
pub fn app_error_from_reqwest(provider: &str, operation: &str, err: &reqwest::Error) -> AppError {
|
|
204
|
+
if err.is_timeout() || err.is_connect() {
|
|
205
|
+
return AppError::network(format!("{provider} {operation} request failed: {err}"))
|
|
206
|
+
.with_provider(provider);
|
|
207
|
+
}
|
|
208
|
+
AppError::provider(format!("{provider} {operation} request failed: {err}"))
|
|
209
|
+
.with_provider(provider)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#[must_use]
|
|
213
|
+
pub fn app_error_from_decode(provider: &str, operation: &str, err: impl Display) -> AppError {
|
|
214
|
+
AppError::provider(format!(
|
|
215
|
+
"{provider} {operation} response decode failed: {err}"
|
|
216
|
+
))
|
|
217
|
+
.with_provider(provider)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
fn body_snippet(body: &str) -> String {
|
|
221
|
+
body.chars()
|
|
222
|
+
.take(200)
|
|
223
|
+
.collect::<String>()
|
|
224
|
+
.replace('\n', " ")
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#[cfg(test)]
|
|
228
|
+
mod tests {
|
|
229
|
+
use super::*;
|
|
230
|
+
|
|
231
|
+
#[test]
|
|
232
|
+
fn exit_code_mapping_is_stable() {
|
|
233
|
+
assert_eq!(ErrorCategory::Usage.exit_code(), 2);
|
|
234
|
+
assert_eq!(ErrorCategory::Auth.exit_code(), 3);
|
|
235
|
+
assert_eq!(ErrorCategory::NotFound.exit_code(), 4);
|
|
236
|
+
assert_eq!(ErrorCategory::RateLimited.exit_code(), 5);
|
|
237
|
+
assert_eq!(ErrorCategory::Network.exit_code(), 6);
|
|
238
|
+
assert_eq!(ErrorCategory::Provider.exit_code(), 7);
|
|
239
|
+
assert_eq!(ErrorCategory::Internal.exit_code(), 1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#[test]
|
|
243
|
+
fn json_renderer_includes_required_fields() {
|
|
244
|
+
let rendered = AppError::auth("invalid token")
|
|
245
|
+
.with_provider("github")
|
|
246
|
+
.with_http_status(StatusCode::UNAUTHORIZED)
|
|
247
|
+
.render_json();
|
|
248
|
+
let value: serde_json::Value = serde_json::from_str(&rendered).unwrap();
|
|
249
|
+
assert_eq!(value["code"], "auth");
|
|
250
|
+
assert_eq!(value["exit_code"], 3);
|
|
251
|
+
assert_eq!(value["provider"], "github");
|
|
252
|
+
assert_eq!(value["http_status"], 401);
|
|
253
|
+
}
|
|
254
|
+
}
|
package/src/format/json.rs
CHANGED
|
@@ -1,12 +1,43 @@
|
|
|
1
|
-
use super::
|
|
1
|
+
use super::StreamFormatter;
|
|
2
2
|
use crate::model::Conversation;
|
|
3
3
|
use anyhow::Result;
|
|
4
|
+
use std::io::Write;
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
#[derive(Default)]
|
|
7
|
+
pub struct JsonStreamFormatter {
|
|
8
|
+
wrote_item: bool,
|
|
9
|
+
}
|
|
6
10
|
|
|
7
|
-
impl
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
impl JsonStreamFormatter {
|
|
12
|
+
#[must_use]
|
|
13
|
+
pub fn new() -> Self {
|
|
14
|
+
Self { wrote_item: false }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
impl StreamFormatter for JsonStreamFormatter {
|
|
19
|
+
fn begin(&mut self, out: &mut dyn Write) -> Result<()> {
|
|
20
|
+
out.write_all(b"[\n")?;
|
|
21
|
+
Ok(())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
fn write_item(&mut self, out: &mut dyn Write, conversation: &Conversation) -> Result<()> {
|
|
25
|
+
if self.wrote_item {
|
|
26
|
+
out.write_all(b",\n")?;
|
|
27
|
+
}
|
|
28
|
+
let rendered = serde_json::to_string_pretty(conversation)?;
|
|
29
|
+
out.write_all(rendered.as_bytes())?;
|
|
30
|
+
self.wrote_item = true;
|
|
31
|
+
Ok(())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fn finish(&mut self, out: &mut dyn Write) -> Result<()> {
|
|
35
|
+
if self.wrote_item {
|
|
36
|
+
out.write_all(b"\n]\n")?;
|
|
37
|
+
} else {
|
|
38
|
+
out.write_all(b"]\n")?;
|
|
39
|
+
}
|
|
40
|
+
Ok(())
|
|
10
41
|
}
|
|
11
42
|
}
|
|
12
43
|
|
|
@@ -15,9 +46,9 @@ mod tests {
|
|
|
15
46
|
use super::*;
|
|
16
47
|
use crate::model::Comment;
|
|
17
48
|
|
|
18
|
-
fn sample() ->
|
|
19
|
-
|
|
20
|
-
id: 42,
|
|
49
|
+
fn sample() -> Conversation {
|
|
50
|
+
Conversation {
|
|
51
|
+
id: "42".into(),
|
|
21
52
|
title: "Test issue".into(),
|
|
22
53
|
state: "closed".into(),
|
|
23
54
|
body: Some("Body text".into()),
|
|
@@ -25,22 +56,32 @@ mod tests {
|
|
|
25
56
|
author: Some("user1".into()),
|
|
26
57
|
created_at: "2024-01-01T00:00:00Z".into(),
|
|
27
58
|
body: Some("A comment".into()),
|
|
59
|
+
kind: None,
|
|
60
|
+
review_path: None,
|
|
61
|
+
review_line: None,
|
|
62
|
+
review_side: None,
|
|
28
63
|
}],
|
|
29
|
-
}
|
|
64
|
+
}
|
|
30
65
|
}
|
|
31
66
|
|
|
32
67
|
#[test]
|
|
33
|
-
fn
|
|
34
|
-
let
|
|
35
|
-
let
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
68
|
+
fn formats_valid_json_array() {
|
|
69
|
+
let mut formatter = JsonStreamFormatter::new();
|
|
70
|
+
let mut out = Vec::new();
|
|
71
|
+
formatter.begin(&mut out).unwrap();
|
|
72
|
+
formatter.write_item(&mut out, &sample()).unwrap();
|
|
73
|
+
formatter.finish(&mut out).unwrap();
|
|
74
|
+
|
|
75
|
+
let parsed: serde_json::Value = serde_json::from_slice(&out).unwrap();
|
|
76
|
+
assert_eq!(parsed[0]["id"], "42");
|
|
39
77
|
}
|
|
40
78
|
|
|
41
79
|
#[test]
|
|
42
|
-
fn
|
|
43
|
-
let
|
|
44
|
-
|
|
80
|
+
fn empty_output_is_empty_array() {
|
|
81
|
+
let mut formatter = JsonStreamFormatter::new();
|
|
82
|
+
let mut out = Vec::new();
|
|
83
|
+
formatter.begin(&mut out).unwrap();
|
|
84
|
+
formatter.finish(&mut out).unwrap();
|
|
85
|
+
assert_eq!(String::from_utf8(out).unwrap(), "[\n]\n");
|
|
45
86
|
}
|
|
46
87
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
use super::StreamFormatter;
|
|
2
|
+
use crate::model::Conversation;
|
|
3
|
+
use anyhow::Result;
|
|
4
|
+
use std::io::Write;
|
|
5
|
+
|
|
6
|
+
pub struct JsonLinesFormatter;
|
|
7
|
+
|
|
8
|
+
impl StreamFormatter for JsonLinesFormatter {
|
|
9
|
+
fn begin(&mut self, _out: &mut dyn Write) -> Result<()> {
|
|
10
|
+
Ok(())
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
fn write_item(&mut self, out: &mut dyn Write, conversation: &Conversation) -> Result<()> {
|
|
14
|
+
serde_json::to_writer(&mut *out, conversation)?;
|
|
15
|
+
out.write_all(b"\n")?;
|
|
16
|
+
Ok(())
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
fn finish(&mut self, _out: &mut dyn Write) -> Result<()> {
|
|
20
|
+
Ok(())
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#[cfg(test)]
|
|
25
|
+
mod tests {
|
|
26
|
+
use super::*;
|
|
27
|
+
use crate::model::Conversation;
|
|
28
|
+
|
|
29
|
+
#[test]
|
|
30
|
+
fn emits_one_json_object_per_line() {
|
|
31
|
+
let mut formatter = JsonLinesFormatter;
|
|
32
|
+
let mut out = Vec::new();
|
|
33
|
+
formatter.begin(&mut out).unwrap();
|
|
34
|
+
formatter
|
|
35
|
+
.write_item(
|
|
36
|
+
&mut out,
|
|
37
|
+
&Conversation {
|
|
38
|
+
id: "1".into(),
|
|
39
|
+
title: "t".into(),
|
|
40
|
+
state: "open".into(),
|
|
41
|
+
body: None,
|
|
42
|
+
comments: vec![],
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
.unwrap();
|
|
46
|
+
formatter.finish(&mut out).unwrap();
|
|
47
|
+
let text = String::from_utf8(out).unwrap();
|
|
48
|
+
assert_eq!(text.lines().count(), 1);
|
|
49
|
+
let parsed: serde_json::Value = serde_json::from_str(text.lines().next().unwrap()).unwrap();
|
|
50
|
+
assert_eq!(parsed["id"], "1");
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/format/mod.rs
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
use crate::model::Conversation;
|
|
2
2
|
use anyhow::Result;
|
|
3
|
+
use std::io::Write;
|
|
3
4
|
|
|
4
5
|
pub mod json;
|
|
6
|
+
pub mod jsonl;
|
|
7
|
+
pub mod text;
|
|
5
8
|
pub mod yaml;
|
|
6
9
|
|
|
7
|
-
/// A pluggable
|
|
8
|
-
pub trait
|
|
9
|
-
|
|
10
|
+
/// A pluggable streaming formatter for conversations.
|
|
11
|
+
pub trait StreamFormatter {
|
|
12
|
+
/// Write optional format prefix.
|
|
13
|
+
///
|
|
14
|
+
/// # Errors
|
|
15
|
+
///
|
|
16
|
+
/// Returns an error if writing fails.
|
|
17
|
+
fn begin(&mut self, out: &mut dyn Write) -> Result<()>;
|
|
18
|
+
|
|
19
|
+
/// Write one conversation item.
|
|
20
|
+
///
|
|
21
|
+
/// # Errors
|
|
22
|
+
///
|
|
23
|
+
/// Returns an error if writing fails.
|
|
24
|
+
fn write_item(&mut self, out: &mut dyn Write, conversation: &Conversation) -> Result<()>;
|
|
25
|
+
|
|
26
|
+
/// Write optional format suffix.
|
|
27
|
+
///
|
|
28
|
+
/// # Errors
|
|
29
|
+
///
|
|
30
|
+
/// Returns an error if writing fails.
|
|
31
|
+
fn finish(&mut self, out: &mut dyn Write) -> Result<()>;
|
|
10
32
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
use super::StreamFormatter;
|
|
2
|
+
use crate::model::{Comment, Conversation};
|
|
3
|
+
use anyhow::Result;
|
|
4
|
+
use std::io::Write;
|
|
5
|
+
|
|
6
|
+
#[derive(Default)]
|
|
7
|
+
pub struct TextFormatter {
|
|
8
|
+
index: usize,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
impl TextFormatter {
|
|
12
|
+
#[must_use]
|
|
13
|
+
pub fn new() -> Self {
|
|
14
|
+
Self { index: 0 }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
impl StreamFormatter for TextFormatter {
|
|
19
|
+
fn begin(&mut self, _out: &mut dyn Write) -> Result<()> {
|
|
20
|
+
Ok(())
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fn write_item(&mut self, out: &mut dyn Write, conversation: &Conversation) -> Result<()> {
|
|
24
|
+
self.index += 1;
|
|
25
|
+
writeln!(out, "Conversation {}", self.index)?;
|
|
26
|
+
writeln!(out, "id: {}", conversation.id)?;
|
|
27
|
+
writeln!(out, "title: {}", conversation.title)?;
|
|
28
|
+
writeln!(out, "state: {}", conversation.state)?;
|
|
29
|
+
writeln!(
|
|
30
|
+
out,
|
|
31
|
+
"body: {}",
|
|
32
|
+
conversation.body.as_deref().unwrap_or("(none)")
|
|
33
|
+
)?;
|
|
34
|
+
writeln!(out, "comments: {}", conversation.comments.len())?;
|
|
35
|
+
for (idx, comment) in conversation.comments.iter().enumerate() {
|
|
36
|
+
render_comment(out, idx, comment)?;
|
|
37
|
+
}
|
|
38
|
+
writeln!(out, "---")?;
|
|
39
|
+
Ok(())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fn finish(&mut self, _out: &mut dyn Write) -> Result<()> {
|
|
43
|
+
Ok(())
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fn render_comment(out: &mut dyn Write, index: usize, comment: &Comment) -> Result<()> {
|
|
48
|
+
writeln!(
|
|
49
|
+
out,
|
|
50
|
+
" [{}] {} {}",
|
|
51
|
+
index + 1,
|
|
52
|
+
comment.created_at,
|
|
53
|
+
comment.author.as_deref().unwrap_or("unknown")
|
|
54
|
+
)?;
|
|
55
|
+
if let Some(kind) = comment.kind.as_deref() {
|
|
56
|
+
writeln!(out, " kind: {kind}")?;
|
|
57
|
+
}
|
|
58
|
+
if let Some(path) = comment.review_path.as_deref() {
|
|
59
|
+
writeln!(out, " review_path: {path}")?;
|
|
60
|
+
}
|
|
61
|
+
if let Some(line) = comment.review_line {
|
|
62
|
+
writeln!(out, " review_line: {line}")?;
|
|
63
|
+
}
|
|
64
|
+
if let Some(side) = comment.review_side.as_deref() {
|
|
65
|
+
writeln!(out, " review_side: {side}")?;
|
|
66
|
+
}
|
|
67
|
+
writeln!(
|
|
68
|
+
out,
|
|
69
|
+
" {}",
|
|
70
|
+
comment.body.as_deref().unwrap_or("(no body)")
|
|
71
|
+
)?;
|
|
72
|
+
Ok(())
|
|
73
|
+
}
|
package/src/format/yaml.rs
CHANGED
|
@@ -1,12 +1,49 @@
|
|
|
1
|
-
use super::
|
|
1
|
+
use super::StreamFormatter;
|
|
2
2
|
use crate::model::Conversation;
|
|
3
3
|
use anyhow::Result;
|
|
4
|
+
use std::io::Write;
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
#[derive(Default)]
|
|
7
|
+
pub struct YamlStreamFormatter {
|
|
8
|
+
wrote_item: bool,
|
|
9
|
+
}
|
|
6
10
|
|
|
7
|
-
impl
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
impl YamlStreamFormatter {
|
|
12
|
+
#[must_use]
|
|
13
|
+
pub fn new() -> Self {
|
|
14
|
+
Self { wrote_item: false }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
impl StreamFormatter for YamlStreamFormatter {
|
|
19
|
+
fn begin(&mut self, _out: &mut dyn Write) -> Result<()> {
|
|
20
|
+
Ok(())
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fn write_item(&mut self, out: &mut dyn Write, conversation: &Conversation) -> Result<()> {
|
|
24
|
+
let rendered = serde_yaml::to_string(conversation)?;
|
|
25
|
+
if self.wrote_item {
|
|
26
|
+
out.write_all(b"\n")?;
|
|
27
|
+
}
|
|
28
|
+
for (idx, line) in rendered.lines().enumerate() {
|
|
29
|
+
if idx == 0 {
|
|
30
|
+
out.write_all(b"- ")?;
|
|
31
|
+
out.write_all(line.as_bytes())?;
|
|
32
|
+
} else {
|
|
33
|
+
out.write_all(b"\n ")?;
|
|
34
|
+
out.write_all(line.as_bytes())?;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
out.write_all(b"\n")?;
|
|
38
|
+
self.wrote_item = true;
|
|
39
|
+
Ok(())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fn finish(&mut self, out: &mut dyn Write) -> Result<()> {
|
|
43
|
+
if !self.wrote_item {
|
|
44
|
+
out.write_all(b"[]\n")?;
|
|
45
|
+
}
|
|
46
|
+
Ok(())
|
|
10
47
|
}
|
|
11
48
|
}
|
|
12
49
|
|
|
@@ -15,9 +52,9 @@ mod tests {
|
|
|
15
52
|
use super::*;
|
|
16
53
|
use crate::model::Comment;
|
|
17
54
|
|
|
18
|
-
fn sample() ->
|
|
19
|
-
|
|
20
|
-
id: 7,
|
|
55
|
+
fn sample() -> Conversation {
|
|
56
|
+
Conversation {
|
|
57
|
+
id: "7".into(),
|
|
21
58
|
title: "YAML issue".into(),
|
|
22
59
|
state: "open".into(),
|
|
23
60
|
body: None,
|
|
@@ -25,20 +62,32 @@ mod tests {
|
|
|
25
62
|
author: None,
|
|
26
63
|
created_at: "2024-06-01T12:00:00Z".into(),
|
|
27
64
|
body: Some("comment".into()),
|
|
65
|
+
kind: None,
|
|
66
|
+
review_path: None,
|
|
67
|
+
review_line: None,
|
|
68
|
+
review_side: None,
|
|
28
69
|
}],
|
|
29
|
-
}
|
|
70
|
+
}
|
|
30
71
|
}
|
|
31
72
|
|
|
32
73
|
#[test]
|
|
33
74
|
fn formats_valid_yaml() {
|
|
34
|
-
let
|
|
35
|
-
|
|
36
|
-
|
|
75
|
+
let mut formatter = YamlStreamFormatter::new();
|
|
76
|
+
let mut out = Vec::new();
|
|
77
|
+
formatter.begin(&mut out).unwrap();
|
|
78
|
+
formatter.write_item(&mut out, &sample()).unwrap();
|
|
79
|
+
formatter.finish(&mut out).unwrap();
|
|
80
|
+
|
|
81
|
+
let parsed: serde_yaml::Value = serde_yaml::from_slice(&out).unwrap();
|
|
82
|
+
assert_eq!(parsed[0]["title"], "YAML issue");
|
|
37
83
|
}
|
|
38
84
|
|
|
39
85
|
#[test]
|
|
40
|
-
fn
|
|
41
|
-
let
|
|
42
|
-
|
|
86
|
+
fn empty_output_is_empty_yaml_list() {
|
|
87
|
+
let mut formatter = YamlStreamFormatter::new();
|
|
88
|
+
let mut out = Vec::new();
|
|
89
|
+
formatter.begin(&mut out).unwrap();
|
|
90
|
+
formatter.finish(&mut out).unwrap();
|
|
91
|
+
assert_eq!(String::from_utf8(out).unwrap(), "[]\n");
|
|
43
92
|
}
|
|
44
93
|
}
|
package/src/lib.rs
CHANGED
package/src/logging.rs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
use anyhow::{Result, anyhow};
|
|
2
|
+
use tracing_subscriber::filter::LevelFilter;
|
|
3
|
+
use tracing_subscriber::fmt;
|
|
4
|
+
|
|
5
|
+
/// Initialize structured stderr logging for CLI lifecycle events.
|
|
6
|
+
///
|
|
7
|
+
/// # Errors
|
|
8
|
+
///
|
|
9
|
+
/// Returns an error if the global tracing subscriber was already initialized.
|
|
10
|
+
pub fn init(verbose: u8, quiet: bool) -> Result<()> {
|
|
11
|
+
let level = level_from_flags(verbose, quiet);
|
|
12
|
+
fmt()
|
|
13
|
+
.with_writer(std::io::stderr)
|
|
14
|
+
.without_time()
|
|
15
|
+
.with_target(false)
|
|
16
|
+
.with_max_level(level)
|
|
17
|
+
.try_init()
|
|
18
|
+
.map_err(|err| anyhow!("failed to initialize logging: {err}"))?;
|
|
19
|
+
Ok(())
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#[must_use]
|
|
23
|
+
fn level_from_flags(verbose: u8, quiet: bool) -> LevelFilter {
|
|
24
|
+
if quiet {
|
|
25
|
+
return LevelFilter::ERROR;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
match verbose {
|
|
29
|
+
0 => LevelFilter::WARN,
|
|
30
|
+
1 => LevelFilter::INFO,
|
|
31
|
+
2 => LevelFilter::DEBUG,
|
|
32
|
+
_ => LevelFilter::TRACE,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#[cfg(test)]
|
|
37
|
+
mod tests {
|
|
38
|
+
use super::*;
|
|
39
|
+
|
|
40
|
+
#[test]
|
|
41
|
+
fn verbosity_maps_to_expected_levels() {
|
|
42
|
+
assert_eq!(level_from_flags(0, false), LevelFilter::WARN);
|
|
43
|
+
assert_eq!(level_from_flags(1, false), LevelFilter::INFO);
|
|
44
|
+
assert_eq!(level_from_flags(2, false), LevelFilter::DEBUG);
|
|
45
|
+
assert_eq!(level_from_flags(3, false), LevelFilter::TRACE);
|
|
46
|
+
assert_eq!(level_from_flags(7, false), LevelFilter::TRACE);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[test]
|
|
50
|
+
fn quiet_overrides_verbose() {
|
|
51
|
+
assert_eq!(level_from_flags(0, true), LevelFilter::ERROR);
|
|
52
|
+
assert_eq!(level_from_flags(3, true), LevelFilter::ERROR);
|
|
53
|
+
}
|
|
54
|
+
}
|