@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.
Files changed (72) hide show
  1. package/.github/ISSUE_TEMPLATE/01-feature.yml +65 -0
  2. package/.github/ISSUE_TEMPLATE/02-bug.yml +122 -0
  3. package/.github/ISSUE_TEMPLATE/99-custom.yml +33 -0
  4. package/.github/ISSUE_TEMPLATE/config.yml +1 -0
  5. package/.github/dependabot.yml +20 -0
  6. package/.github/scripts/publish.js +1 -1
  7. package/.github/workflows/ci.yml +32 -6
  8. package/.github/workflows/man-drift.yml +26 -0
  9. package/.github/workflows/release.yml +7 -7
  10. package/CONTRIBUTING.md +38 -50
  11. package/Cargo.lock +150 -107
  12. package/Cargo.toml +7 -2
  13. package/README.md +107 -85
  14. package/artifacts/binary-aarch64-apple-darwin/99problems +0 -0
  15. package/artifacts/binary-aarch64-unknown-linux-gnu/99problems +0 -0
  16. package/artifacts/binary-x86_64-apple-darwin/99problems +0 -0
  17. package/artifacts/binary-x86_64-pc-windows-msvc/99problems.exe +0 -0
  18. package/artifacts/binary-x86_64-unknown-linux-gnu/99problems +0 -0
  19. package/docs/man/99problems-completions.1 +31 -0
  20. package/docs/man/99problems-config.1 +50 -0
  21. package/docs/man/99problems-get.1 +114 -0
  22. package/docs/man/99problems-help.1 +25 -0
  23. package/docs/man/99problems-man.1 +32 -0
  24. package/docs/man/99problems.1 +52 -0
  25. package/npm/install.js +90 -3
  26. package/package.json +7 -7
  27. package/rust-toolchain.toml +4 -0
  28. package/src/cmd/config/key.rs +126 -0
  29. package/src/cmd/config/mod.rs +218 -0
  30. package/src/cmd/config/render.rs +33 -0
  31. package/src/cmd/config/store.rs +318 -0
  32. package/src/cmd/config/write.rs +173 -0
  33. package/src/cmd/get.rs +658 -0
  34. package/src/cmd/man.rs +117 -0
  35. package/src/cmd/mod.rs +3 -0
  36. package/src/config.rs +618 -118
  37. package/src/error.rs +254 -0
  38. package/src/format/json.rs +59 -18
  39. package/src/format/jsonl.rs +52 -0
  40. package/src/format/mod.rs +25 -3
  41. package/src/format/text.rs +73 -0
  42. package/src/format/yaml.rs +64 -15
  43. package/src/lib.rs +1 -0
  44. package/src/logging.rs +54 -0
  45. package/src/main.rs +225 -138
  46. package/src/model.rs +9 -1
  47. package/src/source/bitbucket/cloud/api.rs +67 -0
  48. package/src/source/bitbucket/cloud/mod.rs +178 -0
  49. package/src/source/bitbucket/cloud/model.rs +211 -0
  50. package/src/source/bitbucket/datacenter/api.rs +74 -0
  51. package/src/source/bitbucket/datacenter/mod.rs +181 -0
  52. package/src/source/bitbucket/datacenter/model.rs +327 -0
  53. package/src/source/bitbucket/mod.rs +90 -0
  54. package/src/source/bitbucket/query.rs +169 -0
  55. package/src/source/bitbucket/shared/auth.rs +54 -0
  56. package/src/source/bitbucket/shared/http.rs +59 -0
  57. package/src/source/bitbucket/shared/mod.rs +5 -0
  58. package/src/source/github/api.rs +128 -0
  59. package/src/source/github/mod.rs +191 -0
  60. package/src/source/github/model.rs +84 -0
  61. package/src/source/github/query.rs +50 -0
  62. package/src/source/gitlab/api.rs +282 -0
  63. package/src/source/gitlab/mod.rs +225 -0
  64. package/src/source/gitlab/model.rs +102 -0
  65. package/src/source/gitlab/query.rs +177 -0
  66. package/src/source/jira/api.rs +222 -0
  67. package/src/source/jira/mod.rs +161 -0
  68. package/src/source/jira/model.rs +99 -0
  69. package/src/source/jira/query.rs +153 -0
  70. package/src/source/mod.rs +65 -7
  71. package/tests/integration.rs +404 -33
  72. 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
+ }
@@ -1,12 +1,43 @@
1
- use super::Formatter;
1
+ use super::StreamFormatter;
2
2
  use crate::model::Conversation;
3
3
  use anyhow::Result;
4
+ use std::io::Write;
4
5
 
5
- pub struct JsonFormatter;
6
+ #[derive(Default)]
7
+ pub struct JsonStreamFormatter {
8
+ wrote_item: bool,
9
+ }
6
10
 
7
- impl Formatter for JsonFormatter {
8
- fn format(&self, conversations: &[Conversation]) -> Result<String> {
9
- Ok(serde_json::to_string_pretty(conversations)?)
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() -> Vec<Conversation> {
19
- vec![Conversation {
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 formats_valid_json() {
34
- let out = JsonFormatter.format(&sample()).unwrap();
35
- let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
36
- assert_eq!(parsed[0]["id"], 42);
37
- assert_eq!(parsed[0]["title"], "Test issue");
38
- assert_eq!(parsed[0]["comments"][0]["author"], "user1");
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 empty_slice_produces_empty_array() {
43
- let out = JsonFormatter.format(&[]).unwrap();
44
- assert_eq!(out.trim(), "[]");
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 output formatter for conversations.
8
- pub trait Formatter {
9
- fn format(&self, conversations: &[Conversation]) -> Result<String>;
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
+ }
@@ -1,12 +1,49 @@
1
- use super::Formatter;
1
+ use super::StreamFormatter;
2
2
  use crate::model::Conversation;
3
3
  use anyhow::Result;
4
+ use std::io::Write;
4
5
 
5
- pub struct YamlFormatter;
6
+ #[derive(Default)]
7
+ pub struct YamlStreamFormatter {
8
+ wrote_item: bool,
9
+ }
6
10
 
7
- impl Formatter for YamlFormatter {
8
- fn format(&self, conversations: &[Conversation]) -> Result<String> {
9
- Ok(serde_yaml::to_string(conversations)?)
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() -> Vec<Conversation> {
19
- vec![Conversation {
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 out = YamlFormatter.format(&sample()).unwrap();
35
- assert!(out.contains("title: YAML issue"));
36
- assert!(out.contains("id: 7"));
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 empty_slice_produces_empty_yaml_list() {
41
- let out = YamlFormatter.format(&[]).unwrap();
42
- assert_eq!(out.trim(), "[]");
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
@@ -1,4 +1,5 @@
1
1
  pub mod config;
2
+ pub mod error;
2
3
  pub mod format;
3
4
  pub mod model;
4
5
  pub mod source;
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
+ }