feat:(first commit)created repository and complete 0.1.0

This commit is contained in:
2026-01-30 14:18:32 +08:00
commit 5d4156e5e0
36 changed files with 8686 additions and 0 deletions

139
src/utils/crypto.rs Normal file
View 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
View 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
View 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
View 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
View 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());
}
}