use std::path::Path;
use crate::dependencies::{ExternalTool, Git};
pub const MAX_GIT_DIFF_MENTION_BYTES: usize = 32 * 1024;
pub const MAX_GIT_STATUS_MENTION_BYTES: usize = 8 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GitMentionKind {
Status,
Diff,
}
impl GitMentionKind {
#[must_use]
pub fn token(self) -> &'static str {
match self {
Self::Status => "git",
Self::Diff => "diff",
}
}
#[must_use]
pub fn byte_budget(self) -> usize {
match self {
Self::Status => MAX_GIT_STATUS_MENTION_BYTES,
Self::Diff => MAX_GIT_DIFF_MENTION_BYTES,
}
}
pub fn iter_all() -> impl Iterator<Item = Self> {
GIT_MENTION_KINDS.into_iter()
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Status => "git status",
Self::Diff => "working-tree diff",
}
}
}
pub const GIT_MENTION_KINDS: [GitMentionKind; 2] = [GitMentionKind::Status, GitMentionKind::Diff];
#[must_use]
pub fn git_mention_kind(raw: &str) -> Option<GitMentionKind> {
let token = raw.trim();
GIT_MENTION_KINDS
.into_iter()
.find(|kind| token.eq_ignore_ascii_case(kind.token()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitMentionPayload {
pub block: String,
pub bytes: usize,
pub truncated: bool,
pub unavailable_reason: Option<String>,
}
impl GitMentionPayload {
fn unavailable(kind: GitMentionKind, reason: &str) -> Self {
Self {
block: format!(
"<git-unavailable mention=\"@{token}\" reason=\"{reason}\" />",
token = kind.token(),
),
bytes: 0,
truncated: false,
unavailable_reason: Some(reason.to_string()),
}
}
}
#[derive(Debug, Default)]
pub struct GitMentionCache {
resolved: std::collections::HashMap<(GitMentionKind, std::path::PathBuf), GitMentionPayload>,
}
impl GitMentionCache {
#[cfg(test)]
#[must_use]
pub fn len(&self) -> usize {
self.resolved.len()
}
pub fn resolve(&mut self, kind: GitMentionKind, workspace: &Path) -> &GitMentionPayload {
self.resolved
.entry((kind, workspace.to_path_buf()))
.or_insert_with(|| resolve_git_mention(kind, workspace))
}
}
#[must_use]
pub fn resolve_git_mention(kind: GitMentionKind, cwd: &Path) -> GitMentionPayload {
if !Git::available() {
return GitMentionPayload::unavailable(kind, "git not found on PATH");
}
if !is_git_repository(cwd) {
return GitMentionPayload::unavailable(kind, "not a git repository");
}
let raw = match kind {
GitMentionKind::Status => git_status_payload(cwd),
GitMentionKind::Diff => git_output(&["diff", "HEAD"], cwd),
};
let Some(raw) = raw else {
return GitMentionPayload::unavailable(kind, "git command failed");
};
if raw.trim().is_empty() {
let reason = match kind {
GitMentionKind::Status => "working tree clean",
GitMentionKind::Diff => "no working-tree changes",
};
return GitMentionPayload::unavailable(kind, reason);
}
let (body, truncated) = truncate_on_char_boundary(&raw, kind.byte_budget());
let tag = match kind {
GitMentionKind::Status => "git-status",
GitMentionKind::Diff => "git-diff",
};
let truncated_attr = if truncated {
format!(
" truncated=\"true\" budget-bytes=\"{}\"",
kind.byte_budget()
)
} else {
String::new()
};
let block = format!(
"<{tag} mention=\"@{token}\" bytes=\"{bytes}\"{truncated_attr}>\n{body}\n</{tag}>",
token = kind.token(),
bytes = body.len(),
);
GitMentionPayload {
block,
bytes: body.len(),
truncated,
unavailable_reason: None,
}
}
fn git_status_payload(cwd: &Path) -> Option<String> {
let status = git_output(&["status", "--short", "--branch"], cwd)?;
Some(status)
}
fn is_git_repository(cwd: &Path) -> bool {
git_output(&["rev-parse", "--is-inside-work-tree"], cwd).is_some_and(|out| out.trim() == "true")
}
fn git_output(args: &[&str], cwd: &Path) -> Option<String> {
let output = Git::output(args, cwd).ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn truncate_on_char_boundary(text: &str, budget: usize) -> (&str, bool) {
if text.len() <= budget {
return (text, false);
}
let mut end = budget;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
(&text[..end], true)
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn init_repo(dir: &Path) {
for args in [
vec!["init", "--initial-branch=main"],
vec!["config", "user.email", "test@example.com"],
vec!["config", "user.name", "Test"],
] {
let status = Command::new("git")
.args(&args)
.current_dir(dir)
.output()
.expect("git available in tests");
assert!(status.status.success(), "git {args:?} failed");
}
}
fn commit_all(dir: &Path, message: &str) {
Command::new("git")
.args(["add", "-A"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", message])
.current_dir(dir)
.output()
.unwrap();
}
#[test]
fn only_exact_tokens_are_git_mentions() {
assert_eq!(git_mention_kind("git"), Some(GitMentionKind::Status));
assert_eq!(git_mention_kind("Diff"), Some(GitMentionKind::Diff));
assert_eq!(git_mention_kind("git/config"), None);
assert_eq!(git_mention_kind("diff.txt"), None);
assert_eq!(git_mention_kind("gitignore"), None);
}
#[test]
fn non_repository_directory_is_explicitly_unavailable() {
let dir = tempfile::tempdir().unwrap();
let payload = resolve_git_mention(GitMentionKind::Diff, dir.path());
assert_eq!(payload.bytes, 0);
assert!(payload.block.contains("git-unavailable"));
assert!(
payload
.unavailable_reason
.as_deref()
.is_some_and(|r| r.contains("not a git repository")),
"unexpected reason: {:?}",
payload.unavailable_reason
);
}
#[test]
fn empty_repository_reports_clean_rather_than_an_empty_block() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
std::fs::write(dir.path().join("a.txt"), "hello\n").unwrap();
commit_all(dir.path(), "initial");
let payload = resolve_git_mention(GitMentionKind::Diff, dir.path());
assert_eq!(payload.bytes, 0);
assert!(payload.block.contains("no working-tree changes"));
}
#[test]
fn status_reports_branch_and_dirty_paths() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
std::fs::write(dir.path().join("a.txt"), "hello\n").unwrap();
commit_all(dir.path(), "initial");
std::fs::write(dir.path().join("b.txt"), "new\n").unwrap();
let payload = resolve_git_mention(GitMentionKind::Status, dir.path());
assert!(payload.unavailable_reason.is_none());
assert!(payload.block.starts_with("<git-status mention=\"@git\""));
assert!(payload.block.contains("b.txt"), "{}", payload.block);
assert!(!payload.truncated);
}
#[test]
fn diff_covers_staged_and_unstaged_changes() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
std::fs::write(dir.path().join("a.txt"), "one\n").unwrap();
std::fs::write(dir.path().join("b.txt"), "one\n").unwrap();
commit_all(dir.path(), "initial");
std::fs::write(dir.path().join("a.txt"), "staged\n").unwrap();
Command::new("git")
.args(["add", "a.txt"])
.current_dir(dir.path())
.output()
.unwrap();
std::fs::write(dir.path().join("b.txt"), "unstaged\n").unwrap();
let payload = resolve_git_mention(GitMentionKind::Diff, dir.path());
assert!(payload.unavailable_reason.is_none());
assert!(payload.block.contains("staged"), "{}", payload.block);
assert!(payload.block.contains("unstaged"), "{}", payload.block);
}
#[test]
fn large_diff_truncates_at_the_documented_budget() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
std::fs::write(dir.path().join("big.txt"), "seed\n").unwrap();
commit_all(dir.path(), "initial");
let bulk: String = (0..40_000).map(|i| format!("line {i}\n")).collect();
std::fs::write(dir.path().join("big.txt"), bulk).unwrap();
let payload = resolve_git_mention(GitMentionKind::Diff, dir.path());
assert!(payload.truncated, "expected truncation");
assert!(payload.bytes <= MAX_GIT_DIFF_MENTION_BYTES);
assert!(payload.block.contains("truncated=\"true\""));
assert!(payload.block.contains("budget-bytes=\"32768\""));
}
#[test]
fn truncation_never_splits_a_utf8_scalar() {
let (cut, truncated) = truncate_on_char_boundary("aéb", 2);
assert!(truncated);
assert_eq!(cut, "a");
}
}