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::routes::{AppState, RepoName, commit_date, commit_timestamp};
17
18#[derive(Deserialize)]
19pub(super) struct TagPath {
20    tag: String,
21}
22
23/// Template context for a single tag in the list.
24struct TagSummary {
25    name: String,
26    date: String,
27    timestamp: i64,
28}
29
30/// Template context for the tag list page.
31#[derive(Template, WebTemplate)]
32#[template(path = "tag_list.html")]
33struct TagList {
34    repo: String,
35    tags: Vec<TagSummary>,
36}
37
38/// Template context for the tag detail page.
39#[derive(Template, WebTemplate)]
40#[template(path = "tag_detail.html")]
41struct TagDetail {
42    repo: String,
43    tag: String,
44    /// Tagger name and date for annotated tags; `None` for lightweight tags.
45    tagger: Option<String>,
46    tag_date: Option<String>,
47    /// Annotation message for annotated tags; `None` for lightweight tags.
48    message: Option<String>,
49    commit_sha: String,
50    commit_author: String,
51    commit_date: String,
52}
53
54/// Renders the full list of tags for a repository, sorted newest first.
55pub async fn list(
56    State(state): State<AppState>,
57    RepoName(repo): RepoName,
58) -> Result<impl IntoResponse> {
59    let git_repo =
60        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;
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_repo
66        .references()
67        .map_err(|e| Error::GitCorrupt(e.to_string()))?
68        .tags()
69        .into_iter()
70        .flatten()
71        .flatten()
72        .filter_map(|mut tag| {
73            let commit = tag.peel_to_commit().ok()?;
74            let date = commit_date(&commit)?;
75            let timestamp = commit_timestamp(&commit)?;
76            let name = tag.name().shorten().to_string();
77
78            Some(TagSummary {
79                name,
80                date,
81                timestamp,
82            })
83        })
84        .collect();
85
86    tags.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
87
88    Ok(TagList { repo, tags })
89}
90
91/// Renders the detail page for a single tag.
92pub async fn detail(
93    State(state): State<AppState>,
94    RepoName(repo): RepoName,
95    Path(params): Path<TagPath>,
96) -> Result<impl IntoResponse> {
97    let tag_name = params.tag;
98
99    let git_repo =
100        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;
101
102    let ref_name = format!("refs/tags/{tag_name}");
103    let mut reference = git_repo
104        .find_reference(&ref_name)
105        .map_err(|_| Error::NotFound(format!("tag {tag_name}")))?;
106
107    // Extract annotation info if this is an annotated tag.
108    let (tagger, tag_date, message) = match reference.peel_to_tag().ok() {
109        None => (None, None, None),
110        Some(t) => match t.decode() {
111            Err(_) => (None, None, None),
112            Ok(decoded) => {
113                let sig = decoded.tagger().ok().flatten();
114                let tagger = sig.as_ref().map(|s| s.name.to_str_lossy().into_owned());
115                let tag_date = sig.as_ref().and_then(|s| {
116                    // Parse the raw "seconds offset" time string.
117                    let mut parts = s.time.splitn(2, ' ');
118                    let seconds: i64 = parts.next()?.parse().ok()?;
119                    let offset_str = parts.next().unwrap_or("+0000");
120                    let sign = if offset_str.starts_with('-') {
121                        -1i32
122                    } else {
123                        1
124                    };
125                    let hhmm = offset_str.trim_start_matches(['+', '-']);
126                    let h: i32 = hhmm.get(..2)?.parse().ok()?;
127                    let m: i32 = hhmm.get(2..4)?.parse().ok()?;
128                    let offset_secs = sign * (h * 3600 + m * 60);
129                    let zone = jiff::tz::TimeZone::fixed(
130                        jiff::tz::Offset::from_seconds(offset_secs).ok()?,
131                    );
132                    jiff::Timestamp::from_second(seconds)
133                        .map(|ts| ts.to_zoned(zone).date().to_string())
134                        .ok()
135                });
136                let message = decoded.message.to_str_lossy().trim().to_string();
137                let message = if message.is_empty() {
138                    None
139                } else {
140                    Some(message)
141                };
142                (tagger, tag_date, message)
143            }
144        },
145    };
146
147    let commit = reference
148        .peel_to_commit()
149        .map_err(|e| Error::GitCorrupt(e.to_string()))?;
150
151    let commit_sha = commit.id().to_string();
152    let commit_author = commit
153        .author()
154        .map(|a| a.name.to_string())
155        .unwrap_or_default();
156    let commit_date = commit_date(&commit).unwrap_or_default();
157
158    Ok(TagDetail {
159        repo,
160        tag: tag_name,
161        tagger,
162        tag_date,
163        message,
164        commit_sha,
165        commit_author,
166        commit_date,
167    })
168}