Skip to main content

gitee_cli_rs/
out.rs

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    /// Render either as JSON (when `--json` was given) or via the human printer.
16    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
59/// Apply a jq expression to the (already projected) value. Each result prints
60/// on its own line: string scalars unquoted, everything else as compact JSON.
61fn 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
76/// Evaluate `expr` against `value` with the pure-Rust jaq engine (no C libjq).
77/// Setup follows the jaq 3.x idiom: core+std+json defs/funs, compile once, then
78/// run over a JustLut context (no `inputs` support — we evaluate a single value).
79fn 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
111/// jaq's `Val` is a JSON superset (byte strings, non-string keys) with no
112/// Serialize impl, so convert back by hand. Plain-JSON results round-trip
113/// exactly; byte strings decode lossy-UTF-8; non-string object keys error.
114fn 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        // Num's Display is its JSON spelling; parse it back as a JSON number.
121        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
151/// Project a JSON value down to the requested fields. Arrays project each
152/// element; objects keep only listed keys; scalars pass through unchanged.
153fn 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
277// --- color --------------------------------------------------------------
278
279fn 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
317/// Style a PR state. `merged` flags merged-ness even when state == closed.
318/// Open drafts show as `draft` (not `open`).
319fn 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
333/// Style an issue state.
334pub(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// --- pull requests ------------------------------------------------------
343
344#[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
399/// Colorize one line of unified diff output.
400pub 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// --- milestones ---------------------------------------------------------
437
438#[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// --- issues -------------------------------------------------------------
532
533#[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// --- labels -------------------------------------------------------------
711
712#[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
731/// One line per commit: `<short-sha> <subject> (author)`.
732pub 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// --- releases -----------------------------------------------------------
747
748#[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// --- gists --------------------------------------------------------------
806
807#[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// --- repositories -------------------------------------------------------
872
873#[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// --- users --------------------------------------------------------------
938
939#[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
965/// Reviewer list with accept status (审查通过 / pending).
966pub 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
988/// Tester list (测试人).
989pub 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// --- org / ssh-key / collaborator / webhook -----------------------------
1002
1003#[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// --- status dashboard ---------------------------------------------------
1434
1435#[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