Krolyxon
Tag v0.1.0

ferreniumsrchandlerssummary.rs

311 lines · 8.9 KB · Rust

1//! `/{repo}` — root tree, README and a rail with stats, clone URLs and the
2//! latest commit.
3
4use askama::Template;
5use axum::http::StatusCode;
6use axum::response::Response;
7
8use super::repo::{Active, Ctx, RepoNav, set_cache_headers, short};
9use super::tree::{Readme, TreeRow, listing, readme, rows};
10use super::{Layout, render};
11use crate::cache::Key;
12use crate::git::refs::{RefKind, Rev};
13use crate::git::{self, CommitInfo};
14use crate::index::languages::{self, Stats};
15use crate::render::time;
16
17pub struct Stat {
18 pub label: &'static str,
19 pub value: String,
20 pub title: Option<String>,
21 pub url: Option<String>,
22}
23
24pub struct CloneUrl {
25 pub label: String,
26 pub url: String,
27}
28
29/// The segmented language bar: 40 blocks, the top three languages at
30/// full, 70% and 45% strength, everything else at 25%.
31pub struct LangBar {
32 /// One class per block (`o0` … `o3`).
33 pub blocks: Vec<&'static str>,
34 pub legend: Vec<LangEntry>,
35}
36
37pub struct LangEntry {
38 pub name: String,
39 pub percent: String,
40 pub class: &'static str,
41}
42
43const BAR_BLOCKS: u64 = 40;
44const BAR_CLASSES: [&str; 4] = ["o0", "o1", "o2", "o3"];
45
46pub fn language_bar(stats: &Stats) -> Option<LangBar> {
47 if stats.total == 0 {
48 return None;
49 }
50 let mut segments: Vec<(String, u64, &'static str)> = stats
51 .languages
52 .iter()
53 .take(3)
54 .enumerate()
55 .map(|(i, (name, bytes))| (name.to_string(), *bytes, BAR_CLASSES[i]))
56 .collect();
57 let rest: u64 = stats.languages.iter().skip(3).map(|(_, n)| n).sum();
58 if rest > 0 {
59 segments.push(("Other".into(), rest, BAR_CLASSES[3]));
60 }
61 // Largest remainder, so the blocks always add up to 40.
62 let mut counts: Vec<(u64, u64, usize)> = segments
63 .iter()
64 .enumerate()
65 .map(|(i, (_, bytes, _))| {
66 let exact = bytes * BAR_BLOCKS;
67 (exact / stats.total, exact % stats.total, i)
68 })
69 .collect();
70 let mut left = BAR_BLOCKS - counts.iter().map(|c| c.0).sum::<u64>();
71 let mut order: Vec<usize> = (0..counts.len()).collect();
72 order.sort_by(|&a, &b| counts[b].1.cmp(&counts[a].1).then(a.cmp(&b)));
73 for i in order {
74 if left == 0 {
75 break;
76 }
77 counts[i].0 += 1;
78 left -= 1;
79 }
80 let blocks = counts
81 .iter()
82 .flat_map(|(n, _, i)| std::iter::repeat_n(segments[*i].2, *n as usize))
83 .collect();
84 let legend = segments
85 .into_iter()
86 .map(|(name, bytes, class)| LangEntry {
87 name,
88 percent: format!("{:.1}%", bytes as f64 * 100.0 / stats.total as f64),
89 class,
90 })
91 .collect();
92 Some(LangBar { blocks, legend })
93}
94
95pub struct Latest {
96 pub subject: String,
97 pub author: String,
98 pub age: String,
99 pub iso: String,
100 pub id: String,
101 pub url: String,
102}
103
104#[derive(Template)]
105#[template(path = "summary.html")]
106pub struct SummaryPage {
107 pub layout: Layout,
108 pub nav: RepoNav,
109 pub name: String,
110 pub description: Option<String>,
111 pub stats: Vec<Stat>,
112 pub clone_urls: Vec<CloneUrl>,
113 pub latest: Option<Latest>,
114 /// `None` for an empty repository.
115 pub rows: Option<Vec<TreeRow>>,
116 pub tree_url: String,
117 pub readme: Option<Readme>,
118 pub languages: Option<LangBar>,
119}
120
121pub fn page(ctx: &Ctx) -> Response {
122 let meta = &ctx.repo.meta;
123 let rev = meta
124 .default_branch
125 .clone()
126 .zip(meta.head)
127 .map(|(name, commit)| Rev {
128 name,
129 kind: RefKind::Branch,
130 commit,
131 });
132 let tree = rev
133 .as_ref()
134 .and_then(|r| languages::root_tree(&ctx.git, r.commit).ok());
135 let lang_stats = tree.and_then(|t| languages::cached(&ctx.state.cache, t));
136 if tree.is_some() && lang_stats.is_none() {
137 ctx.state.warmer.wake.notify_one();
138 }
139 let etag = ctx.etag((
140 "summary",
141 rev.as_ref().map(|r| r.commit),
142 lang_stats.is_some(),
143 ));
144 if let Some(res) = ctx.not_modified(&etag, false) {
145 return res;
146 }
147 let mut page = SummaryPage {
148 layout: Layout {
149 feed: Some(ctx.feed_url()),
150 ..Layout::new(ctx.state, ctx.config, Some(&ctx.repo.name))
151 },
152 nav: ctx.nav(Active::Summary, rev.as_ref(), |name| {
153 ctx.url("tree", name, b"")
154 }),
155 name: ctx.repo.name.clone(),
156 description: meta.description.clone(),
157 stats: Vec::new(),
158 clone_urls: clone_urls(ctx),
159 latest: None,
160 rows: None,
161 tree_url: String::new(),
162 readme: None,
163 languages: lang_stats.as_deref().and_then(language_bar),
164 };
165
166 if let Some(rev) = &rev {
167 let result = (|| -> anyhow::Result<()> {
168 let commits = ctx.state.cache.count(Key::CommitCount(rev.commit), || {
169 git::commit_count(&ctx.git, rev.commit)
170 })?;
171 let info = git::commit_info(&ctx.git, rev.commit)?;
172 page.stats = stats(ctx, Some(commits));
173 page.latest = Some(latest(ctx, &info));
174 let root = git::tree::lookup(&ctx.git, rev.commit, b"")?;
175 if let Some(git::tree::PathTarget::Tree(tree_id)) = root {
176 page.rows = Some(rows(ctx, rev, b"", &listing(ctx, tree_id)?));
177 }
178 page.tree_url = ctx.url("tree", &rev.name, b"");
179 page.readme = meta.readme.as_ref().and_then(|r| {
180 let (dir, name) = r.path.rsplit_once('/').unwrap_or(("", &r.path));
181 readme(ctx, rev, dir.as_bytes(), name, r.oid)
182 });
183 Ok(())
184 })();
185 if let Err(e) = result {
186 return ctx.internal_error(e);
187 }
188 } else {
189 page.stats = stats(ctx, None);
190 }
191
192 let mut res = render(StatusCode::OK, &page);
193 set_cache_headers(&mut res, &etag, false);
194 res
195}
196
197fn stats(ctx: &Ctx, commits: Option<u64>) -> Vec<Stat> {
198 let meta = &ctx.repo.meta;
199 let mut stats = vec![
200 Stat {
201 label: "Commits",
202 value: commits.unwrap_or(0).to_string(),
203 title: None,
204 url: commits.map(|_| format!("{}/log", ctx.repo_url())),
205 },
206 Stat {
207 label: "Branches",
208 value: meta.branches.to_string(),
209 title: None,
210 url: None,
211 },
212 Stat {
213 label: "Tags",
214 value: meta.tags.to_string(),
215 title: None,
216 url: None,
217 },
218 ];
219 if let Some(t) = meta.last_activity {
220 stats.push(Stat {
221 label: "Updated",
222 value: time::relative(t, time::now()),
223 title: Some(time::iso(t)),
224 url: None,
225 });
226 }
227 if let Some(owner) = &meta.owner {
228 stats.push(Stat {
229 label: "Owner",
230 value: owner.clone(),
231 title: None,
232 url: None,
233 });
234 }
235 stats
236}
237
238fn latest(ctx: &Ctx, info: &CommitInfo) -> Latest {
239 let id = info.id.to_string();
240 Latest {
241 subject: info.subject.clone(),
242 author: ctx
243 .mailmap()
244 .resolve(&info.author_name, &info.author_email)
245 .0,
246 age: time::relative(info.committer_time, time::now()),
247 iso: time::iso(info.committer_time),
248 url: ctx.commit_url(&info.id),
249 id: short(&id),
250 }
251}
252
253/// `site.clone_urls` with `{name}` filled in, labeled by protocol.
254fn clone_urls(ctx: &Ctx) -> Vec<CloneUrl> {
255 ctx.config
256 .site
257 .clone_urls
258 .iter()
259 .map(|template| {
260 let url = template.replace("{name}", &ctx.repo.name);
261 let label = match url.split_once("://") {
262 Some(("ssh", _)) => "SSH".to_string(),
263 Some((scheme, _)) => scheme.to_ascii_uppercase(),
264 // scp-like `user@host:path`
265 None => "SSH".to_string(),
266 };
267 CloneUrl { label, url }
268 })
269 .collect()
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn bar_blocks_add_up() {
278 let stats = Stats {
279 languages: vec![
280 ("Rust", 700),
281 ("Shell", 200),
282 ("CSS", 60),
283 ("HTML", 30),
284 ("Nix", 10),
285 ],
286 total: 1000,
287 };
288 let bar = language_bar(&stats).unwrap();
289 assert_eq!(bar.blocks.len(), 40);
290 let count = |c| bar.blocks.iter().filter(|b| **b == c).count();
291 assert_eq!(
292 (count("o0"), count("o1"), count("o2"), count("o3")),
293 (28, 8, 2, 2)
294 );
295 let legend: Vec<_> = bar
296 .legend
297 .iter()
298 .map(|l| (l.name.as_str(), l.percent.as_str()))
299 .collect();
300 assert_eq!(
301 legend,
302 [
303 ("Rust", "70.0%"),
304 ("Shell", "20.0%"),
305 ("CSS", "6.0%"),
306 ("Other", "4.0%")
307 ]
308 );
309 assert!(language_bar(&Stats::default()).is_none());
310 }
311}