development
rs 410 lines 11.8 KB
Raw
1 //! Human-readable renderers. Each is the `--json` counterpart of one command's
2 //! output; see [`crate::ui::emit`].
3
4 use {
5 super::{bold, dim, highlight, relative_time, state_badge},
6 sigit_si_api::models::{
7 Billing, CloudSession, CodeHit, Comment, Hook, Issue, IssueSummary, PullRequest,
8 PullRequestSummary, Repository, RepositorySummary, User,
9 },
10 tabled::{builder::Builder, settings::Style},
11 };
12
13 /// Print a table, or a placeholder line when there is nothing to show.
14 fn table(headers: &[&str], rows: Vec<Vec<String>>, empty: &str) {
15 if rows.is_empty() {
16 println!("{}", dim(empty));
17 return;
18 }
19
20 let mut builder = Builder::default();
21 builder.push_record(headers.iter().map(|h| h.to_string()));
22 for row in rows {
23 builder.push_record(row);
24 }
25
26 let mut table = builder.build();
27 table.with(Style::rounded());
28 println!("{table}");
29 }
30
31 /// A `label value` line, skipped when the value is empty.
32 fn field(label: &str, value: Option<&str>) {
33 if let Some(value) = value.map(str::trim).filter(|v| !v.is_empty()) {
34 println!(" {:<14}{}", label, value);
35 }
36 }
37
38 fn truncate(text: &str, max: usize) -> String {
39 let text = text.replace(['\n', '\r'], " ");
40 if text.chars().count() <= max {
41 return text;
42 }
43 let kept: String = text.chars().take(max.saturating_sub(1)).collect();
44 format!("{kept}…")
45 }
46
47 // ── Account ─────────────────────────────────────────────────────────────────
48
49 pub fn user(user: &User) {
50 println!();
51 println!(" {}", highlight(&user.username));
52 field("Name", user.display_name.as_deref());
53 field("Email", Some(&user.email));
54 field("ID", Some(&user.id.to_string()));
55 field(
56 "Joined",
57 Some(&user.created_at.format("%Y-%m-%d").to_string()),
58 );
59 println!();
60 }
61
62 // ── Repositories ────────────────────────────────────────────────────────────
63
64 pub fn repos(repos: &[Repository]) {
65 let rows = repos
66 .iter()
67 .map(|repo| {
68 vec![
69 repo.full_name.clone(),
70 repo.visibility().to_string(),
71 truncate(repo.description.as_deref().unwrap_or("—"), 48),
72 repo.stars_count.to_string(),
73 relative_time(repo.updated_at),
74 ]
75 })
76 .collect();
77
78 table(
79 &[
80 "Repository",
81 "Visibility",
82 "Description",
83 "Stars",
84 "Updated",
85 ],
86 rows,
87 "You have no repositories yet. Create one with `si repo create <name>`.",
88 );
89 }
90
91 pub fn repo_summaries(repos: &[RepositorySummary]) {
92 let rows = repos
93 .iter()
94 .map(|repo| {
95 vec![
96 repo.full_name.clone(),
97 if repo.private { "private" } else { "public" }.to_string(),
98 truncate(repo.description.as_deref().unwrap_or("—"), 56),
99 repo.default_branch.clone(),
100 ]
101 })
102 .collect();
103
104 table(
105 &["Repository", "Visibility", "Description", "Branch"],
106 rows,
107 "No repositories matched.",
108 );
109 }
110
111 pub fn repo_detail(repo: &Repository) {
112 println!();
113 println!(
114 " {} {}",
115 highlight(&repo.full_name),
116 dim(repo.visibility())
117 );
118 if let Some(description) = repo.description.as_deref().filter(|d| !d.is_empty()) {
119 println!(" {description}");
120 }
121 println!();
122 field("Default branch", Some(&repo.default_branch));
123 field("Stars", Some(&repo.stars_count.to_string()));
124 field(
125 "State",
126 Some(if repo.initialized {
127 "initialized"
128 } else {
129 "empty — push something to get started"
130 }),
131 );
132 field("Updated", Some(&relative_time(repo.updated_at)));
133 field("Clone", Some(&repo.clone_url));
134 field("Web", Some(&repo.web_url));
135 println!();
136 }
137
138 // ── Pull requests ───────────────────────────────────────────────────────────
139
140 pub fn pulls(pulls: &[PullRequestSummary]) {
141 let rows = pulls
142 .iter()
143 .map(|pr| {
144 vec![
145 format!("#{}", pr.number),
146 truncate(&pr.title, 52),
147 state_badge(&pr.state),
148 format!("{} → {}", pr.head, pr.base),
149 ]
150 })
151 .collect();
152
153 table(
154 &["#", "Title", "State", "Branch"],
155 rows,
156 "No pull requests match this filter.",
157 );
158 }
159
160 pub fn pull_detail(pr: &PullRequest) {
161 println!();
162 println!(" {}", bold(&pr.title));
163 println!(
164 " {} · {} · {} wants to merge {} into {}",
165 dim(&format!("#{}", pr.number)),
166 state_badge(&pr.state),
167 pr.author,
168 highlight(&pr.head),
169 highlight(&pr.base),
170 );
171 println!();
172
173 if let Some(body) = pr.body.as_deref().filter(|b| !b.trim().is_empty()) {
174 println!("{body}");
175 println!();
176 }
177
178 comments(&pr.comments);
179
180 if let Some(note) = &pr.diff_note {
181 println!(" {}", dim(note));
182 }
183 let changed = pr.diff.as_deref().map_or(0, count_changed_files);
184 if changed > 0 {
185 println!(
186 " {}",
187 dim(&format!(
188 "{changed} changed file(s) — see them with `si pr diff {}`",
189 pr.number
190 ))
191 );
192 }
193 println!(" {}", dim(&pr.url));
194 println!();
195 }
196
197 /// Count `diff --git` headers, which is one per changed file.
198 fn count_changed_files(diff: &str) -> usize {
199 diff.lines()
200 .filter(|l| l.starts_with("diff --git "))
201 .count()
202 }
203
204 // ── Issues ──────────────────────────────────────────────────────────────────
205
206 pub fn issues(issues: &[IssueSummary]) {
207 let rows = issues
208 .iter()
209 .map(|issue| {
210 vec![
211 format!("#{}", issue.number),
212 truncate(&issue.title, 60),
213 state_badge(&issue.state),
214 issue.author.clone(),
215 ]
216 })
217 .collect();
218
219 table(
220 &["#", "Title", "State", "Author"],
221 rows,
222 "No issues match this filter.",
223 );
224 }
225
226 pub fn issue_detail(issue: &Issue) {
227 println!();
228 println!(" {}", bold(&issue.title));
229 println!(
230 " {} · {} · opened by {}",
231 dim(&format!("#{}", issue.number)),
232 state_badge(&issue.state),
233 issue.author
234 );
235 println!();
236
237 if let Some(body) = issue.body.as_deref().filter(|b| !b.trim().is_empty()) {
238 println!("{body}");
239 println!();
240 }
241
242 comments(&issue.comments);
243 println!(" {}", dim(&issue.url));
244 println!();
245 }
246
247 pub fn comments(comments: &[Comment]) {
248 for comment in comments {
249 println!(
250 " {} {}",
251 highlight(&comment.author),
252 dim(&relative_time(comment.created_at))
253 );
254 for line in comment.body.lines() {
255 println!(" {line}");
256 }
257 println!();
258 }
259 }
260
261 // ── Code search ─────────────────────────────────────────────────────────────
262
263 pub fn code_hits(hits: &[CodeHit]) {
264 if hits.is_empty() {
265 println!("{}", dim("No matches."));
266 return;
267 }
268
269 for hit in hits {
270 println!(
271 "{}{}{}",
272 highlight(&hit.path),
273 dim(":"),
274 dim(&hit.line.to_string())
275 );
276 println!(" {}", hit.snippet.trim_end());
277 }
278 }
279
280 // ── Webhooks ────────────────────────────────────────────────────────────────
281
282 pub fn hooks(hooks: &[Hook]) {
283 let rows = hooks
284 .iter()
285 .map(|hook| {
286 vec![
287 hook.id.to_string(),
288 truncate(&hook.url, 52),
289 hook.events.join(", "),
290 if hook.active { "active" } else { "paused" }.to_string(),
291 hook.last_delivered_at
292 .map(relative_time)
293 .unwrap_or_else(|| "never".to_string()),
294 ]
295 })
296 .collect();
297
298 table(
299 &["ID", "URL", "Events", "State", "Last delivery"],
300 rows,
301 "This repository has no webhooks.",
302 );
303 }
304
305 // ── Cloud Sessions ──────────────────────────────────────────────────────────
306
307 pub fn sessions(sessions: &[CloudSession]) {
308 let rows = sessions
309 .iter()
310 .map(|session| {
311 vec![
312 session.id.to_string(),
313 truncate(session.title.as_deref().unwrap_or("(untitled)"), 48),
314 session.model.clone().unwrap_or_else(|| "—".to_string()),
315 session
316 .last_message_at
317 .map(relative_time)
318 .unwrap_or_else(|| "—".to_string()),
319 ]
320 })
321 .collect();
322
323 table(
324 &["ID", "Title", "Model", "Last message"],
325 rows,
326 "You have no cloud sessions yet. Start one with `si session new`.",
327 );
328 }
329
330 pub fn session_detail(session: &CloudSession) {
331 println!();
332 println!(
333 " {}",
334 highlight(session.title.as_deref().unwrap_or("(untitled session)"))
335 );
336 field("ID", Some(&session.id.to_string()));
337 field("Model", session.model.as_deref());
338 field("Created", Some(&relative_time(session.created_at)));
339 println!();
340
341 let Some(messages) = &session.messages else {
342 return;
343 };
344 if messages.is_empty() {
345 println!("{}", dim(" No messages yet."));
346 println!();
347 return;
348 }
349
350 for message in messages {
351 println!(
352 " {} {}",
353 highlight(&message.role),
354 dim(&relative_time(message.created_at))
355 );
356 for line in message.content.lines() {
357 println!(" {line}");
358 }
359 println!();
360 }
361 }
362
363 // ── Billing ─────────────────────────────────────────────────────────────────
364
365 pub fn billing(billing: &Billing) {
366 println!();
367 println!(" {} {}", highlight(&billing.plan), dim(&billing.status));
368 field(
369 "Cloud",
370 Some(if billing.entitled_to_cloud {
371 "entitled"
372 } else {
373 "not included on this plan"
374 }),
375 );
376 field(
377 "Usage",
378 Some(&format!(
379 "{} / {} requests",
380 billing.cloud_requests_used, billing.cloud_allowance
381 )),
382 );
383 if let Some(period_end) = billing.current_period_end {
384 field("Renews", Some(&period_end.format("%Y-%m-%d").to_string()));
385 }
386 println!();
387 }
388
389 #[cfg(test)]
390 mod tests {
391 use super::*;
392
393 #[test]
394 fn long_text_is_truncated_with_an_ellipsis() {
395 assert_eq!(truncate("abcdefghij", 5), "abcd…");
396 assert_eq!(truncate("abc", 5), "abc");
397 }
398
399 #[test]
400 fn newlines_are_flattened_so_a_row_stays_one_line() {
401 assert_eq!(truncate("a\nb", 10), "a b");
402 }
403
404 #[test]
405 fn changed_files_are_counted_from_diff_headers() {
406 let diff = "diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\ndiff --git a/y b/y\n";
407 assert_eq!(count_changed_files(diff), 2);
408 assert_eq!(count_changed_files(""), 0);
409 }
410 }