Skip to main content

codewhale_build_support/
lib.rs

1//! Shared build-script helpers for the `codewhale-cli` and `codewhale-tui`
2//! build scripts: rerun-condition declarations and the embedded
3//! `DEEPSEEK_BUILD_VERSION` metadata. Only call these functions from a build
4//! script — they emit `cargo:` directives on stdout.
5
6use std::{
7    path::{Path, PathBuf},
8    process::Command,
9};
10
11/// Declare the rerun conditions for the build-metadata directives: the
12/// SHA-override environment variables plus the git files that track `HEAD`.
13///
14/// `manifest_dir` is the calling build script's `CARGO_MANIFEST_DIR`.
15pub fn declare_rerun_conditions(manifest_dir: &Path) {
16    println!("cargo:rerun-if-env-changed=DEEPSEEK_BUILD_SHA");
17    println!("cargo:rerun-if-env-changed=GITHUB_SHA");
18    declare_git_head_rerun(manifest_dir);
19}
20
21/// Emit `cargo:rustc-env=DEEPSEEK_BUILD_VERSION=...` — the package version,
22/// suffixed with the short build SHA when one can be determined.
23///
24/// `manifest_dir` and `package_version` are the calling build script's
25/// `CARGO_MANIFEST_DIR` and `CARGO_PKG_VERSION`.
26pub fn emit_build_version(manifest_dir: &Path, package_version: &str) {
27    let build_version = build_sha(manifest_dir)
28        .map(|sha| format!("{package_version} ({sha})"))
29        .unwrap_or_else(|| package_version.to_string());
30
31    println!("cargo:rustc-env=DEEPSEEK_BUILD_VERSION={build_version}");
32}
33
34/// Tell Cargo to invalidate the cached build script output when `HEAD`
35/// moves, so the embedded short-SHA stays in sync with the checkout.
36///
37/// `.git/HEAD` only changes on branch switches and detached-HEAD moves —
38/// `git commit` on the current branch updates the underlying ref file
39/// (loose `refs/heads/<name>`, or `packed-refs` after `git pack-refs`)
40/// without touching `HEAD` itself. So when `HEAD` is a symbolic ref we
41/// also watch the resolved target and `packed-refs`. A non-existent
42/// `rerun-if-changed` path is treated as "always changed" by Cargo, which
43/// covers the loose→packed transition.
44fn declare_git_head_rerun(manifest_dir: &Path) {
45    let workspace_root = manifest_dir.join("..").join("..");
46    let git_meta = workspace_root.join(".git");
47
48    let gitdir = if git_meta.is_dir() {
49        git_meta
50    } else if git_meta.is_file() {
51        // Worktree pointer file: watch it directly, then follow `gitdir:`.
52        println!("cargo:rerun-if-changed={}", git_meta.display());
53        let Ok(contents) = std::fs::read_to_string(&git_meta) else {
54            return;
55        };
56        let Some(rest) = contents.lines().find_map(|l| l.strip_prefix("gitdir:")) else {
57            return;
58        };
59        let trimmed = rest.trim();
60        if Path::new(trimmed).is_absolute() {
61            PathBuf::from(trimmed)
62        } else {
63            workspace_root.join(trimmed)
64        }
65    } else {
66        return;
67    };
68
69    let head = gitdir.join("HEAD");
70    println!("cargo:rerun-if-changed={}", head.display());
71
72    if let Ok(contents) = std::fs::read_to_string(&head)
73        && let Some(target) = parse_symbolic_ref(&contents)
74    {
75        println!("cargo:rerun-if-changed={}", gitdir.join(target).display());
76        println!(
77            "cargo:rerun-if-changed={}",
78            gitdir.join("packed-refs").display()
79        );
80    }
81}
82
83/// If `.git/HEAD` is a symbolic ref (`ref: refs/heads/...`) return the
84/// target ref path. Returns `None` for a detached HEAD (raw SHA).
85fn parse_symbolic_ref(head_contents: &str) -> Option<&str> {
86    head_contents
87        .lines()
88        .next()
89        .and_then(|line| line.strip_prefix("ref:"))
90        .map(str::trim)
91        .filter(|s| !s.is_empty())
92}
93
94fn build_sha(manifest_dir: &Path) -> Option<String> {
95    env_sha("DEEPSEEK_BUILD_SHA")
96        .or_else(|| env_sha("GITHUB_SHA"))
97        .or_else(|| git_sha(manifest_dir))
98}
99
100fn env_sha(name: &str) -> Option<String> {
101    std::env::var(name).ok().and_then(short_sha)
102}
103
104fn git_sha(manifest_dir: &Path) -> Option<String> {
105    let top_level_output = Command::new("git")
106        .args(["-C"])
107        .arg(manifest_dir)
108        .args(["rev-parse", "--show-toplevel"])
109        .output()
110        .ok()?;
111    if !top_level_output.status.success() {
112        return None;
113    }
114    let top_level = PathBuf::from(String::from_utf8_lossy(&top_level_output.stdout).trim());
115    if !top_level.join("Cargo.toml").is_file() || !top_level.join("crates/tui").is_dir() {
116        return None;
117    }
118
119    let output = Command::new("git")
120        .args(["-C"])
121        .arg(top_level)
122        .args(["rev-parse", "--short=12", "HEAD"])
123        .output()
124        .ok()?;
125    if !output.status.success() {
126        return None;
127    }
128
129    short_sha(String::from_utf8_lossy(&output.stdout).to_string())
130}
131
132fn short_sha(value: String) -> Option<String> {
133    let trimmed = value.trim();
134    if trimmed.is_empty() {
135        return None;
136    }
137    Some(trimmed.chars().take(12).collect())
138}
139
140#[cfg(test)]
141mod tests {
142    use super::parse_symbolic_ref;
143
144    #[test]
145    fn symbolic_ref_strips_prefix_and_whitespace() {
146        assert_eq!(
147            parse_symbolic_ref("ref: refs/heads/main\n"),
148            Some("refs/heads/main")
149        );
150    }
151
152    #[test]
153    fn symbolic_ref_handles_no_trailing_newline() {
154        assert_eq!(
155            parse_symbolic_ref("ref: refs/heads/work/v0.8.26-security"),
156            Some("refs/heads/work/v0.8.26-security")
157        );
158    }
159
160    #[test]
161    fn detached_head_is_not_a_symbolic_ref() {
162        assert_eq!(
163            parse_symbolic_ref("506343f44e48b9c2c8d6b2d3e8e8e8e8e8e8e8e8\n"),
164            None
165        );
166    }
167
168    #[test]
169    fn empty_input_returns_none() {
170        assert_eq!(parse_symbolic_ref(""), None);
171        assert_eq!(parse_symbolic_ref("ref: \n"), None);
172    }
173}