1use crate::command::GitCommand;
31use crate::command::for_each_ref::ForEachRefCommand;
32use crate::command::tag::TagCommand;
33use crate::error::Result;
34use crate::repo::Repository;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39pub struct Tag {
40 pub name: String,
42 pub kind: TagKind,
45 pub target: String,
47 pub message: Option<String>,
49 pub tagger: Option<Tagger>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56pub enum TagKind {
57 Lightweight,
59 Annotated,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub struct Tagger {
67 pub name: String,
69 pub email: String,
71 pub date: String,
73}
74
75#[derive(Debug)]
77pub struct TagOps<'a> {
78 repo: &'a Repository,
79}
80
81impl<'a> TagOps<'a> {
82 pub async fn list(&self) -> Result<Vec<Tag>> {
84 self.list_inner(None).await
85 }
86
87 pub async fn list_matching(&self, pattern: impl Into<String>) -> Result<Vec<Tag>> {
90 self.list_inner(Some(pattern.into())).await
91 }
92
93 pub async fn create(&self, name: impl Into<String>, target: impl Into<String>) -> Result<()> {
95 let mut cmd = TagCommand::new();
96 cmd.name(name).commit(target);
97 cmd.current_dir(self.repo.path());
98 cmd.execute().await?;
99 Ok(())
100 }
101
102 pub async fn create_annotated(
104 &self,
105 name: impl Into<String>,
106 target: impl Into<String>,
107 message: impl Into<String>,
108 ) -> Result<()> {
109 let mut cmd = TagCommand::new();
110 cmd.name(name).commit(target).message(message);
111 cmd.current_dir(self.repo.path());
112 cmd.execute().await?;
113 Ok(())
114 }
115
116 pub async fn delete(&self, name: impl Into<String>) -> Result<()> {
118 let mut cmd = TagCommand::new();
119 cmd.name(name).delete();
120 cmd.current_dir(self.repo.path());
121 cmd.execute().await?;
122 Ok(())
123 }
124
125 async fn list_inner(&self, pattern: Option<String>) -> Result<Vec<Tag>> {
126 let mut cmd = ForEachRefCommand::new();
127 cmd.format(FORMAT.to_string())
128 .pattern(pattern.unwrap_or_else(|| "refs/tags/".to_string()));
129 cmd.current_dir(self.repo.path());
130 let out = cmd.execute().await?;
131 parse_tags(&out.stdout)
132 }
133}
134
135impl Repository {
136 #[must_use]
138 pub fn tags(&self) -> TagOps<'_> {
139 TagOps { repo: self }
140 }
141}
142
143const FORMAT: &str = concat!(
148 "%(refname:short)",
149 "%00",
150 "%(objecttype)",
151 "%00",
152 "%(objectname:short)",
153 "%00",
154 "%(*objectname:short)",
155 "%00",
156 "%(contents:subject)",
157 "%00",
158 "%(taggername)",
159 "%00",
160 "%(taggeremail)",
161 "%00",
162 "%(taggerdate:iso-strict)",
163);
164
165fn parse_tags(stdout: &str) -> Result<Vec<Tag>> {
166 let mut out = Vec::new();
167 for line in stdout.lines() {
168 if line.is_empty() {
169 continue;
170 }
171 let fields: Vec<&str> = line.split('\0').collect();
172 if fields.len() < 8 {
173 return Err(crate::error::Error::parse_error(format!(
174 "tag record has {} fields, expected 8: {line:?}",
175 fields.len()
176 )));
177 }
178 let kind = if fields[1] == "tag" {
179 TagKind::Annotated
180 } else {
181 TagKind::Lightweight
182 };
183 let target = match kind {
184 TagKind::Annotated => fields[3].to_string(),
185 TagKind::Lightweight => fields[2].to_string(),
186 };
187 let (message, tagger) = match kind {
188 TagKind::Annotated => {
189 let msg = if fields[4].is_empty() {
190 None
191 } else {
192 Some(fields[4].to_string())
193 };
194 let email = fields[6]
195 .trim_start_matches('<')
196 .trim_end_matches('>')
197 .to_string();
198 let tagger = if fields[5].is_empty() && email.is_empty() {
199 None
200 } else {
201 Some(Tagger {
202 name: fields[5].to_string(),
203 email,
204 date: fields[7].to_string(),
205 })
206 };
207 (msg, tagger)
208 }
209 TagKind::Lightweight => (None, None),
210 };
211 out.push(Tag {
212 name: fields[0].to_string(),
213 kind,
214 target,
215 message,
216 tagger,
217 });
218 }
219 Ok(out)
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn parses_lightweight_tag() {
228 let input = "v0.1\0commit\0abc1234\0\0\0\0\0\n";
229 let tags = parse_tags(input).unwrap();
230 assert_eq!(tags.len(), 1);
231 assert_eq!(tags[0].name, "v0.1");
232 assert_eq!(tags[0].kind, TagKind::Lightweight);
233 assert_eq!(tags[0].target, "abc1234");
234 assert!(tags[0].message.is_none());
235 assert!(tags[0].tagger.is_none());
236 }
237
238 #[test]
239 fn parses_annotated_tag() {
240 let input = "v1.0\0tag\0deadbeef\0abc1234\0release 1.0\0Alice\0<alice@example.com>\x002026-04-01T12:00:00+00:00\n";
241 let tags = parse_tags(input).unwrap();
242 assert_eq!(tags.len(), 1);
243 let t = &tags[0];
244 assert_eq!(t.name, "v1.0");
245 assert_eq!(t.kind, TagKind::Annotated);
246 assert_eq!(t.target, "abc1234");
247 assert_eq!(t.message.as_deref(), Some("release 1.0"));
248 let tagger = t.tagger.as_ref().unwrap();
249 assert_eq!(tagger.name, "Alice");
250 assert_eq!(tagger.email, "alice@example.com");
251 assert_eq!(tagger.date, "2026-04-01T12:00:00+00:00");
252 }
253
254 #[test]
255 fn parses_mixed_records() {
256 let input = concat!(
257 "lw\0commit\0aaaaaaa\0\0\0\0\0\n",
258 "ann\0tag\0bbbbbbb\0ccccccc\0msg\0Bob\0<b@example.com>\x002026-01-01T00:00:00+00:00\n",
259 );
260 let tags = parse_tags(input).unwrap();
261 assert_eq!(tags.len(), 2);
262 assert_eq!(tags[0].kind, TagKind::Lightweight);
263 assert_eq!(tags[1].kind, TagKind::Annotated);
264 assert_eq!(tags[1].target, "ccccccc");
265 }
266}