Parent [>]
1//! Route handlers for tag pages.
2//!
3//! Covers two routes: the tag list (`/{repo}/tags`) and the tag detail page
4//! (`/{repo}/tags/{*tag}`).  The detail handler peels the tag ref to its
5//! commit and presents a release-oriented view with the annotation message,
6//! commit info, and archive download links.
7
8use askama::Template;
9use askama_web::WebTemplate;
10use axum::extract::{Path, State};
11use axum::response::IntoResponse;
12use gix::bstr::ByteSlice;
13use serde::Deserialize;
14
15use crate::error::{Error, Result};
16use crate::git::{self, GitResultExt as _, commit_date, commit_timestamp};
17use crate::routes::{AppState, RepoName};
18
19#[derive(Deserialize)]
20pub(super) struct TagPath {
21    tag: String,
22}
23
24/// Template context for a single tag in the list.
25struct TagSummary {
26    name: String,
27    date: String,
28    timestamp: i64,
29}
30
31/// Template context for the tag list page.
32#[derive(Template, WebTemplate)]
33#[template(path = "tag_list.html")]
34struct TagList {
35    repo: String,
36    tags: Vec<TagSummary>,
37}
38
39/// Template context for the tag detail page.
40#[derive(Template, WebTemplate)]
41#[template(path = "tag_detail.html")]
42struct TagDetail {
43    repo: String,
44    tag: String,
45    /// Tagger name and date for annotated tags; `None` for lightweight tags.
46    tagger: Option<String>,
47    tag_date: Option<String>,
48    /// Annotation message for annotated tags; `None` for lightweight tags.
49    message: Option<String>,
50    commit_sha: String,
51    commit_author: String,
52    commit_date: String,
53}
54
55/// Renders the full list of tags for a repository, sorted newest first.
56pub async fn list(
57    State(state): State<AppState>,
58    RepoName(repo): RepoName,
59) -> Result<impl IntoResponse> {
60    let git_repo = git::open_repo(&state.root, &repo)?;
61
62    // `.tags()` returns a `Result<impl Iterator<Item = Result<Reference>>>`.
63    // The first `.flatten()` unwraps the outer `Result`, the second unwraps
64    // each `Result<Reference>`, silently discarding any errors.
65    let mut tags: Vec<TagSummary> = git::flatten_refs(git_repo.references().corrupt()?.tags())
66        .filter_map(|mut tag| {
67            let commit = tag.peel_to_commit().ok()?;
68            let date = commit_date(&commit)?;
69            let timestamp = commit_timestamp(&commit)?;
70            let name = tag.name().shorten().to_string();
71
72            Some(TagSummary {
73                name,
74                date,
75                timestamp,
76            })
77        })
78        .collect();
79
80    tags.sort_by_key(|b| std::cmp::Reverse(b.timestamp));
81
82    Ok(TagList { repo, tags })
83}
84
85/// Renders the detail page for a single tag.
86pub async fn detail(
87    State(state): State<AppState>,
88    RepoName(repo): RepoName,
89    Path(params): Path<TagPath>,
90) -> Result<impl IntoResponse> {
91    let tag_name = params.tag;
92
93    let git_repo = git::open_repo(&state.root, &repo)?;
94
95    let ref_name = format!("refs/tags/{tag_name}");
96    let mut reference = git_repo
97        .find_reference(&ref_name)
98        .map_err(|_| Error::NotFound(format!("tag {tag_name}")))?;
99
100    // Extract annotation info if this is an annotated tag.
101    let (tagger, tag_date, message) = reference
102        .peel_to_tag()
103        .ok()
104        .and_then(|t| {
105            let decoded = t.decode().ok()?;
106            let sig = decoded.tagger().ok().flatten();
107            let tagger = sig.as_ref().map(|s| s.name.to_str_lossy().into_owned());
108            let tag_date = sig.as_ref().and_then(|s| {
109                // Parse the raw "seconds offset" time string.
110                let mut parts = s.time.splitn(2, ' ');
111                let seconds: i64 = parts.next()?.parse().ok()?;
112                let offset_str = parts.next().unwrap_or("+0000");
113                let sign = if offset_str.starts_with('-') {
114                    -1i32
115                } else {
116                    1
117                };
118                let hhmm = offset_str.trim_start_matches(['+', '-']);
119                let h: i32 = hhmm.get(..2)?.parse().ok()?;
120                let m: i32 = hhmm.get(2..4)?.parse().ok()?;
121                let offset_secs = sign * (h * 3600 + m * 60);
122                let zone =
123                    jiff::tz::TimeZone::fixed(jiff::tz::Offset::from_seconds(offset_secs).ok()?);
124                jiff::Timestamp::from_second(seconds)
125                    .map(|ts| ts.to_zoned(zone).date().to_string())
126                    .ok()
127            });
128            let message = decoded.message.to_str_lossy().trim().to_string();
129            let message = if message.is_empty() {
130                None
131            } else {
132                Some(message)
133            };
134            Some((tagger, tag_date, message))
135        })
136        .unwrap_or((None, None, None));
137
138    let commit = reference.peel_to_commit().corrupt()?;
139
140    let commit_sha = commit.id().to_string();
141    let commit_author = commit
142        .author()
143        .map(|a| a.name.to_string())
144        .unwrap_or_default();
145    let commit_date = commit_date(&commit).unwrap_or_default();
146
147    Ok(TagDetail {
148        repo,
149        tag: tag_name,
150        tagger,
151        tag_date,
152        message,
153        commit_sha,
154        commit_author,
155        commit_date,
156    })
157}