1use serde::Serialize;
2use std::io::IsTerminal;
3use std::io::{self, Write};
4use tabled::{Table, Tabled};
5
6use crate::error::GiteeError;
7use crate::models::*;
8
9pub struct Output {
10 pub json: Option<String>,
11 pub jq: Option<String>,
12}
13
14impl Output {
15 pub fn render<T: serde::Serialize, W: Write>(
17 &self,
18 w: &mut W,
19 data: &T,
20 human: impl FnOnce(&mut W) -> io::Result<()>,
21 ) -> crate::error::Result<()> {
22 match &self.json {
23 Some(spec) => print_json(w, data, spec, self.jq.as_deref())?,
24 None => human(w)?,
25 }
26 Ok(())
27 }
28}
29
30fn print_json<T: Serialize, W: Write>(
31 w: &mut W,
32 data: &T,
33 spec: &str,
34 jq: Option<&str>,
35) -> crate::error::Result<()> {
36 let value = serde_json::to_value(data)
37 .unwrap_or_else(|e| serde_json::json!({"error": format!("serialize: {e}")}));
38 let out = if spec.trim().is_empty() {
39 value
40 } else {
41 let fields: Vec<String> = spec
42 .split(',')
43 .map(|s| s.trim().to_owned())
44 .filter(|s| !s.is_empty())
45 .collect();
46 project(value, &fields)
47 };
48 if let Some(expr) = jq {
49 return print_jq(w, &out, expr);
50 }
51 writeln!(
52 w,
53 "{}",
54 serde_json::to_string_pretty(&out).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}"))
55 )?;
56 Ok(())
57}
58
59fn print_jq<W: Write>(w: &mut W, value: &serde_json::Value, expr: &str) -> crate::error::Result<()> {
62 for r in run_jq(value, expr)? {
63 match r {
64 serde_json::Value::String(s) => writeln!(w, "{s}")?,
65 other => writeln!(
66 w,
67 "{}",
68 serde_json::to_string(&other)
69 .map_err(|e| GiteeError::Usage(format!("--jq: cannot serialize result: {e}")))?
70 )?,
71 }
72 }
73 Ok(())
74}
75
76fn run_jq(value: &serde_json::Value, expr: &str) -> crate::error::Result<Vec<serde_json::Value>> {
80 use jaq_core::data::JustLut;
81 use jaq_core::load::{Arena, File, Loader};
82 use jaq_core::{Compiler, Ctx, Vars};
83 use jaq_json::Val;
84
85 let arena = Arena::default();
86 let defs = jaq_core::defs()
87 .chain(jaq_std::defs())
88 .chain(jaq_json::defs());
89 let loader = Loader::new(defs);
90 let modules = loader
91 .load(&arena, File { path: (), code: expr })
92 .map_err(|errs| invalid_jq(expr, format!("{errs:?}")))?;
93 let funs = jaq_core::funs()
94 .chain(jaq_std::funs())
95 .chain(jaq_json::funs());
96 let filter: jaq_core::Filter<JustLut<Val>> = Compiler::default()
97 .with_funs(funs)
98 .compile(modules)
99 .map_err(|errs| invalid_jq(expr, format!("{errs:?}")))?;
100 let input: Val = serde_json::from_value(value.clone())
101 .map_err(|e| GiteeError::Usage(format!("--jq: cannot convert input: {e}")))?;
102 let ctx: Ctx<JustLut<Val>> = Ctx::new(&filter.lut, Vars::new([]));
103 let mut out = Vec::new();
104 for r in filter.id.run((ctx, input)) {
105 let v = r.map_err(|e| GiteeError::Usage(format!("--jq evaluation failed: {e:?}")))?;
106 out.push(val_to_json(&v)?);
107 }
108 Ok(out)
109}
110
111fn val_to_json(v: &jaq_json::Val) -> crate::error::Result<serde_json::Value> {
115 use jaq_json::Val;
116 use serde_json::Value;
117 Ok(match v {
118 Val::Null => Value::Null,
119 Val::Bool(b) => Value::Bool(*b),
120 Val::Num(n) => serde_json::from_str(&n.to_string())
122 .map_err(|e| GiteeError::Usage(format!("--jq: cannot convert number {n}: {e}")))?,
123 Val::TStr(s) | Val::BStr(s) => Value::String(String::from_utf8_lossy(s).into_owned()),
124 Val::Arr(a) => Value::Array(
125 a.iter()
126 .map(val_to_json)
127 .collect::<crate::error::Result<Vec<_>>>()?,
128 ),
129 Val::Obj(o) => {
130 let mut map = serde_json::Map::new();
131 for (k, val) in o.iter() {
132 let key = match k {
133 Val::TStr(s) => String::from_utf8_lossy(s).into_owned(),
134 other => {
135 return Err(GiteeError::Usage(format!(
136 "--jq: object key is not a string ({other:?}); cannot render as JSON"
137 )))
138 }
139 };
140 map.insert(key, val_to_json(val)?);
141 }
142 Value::Object(map)
143 }
144 })
145}
146
147fn invalid_jq(expr: &str, details: String) -> GiteeError {
148 GiteeError::Usage(format!("invalid --jq expression '{expr}': {details}"))
149}
150
151fn project(value: serde_json::Value, fields: &[String]) -> serde_json::Value {
154 match value {
155 serde_json::Value::Array(items) => {
156 serde_json::Value::Array(items.into_iter().map(|v| project(v, fields)).collect())
157 }
158 other => pick(other, fields),
159 }
160}
161
162fn pick(value: serde_json::Value, fields: &[String]) -> serde_json::Value {
163 if let serde_json::Value::Object(map) = value {
164 let mut out = serde_json::Map::new();
165 for f in fields {
166 if let Some(v) = map.get(f) {
167 out.insert(f.clone(), v.clone());
168 }
169 }
170 serde_json::Value::Object(out)
171 } else {
172 value
173 }
174}
175
176#[cfg(test)]
177mod jq_tests {
178 use crate::out::Output;
179 use serde_json::json;
180
181 fn render_json(value: serde_json::Value, json: &str, jq: &str) -> String {
182 let out = Output {
183 json: Some(json.to_string()),
184 jq: Some(jq.to_string()),
185 };
186 let mut buf = Vec::new();
187 out.render(&mut buf, &value, |_w| unreachable!("human path"))
188 .expect("render should succeed");
189 String::from_utf8(buf).unwrap()
190 }
191
192 #[test]
193 fn jq_string_scalar_prints_unquoted() {
194 let data = json!([{"title": "Fix bug", "number": 1}]);
195 assert_eq!(render_json(data, "", ".[0].title"), "Fix bug\n");
196 }
197
198 #[test]
199 fn jq_applies_after_field_projection() {
200 let data = json!([
201 {"number": 1, "title": "a", "extra": true},
202 {"number": 2, "title": "b", "extra": false}
203 ]);
204 assert_eq!(render_json(data, "number,title", "map(.number)"), "[1,2]\n");
205 }
206
207 #[test]
208 fn jq_invalid_expression_is_usage_error() {
209 let out = Output {
210 json: Some("".to_string()),
211 jq: Some(".[".to_string()),
212 };
213 let mut buf = Vec::new();
214 let err = out
215 .render(&mut buf, &json!([1]), |_w| unreachable!("human path"))
216 .expect_err("invalid expression must fail");
217 let msg = err.to_string();
218 assert!(msg.contains(".["), "message names the expression: {msg}");
219 }
220
221 #[test]
222 fn jq_multiple_results_print_one_per_line() {
223 let data = json!([1, 2, 3]);
224 assert_eq!(render_json(data, "", ".[]"), "1\n2\n3\n");
225 }
226
227 #[test]
228 fn jq_non_string_scalar_prints_as_json() {
229 let data = json!([{"number": 42}]);
230 assert_eq!(render_json(data, "", ".[0].number"), "42\n");
231 }
232}
233
234#[cfg(test)]
235mod project_tests {
236 use super::project;
237 use serde_json::json;
238
239 #[test]
240 fn object_keeps_only_listed_keys() {
241 let value = json!({"a": 1, "b": 2, "c": 3});
242 let fields = vec!["a".to_string(), "c".to_string()];
243 assert_eq!(project(value, &fields), json!({"a": 1, "c": 3}));
244 }
245
246 #[test]
247 fn array_projects_each_element() {
248 let value = json!([
249 {"a": 1, "b": 2},
250 {"a": 3, "b": 4}
251 ]);
252 let fields = vec!["a".to_string()];
253 assert_eq!(project(value, &fields), json!([{"a": 1}, {"a": 3}]));
254 }
255
256 #[test]
257 fn missing_keys_are_omitted() {
258 let value = json!({"a": 1});
259 let fields = vec!["a".to_string(), "missing".to_string()];
260 assert_eq!(project(value, &fields), json!({"a": 1}));
261 }
262
263 #[test]
264 fn empty_field_list_yields_empty_object() {
265 let value = json!({"a": 1, "b": 2});
266 assert_eq!(project(value, &[]), json!({}));
267 }
268
269 #[test]
270 fn scalar_passes_through_unchanged() {
271 let value = json!(42);
272 let fields = vec!["a".to_string()];
273 assert_eq!(project(value, &fields), json!(42));
274 }
275}
276
277fn color() -> bool {
280 std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal()
281}
282
283fn paint(code: &str, s: &str) -> String {
284 paint_if(color(), code, s)
285}
286
287fn paint_if(enabled: bool, code: &str, s: &str) -> String {
288 if enabled {
289 format!("\x1b[{code}m{s}\x1b[0m")
290 } else {
291 s.to_string()
292 }
293}
294
295pub fn green(s: &str) -> String {
296 paint("32", s)
297}
298pub fn red(s: &str) -> String {
299 paint("31", s)
300}
301pub fn magenta(s: &str) -> String {
302 paint("35", s)
303}
304pub fn yellow(s: &str) -> String {
305 paint("33", s)
306}
307pub fn cyan(s: &str) -> String {
308 paint("36", s)
309}
310pub fn bold(s: &str) -> String {
311 paint("1", s)
312}
313pub fn dim(s: &str) -> String {
314 paint("2", s)
315}
316
317fn pr_state_style(state: PrState, merged: bool, draft: bool) -> String {
320 if merged || state == PrState::Merged {
321 return magenta(state.as_str());
322 }
323 if draft && state == PrState::Open {
324 return yellow("draft");
325 }
326 match state {
327 PrState::Open => green(state.as_str()),
328 PrState::Closed => red(state.as_str()),
329 _ => state.as_str().to_string(),
330 }
331}
332
333pub(crate) fn issue_state_style(state: IssueState) -> String {
335 match state {
336 IssueState::Open | IssueState::Progressing => green(state.as_str()),
337 IssueState::Closed | IssueState::Rejected => red(state.as_str()),
338 _ => state.as_str().to_string(),
339 }
340}
341
342#[derive(Tabled)]
345struct PrRow {
346 number: i64,
347 state: String,
348 title: String,
349 branch: String,
350 author: String,
351}
352
353pub fn pr_table(w: &mut impl Write, items: &[PullRequest]) -> std::io::Result<()> {
354 let rows: Vec<PrRow> = items
355 .iter()
356 .map(|p| PrRow {
357 number: p.number,
358 state: pr_state_style(p.state, p.merged_at.is_some(), p.draft.unwrap_or(false)),
359 title: p.title.clone(),
360 branch: format!("{} -> {}", p.head.git_ref, p.base.git_ref),
361 author: p.user.as_ref().map(|u| u.login.clone()).unwrap_or_default(),
362 })
363 .collect();
364 writeln!(w, "{}", Table::new(rows))
365}
366
367pub fn one_pr(w: &mut impl Write, p: &PullRequest) -> std::io::Result<()> {
368 let merged = p.merged.unwrap_or_else(|| p.merged_at.is_some());
369 let state = pr_state_style(p.state, merged, p.draft.unwrap_or(false));
370 writeln!(
371 w,
372 "{} {} [{}]",
373 bold(&format!("!{}", p.number)),
374 p.title,
375 state
376 )?;
377 writeln!(w, "{} -> {}", dim(&p.head.git_ref), dim(&p.base.git_ref))?;
378 if p.merged.is_some() {
379 writeln!(
380 w,
381 "merged: {}",
382 if p.merged.unwrap_or(false) {
383 "yes"
384 } else {
385 "no"
386 }
387 )?;
388 }
389 writeln!(w, "{}", dim(&p.html_url))?;
390 if let Some(b) = &p.body {
391 let b = b.trim();
392 if !b.is_empty() {
393 writeln!(w, "\n{b}")?;
394 }
395 }
396 Ok(())
397}
398
399pub fn color_diff_line(line: &str) -> String {
401 if line.starts_with("@@") {
402 cyan(line)
403 } else if line.starts_with('+') && !line.starts_with("+++") {
404 green(line)
405 } else if line.starts_with('-') && !line.starts_with("---") {
406 red(line)
407 } else {
408 line.to_string()
409 }
410}
411
412pub fn pr_diff(w: &mut impl Write, files: &[FileDiff]) -> std::io::Result<()> {
413 if files.is_empty() {
414 writeln!(w, "(no changed files)")?;
415 return Ok(());
416 }
417 for (i, f) in files.iter().enumerate() {
418 if i > 0 {
419 writeln!(w)?;
420 }
421 let name = &f.path;
422 writeln!(w, "{}", bold(&format!("diff --git a/{name} b/{name}")))?;
423 writeln!(w, "{}", bold(name))?;
424 match &f.patch {
425 Some(p) if !p.is_empty() => {
426 for line in p.lines() {
427 writeln!(w, "{}", color_diff_line(line))?;
428 }
429 }
430 _ => writeln!(w, "{}", dim("(no text diff — binary or too large)"))?,
431 }
432 }
433 Ok(())
434}
435
436#[derive(Tabled)]
439struct MilestoneRow {
440 number: String,
441 title: String,
442 state: String,
443 due_on: String,
444}
445
446fn milestone_state_label(state: Option<&String>) -> String {
447 match state.map(|s| s.as_str()) {
448 Some("open") => green("open"),
449 Some("closed") => red("closed"),
450 other => other.unwrap_or("").to_string(),
451 }
452}
453
454pub fn milestone_table(w: &mut impl Write, items: &[Milestone]) -> std::io::Result<()> {
455 let rows: Vec<MilestoneRow> = items
456 .iter()
457 .map(|m| MilestoneRow {
458 number: m.number.to_string(),
459 title: m.title.clone(),
460 state: milestone_state_label(m.state.as_ref()),
461 due_on: m.due_on.clone().unwrap_or_default(),
462 })
463 .collect();
464 writeln!(w, "{}", Table::new(rows))
465}
466
467pub fn one_milestone(w: &mut impl Write, m: &Milestone) -> std::io::Result<()> {
468 let state = milestone_state_label(m.state.as_ref());
469 writeln!(
470 w,
471 "{} {} [{}]",
472 bold(&format!("#{}", m.number)),
473 m.title,
474 state
475 )?;
476 if let Some(d) = &m.due_on {
477 writeln!(w, "Due: {d}")?;
478 }
479 writeln!(w, "{}", dim(m.html_url.as_deref().unwrap_or("")))?;
480 let open = m.open_issues.unwrap_or(0);
481 let closed = m.closed_issues.unwrap_or(0);
482 writeln!(w, "Issues: {} open, {} closed", open, closed)?;
483 if let Some(desc) = &m.description {
484 let d = desc.trim();
485 if !d.is_empty() {
486 writeln!(w, "\n{d}")?;
487 }
488 }
489 Ok(())
490}
491
492#[cfg(test)]
493mod milestone_printer_tests {
494 use super::*;
495
496 #[test]
497 fn milestone_table_contains_number_title_and_state() {
498 let milestone = Milestone {
499 number: 3,
500 title: "v1.0".into(),
501 state: Some("open".into()),
502 due_on: Some("2026-12-31".into()),
503 ..Default::default()
504 };
505
506 let mut buf = Vec::new();
507 milestone_table(&mut buf, &[milestone]).unwrap();
508 let out = String::from_utf8(buf).unwrap();
509 assert!(out.contains("3"));
510 assert!(out.contains("v1.0"));
511 assert!(out.contains("open"));
512 }
513}
514
515
516#[cfg(test)]
517mod diff_tests {
518 use super::*;
519
520 #[test]
521 fn color_diff_line_marks_hunks_and_changes() {
522 assert_eq!(color_diff_line("@@ -1,3 +1,4 @@"), cyan("@@ -1,3 +1,4 @@"));
523 assert_eq!(color_diff_line("+added"), green("+added"));
524 assert_eq!(color_diff_line("-removed"), red("-removed"));
525 assert_eq!(color_diff_line(" context"), " context");
526 assert_eq!(color_diff_line("+++ b/file"), "+++ b/file");
527 assert_eq!(color_diff_line("--- a/file"), "--- a/file");
528 }
529}
530
531#[derive(Tabled)]
534struct IssueRow {
535 number: String,
536 state: String,
537 title: String,
538 assignee: String,
539}
540
541pub fn issue_table(w: &mut impl Write, items: &[Issue]) -> std::io::Result<()> {
542 let rows: Vec<IssueRow> = items
543 .iter()
544 .map(|i| IssueRow {
545 number: i.number.clone(),
546 state: issue_state_style(i.state),
547 title: i.title.clone(),
548 assignee: i
549 .assignee
550 .as_ref()
551 .map(|a| a.login.clone())
552 .unwrap_or_default(),
553 })
554 .collect();
555 writeln!(w, "{}", Table::new(rows))
556}
557
558pub fn one_issue(w: &mut impl Write, i: &Issue) -> std::io::Result<()> {
559 let state = issue_state_style(i.state);
560 writeln!(
561 w,
562 "{} {} [{}]",
563 bold(&format!("#{}", i.number)),
564 i.title,
565 state
566 )?;
567 writeln!(w, "{}", dim(&i.html_url))?;
568 if let Some(b) = &i.body {
569 let b = b.trim();
570 if !b.is_empty() {
571 writeln!(w, "\n{b}")?;
572 }
573 }
574 Ok(())
575}
576
577
578#[derive(Serialize)]
579pub struct PrStatus {
580 pub created: Vec<PullRequest>,
581 pub assigned: Vec<PullRequest>,
582 pub awaiting_test: Vec<PullRequest>,
583}
584
585#[derive(Serialize)]
586pub struct IssueStatus {
587 pub created: Vec<Issue>,
588 pub assigned: Vec<Issue>,
589}
590
591pub fn pr_status(w: &mut impl Write, s: &PrStatus) -> std::io::Result<()> {
592 writeln!(w, "{}", bold("Created by me"))?;
593 if s.created.is_empty() {
594 writeln!(w, "{}", dim("(none)"))?;
595 } else {
596 pr_table(w, &s.created)?;
597 }
598 writeln!(w)?;
599 writeln!(w, "{}", bold("Assigned to me"))?;
600 if s.assigned.is_empty() {
601 writeln!(w, "{}", dim("(none)"))?;
602 } else {
603 pr_table(w, &s.assigned)?;
604 }
605 writeln!(w)?;
606 writeln!(w, "{}", bold("Awaiting my test"))?;
607 if s.awaiting_test.is_empty() {
608 writeln!(w, "{}", dim("(none)"))?;
609 } else {
610 pr_table(w, &s.awaiting_test)?;
611 }
612 Ok(())
613}
614
615pub fn issue_status(w: &mut impl Write, s: &IssueStatus) -> std::io::Result<()> {
616 writeln!(w, "{}", bold("Created by me"))?;
617 if s.created.is_empty() {
618 writeln!(w, "{}", dim("(none)"))?;
619 } else {
620 issue_table(w, &s.created)?;
621 }
622 writeln!(w)?;
623 writeln!(w, "{}", bold("Assigned to me"))?;
624 if s.assigned.is_empty() {
625 writeln!(w, "{}", dim("(none)"))?;
626 } else {
627 issue_table(w, &s.assigned)?;
628 }
629 Ok(())
630}
631
632
633fn write_comment_line(
634 w: &mut impl Write,
635 who: &str,
636 body: &str,
637 html_url: Option<&str>,
638) -> std::io::Result<()> {
639 writeln!(
640 w,
641 "@{who} commented:\n{body}\n{}",
642 html_url.unwrap_or("")
643 )
644}
645
646pub fn comment_line(w: &mut impl Write, c: &Comment) -> std::io::Result<()> {
647 let who = c.user.as_ref().map(|u| u.login.as_str()).unwrap_or("?");
648 write_comment_line(w, who, &c.body, c.html_url.as_deref())
649}
650
651pub fn pr_comment_line(w: &mut impl Write, c: &PrComment) -> std::io::Result<()> {
652 let who = c.user.as_ref().map(|u| u.login.as_str()).unwrap_or("?");
653 write_comment_line(w, who, &c.body, c.html_url.as_deref())
654}
655
656#[derive(Tabled)]
657struct CommentRow {
658 id: String,
659 author: String,
660 created: String,
661 body: String,
662}
663
664pub fn comment_table(w: &mut impl Write, items: &[Comment]) -> std::io::Result<()> {
665 let rows: Vec<CommentRow> = items
666 .iter()
667 .map(|c| CommentRow {
668 id: c.id.to_string(),
669 author: c
670 .user
671 .as_ref()
672 .map(|u| u.login.clone())
673 .unwrap_or_default(),
674 created: c.created_at.clone().unwrap_or_default(),
675 body: c.body.clone(),
676 })
677 .collect();
678 writeln!(w, "{}", Table::new(rows))
679}
680
681#[derive(Tabled)]
682struct PrCommentRow {
683 id: String,
684 author: String,
685 created: String,
686 path: String,
687 position: String,
688 body: String,
689}
690
691pub fn pr_comment_table(w: &mut impl Write, items: &[PrComment]) -> std::io::Result<()> {
692 let rows: Vec<PrCommentRow> = items
693 .iter()
694 .map(|c| PrCommentRow {
695 id: c.id.to_string(),
696 author: c
697 .user
698 .as_ref()
699 .map(|u| u.login.clone())
700 .unwrap_or_default(),
701 created: c.created_at.clone().unwrap_or_default(),
702 path: c.path.clone().unwrap_or_default(),
703 position: c.position.clone().unwrap_or_default(),
704 body: c.body.clone(),
705 })
706 .collect();
707 writeln!(w, "{}", Table::new(rows))
708}
709
710#[derive(Tabled)]
713struct LabelRow {
714 name: String,
715 color: String,
716 id: String,
717}
718
719pub fn label_table(w: &mut impl Write, items: &[Label]) -> std::io::Result<()> {
720 let rows: Vec<LabelRow> = items
721 .iter()
722 .map(|l| LabelRow {
723 name: l.name.clone(),
724 color: format!("#{}", l.color.as_deref().unwrap_or("000000")),
725 id: l.id.to_string(),
726 })
727 .collect();
728 writeln!(w, "{}", Table::new(rows))
729}
730
731pub fn pr_commits(w: &mut impl Write, items: &[PrCommit]) -> std::io::Result<()> {
733 for c in items {
734 writeln!(
735 w,
736 "{} {} ({})",
737 c.short_sha(),
738 c.subject(),
739 c.author_label()
740 )?;
741 }
742 Ok(())
743}
744
745
746#[derive(Tabled)]
749struct ReleaseRow {
750 tag: String,
751 name: String,
752 status: String,
753 created: String,
754}
755
756fn release_status(prerelease: Option<bool>) -> String {
757 if prerelease.unwrap_or(false) {
758 yellow("pre")
759 } else {
760 green("release")
761 }
762}
763
764pub fn release_table(w: &mut impl Write, items: &[Release]) -> std::io::Result<()> {
765 let rows: Vec<ReleaseRow> = items
766 .iter()
767 .map(|rel| ReleaseRow {
768 tag: rel.tag_name.clone(),
769 name: rel.name.clone().unwrap_or_default(),
770 status: release_status(rel.prerelease),
771 created: rel.created_at.clone().unwrap_or_default(),
772 })
773 .collect();
774 writeln!(w, "{}", Table::new(rows))
775}
776
777pub fn one_release(w: &mut impl Write, rel: &Release) -> std::io::Result<()> {
778 let title = rel
779 .name
780 .as_deref()
781 .filter(|n| !n.is_empty())
782 .unwrap_or(&rel.tag_name);
783 writeln!(
784 w,
785 "{} {} [{}]",
786 bold(&rel.tag_name),
787 title,
788 release_status(rel.prerelease)
789 )?;
790 if let Some(b) = &rel.body {
791 let b = b.trim();
792 if !b.is_empty() {
793 writeln!(w, "\n{b}")?;
794 }
795 }
796 let assets = rel.assets.as_deref().unwrap_or(&[]);
797 writeln!(w, "\n{} asset(s)", assets.len())?;
798 for asset in assets {
799 writeln!(w, " {}", asset.name)?;
800 }
801 Ok(())
802}
803
804
805#[derive(Tabled)]
808struct GistRow {
809 id: String,
810 description: String,
811 files: String,
812 updated: String,
813}
814
815fn gist_visibility(public: Option<bool>) -> String {
816 if public.unwrap_or(false) {
817 green("public")
818 } else {
819 dim("secret")
820 }
821}
822
823pub fn gist_table(w: &mut impl Write, items: &[Gist]) -> std::io::Result<()> {
824 let rows: Vec<GistRow> = items
825 .iter()
826 .map(|g| GistRow {
827 id: g.id.clone(),
828 description: g.description.clone().unwrap_or_default(),
829 files: g.files.as_ref().map(|f| f.len().to_string()).unwrap_or_else(|| "0".into()),
830 updated: g.updated_at.clone().unwrap_or_default(),
831 })
832 .collect();
833 writeln!(w, "{}", Table::new(rows))
834}
835
836pub fn one_gist(w: &mut impl Write, g: &Gist) -> std::io::Result<()> {
837 writeln!(
838 w,
839 "{} {} [{}]",
840 bold(&g.id),
841 g.description.as_deref().unwrap_or("(no description)"),
842 gist_visibility(g.public),
843 )?;
844 if let Some(updated) = &g.updated_at {
845 writeln!(w, "updated: {updated}")?;
846 }
847 let files = g.files.as_ref();
848 writeln!(w, "
849{} file(s)", files.map(|f| f.len()).unwrap_or(0))?;
850 if let Some(files) = files {
851 for (name, file) in files {
852 let size = file.size.map(|s| s.to_string()).unwrap_or_else(|| "?".into());
853 writeln!(w, " {name} ({size} bytes)")?;
854 }
855 }
856 Ok(())
857}
858
859pub fn gist_raw(w: &mut impl Write, g: &Gist) -> std::io::Result<()> {
860 for (i, (_name, file)) in g.files.iter().flatten().enumerate() {
861 if i > 0 {
862 writeln!(w)?;
863 }
864 if let Some(content) = &file.content {
865 write!(w, "{content}")?;
866 }
867 }
868 Ok(())
869}
870
871#[derive(Tabled)]
874struct RepoRow {
875 name: String,
876 visibility: String,
877 stars: String,
878 description: String,
879}
880
881pub fn repo_table(w: &mut impl Write, items: &[RepoDetails]) -> std::io::Result<()> {
882 let rows: Vec<RepoRow> = items
883 .iter()
884 .map(|r| RepoRow {
885 name: r.full_name.clone(),
886 visibility: if r.private.unwrap_or(false) {
887 red("private")
888 } else {
889 green("public")
890 },
891 stars: r.stargazers_count.unwrap_or(0).to_string(),
892 description: r.description.clone().unwrap_or_default(),
893 })
894 .collect();
895 writeln!(w, "{}", Table::new(rows))
896}
897
898pub fn one_repo(w: &mut impl Write, r: &RepoDetails) -> std::io::Result<()> {
899 let vis = if r.private.unwrap_or(false) {
900 red("private")
901 } else {
902 green("public")
903 };
904 writeln!(w, "{} [{}]", bold(&r.full_name), vis)?;
905 if let Some(d) = &r.description {
906 let d = d.trim();
907 if !d.is_empty() {
908 writeln!(w, "{d}")?;
909 }
910 }
911 writeln!(
912 w,
913 "default: {} stars: {} forks: {} issues: {}",
914 r.default_branch.as_deref().unwrap_or("-"),
915 r.stargazers_count.unwrap_or(0),
916 r.fork_count.unwrap_or(0),
917 r.open_issues_count.unwrap_or(0),
918 )?;
919 if r.starred.is_some() || r.watching.is_some() {
920 writeln!(
921 w,
922 "starred: {} watching: {}",
923 r.starred
924 .map(|b| if b { "yes" } else { "no" })
925 .unwrap_or("-"),
926 r.watching
927 .map(|b| if b { "yes" } else { "no" })
928 .unwrap_or("-"),
929 )?;
930 }
931 if !r.html_url.is_empty() {
932 writeln!(w, "{}", dim(&r.html_url))?;
933 }
934 Ok(())
935}
936
937#[derive(Tabled)]
940struct UserRow {
941 login: String,
942 name: String,
943 html_url: String,
944}
945
946pub fn user_table(w: &mut impl Write, items: &[UserBasic]) -> std::io::Result<()> {
947 let rows: Vec<UserRow> = items
948 .iter()
949 .map(|u| UserRow {
950 login: u.login.clone(),
951 name: u.name.clone().unwrap_or_default(),
952 html_url: u.html_url.clone().unwrap_or_default(),
953 })
954 .collect();
955 writeln!(w, "{}", Table::new(rows))
956}
957
958#[derive(Tabled)]
959struct AssigneeRow {
960 login: String,
961 name: String,
962 accept: String,
963}
964
965pub fn assignee_table(w: &mut impl Write, items: &[UserAssignee]) -> std::io::Result<()> {
967 let rows: Vec<AssigneeRow> = items
968 .iter()
969 .map(|u| AssigneeRow {
970 login: u.login.clone(),
971 name: u.name.clone().unwrap_or_default(),
972 accept: match u.accept {
973 Some(true) => "审查通过".into(),
974 _ => "pending".into(),
975 },
976 })
977 .collect();
978 writeln!(w, "{}", Table::new(rows))
979}
980
981#[derive(Tabled)]
982struct TesterRow {
983 login: String,
984 name: String,
985 html_url: String,
986}
987
988pub fn tester_table(w: &mut impl Write, items: &[UserAssignee]) -> std::io::Result<()> {
990 let rows: Vec<TesterRow> = items
991 .iter()
992 .map(|u| TesterRow {
993 login: u.login.clone(),
994 name: u.name.clone().unwrap_or_default(),
995 html_url: u.html_url.clone().unwrap_or_default(),
996 })
997 .collect();
998 writeln!(w, "{}", Table::new(rows))
999}
1000
1001#[derive(Tabled)]
1004struct OrgRow {
1005 login: String,
1006 description: String,
1007}
1008
1009pub fn org_table(w: &mut impl Write, items: &[Org]) -> std::io::Result<()> {
1010 let rows: Vec<OrgRow> = items
1011 .iter()
1012 .map(|o| OrgRow {
1013 login: o.login.clone(),
1014 description: o.description.clone().unwrap_or_default(),
1015 })
1016 .collect();
1017 writeln!(w, "{}", Table::new(rows))
1018}
1019
1020#[derive(Tabled)]
1021struct SshKeyRow {
1022 id: String,
1023 title: String,
1024 key: String,
1025}
1026
1027pub fn ssh_key_table(w: &mut impl Write, items: &[SshKey]) -> std::io::Result<()> {
1028 let rows: Vec<SshKeyRow> = items
1029 .iter()
1030 .map(|k| SshKeyRow {
1031 id: k.id.to_string(),
1032 title: k.title.clone().unwrap_or_default(),
1033 key: truncate_key(&k.key),
1034 })
1035 .collect();
1036 writeln!(w, "{}", Table::new(rows))
1037}
1038
1039fn truncate_key(key: &str) -> String {
1040 let parts: Vec<&str> = key.split_whitespace().collect();
1041 if parts.len() >= 2 {
1042 let blob = parts[1];
1043 let short = if blob.len() > 16 {
1044 format!("{}…", &blob[..16])
1045 } else {
1046 blob.to_string()
1047 };
1048 format!("{} {}", parts[0], short)
1049 } else if key.len() > 24 {
1050 format!("{}…", &key[..24])
1051 } else {
1052 key.to_string()
1053 }
1054}
1055
1056#[derive(Tabled)]
1057struct CollaboratorRow {
1058 login: String,
1059 name: String,
1060 permission: String,
1061}
1062
1063pub fn collaborator_table(w: &mut impl Write, items: &[Collaborator]) -> std::io::Result<()> {
1064 let rows: Vec<CollaboratorRow> = items
1065 .iter()
1066 .map(|c| CollaboratorRow {
1067 login: c.login.clone(),
1068 name: c.name.clone().unwrap_or_default(),
1069 permission: permission_label(c.permissions.as_ref()),
1070 })
1071 .collect();
1072 writeln!(w, "{}", Table::new(rows))
1073}
1074
1075fn permission_label(p: Option<&CollaboratorPermissions>) -> String {
1076 let Some(p) = p else { return String::new() };
1077 if p.admin.unwrap_or(false) {
1078 "admin".into()
1079 } else if p.push.unwrap_or(false) {
1080 "push".into()
1081 } else if p.pull.unwrap_or(false) {
1082 "pull".into()
1083 } else {
1084 String::new()
1085 }
1086}
1087
1088#[derive(Tabled)]
1089struct WebhookRow {
1090 id: String,
1091 url: String,
1092}
1093
1094pub fn webhook_table(w: &mut impl Write, items: &[Webhook]) -> std::io::Result<()> {
1095 let rows: Vec<WebhookRow> = items
1096 .iter()
1097 .map(|h| WebhookRow {
1098 id: h.id.to_string(),
1099 url: h.url.clone().unwrap_or_default(),
1100 })
1101 .collect();
1102 writeln!(w, "{}", Table::new(rows))
1103}
1104
1105
1106
1107#[cfg(test)]
1108mod printer_tests {
1109 use super::*;
1110
1111 fn pr_fixture() -> PullRequest {
1112 PullRequest {
1113 number: 12,
1114 title: "Add pagination helpers".into(),
1115 head: PrBranch {
1116 git_ref: "feature/paging".into(),
1117 ..Default::default()
1118 },
1119 base: PrBranch {
1120 git_ref: "master".into(),
1121 ..Default::default()
1122 },
1123 ..Default::default()
1124 }
1125 }
1126
1127 #[test]
1128 fn one_pr_shows_merged_when_set() {
1129 let mut pr = pr_fixture();
1130 pr.merged = Some(true);
1131 let mut buf = Vec::new();
1132 one_pr(&mut buf, &pr).unwrap();
1133 let out = String::from_utf8(buf).unwrap();
1134 assert!(out.contains("merged: yes"));
1135 }
1136
1137 #[test]
1138 fn one_pr_shows_draft_when_draft_true() {
1139 let mut pr = pr_fixture();
1140 pr.draft = Some(true);
1141 let mut buf = Vec::new();
1142 one_pr(&mut buf, &pr).unwrap();
1143 let out = String::from_utf8(buf).unwrap();
1144 assert!(out.contains("[draft]"), "got: {out}");
1145 assert!(!out.contains("[open]"), "draft should replace open: {out}");
1146 }
1147
1148 #[test]
1149 fn pr_table_contains_number_title_and_branch() {
1150 let mut buf = Vec::new();
1151 pr_table(&mut buf, &[pr_fixture()]).unwrap();
1152 let out = String::from_utf8(buf).unwrap();
1153 assert!(out.contains("12"));
1154 assert!(out.contains("Add pagination helpers"));
1155 assert!(out.contains("feature/paging -> master"));
1156 }
1157
1158 #[test]
1159 fn one_issue_shows_number_and_title() {
1160 let issue = Issue {
1161 number: "88".into(),
1162 title: "Login fails with expired token".into(),
1163 html_url: "https://gitee.com/oschina/gitee-cli/issues/I88".into(),
1164 ..Default::default()
1165 };
1166
1167 let mut buf = Vec::new();
1168 one_issue(&mut buf, &issue).unwrap();
1169 let out = String::from_utf8(buf).unwrap();
1170 assert!(out.contains("#88"));
1171 assert!(out.contains("Login fails with expired token"));
1172 }
1173
1174 #[test]
1175 fn pr_diff_renders_git_header_and_no_text_fallback() {
1176 let with_patch = FileDiff {
1177 path: "pom.xml".into(),
1178 patch: Some("@@ -1 +1 @@\n-old\n+new".into()),
1179 ..Default::default()
1180 };
1181
1182 let without_patch = FileDiff {
1183 path: "logo.png".into(),
1184 ..Default::default()
1185 };
1186
1187 let mut buf = Vec::new();
1188 pr_diff(&mut buf, &[with_patch, without_patch]).unwrap();
1189 let out = String::from_utf8(buf).unwrap();
1190 assert!(out.contains("diff --git a/pom.xml b/pom.xml"));
1191 assert!(out.contains("@@ -1 +1 @@"));
1192 assert!(out.contains("(no text diff — binary or too large)"));
1193 }
1194
1195 #[test]
1196 fn gist_table_shows_id_and_description() {
1197 let gist = Gist {
1198 id: "abc123".into(),
1199 description: Some("test gist snippet".into()),
1200 updated_at: Some("2024-06-02T12:30:00+08:00".into()),
1201 files: Some(std::collections::BTreeMap::from([(
1202 "a.txt".into(),
1203 GistFile {
1204 size: Some(13),
1205 ..Default::default()
1206 },
1207 )])),
1208 ..Default::default()
1209 };
1210
1211 let mut buf = Vec::new();
1212 gist_table(&mut buf, &[gist]).unwrap();
1213 let out = String::from_utf8(buf).unwrap();
1214 assert!(out.contains("abc123"));
1215 assert!(out.contains("test gist snippet"));
1216 }
1217
1218 #[test]
1219 fn one_release_prints_asset_count_and_names() {
1220 let release = Release {
1221 tag_name: "v1.2.0".into(),
1222 name: Some("v1.2.0".into()),
1223 prerelease: Some(false),
1224 assets: Some(vec![
1225 ReleaseAsset {
1226 name: "gitee-linux-amd64.tar.xz".into(),
1227 browser_download_url: "https://example.com/linux".into(),
1228 },
1229 ReleaseAsset {
1230 name: "gitee-darwin-arm64.tar.xz".into(),
1231 browser_download_url: "https://example.com/darwin".into(),
1232 },
1233 ]),
1234 ..Default::default()
1235 };
1236
1237 let mut buf = Vec::new();
1238 one_release(&mut buf, &release).unwrap();
1239 let out = String::from_utf8(buf).unwrap();
1240 assert!(out.contains("2 asset(s)"));
1241 assert!(out.contains("gitee-linux-amd64.tar.xz"));
1242 assert!(out.contains("gitee-darwin-arm64.tar.xz"));
1243 }
1244
1245 #[test]
1246 fn comment_line_format() {
1247 let comment = Comment {
1248 body: "Looks good to me".into(),
1249 html_url: Some("https://gitee.com/oschina/gitee-cli/pulls/12#note_1".into()),
1250 user: Some(UserBasic {
1251 login: "dev1".into(),
1252 ..Default::default()
1253 }),
1254 ..Default::default()
1255 };
1256
1257 let mut buf = Vec::new();
1258 comment_line(&mut buf, &comment).unwrap();
1259 let out = String::from_utf8(buf).unwrap();
1260 assert!(out.contains("@dev1 commented:"));
1261 assert!(out.contains("Looks good to me"));
1262 assert!(out.contains("https://gitee.com/oschina/gitee-cli/pulls/12#note_1"));
1263 }
1264
1265 #[test]
1266 fn comment_table_shows_id_author_created_body() {
1267 let items = vec![Comment {
1268 id: 7,
1269 body: "thanks".into(),
1270 user: Some(UserBasic {
1271 login: "dev1".into(),
1272 ..Default::default()
1273 }),
1274 created_at: Some("2026-01-01T00:00:00+08:00".into()),
1275 ..Default::default()
1276 }];
1277 let mut buf = Vec::new();
1278 comment_table(&mut buf, &items).unwrap();
1279 let out = String::from_utf8(buf).unwrap();
1280 assert!(out.contains("7"));
1281 assert!(out.contains("dev1"));
1282 assert!(out.contains("2026-01-01T00:00:00+08:00"));
1283 assert!(out.contains("thanks"));
1284 }
1285
1286 #[test]
1287 fn pr_comment_table_shows_path_and_position_for_diff() {
1288 let items = vec![PrComment {
1289 id: 100,
1290 body: "nit".into(),
1291 user: Some(UserBasic {
1292 login: "rev".into(),
1293 ..Default::default()
1294 }),
1295 created_at: Some("2026-01-02T00:00:00+08:00".into()),
1296 path: Some("src/main.rs".into()),
1297 position: Some("42".into()),
1298 comment_type: Some("diff_comment".into()),
1299 ..Default::default()
1300 }];
1301 let mut buf = Vec::new();
1302 pr_comment_table(&mut buf, &items).unwrap();
1303 let out = String::from_utf8(buf).unwrap();
1304 assert!(out.contains("100"));
1305 assert!(out.contains("src/main.rs"));
1306 assert!(out.contains("42"));
1307 assert!(out.contains("nit"));
1308 }
1309
1310 #[test]
1311 fn user_table_contains_login_name_and_url() {
1312 let users = vec![UserBasic {
1313 login: "kip".into(),
1314 name: Some("Kip Yin".into()),
1315 html_url: Some("https://gitee.com/kip".into()),
1316 ..Default::default()
1317 }];
1318
1319 let mut buf = Vec::new();
1320 user_table(&mut buf, &users).unwrap();
1321 let out = String::from_utf8(buf).unwrap();
1322 assert!(out.contains("kip"));
1323 assert!(out.contains("Kip Yin"));
1324 assert!(out.contains("https://gitee.com/kip"));
1325 }
1326
1327 #[test]
1328 fn label_table_shows_name_color_and_id() {
1329 let labels = vec![Label {
1330 id: 42,
1331 name: "bug".into(),
1332 color: Some("ff0000".into()),
1333 }];
1334 let mut buf = Vec::new();
1335 label_table(&mut buf, &labels).unwrap();
1336 let out = String::from_utf8(buf).unwrap();
1337 assert!(out.contains("bug"));
1338 assert!(out.contains("#ff0000"));
1339 assert!(out.contains("42"));
1340 }
1341
1342 #[test]
1343 fn pr_commits_git_log_style_one_line_per_commit() {
1344 let items = vec![PrCommit {
1345 sha: "abc1234567890deadbeef00000000000000000000".into(),
1346 author: Some(UserBasic {
1347 login: "dev1".into(),
1348 ..Default::default()
1349 }),
1350 commit: Some(GitCommitInfo {
1351 message: Some("Add pagination helpers".into()),
1352 ..Default::default()
1353 }),
1354 ..Default::default()
1355 }];
1356 let mut buf = Vec::new();
1357 pr_commits(&mut buf, &items).unwrap();
1358 let out = String::from_utf8(buf).unwrap();
1359 assert_eq!(out, "abc1234 Add pagination helpers (dev1)\n");
1360 }
1361
1362 #[test]
1363 fn pr_status_renders_sections_and_titles() {
1364 let status = PrStatus {
1365 created: vec![pr_fixture()],
1366 assigned: vec![],
1367 awaiting_test: vec![PullRequest {
1368 number: 7,
1369 title: "Needs QA sign-off".into(),
1370 ..Default::default()
1371 }],
1372 };
1373
1374 let mut buf = Vec::new();
1375 pr_status(&mut buf, &status).unwrap();
1376 let out = String::from_utf8(buf).unwrap();
1377 assert!(out.contains("Created by me"));
1378 assert!(out.contains("Assigned to me"));
1379 assert!(out.contains("Awaiting my test"));
1380 assert!(out.contains("Add pagination helpers"));
1381 assert!(out.contains("Needs QA sign-off"));
1382 assert!(out.contains("(none)"));
1383 }
1384
1385 #[test]
1386 fn issue_status_renders_sections_and_empty_placeholder() {
1387 let status = IssueStatus {
1388 created: vec![Issue {
1389 number: "42".into(),
1390 title: "Broken deploy".into(),
1391 ..Default::default()
1392 }],
1393 assigned: vec![],
1394 };
1395
1396 let mut buf = Vec::new();
1397 issue_status(&mut buf, &status).unwrap();
1398 let out = String::from_utf8(buf).unwrap();
1399 assert!(out.contains("Created by me"));
1400 assert!(out.contains("Assigned to me"));
1401 assert!(out.contains("Broken deploy"));
1402 assert!(out.contains("(none)"));
1403 }
1404
1405 #[test]
1406 fn status_structs_serialize_expected_json_keys() {
1407 let pr_status = PrStatus {
1408 created: vec![pr_fixture()],
1409 assigned: vec![],
1410 awaiting_test: vec![],
1411 };
1412 let pr_json = serde_json::to_value(&pr_status).unwrap();
1413 assert!(pr_json.get("created").unwrap().is_array());
1414 assert!(pr_json.get("assigned").unwrap().is_array());
1415 assert!(pr_json.get("awaiting_test").unwrap().is_array());
1416 assert_eq!(pr_json["created"][0]["title"], "Add pagination helpers");
1417
1418 let issue_status = IssueStatus {
1419 created: vec![],
1420 assigned: vec![Issue {
1421 number: "1".into(),
1422 title: "Assigned item".into(),
1423 ..Default::default()
1424 }],
1425 };
1426 let issue_json = serde_json::to_value(&issue_status).unwrap();
1427 assert!(issue_json.get("created").unwrap().is_array());
1428 assert!(issue_json.get("assigned").unwrap().is_array());
1429 assert_eq!(issue_json["assigned"][0]["title"], "Assigned item");
1430 }
1431
1432}
1433#[derive(Serialize)]
1436pub struct Dashboard {
1437 pub assigned: Vec<Issue>,
1438 pub created: Vec<Issue>,
1439}
1440
1441#[derive(Tabled)]
1442struct DashboardIssueRow {
1443 repo: String,
1444 number: String,
1445 state: String,
1446 title: String,
1447}
1448
1449fn dashboard_issue_table(w: &mut impl Write, items: &[Issue]) -> std::io::Result<()> {
1450 let rows: Vec<DashboardIssueRow> = items
1451 .iter()
1452 .map(|i| DashboardIssueRow {
1453 repo: i
1454 .repository
1455 .as_ref()
1456 .and_then(|r| r.full_name.clone())
1457 .unwrap_or_default(),
1458 number: i.number.clone(),
1459 state: issue_state_style(i.state),
1460 title: i.title.clone(),
1461 })
1462 .collect();
1463 writeln!(w, "{}", Table::new(rows))
1464}
1465
1466pub fn dashboard(w: &mut impl Write, d: &Dashboard) -> std::io::Result<()> {
1467 writeln!(w, "{}", bold("Assigned to me"))?;
1468 if d.assigned.is_empty() {
1469 writeln!(w, "{}", dim("(none)"))?;
1470 } else {
1471 dashboard_issue_table(w, &d.assigned)?;
1472 }
1473 writeln!(w)?;
1474 writeln!(w, "{}", bold("Created by me"))?;
1475 if d.created.is_empty() {
1476 writeln!(w, "{}", dim("(none)"))?;
1477 } else {
1478 dashboard_issue_table(w, &d.created)?;
1479 }
1480 Ok(())
1481}
1482
1483#[cfg(test)]
1484mod dashboard_printer_tests {
1485 use super::*;
1486
1487 #[test]
1488 fn dashboard_shows_repo_when_set() {
1489 let d = Dashboard {
1490 assigned: vec![Issue {
1491 number: "1".into(),
1492 title: "Fix bug".into(),
1493 state: IssueState::Open,
1494 repository: Some(IssueRepoRef {
1495 full_name: Some("owner/repo".into()),
1496 ..Default::default()
1497 }),
1498 ..Default::default()
1499 }],
1500 created: vec![],
1501 };
1502
1503 let mut buf = Vec::new();
1504 dashboard(&mut buf, &d).unwrap();
1505 let out = String::from_utf8(buf).unwrap();
1506 assert!(out.contains("owner/repo"));
1507 assert!(out.contains("(none)"));
1508 }
1509
1510 #[test]
1511 fn dashboard_serialization_has_assigned_created_keys() {
1512 let d = Dashboard {
1513 assigned: vec![],
1514 created: vec![],
1515 };
1516 let value = serde_json::to_value(&d).unwrap();
1517 let obj = value.as_object().unwrap();
1518 assert!(obj.contains_key("assigned"));
1519 assert!(obj.contains_key("created"));
1520 }
1521
1522 #[test]
1523 fn dashboard_json_projection_keeps_section_arrays() {
1524 let d = Dashboard {
1525 assigned: vec![Issue {
1526 number: "1".into(),
1527 ..Default::default()
1528 }],
1529 created: vec![Issue {
1530 number: "2".into(),
1531 ..Default::default()
1532 }],
1533 };
1534 let value = serde_json::to_value(&d).unwrap();
1535 let fields = vec!["assigned".to_string(), "created".to_string()];
1536 let projected = project(value, &fields);
1537 let obj = projected.as_object().unwrap();
1538 assert_eq!(obj["assigned"].as_array().unwrap().len(), 1);
1539 assert_eq!(obj["created"].as_array().unwrap().len(), 1);
1540 }
1541}
1542