git_testament_derive/
lib.rs

1//! Derive macro for `git_testament`
2//!
3extern crate proc_macro;
4
5use std::env;
6use std::error::Error;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9
10use proc_macro::TokenStream;
11use proc_macro2::Span;
12use quote::quote;
13use syn::parse::{Parse, ParseStream};
14use syn::{parse, Visibility};
15use syn::{parse_macro_input, Ident, LitStr};
16
17use log::warn;
18
19use time::{format_description::FormatItem, macros::format_description, OffsetDateTime, UtcOffset};
20
21const DATE_FORMAT: &[FormatItem<'_>] = format_description!("[year]-[month]-[day]");
22
23struct TestamentOptions {
24    crate_: Ident,
25    name: Ident,
26    vis: Option<Visibility>,
27}
28
29impl Parse for TestamentOptions {
30    fn parse(input: ParseStream) -> parse::Result<Self> {
31        let crate_ = input.parse()?;
32        let name = input.parse()?;
33        let vis = if input.is_empty() {
34            None
35        } else {
36            Some(input.parse()?)
37        };
38        Ok(TestamentOptions { crate_, name, vis })
39    }
40}
41
42struct StaticTestamentOptions {
43    crate_: Ident,
44    name: Ident,
45    trusted: Option<LitStr>,
46}
47
48impl Parse for StaticTestamentOptions {
49    fn parse(input: ParseStream) -> parse::Result<Self> {
50        Ok(StaticTestamentOptions {
51            crate_: input.parse()?,
52            name: input.parse()?,
53            trusted: input.parse()?,
54        })
55    }
56}
57
58fn run_git<GD>(dir: GD, args: &[&str]) -> Result<Vec<u8>, Box<dyn Error>>
59where
60    GD: AsRef<Path>,
61{
62    let output = Command::new("git")
63        .args(args)
64        .stdin(Stdio::null())
65        .current_dir(dir)
66        .output()?;
67    if output.status.success() {
68        Ok(output.stdout)
69    } else {
70        Err(String::from_utf8(output.stderr)?.into())
71    }
72}
73
74fn find_git_dir() -> Result<PathBuf, Box<dyn Error>> {
75    // run git rev-parse --show-toplevel in the MANIFEST DIR
76    let dir = run_git(
77        env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR env variable not set"),
78        &["rev-parse", "--show-toplevel"],
79    )?;
80    // TODO: Find a way to go from the stdout to a pathbuf cleanly
81    // without relying on utf8ness
82    Ok(String::from_utf8(dir)?.trim_end().into())
83}
84
85fn revparse_single(git_dir: &Path, refname: &str) -> Result<(String, i64, i32), Box<dyn Error>> {
86    // TODO: Again, try and remove UTF8 assumptions somehow
87    let sha = String::from_utf8(run_git(git_dir, &["rev-parse", refname])?)?
88        .trim_end()
89        .to_owned();
90    let show = String::from_utf8(run_git(git_dir, &["cat-file", "-p", &sha])?)?;
91
92    for line in show.lines() {
93        if line.starts_with("committer ") {
94            let parts: Vec<&str> = line.split_whitespace().collect();
95            if parts.len() < 2 {
96                return Err(format!("Insufficient committer data in {line}").into());
97            }
98            let time: i64 = parts[parts.len() - 2].parse()?;
99            let offset: &str = parts[parts.len() - 1];
100            if offset.len() != 5 {
101                return Err(
102                    format!("Insufficient/Incorrect data in timezone offset: {offset}").into(),
103                );
104            }
105            let hours: i32 = offset[1..=2].parse()?;
106            let mins: i32 = offset[3..=4].parse()?;
107            let absoffset: i32 = mins + (hours * 60);
108            let offset: i32 = if offset.starts_with('-') {
109                // Negative...
110                -absoffset
111            } else {
112                // Positive...
113                absoffset
114            };
115            return Ok((sha, time, offset));
116        } else if line.is_empty() {
117            // Ran out of input, without finding committer
118            return Err(format!("Unable to find committer information in {refname}").into());
119        }
120    }
121
122    Err("Somehow fell off the end of the commit data".into())
123}
124
125fn branch_name(dir: &Path) -> Result<Option<String>, Box<dyn Error>> {
126    let symref = match run_git(dir, &["symbolic-ref", "-q", "HEAD"]) {
127        Ok(s) => s,
128        Err(_) => run_git(dir, &["name-rev", "--name-only", "HEAD"])?,
129    };
130    let mut name = String::from_utf8(symref)?.trim().to_owned();
131    if name.starts_with("refs/heads/") {
132        name = name[11..].to_owned();
133    }
134    if name.is_empty() {
135        Ok(None)
136    } else {
137        Ok(Some(name))
138    }
139}
140
141fn describe(dir: &Path, sha: &str) -> Result<String, Box<dyn Error>> {
142    // TODO: Work out a way to not use UTF8?
143    Ok(
144        String::from_utf8(run_git(dir, &["describe", "--tags", "--long", sha])?)?
145            .trim_end()
146            .to_owned(),
147    )
148}
149
150#[derive(Clone, Copy)]
151enum StatusFlag {
152    Added,
153    Deleted,
154    Modified,
155    Untracked,
156}
157use StatusFlag::*;
158
159#[derive(Clone)]
160struct StatusEntry {
161    path: String,
162    status: StatusFlag,
163}
164
165fn status(dir: &Path) -> Result<Vec<StatusEntry>, Box<dyn Error>> {
166    // TODO: Work out a way to not use UTF8?
167    let info = String::from_utf8(run_git(
168        dir,
169        &[
170            "status",
171            "--porcelain",
172            "--untracked-files=normal",
173            "--ignore-submodules=all",
174        ],
175    )?)?;
176
177    let mut ret = Vec::new();
178
179    for line in info.lines() {
180        let index_change = line.chars().next().unwrap();
181        let worktree_change = line.chars().nth(1).unwrap();
182        match (index_change, worktree_change) {
183            ('?', _) | (_, '?') => ret.push(StatusEntry {
184                path: line[3..].to_owned(),
185                status: Untracked,
186            }),
187            ('A', _) | (_, 'A') => ret.push(StatusEntry {
188                path: line[3..].to_owned(),
189                status: Added,
190            }),
191            ('M', _) | (_, 'M') => ret.push(StatusEntry {
192                path: line[3..].to_owned(),
193                status: Modified,
194            }),
195            ('D', _) | (_, 'D') => ret.push(StatusEntry {
196                path: line[3..].to_owned(),
197                status: Deleted,
198            }),
199            _ => {}
200        }
201    }
202
203    Ok(ret)
204}
205
206struct InvocationInformation {
207    pkgver: String,
208    now: String,
209}
210
211impl InvocationInformation {
212    fn acquire() -> Self {
213        let pkgver = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "?.?.?".to_owned());
214        let now = OffsetDateTime::now_utc();
215        let now = now.format(DATE_FORMAT).expect("unable to format now");
216        let sde = match env::var("SOURCE_DATE_EPOCH") {
217            Ok(sde) => match sde.parse::<i64>() {
218                Ok(sde) => Some(
219                    OffsetDateTime::from_unix_timestamp(sde)
220                        .expect("couldn't contruct datetime from source date epoch")
221                        .format(DATE_FORMAT)
222                        .expect("couldn't format source date epoch datetime"),
223                ),
224                Err(_) => None,
225            },
226            Err(_) => None,
227        };
228        let now = sde.unwrap_or(now);
229
230        Self { pkgver, now }
231    }
232}
233
234#[derive(Clone)]
235struct CommitInfo {
236    id: String,
237    date: String,
238    tag: String,
239    distance: usize,
240}
241
242#[derive(Clone)]
243struct GitInformation {
244    branch: Option<String>,
245    commitinfo: Option<CommitInfo>,
246    status: Vec<StatusEntry>,
247}
248
249impl GitInformation {
250    fn acquire() -> Result<Self, Box<dyn std::error::Error>> {
251        let git_dir = find_git_dir()?;
252        let branch = match branch_name(&git_dir) {
253            Ok(b) => b,
254            Err(e) => {
255                warn!("Unable to determine branch name: {e}");
256                None
257            }
258        };
259
260        let commitinfo = (|| {
261            let (commit, commit_time, commit_offset) = match revparse_single(&git_dir, "HEAD") {
262                Ok(commit_data) => commit_data,
263                Err(e) => {
264                    warn!("No commit at HEAD: {e}");
265                    return None;
266                }
267            };
268            // Acquire the commit info
269            let commit_id = commit;
270            let naive =
271                OffsetDateTime::from_unix_timestamp(commit_time).expect("Invalid commit time");
272            let offset = UtcOffset::from_whole_seconds(commit_offset * 60)
273                .expect("Invalid UTC offset (seconds)");
274            let commit_time = naive.replace_offset(offset);
275            let commit_date = commit_time
276                .format(DATE_FORMAT)
277                .expect("unable to format commit date");
278
279            let (tag, distance) = match describe(&git_dir, &commit_id) {
280                Ok(res) => {
281                    let res = &res[..res.rfind('-').expect("No commit info in describe!")];
282                    let tag_name = &res[..res.rfind('-').expect("No commit count in describe!")];
283                    let commit_count = res[tag_name.len() + 1..]
284                        .parse::<usize>()
285                        .expect("Unable to parse commit count in describe!");
286                    (tag_name.to_owned(), commit_count)
287                }
288                Err(e) => {
289                    warn!("No tag info found!\n{:?}", e);
290                    ("".to_owned(), 0)
291                }
292            };
293
294            Some(CommitInfo {
295                id: commit_id,
296                date: commit_date,
297                tag,
298                distance,
299            })
300        })();
301
302        let status = if commitinfo.is_some() {
303            status(&git_dir).expect("Unable to generate status information")
304        } else {
305            vec![]
306        };
307
308        Ok(Self {
309            branch,
310            commitinfo,
311            status,
312        })
313    }
314}
315
316#[proc_macro]
317pub fn git_testament(input: TokenStream) -> TokenStream {
318    let TestamentOptions { crate_, name, vis } = parse_macro_input!(input);
319
320    let InvocationInformation { pkgver, now } = InvocationInformation::acquire();
321    let gitinfo = match GitInformation::acquire() {
322        Ok(gi) => gi,
323        Err(e) => {
324            warn!(
325                "Unable to open a repo at {}: {}",
326                env::var("CARGO_MANIFEST_DIR").unwrap(),
327                e
328            );
329            return (quote! {
330                #[allow(clippy::needless_update)]
331                #vis const #name: #crate_::GitTestament<'static> = #crate_::GitTestament {
332                    commit: #crate_::CommitKind::NoRepository(#pkgver, #now),
333                    .. #crate_::EMPTY_TESTAMENT
334                };
335            })
336            .into();
337        }
338    };
339
340    // Second simple preliminary step: attempt to get a branch name to report
341    let branch_name = {
342        if let Some(branch) = gitinfo.branch {
343            quote! {#crate_::__core::option::Option::Some(#branch)}
344        } else {
345            quote! {#crate_::__core::option::Option::None}
346        }
347    };
348
349    // Step one, determine the current commit ID and the date of that commit
350    if gitinfo.commitinfo.is_none() {
351        return (quote! {
352            #[allow(clippy::needless_update)]
353            #vis const #name: #crate_::GitTestament<'static> = #crate_::GitTestament {
354                commit: #crate_::CommitKind::NoCommit(#pkgver, #now),
355                branch_name: #branch_name,
356                .. #crate_::EMPTY_TESTAMENT
357            };
358        })
359        .into();
360    }
361
362    let commitinfo = gitinfo.commitinfo.as_ref().unwrap();
363
364    let commit = if !commitinfo.tag.is_empty() {
365        // We've a tag
366        let (tag, id, date, distance) = (
367            &commitinfo.tag,
368            &commitinfo.id,
369            &commitinfo.date,
370            commitinfo.distance,
371        );
372        quote! {
373            #crate_::CommitKind::FromTag(#tag, #id, #date, #distance)
374        }
375    } else {
376        let (id, date) = (&commitinfo.id, &commitinfo.date);
377        quote! {
378            #crate_::CommitKind::NoTags(#id, #date)
379        }
380    };
381
382    // Finally, we need to gather the modifications to the tree...
383    let statuses: Vec<_> = gitinfo
384        .status
385        .iter()
386        .map(|status| {
387            let path = status.path.clone().into_bytes();
388            match status.status {
389                Untracked => quote! {
390                    #crate_::GitModification::Untracked(&[#(#path),*])
391                },
392                Added => quote! {
393                    #crate_::GitModification::Added(&[#(#path),*])
394                },
395                Modified => quote! {
396                    #crate_::GitModification::Modified(&[#(#path),*])
397                },
398                Deleted => quote! {
399                    #crate_::GitModification::Removed(&[#(#path),*])
400                },
401            }
402        })
403        .collect();
404
405    (quote! {
406        #[allow(clippy::needless_update)]
407        #vis const #name: #crate_::GitTestament<'static> = #crate_::GitTestament {
408            commit: #commit,
409            modifications: &[#(#statuses),*],
410            branch_name: #branch_name,
411            .. #crate_::EMPTY_TESTAMENT
412        };
413    })
414    .into()
415}
416
417#[proc_macro]
418pub fn git_testament_macros(input: TokenStream) -> TokenStream {
419    let StaticTestamentOptions {
420        crate_,
421        name,
422        trusted,
423    } = parse_macro_input!(input);
424    let sname = name.to_string();
425    let (pkgver, now, gitinfo, macros) = macro_content(&crate_, &sname);
426
427    // Render the testament string
428    let testament = if let Some(gitinfo) = gitinfo {
429        let commitstr = if let Some(ref commitinfo) = gitinfo.commitinfo {
430            if commitinfo.tag.is_empty() {
431                // No tag
432                format!("unknown ({} {})", &commitinfo.id[..9], commitinfo.date)
433            } else {
434                let trusted = if gitinfo.branch == trusted.map(|v| v.value()) {
435                    gitinfo.status.is_empty()
436                } else {
437                    false
438                };
439                // Full behaviour
440                if trusted {
441                    format!("{} ({} {})", pkgver, &commitinfo.id[..9], commitinfo.date)
442                } else {
443                    let basis = if commitinfo.distance > 0 {
444                        format!(
445                            "{}+{} ({} {})",
446                            commitinfo.tag,
447                            commitinfo.distance,
448                            &commitinfo.id[..9],
449                            commitinfo.date
450                        )
451                    } else {
452                        // Not dirty
453                        format!(
454                            "{} ({} {})",
455                            commitinfo.tag,
456                            &commitinfo.id[..9],
457                            commitinfo.date
458                        )
459                    };
460                    if commitinfo.tag.contains(&pkgver) {
461                        basis
462                    } else {
463                        format!("{pkgver} :: {basis}")
464                    }
465                }
466            }
467        } else {
468            // We're in a repo, but with no commit
469            format!("{pkgver} (uncommitted {now})")
470        };
471        if gitinfo.status.is_empty() {
472            commitstr
473        } else {
474            format!(
475                "{} dirty {} modification{}",
476                commitstr,
477                gitinfo.status.len(),
478                if gitinfo.status.len() == 1 { "" } else { "s" }
479            )
480        }
481    } else {
482        // No git information whatsoever
483        format!("{pkgver} ({now})")
484    };
485
486    let mac_testament = concat_ident(&sname, "testament");
487
488    (quote! {
489            #macros
490            #[allow(unused_macros)]
491            macro_rules! #mac_testament { () => {#testament}}
492    })
493    .into()
494}
495
496fn macro_content(
497    crate_: &Ident,
498    prefix: &str,
499) -> (String, String, Option<GitInformation>, impl quote::ToTokens) {
500    let InvocationInformation { pkgver, now } = InvocationInformation::acquire();
501    let mac_branch = concat_ident(prefix, "branch");
502    let mac_repo_present = concat_ident(prefix, "repo_present");
503    let mac_commit_present = concat_ident(prefix, "commit_present");
504    let mac_tag_present = concat_ident(prefix, "tag_present");
505    let mac_commit_hash = concat_ident(prefix, "commit_hash");
506    let mac_commit_date = concat_ident(prefix, "commit_date");
507    let mac_tag_name = concat_ident(prefix, "tag_name");
508    let mac_tag_distance = concat_ident(prefix, "tag_distance");
509    let gitinfo = match GitInformation::acquire() {
510        Ok(gi) => gi,
511        Err(e) => {
512            warn!(
513                "Unable to open a repo at {}: {}",
514                env::var("CARGO_MANIFEST_DIR").unwrap(),
515                e
516            );
517            return (
518                pkgver.clone(),
519                now.clone(),
520                None,
521                quote! {
522                    #[allow(unused_macros)]
523                    macro_rules! #mac_branch { () => {None}}
524                    #[allow(unused_macros)]
525                    macro_rules! #mac_repo_present { () => {false}}
526                    #[allow(unused_macros)]
527                    macro_rules! #mac_commit_present { () => {false}}
528                    #[allow(unused_macros)]
529                    macro_rules! #mac_tag_present { () => {false}}
530                    #[allow(unused_macros)]
531                    macro_rules! #mac_commit_hash { () => {#pkgver}}
532                    #[allow(unused_macros)]
533                    macro_rules! #mac_commit_date { () => {#now}}
534                    #[allow(unused_macros)]
535                    macro_rules! #mac_tag_name { () => {#pkgver}}
536                    #[allow(unused_macros)]
537                    macro_rules! #mac_tag_distance { () => {0}}
538                },
539            );
540        }
541    };
542
543    let branch_name = {
544        if let Some(ref branch) = gitinfo.branch {
545            quote! {#crate_::__core::option::Option::Some(#branch)}
546        } else {
547            quote! {#crate_::__core::option::Option::None}
548        }
549    };
550
551    let basics = quote! {
552        #[allow(unused_macros)]
553        macro_rules! #mac_repo_present { () => {true}}
554        #[allow(unused_macros)]
555        macro_rules! #mac_branch { () => {#branch_name}}
556    };
557
558    // Step one, determine the current commit ID and the date of that commit
559    if gitinfo.commitinfo.is_none() {
560        return (
561            pkgver.clone(),
562            now.clone(),
563            Some(gitinfo),
564            quote! {
565                #basics
566                #[allow(unused_macros)]
567                macro_rules! #mac_commit_present { () => {false}}
568                #[allow(unused_macros)]
569                macro_rules! #mac_tag_present { () => {false}}
570                #[allow(unused_macros)]
571                macro_rules! #mac_commit_hash { () => {#pkgver}}
572                #[allow(unused_macros)]
573                macro_rules! #mac_commit_date { () => {#now}}
574                #[allow(unused_macros)]
575                macro_rules! #mac_tag_name { () => {#pkgver}}
576                #[allow(unused_macros)]
577                macro_rules! #mac_tag_distance { () => {0}}
578            },
579        );
580    }
581
582    let commitinfo = gitinfo.commitinfo.as_ref().unwrap();
583    let (commit_hash, commit_date) = (&commitinfo.id, &commitinfo.date);
584    let (tag, distance) = (&commitinfo.tag, commitinfo.distance);
585
586    let basics = quote! {
587        #basics
588        #[allow(unused_macros)]
589        macro_rules! #mac_commit_present { () => {true}}
590        #[allow(unused_macros)]
591        macro_rules! #mac_commit_hash { () => {#commit_hash}}
592        #[allow(unused_macros)]
593        macro_rules! #mac_commit_date { () => {#commit_date}}
594    };
595
596    (
597        pkgver.clone(),
598        now,
599        Some(gitinfo.clone()),
600        if commitinfo.tag.is_empty() {
601            quote! {
602                #basics
603                #[allow(unused_macros)]
604                macro_rules! #mac_tag_present { () => {false}}
605                #[allow(unused_macros)]
606                macro_rules! #mac_tag_name { () => {#pkgver}}
607                #[allow(unused_macros)]
608                macro_rules! #mac_tag_distance { () => {0}}
609            }
610        } else {
611            quote! {
612                #basics
613                #[allow(unused_macros)]
614                macro_rules! #mac_tag_present { () => {true}}
615                #[allow(unused_macros)]
616                macro_rules! #mac_tag_name { () => {#tag}}
617                #[allow(unused_macros)]
618                macro_rules! #mac_tag_distance { () => {#distance}}
619            }
620        },
621    )
622}
623
624fn concat_ident(prefix: &str, suffix: &str) -> Ident {
625    Ident::new(&format!("{prefix}_{suffix}"), Span::call_site())
626}