use crate::git::message::GitMessage;
use crate::git::repository::Repository;
use std::fs;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::trace;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
const CHECKED_ENV_VARS: &[&str] = &[
"OPENAI_API_BASE",
"OPENAI_API_TOKEN",
"OPENAI_MODEL_NAME",
"OPENAI_API_PROXY",
"OPENAI_API_TIMEOUT",
"AIGITCOMMIT_SIGNOFF",
];
pub mod env {
use std::env;
use tracing::{debug, warn};
pub fn get(key: &str, default: &str) -> String {
env::var(key).unwrap_or_else(|_| default.to_string())
}
pub fn get_bool(key: &str) -> bool {
const TRUTHY: [&str; 4] = ["1", "true", "yes", "on"];
match env::var(key) {
Ok(v) => TRUTHY.iter().any(|t| v.eq_ignore_ascii_case(t)),
Err(_) => false,
}
}
pub fn exists(var_name: &str) -> bool {
match env::var(var_name) {
Ok(value) => {
debug!("{} is set to {}", var_name, value);
true
}
Err(_) => {
warn!("{} is not set", var_name);
false
}
}
}
}
pub fn should_signoff(repository: &Repository, cli_signoff: bool) -> bool {
cli_signoff || repository.should_signoff()
}
#[derive(Debug, PartialEq, Eq)]
pub enum OutputFormat {
Stdout,
Table,
Json,
}
impl OutputFormat {
pub fn detect(json: bool, no_table: bool) -> Self {
match (json, no_table) {
(true, _) => Self::Json,
(false, true) => Self::Stdout,
(false, false) => Self::Table,
}
}
pub fn write(&self, message: &GitMessage) -> Result<()> {
let mut out = std::io::stdout().lock();
match self {
Self::Stdout => writeln!(out, "{message}")?,
Self::Json => writeln!(out, "{}", serde_json::to_string_pretty(message)?)?,
Self::Table => print_table(&message.title, &message.content),
}
Ok(())
}
}
fn print_table(title: &str, content: &str) {
let table =
tabled::builder::Builder::from_iter([["Title", title.trim()], ["Content", content.trim()]])
.build()
.with(tabled::settings::Style::rounded())
.with(tabled::settings::Width::wrap(120))
.with(tabled::settings::Alignment::left())
.to_string();
println!("{table}");
}
pub fn check_env_variables() {
for var in CHECKED_ENV_VARS {
env::exists(var);
}
}
pub fn save_to_file(path: &str, content: &dyn std::fmt::Display) -> Result<()> {
fs::write(path, content.to_string())?;
Ok(())
}
pub fn install_hook(path: &str, name: &str, content: &str) -> Result<()> {
let repo_dir =
fs::canonicalize(path).map_err(|e| format!("resolve repository path failed: {e}"))?;
let git_dir = repo_dir.join(".git");
if !git_dir.is_dir() {
return Err("not a git repository (missing .git directory)".into());
}
let hooks_dir = git_dir.join("hooks");
fs::create_dir_all(&hooks_dir).map_err(|e| format!("create hooks dir failed: {e}"))?;
let hook_path = hooks_dir.join(name);
if hook_path.exists() {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut backup = hooks_dir.join(format!("{name}.bak.{ts}"));
let mut counter: u32 = 1;
while backup.exists() {
backup = hooks_dir.join(format!("{name}.bak.{ts}.{counter}"));
counter += 1;
}
if let Err(e) = fs::rename(&hook_path, &backup) {
return Err(format!(
"failed to back up existing hook {hook_path:?} -> {backup:?}: {e}"
)
.into());
}
trace!("backed up existing hook to {:?}", backup);
}
fs::write(&hook_path, content).map_err(|e| format!("write hook file failed: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&hook_path)
.map_err(|e| format!("get hook metadata failed: {e}"))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_path, perms)
.map_err(|e| format!("set executable permission failed: {e}"))?;
}
trace!("hook installed at {:?}", hook_path);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_print_table_simple() {
print_table(
"Test Title",
"This is a test content for the commit message.",
);
}
#[test]
fn test_print_table_with_message() {
const TITLE: &str = r#"feat: bump version to 1.4.0 and update system template 🚀"#;
const CONTENT: &str = r#"
- Update version from 1.3.3 to 1.4.0 in Cargo.toml
- Enhance system template with additional instructions
- Simplify and clarify template content for better usability
- Remove redundant information to streamline template
- Ensure template aligns with latest commit message standards
Signed-off-by: mingcheng <mingcheng@apache.org>
"#;
print_table(TITLE, CONTENT);
}
#[test]
fn test_get_env() {
let result = env::get("NONEXISTENT_VAR_XYZ", "default_value");
assert_eq!(result, "default_value");
}
#[test]
fn test_get_bool_truthy_and_falsy() {
unsafe {
std::env::set_var("AIGC_TEST_BOOL_T1", "1");
std::env::set_var("AIGC_TEST_BOOL_T2", "TRUE");
std::env::set_var("AIGC_TEST_BOOL_T3", "Yes");
std::env::set_var("AIGC_TEST_BOOL_T4", "on");
std::env::set_var("AIGC_TEST_BOOL_F1", "0");
std::env::set_var("AIGC_TEST_BOOL_F2", "no");
}
assert!(env::get_bool("AIGC_TEST_BOOL_T1"));
assert!(env::get_bool("AIGC_TEST_BOOL_T2"));
assert!(env::get_bool("AIGC_TEST_BOOL_T3"));
assert!(env::get_bool("AIGC_TEST_BOOL_T4"));
assert!(!env::get_bool("AIGC_TEST_BOOL_F1"));
assert!(!env::get_bool("AIGC_TEST_BOOL_F2"));
assert!(!env::get_bool("AIGC_TEST_BOOL_MISSING"));
}
#[test]
fn test_output_format_detect() {
assert_eq!(OutputFormat::detect(true, false), OutputFormat::Json);
assert_eq!(OutputFormat::detect(true, true), OutputFormat::Json);
assert_eq!(OutputFormat::detect(false, true), OutputFormat::Stdout);
assert_eq!(OutputFormat::detect(false, false), OutputFormat::Table);
}
#[test]
fn test_save_to_file_roundtrip() {
let path =
std::env::temp_dir().join(format!("aigitcommit-save-{}.txt", std::process::id()));
let path_str = path.to_string_lossy().into_owned();
save_to_file(&path_str, &"hello world").unwrap();
let read = std::fs::read_to_string(&path).unwrap();
assert_eq!(read, "hello world");
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_install_hook() {
let tmp =
std::env::temp_dir().join(format!("aigitcommit-install-hook-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join(".git")).unwrap();
let path = tmp.to_str().unwrap();
install_hook(path, "prepare-commit-msg", "#!/bin/sh\necho a\n").unwrap();
let hook = tmp.join(".git/hooks/prepare-commit-msg");
assert_eq!(
std::fs::read_to_string(&hook).unwrap(),
"#!/bin/sh\necho a\n"
);
install_hook(path, "prepare-commit-msg", "#!/bin/sh\necho b\n").unwrap();
let hooks_dir = tmp.join(".git/hooks");
let backup = std::fs::read_dir(&hooks_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.find(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("prepare-commit-msg.bak."))
})
.expect("backup file should exist after reinstall");
assert_eq!(
std::fs::read_to_string(&backup).unwrap(),
"#!/bin/sh\necho a\n"
);
assert_eq!(
std::fs::read_to_string(&hook).unwrap(),
"#!/bin/sh\necho b\n"
);
install_hook(path, "prepare-commit-msg", "#!/bin/sh\necho c\n").unwrap();
let backups: Vec<_> = std::fs::read_dir(&hooks_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("prepare-commit-msg.bak."))
})
.collect();
assert!(
backups.len() >= 2,
"expected at least two distinct backups, found {}",
backups.len()
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_install_hook_rejects_non_git_dir() {
let tmp =
std::env::temp_dir().join(format!("aigitcommit-not-a-repo-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let result = install_hook(tmp.to_str().unwrap(), "x", "#!/bin/sh\n");
assert!(result.is_err());
let _ = std::fs::remove_dir_all(&tmp);
}
}