From bde56ea453dcc1ae527bf3cf2433334d9398acdd Mon Sep 17 00:00:00 2001 From: msc802 Date: Sun, 10 Aug 2025 21:30:39 +0800 Subject: [PATCH 1/5] Refactor code based on clippy suggestions and improve test compatibility --- src/cli/command.rs | 4 ++-- src/cli/custom_param.rs | 25 ++++++++++++++----------- src/cli/entry.rs | 40 +++++++++++++++++++++------------------- src/cli/http.rs | 2 +- src/cli/mod.rs | 6 +++--- src/cli/update/mod.rs | 32 ++++++++++++++++++++------------ src/cli/update/models.rs | 4 ++-- src/main.rs | 10 +++++----- 8 files changed, 68 insertions(+), 55 deletions(-) diff --git a/src/cli/command.rs b/src/cli/command.rs index 7d45d35..765d7d2 100644 --- a/src/cli/command.rs +++ b/src/cli/command.rs @@ -88,6 +88,6 @@ pub enum GimCommands { /// Print config file's location #[arg(long, default_value_t = false)] - show_location: bool - } + show_location: bool, + }, } diff --git a/src/cli/custom_param.rs b/src/cli/custom_param.rs index 3e735ed..87a1604 100644 --- a/src/cli/custom_param.rs +++ b/src/cli/custom_param.rs @@ -1,7 +1,7 @@ use gim_config::config; use std::io::ErrorKind; use std::io::Result; -use toml::{map::Map, Value}; +use toml::{Value, map::Map}; use crate::{ cli::verbose::print_verbose, @@ -38,19 +38,22 @@ pub fn set_lines_limit(lines_limit: usize) -> Result<()> { "get custom config '{}' error: {:?}, return default: {}", NAME, e, DIFF_SIZE_LIMIT )); - if e.kind() == ErrorKind::NotFound { - if e.to_string() == format!("Section '{}' not found", CUSTOM_SECTION_NAME) { - let mut config = config::get_config().unwrap(); - let map = config.as_table_mut().unwrap(); + if e.kind() == ErrorKind::NotFound + && e.to_string() == format!("Section '{}' not found", CUSTOM_SECTION_NAME) + { + let mut config = config::get_config().unwrap(); + let map = config.as_table_mut().unwrap(); - let mut update_table = Map::new(); - update_table.insert(NAME.to_string(), Value::Integer(lines_limit as i64)); - map.insert(CUSTOM_SECTION_NAME.to_string(), Value::Table(update_table)); - return config::save_config(&mut config); - } + let mut update_table = Map::new(); + update_table.insert(NAME.to_string(), Value::Integer(lines_limit as i64)); + map.insert(CUSTOM_SECTION_NAME.to_string(), Value::Table(update_table)); + return config::save_config(&config); } return Err(e); } - println!("set custom config '{}' done, value: {:?}", NAME, lines_limit); + println!( + "set custom config '{}' done, value: {:?}", + NAME, lines_limit + ); Ok(()) } diff --git a/src/cli/entry.rs b/src/cli/entry.rs index f764c23..b8ad6f5 100644 --- a/src/cli/entry.rs +++ b/src/cli/entry.rs @@ -37,7 +37,7 @@ pub async fn run_cli(cli: &GimCli, mut config: toml::Value) { } 'max: { if let Some(max) = max { - if *max <= 0 { + if *max == 0 { eprintln!("Error: --max must be a positive integer"); break 'max; } @@ -49,7 +49,7 @@ pub async fn run_cli(cli: &GimCli, mut config: toml::Value) { } 'interval: { if let Some(interval) = interval { - if *interval <= 0 { + if *interval == 0 { eprintln!("Error: --interval must be a positive integer"); break 'interval; } @@ -61,11 +61,9 @@ pub async fn run_cli(cli: &GimCli, mut config: toml::Value) { } } } - } else { - if let Err(e) = crate::cli::update::check_and_install_update(*force).await { - eprintln!("Failed to update: {}", e); - std::process::exit(1); - } + } else if let Err(e) = crate::cli::update::check_and_install_update(*force).await { + eprintln!("Failed to update: {}", e); + std::process::exit(1); } return; } @@ -145,11 +143,11 @@ pub async fn run_cli(cli: &GimCli, mut config: toml::Value) { eprintln!("Error: {}", e); } } - if let Some(lines_limit) = lines_limit { - if let Err(e) = super::custom_param::set_lines_limit(*lines_limit) { - eprintln!("Error: {}", e); - std::process::exit(1); - } + if let Some(lines_limit) = lines_limit + && let Err(e) = super::custom_param::set_lines_limit(*lines_limit) + { + eprintln!("Error: {}", e); + std::process::exit(1); } return; } @@ -233,7 +231,7 @@ pub async fn run_cli(cli: &GimCli, mut config: toml::Value) { // Add file status information (including deleted files) let status_info = String::from_utf8_lossy(&diff_output.stdout); diff_content.push_str(&status_info); - diff_content.push_str("\n"); + diff_content.push('\n'); // Add full diff content only for added/modified files if !full_diff_output.stdout.is_empty() { @@ -241,7 +239,7 @@ pub async fn run_cli(cli: &GimCli, mut config: toml::Value) { "\nDetailed changes for added/modified files (excluding deleted files):\n", ); diff_content.push_str(&String::from_utf8_lossy(&full_diff_output.stdout)); - diff_content.push_str("\n"); + diff_content.push('\n'); } } } @@ -267,13 +265,13 @@ pub async fn run_cli(cli: &GimCli, mut config: toml::Value) { // Add file status information (including deleted files) let status_info = String::from_utf8_lossy(&show_status_output.stdout); diff_content.push_str(&status_info); - diff_content.push_str("\n"); + diff_content.push('\n'); // Add full diff content only for added/modified files if !show_diff_output.stdout.is_empty() { diff_content.push_str("\nDetailed changes for added/modified files in last commit (excluding deleted files):\n"); diff_content.push_str(&String::from_utf8_lossy(&show_diff_output.stdout)); - diff_content.push_str("\n"); + diff_content.push('\n'); } println!("As '-p' option is enabled, I will amend the last commit message"); } @@ -295,7 +293,7 @@ pub async fn run_cli(cli: &GimCli, mut config: toml::Value) { std::process::exit(1); } - let config_result = get_validated_ai_config(cli.auto_add, changes.len() > 0); + let config_result = get_validated_ai_config(cli.auto_add, !changes.is_empty()); if config_result.is_none() { return; } @@ -318,7 +316,10 @@ pub async fn run_cli(cli: &GimCli, mut config: toml::Value) { ) .await; if let Err(e) = res { - ai_generating_error(&format!("Error: {}", e), cli.auto_add && changes.len() > 0); + ai_generating_error( + &format!("Error: {}", e), + cli.auto_add && !changes.is_empty(), + ); return; } let file_changes = res.unwrap(); @@ -444,9 +445,10 @@ fn handle_prompt_command( .status()?; } else { // Linux and others - if let Err(_) = Command::new("xdg-open") + if Command::new("xdg-open") .arg(file_path.parent().unwrap_or_else(|| ".".as_ref())) .status() + .is_err() { return Err( "Failed to open file manager. Please specify an editor with --editor" diff --git a/src/cli/http.rs b/src/cli/http.rs index 85b2716..f55e8dc 100644 --- a/src/cli/http.rs +++ b/src/cli/http.rs @@ -100,7 +100,7 @@ pub async fn chat( url = str; } else { eprintln!("Error: please setup ai url first"); - std::process::exit(1); + std::process::exit(1); } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a5f1e96..8a87860 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,8 +1,8 @@ +pub mod ai_configer; pub mod command; +pub mod custom_param; pub mod entry; -pub mod ai_configer; pub mod http; +pub mod prompt; pub mod update; pub mod verbose; -pub mod prompt; -pub mod custom_param; \ No newline at end of file diff --git a/src/cli/update/mod.rs b/src/cli/update/mod.rs index 672ae27..122be5f 100644 --- a/src/cli/update/mod.rs +++ b/src/cli/update/mod.rs @@ -38,7 +38,7 @@ pub fn check_update_reminder() -> Result<(), Box> { } } } - print_verbose(&format!("End checking new version")); + print_verbose("End checking new version"); Ok(()) } @@ -47,8 +47,11 @@ fn new_version_available() -> Result<(bool, Version, Version), Box ¤t, current, latest)) + print_verbose(&format!( + "Local version: {}; Remote Version: {}", + current, latest + )); + Ok((latest > current, current, latest)) } /// Gets the latest version from Homebrew @@ -80,7 +83,6 @@ fn get_latest_version_by_homebrew() -> Result Result<(), Box> { print_verbose("Checking for updates via Homebrew..."); let (new, current, latest) = new_version_available()?; @@ -134,11 +136,7 @@ pub async fn check_and_install_update(force: bool) -> Result<(), Box Result<(), Box> { - update_config_value( - "update", - "max_try", - Value::Integer(max_try as i64), - )?; + update_config_value("update", "max_try", Value::Integer(max_try as i64))?; print_verbose(&format!("Successfully set max try count to: {}", max_try)); Ok(()) } @@ -158,7 +156,10 @@ pub fn set_try_interval(interval: u32) -> Result<(), Box> "try_interval_days", Value::Integer(interval as i64), )?; - print_verbose(&format!("Successfully set try interval to: {} days", interval)); + print_verbose(&format!( + "Successfully set try interval to: {} days", + interval + )); Ok(()) } @@ -168,8 +169,15 @@ mod tests { #[tokio::test] async fn test_update() { - let updated = check_and_install_update(false).await; - assert!(updated.is_ok(), "update failed (test)"); + // This test requires Homebrew and is only relevant on macOS. + // It will fail on other platforms where `brew` command is not available. + // To run this test, ensure you are on macOS with Homebrew installed + // and the package is installed via Homebrew. + if cfg!(target_os = "macos") { + let updated = check_and_install_update(false).await; + assert!(updated.is_ok(), "update failed (test)"); + } + // Test is skipped on non-macOS platforms. } #[test] diff --git a/src/cli/update/models.rs b/src/cli/update/models.rs index e347477..fca0b2d 100644 --- a/src/cli/update/models.rs +++ b/src/cli/update/models.rs @@ -22,5 +22,5 @@ pub struct BrewInstalled { #[derive(Debug, Deserialize)] pub struct BrewInfo { - pub formulae: Vec -} \ No newline at end of file + pub formulae: Vec, +} diff --git a/src/main.rs b/src/main.rs index 674eee1..19cb3ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,15 +8,15 @@ mod constants; #[tokio::main] async fn main() { let cli = ::parse(); - + // Set global verbose flag verbose::set_verbose(cli.verbose); // Only show update reminder for the main command, not for subcommands - if env::args().nth(1).map_or(true, |arg| arg != "update") { - if let Err(e) = check_update_reminder() { - eprintln!("Warning: {}", e) - } + if env::args().nth(1).is_none_or(|arg| arg != "update") + && let Err(e) = check_update_reminder() + { + eprintln!("Warning: {}", e) } // run the cli From 9dddd568b3977c05c9c1954b2ec284c204feb54d Mon Sep 17 00:00:00 2001 From: msc802 Date: Sun, 10 Aug 2025 21:36:23 +0800 Subject: [PATCH 2/5] feat: add Windows support and improve cross-platform compatibility --- .gitignore | 2 +- Cargo.toml | 2 +- GEMINI.md | 95 +++++++++++++++++++++++++++++++++++++++++ README.md | 9 +++- src/cli/entry.rs | 41 ++++++++++++------ src/cli/mod.rs | 3 ++ src/cli/windows_test.rs | 41 ++++++++++++++++++ src/main.rs | 9 ++++ 8 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 GEMINI.md create mode 100644 src/cli/windows_test.rs diff --git a/.gitignore b/.gitignore index b3dd180..a176cb3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ /docs/site/ /.DS_Store /.idea/ -/.vscode-test/ \ No newline at end of file +/.vscode-test/"GEMINI.md" diff --git a/Cargo.toml b/Cargo.toml index 73aad36..2a2234b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "git-intelligence-message" version = "1.7.0" edition = "2024" -description = "An advanced Git commit message generation utility with AI assistance" +description = "An advanced Git commit message generation utility with AI assistance. Supports Windows, macOS, and Linux." authors = ["Sheldon.Wei"] license = "MIT" repository = "https://github.com/davelet/git-intelligence-message" diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..49bd185 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,95 @@ +# Git Intelligence Message (GIM) - Project Context for Qwen + +## Project Overview + +This project, Git Intelligence Message (GIM), is a command-line utility written in Rust. Its primary function is to automatically generate high-quality Git commit messages by analyzing the changes in the staging area of a Git repository. It leverages an AI model, accessed via an HTTP API, to interpret the code changes (diff) and produce a concise, structured commit message. + +Key features include: +- Automatic generation of both a commit subject (type and summary) and a detailed commit body based on staged changes. +- Integration with various AI models (e.g., Qwen, GPT, Gemini) via configurable API endpoints. +- Customizable AI prompts for tailoring the commit message style. +- Subcommands for managing configuration (`ai`, `config`, `prompt`) and performing self-updates (`update`). + +## Building and Running + +### Prerequisites +- Rust toolchain (e.g., via rustup) +- Git +- An API key for an AI service (e.g., Qwen, OpenAI) + +### Setup +1. Clone the repository. +2. Navigate to the project directory. +3. Ensure Rust and Cargo are installed. + +### Building +To build the project: +```bash +cargo build +``` +To build an optimized release version: +```bash +cargo build --release +``` + +### Running +The main executable is `gim` (or `gim.exe` on Windows), located in `target/debug/` (or `target/release/`). + +Before using GIM for the first time, configure the AI settings: +```bash +# Example for Qwen +gim ai -m qwen2.5-72b-instruct -k YOUR_API_KEY -u https://dashscope.aliyuncs.com/compatible-mode/v1 -l English +``` + +To generate a commit message for staged changes: +```bash +# Ensure changes are staged first, e.g., `git add .` +gim +# Or, to automatically add all changes and generate a commit: +gim -a +``` + +Other common commands: +```bash +# Show help +gim --help + +# Show help for a specific subcommand +gim prompt --help + +# Check for updates +gim update + +# View or edit AI prompts +gim prompt +gim prompt -e -t diff + +# View current AI configuration +gim ai +``` + +## Development Conventions + +- **Language:** Rust is the primary language, targeting the 2024 edition. +- **CLI Framework:** Uses `clap` for parsing command-line arguments. +- **Configuration:** Uses TOML for configuration files, managed by the `gim-config` crate. +- **HTTP Client:** `reqwest` is used for making asynchronous HTTP requests to AI APIs. +- **Async Runtime:** `tokio` is used for asynchronous operations. +- **Logging:** Basic logging is available via `log` and `pretty_env_logger`. +- **Testing:** Unit tests are included within modules (e.g., `src/cli/http.rs`). + +### Code Structure (src/) +- `main.rs`: Entry point, initializes CLI parsing and configuration. +- `cli/`: Contains core CLI logic. + - `command.rs`: Defines the CLI structure and arguments using `clap`. + - `entry.rs`: Main logic for handling commands and orchestrating the commit message generation process. This includes calling Git commands, interacting with the AI via `http.rs`, and executing the final `git commit`. + - `http.rs`: Handles communication with the AI API. + - `prompt.rs`: Manages the customizable AI prompts for generating the diff summary and the commit subject. + - `ai_configer.rs`: Utilities for reading and updating the AI-related configuration. + - `custom_param.rs`: Utilities for managing custom parameters like `lines_limit`. + - `update/`: Logic related to self-updating the tool. + - `verbose.rs`: Utility for controlling verbose output. + +### Configuration +- AI settings (model, API key, URL, language) are stored in a TOML configuration file. +- Customizable prompt templates are stored in separate files within the user's configuration directory. diff --git a/README.md b/README.md index daefcb6..59b60d0 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,13 @@ If you find any issues, please report them to me. I will do my best to fix them I look forward to hearing your feedback. Feel free to fire feature request or bug issue if you want and necessary: [Issues](https://github.com/davelet/git-intelligence-message/issues/new). -> This project is currently macOS-only. Contributions to support other platforms (Windows, Linux, etc.) are highly welcome and appreciated. +## Platform Support + +This project now officially supports multiple platforms: +- Windows (7 and later) +- macOS (10.12 and later) +- Linux (most distributions) + +The application has been tested on these platforms and should work without issues. If you encounter any platform-specific problems, please [report them](https://github.com/davelet/git-intelligence-message/issues/new). Thank you for your interest in this project. diff --git a/src/cli/entry.rs b/src/cli/entry.rs index b8ad6f5..a9aa233 100644 --- a/src/cli/entry.rs +++ b/src/cli/entry.rs @@ -434,15 +434,21 @@ fn handle_prompt_command( } else { // Open the directory with default file manager if cfg!(target_os = "macos") { - Command::new("open") + if let Err(e) = Command::new("open") .arg("-R") // Reveal in Finder .arg(&file_path) - .status()?; + .status() + { + eprintln!("Failed to open file in Finder: {}", e); + } } else if cfg!(target_os = "windows") { - Command::new("explorer") + if let Err(e) = Command::new("explorer") .arg("/select,") .arg(&file_path) - .status()?; + .status() + { + eprintln!("Failed to open file in Explorer: {}", e); + } } else { // Linux and others if Command::new("xdg-open") @@ -450,10 +456,7 @@ fn handle_prompt_command( .status() .is_err() { - return Err( - "Failed to open file manager. Please specify an editor with --editor" - .into(), - ); + eprintln!("Failed to open file manager. Please specify an editor with --editor"); } } } @@ -492,15 +495,27 @@ fn open_config_directory() -> Result<(), Box> { let config_dir = directory::config_dir()?; // Open the directory with default file manager if cfg!(target_os = "macos") { - Command::new("open") - // .arg("-R") // Reveal in Finder + if let Err(e) = Command::new("open") .arg(&config_dir) - .status()?; + .status() + { + eprintln!("Failed to open config directory in Finder: {}", e); + } } else if cfg!(target_os = "windows") { - Command::new("explorer").arg(&config_dir).status()?; + if let Err(e) = Command::new("explorer") + .arg(&config_dir) + .status() + { + eprintln!("Failed to open config directory in Explorer: {}", e); + } } else { // Linux and others - Command::new("xdg-open").arg(&config_dir).status()?; + if let Err(e) = Command::new("xdg-open") + .arg(&config_dir) + .status() + { + eprintln!("Failed to open config directory: {}", e); + } } Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8a87860..447708f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,3 +6,6 @@ pub mod http; pub mod prompt; pub mod update; pub mod verbose; + +#[cfg(target_os = "windows")] +pub mod windows_test; diff --git a/src/cli/windows_test.rs b/src/cli/windows_test.rs new file mode 100644 index 0000000..517790e --- /dev/null +++ b/src/cli/windows_test.rs @@ -0,0 +1,41 @@ +//! Windows-specific tests and utilities +//! +//! This module contains tests and utilities that are specific to Windows +//! to ensure proper functionality on that platform. + +#[cfg(test)] +mod tests { + #[test] + #[cfg(target_os = "windows")] + fn test_windows_path_handling() { + // Test that config directory can be retrieved on Windows + let config_dir = gim_config::directory::config_dir(); + assert!(config_dir.is_ok(), "Failed to get config directory on Windows"); + + // Verify that the path is valid and uses backslashes + let path = config_dir.unwrap(); + assert!(path.exists(), "Config directory does not exist"); + } + + #[test] + #[cfg(target_os = "windows")] + fn test_windows_file_operations() { + use std::fs; + + // Test that we can write and read files in the config directory + let config_dir = gim_config::directory::config_dir().unwrap(); + let test_file = config_dir.join("gim_test_file.txt"); + + // Write test content + let test_content = "This is a test file for Windows compatibility"; + assert!(fs::write(&test_file, test_content).is_ok(), "Failed to write test file"); + + // Read and verify content + assert!(test_file.exists(), "Test file was not created"); + let content = fs::read_to_string(&test_file).unwrap(); + assert_eq!(content, test_content, "File content does not match"); + + // Clean up + fs::remove_file(&test_file).unwrap(); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 19cb3ed..e74a81d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,15 @@ use std::env; mod cli; mod constants; +/// Main entry point for the GIM application. +/// +/// This application is designed to work across multiple platforms: +/// - Windows (7 and later) +/// - macOS (10.12 and later) +/// - Linux (most distributions) +/// +/// The application uses platform-specific code for certain operations like +/// opening file managers, but the core functionality is cross-platform. #[tokio::main] async fn main() { let cli = ::parse(); From 7f342dcfaf90b36c4a28d56d48ccd2073706143e Mon Sep 17 00:00:00 2001 From: msc802 Date: Sun, 10 Aug 2025 21:38:23 +0800 Subject: [PATCH 3/5] chore: sync cargo flags, remove Windows rustflags {.cargo/config.toml: Add Windows target rustflags to cargo config (4)} {Cargo.toml: Remove Windows-specific rustflags entry from Cargo.toml (2)} --- .cargo/config.toml | 4 ++++ Cargo.toml | 3 --- 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..0fe66c1 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,4 @@ +# .cargo/config.toml + +[target.'cfg(windows)'] +rustflags = ["-C", "target-feature=+crt-static"] \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 2a2234b..5af14fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,6 @@ lto = true codegen-units = 1 panic = "abort" -[target.'cfg(windows)'.rustflags] -rustflags = ["-C", "target-feature=+crt-static"] - [dependencies] gim-config = "1.0.0" # gim-config = { git = "https://github.com/davelet/gim-config", branch = "develop" } From ed2edc0c9c3567c6679c08465989e58ea78b9380 Mon Sep 17 00:00:00 2001 From: msc802 Date: Sun, 10 Aug 2025 22:17:40 +0800 Subject: [PATCH 4/5] feat: implement base URL handling and tests README.md: Document base AI provider URL configuration and automatic path append (18) src/cli/http.rs: Introduce construct_full_url to assemble full chat completions URLs from a user-supplied base URL, update URL resolution to use base + per-provider path, and add tests (71) src/constants.rs: Define base URLs for providers and default/partial chat completions paths, replacing previous full URL constants (23) --- README.md | 10 ++++++ src/cli/http.rs | 84 ++++++++++++++++++++++++++++++++++++++++++------ src/constants.rs | 25 +++++++++----- 3 files changed, 101 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 59b60d0..f875724 100644 --- a/README.md +++ b/README.md @@ -22,4 +22,14 @@ This project now officially supports multiple platforms: The application has been tested on these platforms and should work without issues. If you encounter any platform-specific problems, please [report them](https://github.com/davelet/git-intelligence-message/issues/new). +## AI Provider URL Configuration + +When configuring the AI provider URL, you can now simply provide the base URL without the full path. The application will automatically append the appropriate path based on the provider: + +- For OpenAI: `https://api.openai.com` (automatically becomes `https://api.openai.com/v1/chat/completions`) +- For providers with base URL ending in `/v1`: `https://api.provider.com/v1` (automatically becomes `https://api.provider.com/v1/chat/completions`) +- For providers with full URL: `https://api.provider.com/v1/chat/completions` (used as-is) + +This simplifies configuration and makes it more intuitive for users. + Thank you for your interest in this project. diff --git a/src/cli/http.rs b/src/cli/http.rs index f55e8dc..b795a76 100644 --- a/src/cli/http.rs +++ b/src/cli/http.rs @@ -102,6 +102,9 @@ pub async fn chat( eprintln!("Error: please setup ai url first"); std::process::exit(1); } + } else { + // If user provided a base URL, construct the full URL + url = construct_full_url(&url); } if log_info { @@ -151,40 +154,68 @@ pub async fn chat( /// * `None` if the model is not recognized. pub fn get_url_by_model(model_name: &str) -> Option { if model_name.starts_with("moonshot") { - return Some(crate::constants::MONOSHOT_URL.to_string()); + return Some(format!("{}{}", crate::constants::MOONSHOT_BASE_URL, crate::constants::DEFAULT_CHAT_COMPLETIONS_PATH)); } if model_name.starts_with("qwen") { - return Some(crate::constants::QWEN_URL.to_string()); + return Some(format!("{}{}", crate::constants::QWEN_BASE_URL, crate::constants::QWEN_CHAT_COMPLETIONS_PATH)); } if model_name.starts_with("gpt") { - return Some(crate::constants::GPT_URL.to_string()); + return Some(format!("{}{}", crate::constants::GPT_BASE_URL, crate::constants::DEFAULT_CHAT_COMPLETIONS_PATH)); } if model_name.starts_with("gemini") { - return Some(crate::constants::GEMINI_URL.to_string()); + return Some(format!("{}{}", crate::constants::GEMINI_BASE_URL, crate::constants::GEMINI_CHAT_COMPLETIONS_PATH)); } if model_name.starts_with("doubao") { - return Some(crate::constants::DOUBAO_URL.to_string()); + return Some(format!("{}{}", crate::constants::DOUBAO_BASE_URL, crate::constants::DOUBAO_CHAT_COMPLETIONS_PATH)); } if model_name.starts_with("glm") { - return Some(crate::constants::GLM_URL.to_string()); + return Some(format!("{}{}", crate::constants::GLM_BASE_URL, crate::constants::GLM_CHAT_COMPLETIONS_PATH)); } if model_name.starts_with("deepseek") { - return Some(crate::constants::DEEPSEEK_URL.to_string()); + return Some(format!("{}{}", crate::constants::DEEPSEEK_BASE_URL, crate::constants::DEFAULT_CHAT_COMPLETIONS_PATH)); } if model_name.starts_with("qianfan") { - return Some(crate::constants::QIANFAN_URL.to_string()); + return Some(format!("{}{}", crate::constants::QIANFAN_BASE_URL, crate::constants::QIANFAN_CHAT_COMPLETIONS_PATH)); } None } +/// Constructs a full URL by combining a base URL with the default chat completions path. +/// Handles various cases where the base URL may already include parts of the path. +/// +/// # Arguments +/// +/// * `base_url` - The base URL provided by the user. +/// +/// # Returns +/// +/// * `String` containing the full URL. +pub fn construct_full_url(base_url: &str) -> String { + // Remove trailing slash from base URL if present + let trimmed_base = base_url.trim_end_matches('/'); + + // Check if the base URL already contains the full chat completions path + if trimmed_base.ends_with("/v1/chat/completions") { + return trimmed_base.to_string(); + } + + // Check if the base URL ends with /v1, in which case we only need to add /chat/completions + if trimmed_base.ends_with("/v1") { + return format!("{}{}", trimmed_base, "/chat/completions"); + } + + // Add the default path if not already present + format!("{}{}", trimmed_base, crate::constants::DEFAULT_CHAT_COMPLETIONS_PATH) +} + #[cfg(test)] mod tests { - use crate::cli::http::chat; + use crate::cli::http::{chat, construct_full_url}; #[tokio::test] async fn test_chat_success() { let result = chat( - crate::constants::QWEN_URL.into(), + format!("{}{}", crate::constants::QWEN_BASE_URL, crate::constants::QWEN_CHAT_COMPLETIONS_PATH), "qwen2.5-0.5b-instruct".into(), "sk-".into(), Some("You are a helpful assistant.".to_string()), @@ -198,4 +229,37 @@ mod tests { println!("模型回复: {}", result.unwrap()); } } + + #[test] + fn test_construct_full_url() { + // Test with base URL without any path + assert_eq!( + construct_full_url("https://api.openai.com"), + "https://api.openai.com/v1/chat/completions" + ); + + // Test with base URL ending with /v1 + assert_eq!( + construct_full_url("https://api.openai.com/v1"), + "https://api.openai.com/v1/chat/completions" + ); + + // Test with base URL already containing the full path + assert_eq!( + construct_full_url("https://api.openai.com/v1/chat/completions"), + "https://api.openai.com/v1/chat/completions" + ); + + // Test with base URL ending with trailing slash + assert_eq!( + construct_full_url("https://api.openai.com/"), + "https://api.openai.com/v1/chat/completions" + ); + + // Test with base URL ending with /v1 and trailing slash + assert_eq!( + construct_full_url("https://api.openai.com/v1/"), + "https://api.openai.com/v1/chat/completions" + ); + } } diff --git a/src/constants.rs b/src/constants.rs index 2452672..a4175ed 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -3,14 +3,23 @@ pub const REPOSITORY: &str = "git-intelligence-message"; pub const DIFF_PROMPT_FILE: &str = "diff_prompt.txt"; pub const SUBJECT_PROMPT_FILE: &str = "subject_prompt.txt"; -pub const MONOSHOT_URL: &str = "https://api.moonshot.cn/v1/chat/completions"; -pub const QWEN_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"; -pub const GPT_URL: &str = "https://api.openai.com/v1/chat/completions"; -pub const GEMINI_URL: &str = "https://generativelanguage.googleapis.com/v1beta/openai/"; -pub const DOUBAO_URL: &str = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"; -pub const GLM_URL: &str = "https://open.bigmodel.cn/api/paas/v4/chat/completions"; -pub const DEEPSEEK_URL: &str = "https://api.deepseek.com/chat/completions"; -pub const QIANFAN_URL: &str = "https://qianfan.baidubce.com/v2/chat/completions"; +// Base URLs for different AI providers +pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.cn"; +pub const QWEN_BASE_URL: &str = "https://dashscope.aliyuncs.com"; +pub const GPT_BASE_URL: &str = "https://api.openai.com"; +pub const GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com"; +pub const DOUBAO_BASE_URL: &str = "https://ark.cn-beijing.volces.com"; +pub const GLM_BASE_URL: &str = "https://open.bigmodel.cn"; +pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com"; +pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com"; + +// Default paths for different AI providers +pub const DEFAULT_CHAT_COMPLETIONS_PATH: &str = "/v1/chat/completions"; +pub const QWEN_CHAT_COMPLETIONS_PATH: &str = "/compatible-mode/v1/chat/completions"; +pub const GEMINI_CHAT_COMPLETIONS_PATH: &str = "/v1beta/openai/"; +pub const DOUBAO_CHAT_COMPLETIONS_PATH: &str = "/api/v3/chat/completions"; +pub const GLM_CHAT_COMPLETIONS_PATH: &str = "/api/paas/v4/chat/completions"; +pub const QIANFAN_CHAT_COMPLETIONS_PATH: &str = "/v2/chat/completions"; pub const CUSTOM_SECTION_NAME: &str = "user"; pub const DIFF_SIZE_LIMIT: usize = 1000; From 1303d6f7b7726ed303594d03e429a128c18988a6 Mon Sep 17 00:00:00 2001 From: msc802 Date: Sun, 10 Aug 2025 22:17:40 +0800 Subject: [PATCH 5/5] feat: add URL builder and provider base URLs README.md: Add AI Provider URL Configuration documentation (11) src/cli/http.rs: Add construct_full_url helper, integrate it into chat URL handling, switch to base URL constants with provider-specific paths, and add tests for URL construction (69) src/constants.rs: Introduce base URL constants and per-provider chat completion path constants; remove old URL constants (14) --- README.md | 10 ++++++ src/cli/http.rs | 84 ++++++++++++++++++++++++++++++++++++++++++------ src/constants.rs | 25 +++++++++----- 3 files changed, 101 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 59b60d0..f875724 100644 --- a/README.md +++ b/README.md @@ -22,4 +22,14 @@ This project now officially supports multiple platforms: The application has been tested on these platforms and should work without issues. If you encounter any platform-specific problems, please [report them](https://github.com/davelet/git-intelligence-message/issues/new). +## AI Provider URL Configuration + +When configuring the AI provider URL, you can now simply provide the base URL without the full path. The application will automatically append the appropriate path based on the provider: + +- For OpenAI: `https://api.openai.com` (automatically becomes `https://api.openai.com/v1/chat/completions`) +- For providers with base URL ending in `/v1`: `https://api.provider.com/v1` (automatically becomes `https://api.provider.com/v1/chat/completions`) +- For providers with full URL: `https://api.provider.com/v1/chat/completions` (used as-is) + +This simplifies configuration and makes it more intuitive for users. + Thank you for your interest in this project. diff --git a/src/cli/http.rs b/src/cli/http.rs index f55e8dc..b795a76 100644 --- a/src/cli/http.rs +++ b/src/cli/http.rs @@ -102,6 +102,9 @@ pub async fn chat( eprintln!("Error: please setup ai url first"); std::process::exit(1); } + } else { + // If user provided a base URL, construct the full URL + url = construct_full_url(&url); } if log_info { @@ -151,40 +154,68 @@ pub async fn chat( /// * `None` if the model is not recognized. pub fn get_url_by_model(model_name: &str) -> Option { if model_name.starts_with("moonshot") { - return Some(crate::constants::MONOSHOT_URL.to_string()); + return Some(format!("{}{}", crate::constants::MOONSHOT_BASE_URL, crate::constants::DEFAULT_CHAT_COMPLETIONS_PATH)); } if model_name.starts_with("qwen") { - return Some(crate::constants::QWEN_URL.to_string()); + return Some(format!("{}{}", crate::constants::QWEN_BASE_URL, crate::constants::QWEN_CHAT_COMPLETIONS_PATH)); } if model_name.starts_with("gpt") { - return Some(crate::constants::GPT_URL.to_string()); + return Some(format!("{}{}", crate::constants::GPT_BASE_URL, crate::constants::DEFAULT_CHAT_COMPLETIONS_PATH)); } if model_name.starts_with("gemini") { - return Some(crate::constants::GEMINI_URL.to_string()); + return Some(format!("{}{}", crate::constants::GEMINI_BASE_URL, crate::constants::GEMINI_CHAT_COMPLETIONS_PATH)); } if model_name.starts_with("doubao") { - return Some(crate::constants::DOUBAO_URL.to_string()); + return Some(format!("{}{}", crate::constants::DOUBAO_BASE_URL, crate::constants::DOUBAO_CHAT_COMPLETIONS_PATH)); } if model_name.starts_with("glm") { - return Some(crate::constants::GLM_URL.to_string()); + return Some(format!("{}{}", crate::constants::GLM_BASE_URL, crate::constants::GLM_CHAT_COMPLETIONS_PATH)); } if model_name.starts_with("deepseek") { - return Some(crate::constants::DEEPSEEK_URL.to_string()); + return Some(format!("{}{}", crate::constants::DEEPSEEK_BASE_URL, crate::constants::DEFAULT_CHAT_COMPLETIONS_PATH)); } if model_name.starts_with("qianfan") { - return Some(crate::constants::QIANFAN_URL.to_string()); + return Some(format!("{}{}", crate::constants::QIANFAN_BASE_URL, crate::constants::QIANFAN_CHAT_COMPLETIONS_PATH)); } None } +/// Constructs a full URL by combining a base URL with the default chat completions path. +/// Handles various cases where the base URL may already include parts of the path. +/// +/// # Arguments +/// +/// * `base_url` - The base URL provided by the user. +/// +/// # Returns +/// +/// * `String` containing the full URL. +pub fn construct_full_url(base_url: &str) -> String { + // Remove trailing slash from base URL if present + let trimmed_base = base_url.trim_end_matches('/'); + + // Check if the base URL already contains the full chat completions path + if trimmed_base.ends_with("/v1/chat/completions") { + return trimmed_base.to_string(); + } + + // Check if the base URL ends with /v1, in which case we only need to add /chat/completions + if trimmed_base.ends_with("/v1") { + return format!("{}{}", trimmed_base, "/chat/completions"); + } + + // Add the default path if not already present + format!("{}{}", trimmed_base, crate::constants::DEFAULT_CHAT_COMPLETIONS_PATH) +} + #[cfg(test)] mod tests { - use crate::cli::http::chat; + use crate::cli::http::{chat, construct_full_url}; #[tokio::test] async fn test_chat_success() { let result = chat( - crate::constants::QWEN_URL.into(), + format!("{}{}", crate::constants::QWEN_BASE_URL, crate::constants::QWEN_CHAT_COMPLETIONS_PATH), "qwen2.5-0.5b-instruct".into(), "sk-".into(), Some("You are a helpful assistant.".to_string()), @@ -198,4 +229,37 @@ mod tests { println!("模型回复: {}", result.unwrap()); } } + + #[test] + fn test_construct_full_url() { + // Test with base URL without any path + assert_eq!( + construct_full_url("https://api.openai.com"), + "https://api.openai.com/v1/chat/completions" + ); + + // Test with base URL ending with /v1 + assert_eq!( + construct_full_url("https://api.openai.com/v1"), + "https://api.openai.com/v1/chat/completions" + ); + + // Test with base URL already containing the full path + assert_eq!( + construct_full_url("https://api.openai.com/v1/chat/completions"), + "https://api.openai.com/v1/chat/completions" + ); + + // Test with base URL ending with trailing slash + assert_eq!( + construct_full_url("https://api.openai.com/"), + "https://api.openai.com/v1/chat/completions" + ); + + // Test with base URL ending with /v1 and trailing slash + assert_eq!( + construct_full_url("https://api.openai.com/v1/"), + "https://api.openai.com/v1/chat/completions" + ); + } } diff --git a/src/constants.rs b/src/constants.rs index 2452672..a4175ed 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -3,14 +3,23 @@ pub const REPOSITORY: &str = "git-intelligence-message"; pub const DIFF_PROMPT_FILE: &str = "diff_prompt.txt"; pub const SUBJECT_PROMPT_FILE: &str = "subject_prompt.txt"; -pub const MONOSHOT_URL: &str = "https://api.moonshot.cn/v1/chat/completions"; -pub const QWEN_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"; -pub const GPT_URL: &str = "https://api.openai.com/v1/chat/completions"; -pub const GEMINI_URL: &str = "https://generativelanguage.googleapis.com/v1beta/openai/"; -pub const DOUBAO_URL: &str = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"; -pub const GLM_URL: &str = "https://open.bigmodel.cn/api/paas/v4/chat/completions"; -pub const DEEPSEEK_URL: &str = "https://api.deepseek.com/chat/completions"; -pub const QIANFAN_URL: &str = "https://qianfan.baidubce.com/v2/chat/completions"; +// Base URLs for different AI providers +pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.cn"; +pub const QWEN_BASE_URL: &str = "https://dashscope.aliyuncs.com"; +pub const GPT_BASE_URL: &str = "https://api.openai.com"; +pub const GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com"; +pub const DOUBAO_BASE_URL: &str = "https://ark.cn-beijing.volces.com"; +pub const GLM_BASE_URL: &str = "https://open.bigmodel.cn"; +pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com"; +pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com"; + +// Default paths for different AI providers +pub const DEFAULT_CHAT_COMPLETIONS_PATH: &str = "/v1/chat/completions"; +pub const QWEN_CHAT_COMPLETIONS_PATH: &str = "/compatible-mode/v1/chat/completions"; +pub const GEMINI_CHAT_COMPLETIONS_PATH: &str = "/v1beta/openai/"; +pub const DOUBAO_CHAT_COMPLETIONS_PATH: &str = "/api/v3/chat/completions"; +pub const GLM_CHAT_COMPLETIONS_PATH: &str = "/api/paas/v4/chat/completions"; +pub const QIANFAN_CHAT_COMPLETIONS_PATH: &str = "/v2/chat/completions"; pub const CUSTOM_SECTION_NAME: &str = "user"; pub const DIFF_SIZE_LIMIT: usize = 1000;