feat:(first commit)created repository and complete 0.1.0
This commit is contained in:
139
src/utils/crypto.rs
Normal file
139
src/utils/crypto.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit},
|
||||
Aes256Gcm, Nonce,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use rand::Rng;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const KEY_LEN: usize = 32;
|
||||
const NONCE_LEN: usize = 12;
|
||||
const SALT_LEN: usize = 32;
|
||||
|
||||
/// Encrypt data with password
|
||||
pub fn encrypt(data: &[u8], password: &str) -> Result<String> {
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
rand::thread_rng().fill(&mut salt);
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
rand::thread_rng().fill(&mut nonce_bytes);
|
||||
|
||||
let key = derive_key(password, &salt)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
||||
.context("Failed to create cipher")?;
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let encrypted = cipher
|
||||
.encrypt(nonce, data)
|
||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {:?}", e))?;
|
||||
|
||||
// Combine salt + nonce + encrypted data
|
||||
let mut result = Vec::with_capacity(SALT_LEN + NONCE_LEN + encrypted.len());
|
||||
result.extend_from_slice(&salt);
|
||||
result.extend_from_slice(&nonce_bytes);
|
||||
result.extend_from_slice(&encrypted);
|
||||
|
||||
Ok(BASE64.encode(&result))
|
||||
}
|
||||
|
||||
/// Decrypt data with password
|
||||
pub fn decrypt(encrypted_data: &str, password: &str) -> Result<Vec<u8>> {
|
||||
let data = BASE64.decode(encrypted_data)
|
||||
.context("Invalid base64 encoding")?;
|
||||
|
||||
if data.len() < SALT_LEN + NONCE_LEN {
|
||||
anyhow::bail!("Invalid encrypted data format");
|
||||
}
|
||||
|
||||
let salt = &data[..SALT_LEN];
|
||||
let nonce_bytes = &data[SALT_LEN..SALT_LEN + NONCE_LEN];
|
||||
let encrypted = &data[SALT_LEN + NONCE_LEN..];
|
||||
|
||||
let key = derive_key(password, salt)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key)
|
||||
.context("Failed to create cipher")?;
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
let decrypted = cipher
|
||||
.decrypt(nonce, encrypted)
|
||||
.map_err(|e| anyhow::anyhow!("Decryption failed: {:?}", e))?;
|
||||
|
||||
Ok(decrypted)
|
||||
}
|
||||
|
||||
/// Derive key from password using simple method
|
||||
fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; KEY_LEN]> {
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(salt);
|
||||
hasher.update(password.as_bytes());
|
||||
hasher.update(b"quicommit_key_derivation_v1");
|
||||
|
||||
let hash = hasher.finalize();
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
key.copy_from_slice(&hash[..KEY_LEN]);
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Encrypt and save to file
|
||||
pub fn encrypt_to_file(data: &[u8], password: &str, path: &Path) -> Result<()> {
|
||||
let encrypted = encrypt(data, password)?;
|
||||
fs::write(path, encrypted)
|
||||
.with_context(|| format!("Failed to write encrypted file: {:?}", path))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decrypt from file
|
||||
pub fn decrypt_from_file(path: &Path, password: &str) -> Result<Vec<u8>> {
|
||||
let encrypted = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read encrypted file: {:?}", path))?;
|
||||
decrypt(&encrypted, password)
|
||||
}
|
||||
|
||||
/// Generate a secure random token
|
||||
pub fn generate_token(length: usize) -> String {
|
||||
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
(0..length)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0..CHARSET.len());
|
||||
CHARSET[idx] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Hash data using SHA-256
|
||||
pub fn sha256(data: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt() {
|
||||
let data = b"Hello, World!";
|
||||
let password = "my_secret_password";
|
||||
|
||||
let encrypted = encrypt(data, password).unwrap();
|
||||
let decrypted = decrypt(&encrypted, password).unwrap();
|
||||
|
||||
assert_eq!(data.to_vec(), decrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_password() {
|
||||
let data = b"Hello, World!";
|
||||
let encrypted = encrypt(data, "correct_password").unwrap();
|
||||
|
||||
assert!(decrypt(&encrypted, "wrong_password").is_err());
|
||||
}
|
||||
}
|
||||
57
src/utils/editor.rs
Normal file
57
src/utils/editor.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Open editor for user to input/edit content
|
||||
pub fn edit_content(initial_content: &str) -> Result<String> {
|
||||
edit::edit(initial_content).context("Failed to open editor")
|
||||
}
|
||||
|
||||
/// Edit file in user's default editor
|
||||
pub fn edit_file(path: &Path) -> Result<String> {
|
||||
let content = fs::read_to_string(path)
|
||||
.unwrap_or_default();
|
||||
|
||||
let edited = edit::edit(&content)
|
||||
.context("Failed to open editor")?;
|
||||
|
||||
fs::write(path, &edited)
|
||||
.with_context(|| format!("Failed to write file: {:?}", path))?;
|
||||
|
||||
Ok(edited)
|
||||
}
|
||||
|
||||
/// Create temporary file and open in editor
|
||||
pub fn edit_temp(initial_content: &str, extension: &str) -> Result<String> {
|
||||
let temp_file = tempfile::Builder::new()
|
||||
.suffix(&format!(".{}", extension))
|
||||
.tempfile()
|
||||
.context("Failed to create temp file")?;
|
||||
|
||||
let path = temp_file.path();
|
||||
fs::write(path, initial_content)
|
||||
.context("Failed to write temp file")?;
|
||||
|
||||
edit_file(path)
|
||||
}
|
||||
|
||||
/// Get editor command from environment
|
||||
pub fn get_editor() -> String {
|
||||
std::env::var("EDITOR")
|
||||
.or_else(|_| std::env::var("VISUAL"))
|
||||
.unwrap_or_else(|_| {
|
||||
if cfg!(target_os = "windows") {
|
||||
"notepad".to_string()
|
||||
} else {
|
||||
"vi".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if editor is available
|
||||
pub fn check_editor() -> Result<()> {
|
||||
let editor = get_editor();
|
||||
which::which(&editor)
|
||||
.with_context(|| format!("Editor '{}' not found in PATH", editor))?;
|
||||
Ok(())
|
||||
}
|
||||
198
src/utils/formatter.rs
Normal file
198
src/utils/formatter.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use regex::Regex;
|
||||
|
||||
/// Format commit message with conventional commit format
|
||||
pub fn format_conventional_commit(
|
||||
commit_type: &str,
|
||||
scope: Option<&str>,
|
||||
description: &str,
|
||||
body: Option<&str>,
|
||||
footer: Option<&str>,
|
||||
breaking: bool,
|
||||
) -> String {
|
||||
let mut message = String::new();
|
||||
|
||||
// Type and scope
|
||||
message.push_str(commit_type);
|
||||
if let Some(s) = scope {
|
||||
message.push_str(&format!("({})", s));
|
||||
}
|
||||
if breaking {
|
||||
message.push('!');
|
||||
}
|
||||
message.push_str(&format!(": {}", description));
|
||||
|
||||
// Body
|
||||
if let Some(b) = body {
|
||||
message.push_str(&format!("\n\n{}", b));
|
||||
}
|
||||
|
||||
// Footer
|
||||
if let Some(f) = footer {
|
||||
message.push_str(&format!("\n\n{}", f));
|
||||
}
|
||||
|
||||
message
|
||||
}
|
||||
|
||||
/// Format commit with @commitlint format
|
||||
pub fn format_commitlint_commit(
|
||||
commit_type: &str,
|
||||
scope: Option<&str>,
|
||||
subject: &str,
|
||||
body: Option<&str>,
|
||||
footer: Option<&str>,
|
||||
references: Option<&[&str]>,
|
||||
) -> String {
|
||||
let mut message = String::new();
|
||||
|
||||
// Header
|
||||
message.push_str(commit_type);
|
||||
if let Some(s) = scope {
|
||||
message.push_str(&format!("({})", s));
|
||||
}
|
||||
message.push_str(&format!(": {}", subject));
|
||||
|
||||
// References
|
||||
if let Some(refs) = references {
|
||||
for reference in refs {
|
||||
message.push_str(&format!(" #{}", reference));
|
||||
}
|
||||
}
|
||||
|
||||
// Body
|
||||
if let Some(b) = body {
|
||||
message.push_str(&format!("\n\n{}", b));
|
||||
}
|
||||
|
||||
// Footer
|
||||
if let Some(f) = footer {
|
||||
message.push_str(&format!("\n\n{}", f));
|
||||
}
|
||||
|
||||
message
|
||||
}
|
||||
|
||||
/// Format date for commit message
|
||||
pub fn format_commit_date(date: &DateTime<Local>) -> String {
|
||||
date.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
/// Format date for changelog
|
||||
pub fn format_changelog_date(date: &DateTime<Utc>) -> String {
|
||||
date.format("%Y-%m-%d").to_string()
|
||||
}
|
||||
|
||||
/// Format tag name with version
|
||||
pub fn format_tag_name(version: &str, prefix: Option<&str>) -> String {
|
||||
match prefix {
|
||||
Some(p) => format!("{}{}", p, version),
|
||||
None => version.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap text at specified width
|
||||
pub fn wrap_text(text: &str, width: usize) -> String {
|
||||
textwrap::fill(text, width)
|
||||
}
|
||||
|
||||
/// Truncate text with ellipsis
|
||||
pub fn truncate(text: &str, max_len: usize) -> String {
|
||||
if text.len() <= max_len {
|
||||
text.to_string()
|
||||
} else {
|
||||
format!("{}...", &text[..max_len.saturating_sub(3)])
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean commit message (remove comments, extra whitespace)
|
||||
pub fn clean_message(message: &str) -> String {
|
||||
let comment_regex = Regex::new(r"^#.*$").unwrap();
|
||||
|
||||
message
|
||||
.lines()
|
||||
.filter(|line| !comment_regex.is_match(line.trim()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Format list as markdown bullet points
|
||||
pub fn format_markdown_list(items: &[String]) -> String {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| format!("- {}", item))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Format changelog section
|
||||
pub fn format_changelog_section(
|
||||
version: &str,
|
||||
date: &str,
|
||||
changes: &[(String, Vec<String>)],
|
||||
) -> String {
|
||||
let mut section = format!("## [{}] - {}\n\n", version, date);
|
||||
|
||||
for (category, items) in changes {
|
||||
if !items.is_empty() {
|
||||
section.push_str(&format!("### {}\n\n", category));
|
||||
for item in items {
|
||||
section.push_str(&format!("- {}\n", item));
|
||||
}
|
||||
section.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
section
|
||||
}
|
||||
|
||||
/// Format git config key
|
||||
pub fn format_git_config_key(section: &str, subsection: Option<&str>, key: &str) -> String {
|
||||
match subsection {
|
||||
Some(sub) => format!("{}.{}.{}", section, sub, key),
|
||||
None => format!("{}.{}", section, key),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_conventional_commit() {
|
||||
let msg = format_conventional_commit(
|
||||
"feat",
|
||||
Some("auth"),
|
||||
"add login functionality",
|
||||
Some("This adds OAuth2 login support."),
|
||||
Some("Closes #123"),
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(msg.contains("feat(auth): add login functionality"));
|
||||
assert!(msg.contains("This adds OAuth2 login support."));
|
||||
assert!(msg.contains("Closes #123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_conventional_commit_breaking() {
|
||||
let msg = format_conventional_commit(
|
||||
"feat",
|
||||
None,
|
||||
"change API response format",
|
||||
None,
|
||||
Some("BREAKING CHANGE: response format changed"),
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(msg.starts_with("feat!: change API response format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate() {
|
||||
assert_eq!(truncate("hello", 10), "hello");
|
||||
assert_eq!(truncate("hello world", 8), "hello...");
|
||||
}
|
||||
}
|
||||
76
src/utils/mod.rs
Normal file
76
src/utils/mod.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
pub mod crypto;
|
||||
pub mod editor;
|
||||
pub mod formatter;
|
||||
pub mod validators;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use colored::Colorize;
|
||||
use std::io::{self, Write};
|
||||
|
||||
/// Print success message
|
||||
pub fn print_success(msg: &str) {
|
||||
println!("{} {}", "✓".green().bold(), msg);
|
||||
}
|
||||
|
||||
/// Print error message
|
||||
pub fn print_error(msg: &str) {
|
||||
eprintln!("{} {}", "✗".red().bold(), msg);
|
||||
}
|
||||
|
||||
/// Print warning message
|
||||
pub fn print_warning(msg: &str) {
|
||||
println!("{} {}", "⚠".yellow().bold(), msg);
|
||||
}
|
||||
|
||||
/// Print info message
|
||||
pub fn print_info(msg: &str) {
|
||||
println!("{} {}", "ℹ".blue().bold(), msg);
|
||||
}
|
||||
|
||||
/// Confirm action with user
|
||||
pub fn confirm(prompt: &str) -> Result<bool> {
|
||||
print!("{} [y/N] ", prompt);
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
|
||||
Ok(input.trim().to_lowercase().starts_with('y'))
|
||||
}
|
||||
|
||||
/// Get user input
|
||||
pub fn input(prompt: &str) -> Result<String> {
|
||||
print!("{}: ", prompt);
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
/// Get password input (hidden)
|
||||
pub fn password_input(prompt: &str) -> Result<String> {
|
||||
use dialoguer::Password;
|
||||
|
||||
Password::new()
|
||||
.with_prompt(prompt)
|
||||
.interact()
|
||||
.context("Failed to read password")
|
||||
}
|
||||
|
||||
/// Check if running in a terminal
|
||||
pub fn is_terminal() -> bool {
|
||||
atty::is(atty::Stream::Stdout)
|
||||
}
|
||||
|
||||
/// Format duration in human-readable format
|
||||
pub fn format_duration(secs: u64) -> String {
|
||||
if secs < 60 {
|
||||
format!("{}s", secs)
|
||||
} else if secs < 3600 {
|
||||
format!("{}m {}s", secs / 60, secs % 60)
|
||||
} else {
|
||||
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
|
||||
}
|
||||
}
|
||||
270
src/utils/validators.rs
Normal file
270
src/utils/validators.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
use anyhow::{bail, Result};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
|
||||
/// Conventional commit types
|
||||
pub const CONVENTIONAL_TYPES: &[&str] = &[
|
||||
"feat", // A new feature
|
||||
"fix", // A bug fix
|
||||
"docs", // Documentation only changes
|
||||
"style", // Changes that do not affect the meaning of the code
|
||||
"refactor", // A code change that neither fixes a bug nor adds a feature
|
||||
"perf", // A code change that improves performance
|
||||
"test", // Adding missing tests or correcting existing tests
|
||||
"build", // Changes that affect the build system or external dependencies
|
||||
"ci", // Changes to CI configuration files and scripts
|
||||
"chore", // Other changes that don't modify src or test files
|
||||
"revert", // Reverts a previous commit
|
||||
];
|
||||
|
||||
/// Commitlint configuration types (extends conventional)
|
||||
pub const COMMITLINT_TYPES: &[&str] = &[
|
||||
"feat", // A new feature
|
||||
"fix", // A bug fix
|
||||
"docs", // Documentation only changes
|
||||
"style", // Changes that do not affect the meaning of the code
|
||||
"refactor", // A code change that neither fixes a bug nor adds a feature
|
||||
"perf", // A code change that improves performance
|
||||
"test", // Adding missing tests or correcting existing tests
|
||||
"build", // Changes that affect the build system or external dependencies
|
||||
"ci", // Changes to CI configuration files and scripts
|
||||
"chore", // Other changes that don't modify src or test files
|
||||
"revert", // Reverts a previous commit
|
||||
"wip", // Work in progress
|
||||
"init", // Initial commit
|
||||
"update", // Update existing functionality
|
||||
"remove", // Remove functionality
|
||||
"security", // Security-related changes
|
||||
];
|
||||
|
||||
lazy_static! {
|
||||
/// Regex for conventional commit format
|
||||
static ref CONVENTIONAL_COMMIT_REGEX: Regex = Regex::new(
|
||||
r"^(?P<type>feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?: (?P<description>.+)$"
|
||||
).unwrap();
|
||||
|
||||
/// Regex for scope validation
|
||||
static ref SCOPE_REGEX: Regex = Regex::new(
|
||||
r"^[a-z0-9-]+$"
|
||||
).unwrap();
|
||||
|
||||
/// Regex for version tag validation (semver)
|
||||
static ref SEMVER_REGEX: Regex = Regex::new(
|
||||
r"^v?(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
|
||||
).unwrap();
|
||||
|
||||
/// Regex for email validation
|
||||
static ref EMAIL_REGEX: Regex = Regex::new(
|
||||
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||
).unwrap();
|
||||
|
||||
/// Regex for SSH key validation (basic)
|
||||
static ref SSH_KEY_REGEX: Regex = Regex::new(
|
||||
r"^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)\s+[A-Za-z0-9+/]+={0,2}\s+.*$"
|
||||
).unwrap();
|
||||
|
||||
/// Regex for GPG key ID validation
|
||||
static ref GPG_KEY_ID_REGEX: Regex = Regex::new(
|
||||
r"^[A-F0-9]{16,40}$"
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
/// Validate conventional commit message
|
||||
pub fn validate_conventional_commit(message: &str) -> Result<()> {
|
||||
let first_line = message.lines().next().unwrap_or("");
|
||||
|
||||
if !CONVENTIONAL_COMMIT_REGEX.is_match(first_line) {
|
||||
bail!(
|
||||
"Invalid conventional commit format. Expected: <type>[optional scope]: <description>\n\
|
||||
Valid types: {}",
|
||||
CONVENTIONAL_TYPES.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
// Check description length (max 100 chars for first line)
|
||||
if first_line.len() > 100 {
|
||||
bail!("Commit subject too long (max 100 characters)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate @commitlint commit message
|
||||
pub fn validate_commitlint_commit(message: &str) -> Result<()> {
|
||||
let first_line = message.lines().next().unwrap_or("");
|
||||
|
||||
// Commitlint is more lenient but still requires type prefix
|
||||
let parts: Vec<&str> = first_line.splitn(2, ':').collect();
|
||||
if parts.len() != 2 {
|
||||
bail!("Invalid commit format. Expected: <type>[optional scope]: <subject>");
|
||||
}
|
||||
|
||||
let type_part = parts[0];
|
||||
let subject = parts[1].trim();
|
||||
|
||||
// Extract type (handle scope and breaking indicator)
|
||||
let commit_type = type_part
|
||||
.split('(')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim_end_matches('!');
|
||||
|
||||
if !COMMITLINT_TYPES.contains(&commit_type) {
|
||||
bail!(
|
||||
"Invalid commit type: '{}'. Valid types: {}",
|
||||
commit_type,
|
||||
COMMITLINT_TYPES.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
// Validate subject
|
||||
if subject.is_empty() {
|
||||
bail!("Commit subject cannot be empty");
|
||||
}
|
||||
|
||||
if subject.len() < 4 {
|
||||
bail!("Commit subject too short (min 4 characters)");
|
||||
}
|
||||
|
||||
if subject.len() > 100 {
|
||||
bail!("Commit subject too long (max 100 characters)");
|
||||
}
|
||||
|
||||
// Subject should not start with uppercase
|
||||
if subject.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) {
|
||||
bail!("Commit subject should not start with uppercase letter");
|
||||
}
|
||||
|
||||
// Subject should not end with period
|
||||
if subject.ends_with('.') {
|
||||
bail!("Commit subject should not end with a period");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate scope name
|
||||
pub fn validate_scope(scope: &str) -> Result<()> {
|
||||
if scope.is_empty() {
|
||||
bail!("Scope cannot be empty");
|
||||
}
|
||||
|
||||
if !SCOPE_REGEX.is_match(scope) {
|
||||
bail!("Invalid scope format. Use lowercase letters, numbers, and hyphens only");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate semantic version tag
|
||||
pub fn validate_semver(version: &str) -> Result<()> {
|
||||
let version = version.trim_start_matches('v');
|
||||
|
||||
if !SEMVER_REGEX.is_match(version) {
|
||||
bail!(
|
||||
"Invalid semantic version format. Expected: MAJOR.MINOR.PATCH[-prerelease][+build]\n\
|
||||
Examples: 1.0.0, 1.2.3-beta, v2.0.0+build123"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate email address
|
||||
pub fn validate_email(email: &str) -> Result<()> {
|
||||
if !EMAIL_REGEX.is_match(email) {
|
||||
bail!("Invalid email address format");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate SSH key format
|
||||
pub fn validate_ssh_key(key: &str) -> Result<()> {
|
||||
if !SSH_KEY_REGEX.is_match(key.trim()) {
|
||||
bail!("Invalid SSH public key format");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate GPG key ID
|
||||
pub fn validate_gpg_key_id(key_id: &str) -> Result<()> {
|
||||
if !GPG_KEY_ID_REGEX.is_match(key_id) {
|
||||
bail!("Invalid GPG key ID format. Expected 16-40 hexadecimal characters");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate profile name
|
||||
pub fn validate_profile_name(name: &str) -> Result<()> {
|
||||
if name.is_empty() {
|
||||
bail!("Profile name cannot be empty");
|
||||
}
|
||||
|
||||
if name.len() > 50 {
|
||||
bail!("Profile name too long (max 50 characters)");
|
||||
}
|
||||
|
||||
if !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||
bail!("Profile name can only contain letters, numbers, hyphens, and underscores");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if commit type is valid
|
||||
pub fn is_valid_commit_type(commit_type: &str, use_commitlint: bool) -> bool {
|
||||
let types = if use_commitlint {
|
||||
COMMITLINT_TYPES
|
||||
} else {
|
||||
CONVENTIONAL_TYPES
|
||||
};
|
||||
|
||||
types.contains(&commit_type)
|
||||
}
|
||||
|
||||
/// Get available commit types
|
||||
pub fn get_commit_types(use_commitlint: bool) -> &'static [&'static str] {
|
||||
if use_commitlint {
|
||||
COMMITLINT_TYPES
|
||||
} else {
|
||||
CONVENTIONAL_TYPES
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_conventional_commit() {
|
||||
assert!(validate_conventional_commit("feat: add new feature").is_ok());
|
||||
assert!(validate_conventional_commit("fix(auth): fix login bug").is_ok());
|
||||
assert!(validate_conventional_commit("feat!: breaking change").is_ok());
|
||||
assert!(validate_conventional_commit("invalid: message").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_semver() {
|
||||
assert!(validate_semver("1.0.0").is_ok());
|
||||
assert!(validate_semver("v1.2.3").is_ok());
|
||||
assert!(validate_semver("2.0.0-beta.1").is_ok());
|
||||
assert!(validate_semver("invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_email() {
|
||||
assert!(validate_email("test@example.com").is_ok());
|
||||
assert!(validate_email("invalid-email").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_profile_name() {
|
||||
assert!(validate_profile_name("personal").is_ok());
|
||||
assert!(validate_profile_name("work-company").is_ok());
|
||||
assert!(validate_profile_name("").is_err());
|
||||
assert!(validate_profile_name("invalid name").is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user