Journey in Rust: Expanding Our ChatGPT API Command Line Tool - Part 4
Table of contents
- Step 1: Introducing subcommands
- Display help information for the tool:
- Step 2: Implementing the config subcommand
- Refactoring
- Step 1: Utilizing environment variables for the OpenAI API key
- Step 2: Enhancing the way we get the operating system information
- Step 3: Creating separate functions for getting the system message and body
- Step 4: Refactoring the get_response function to handle the API request
- Expanding the Config command: Introduction to new flags
- Step 5: Expanding the Config command with new options
In this part, we will be focusing on adding more options for our command line arguments as well as refactoring the code to make it look a lot better and readable.
Let's get started! ๐
Step 1: Introducing subcommands
We'll begin by refactoring the Args
struct to introduce subcommands. We'll create an enum Commands
to store the subcommands and modify the Args
struct to include a command
field of type Commands
:
#[derive(Parser, Debug)]
#[clap(
version = "0.1.0",
author = "Your Name <your.email@example.com>",
about = "A simple command line tool"
)]
struct Args {
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Config,
#[clap(about = "Search for a command")]
Search {
query: String,
#[clap(short = 't')]
tokens: Option<u32>,
},
}
A subcommand in Rust is a way to provide additional functionality to a command-line application by allowing users to perform more specific tasks with your tool. Subcommands are similar to options and flags, but they are more powerful because they can have their own set of options and flags. For example, the
git
command has acommit
subcommand that allows users to commit changes to a repository. Thecommit
subcommand has its own set of options and flags, such as--amend
and--no-verify
.
In the example above, we are defining an enum Commands
to store the subcommands supported by our application. Each variant of the Commands
enum represents a different subcommand, and they can carry specific data related to the subcommand, such as options and flags. In this case, we have two subcommands: Config
and Search
.Config
is a simple subcommand with no additional data, while Search
has its own set of data: a query
field of type String
and an optional tokens
field of type Option<u32>
. We can use the about
attribute to provide a short description of the Search
subcommand, and the short
attribute to define a shorthand flag for the tokens
field.
The Args
struct now includes a field command
of type Commands
, which will store the subcommand provided by the user when running the application. By using the #[clap(subcommand)]
attribute, we tell the clap
crate to parse the command-line arguments and match
Display help information for the tool:
$ ./termoil --help
This will output the help information generated by the clap
crate, including the available subcommands and their descriptions:
A simple command line tool
USAGE:
my_tool <SUBCOMMAND>
FLAGS:
-h, --help Print help information
-V, --version Print version information
SUBCOMMANDS:
config (No description provided)
search Search for a command
help Print this message or the help of the given subcommand(s)
Step 2: Implementing the config
subcommand
Next, we'll implement the config
subcommand. For now, we'll simply use the todo!()
macro to mark it as unimplemented:
match arguments.command {
Commands::Config => todo!(),
Commands::Search { tokens, query } => {
// ...
}
}
That's it! We've successfully introduced subcommands to our command line tool and added a placeholder for the config
subcommand. Stay tuned for more improvements and features in the next part of our Rust journey. ๐
Refactoring
Why do I need this refactoring ?? Refactoring is like decluttering your closet. Imagine your closet is filled with clothes, shoes, and accessories all jumbled up, making it hard to find the right outfit. You know you have some great pieces, but because they're hidden or buried under a mess, you struggle to make the most out of them. This cluttered state not only takes up valuable space but also wastes time and energy as you search for the perfect piece to complete your ensemble.
Now, think of your code as that closet. As a programmer, your goal is to create functional, efficient, and maintainable code. However, over time and with continual additions and modifications, your code can become cluttered, making it difficult to understand and manage. This is where refactoring comes into play.
Refactoring is the process of reorganizing and cleaning up your code without changing its functionality. It's like organizing your closet by removing items you no longer need, grouping similar items together, and properly labeling them. This process makes it easier for you (and others) to find and maintain the code in the future.
In summary, refactoring is an essential part of software development that helps keep your code clean, organized, and maintainable.
Let's dive in! ๐
Step 1: Utilizing environment variables for the OpenAI API key
Instead of hardcoding the API key in our code, let's use environment variables to store and access it. We'll create a get_api_key()
function that retrieves the API key from the environment variable OPEN_AI_API_KEY
:
fn get_api_key() -> String {
env::var("OPEN_AI_API_KEY").expect("OPEN_AI_API_KEY not set")
}
This way, our tool will be more flexible and secure, as we can easily change the API key without modifying our code.
Step 2: Enhancing the way we get the operating system information
We'll improve how we get the operating system information by creating a get_os()
function. This function will call the get_pretty_name()
function we previously wrote and will return the operating system information as a String
:
fn get_os() -> String {
get_pretty_name().unwrap_or("Linux".to_owned())
}
Step 3: Creating separate functions for getting the system message and body
In the current implementation, we're creating the system message and body directly within the main
function. To make our code more modular, let's create separate functions for getting the system message and body.
First, we'll create the get_system_message()
function:
fn get_system_message() -> String {
format!(
"Act as a terminal expert, answer should be the COMMAND ONLY, no need to explain. OS: {OS}",
OS = get_os()
)
}
Next, we'll create the get_body()
function:
fn get_body(query: String, tokens: u32) -> serde_json::Value {
json!(
{
"model":"gpt-3.5-turbo",
"messages":[
{"role": "system",
"content": get_system_message()
},
{
"role":"user",
"content": query,
}
],
"max_tokens": tokens,
}
)
}
Now, let's replace the existing code in the main
function with calls to these new functions. This will make our code cleaner and easier to maintain.
Step 4: Refactoring the get_response
function to handle the API request
Currently, the main function handles the API request. To make our code more modular, let's move this functionality to a separate function called get_response
.
First, we'll create the get_response
function:
async fn get_response(query: String, tokens: u32) -> Result<ApiResponse, Box<dyn Error>> {
let client = Client::new();
let url = "https://api.openai.com/v1/chat/completions";
let response: ApiResponse = client
.post(url)
.headers(get_header())
.json(&get_body(query, tokens))
.send()
.await?
.json()
.await?;
Ok(response)
}
Now, we can simply call this function from the main
function:
let response: ApiResponse = get_response(query, tokens).await?;
This change makes our code more modular and maintainable, as each function now has a specific responsibility.
Expanding the Config
command: Introduction to new flags
Before diving into next Step, let's discuss why we need to add new flags to the Config
command. These flags will allow users to customize the application behavior and make it more flexible to their needs. Think of it like a paint set, where each flag represents a different color that users can mix and match to create their ideal painting.
tokens
flag: This flag allows users to set a custom value for the maximum number of tokens the AI can generate in a response.manual_commands
flag: With this flag, users can supply their own set of system commands, giving them more control over the AI's behavior.auto_commands
flag: This flag enables us to determine what all commands you have it is best for the popular operating systems.display_commands
flag: When this flag is enabled, the application will display all the information about the system commands collected, either manual or automatic.
Now that we understand the purpose of these flags, let's move on to Step 5, where we'll add these new options to the Config
command and make our application even more versatile and customizable.
Step 5: Expanding the Config
command with new options
The existing Config
command is quite simple, and we'd like to enhance it by adding new options for tokens, manual commands, auto commands, and displaying commands. It's like upgrading a basic burger with additional toppings to make it more delicious and personalized.
Here's the modified Commands
enum with the new options:
#[derive(Subcommand, Debug)]
enum Commands {
Config {
#[arg(short = 't')]
tokens: Option<u32>,
// supply manual info about the system commands
#[arg(short = 'm', long = "manual", group = "commands")]
manual_commands: Option<String>,
// automatically generate the system commands
#[arg(short = 'a', long = "auto", group = "commands")]
auto_commands: bool,
// display all the information about the system collected
#[arg(short = 'd', long = "display", group = "commands")]
display_commands: bool,
},
// ...
}
Now, let's update the main
function to handle these new options, just like a chef preparing a burger with the chosen toppings:
match arguments.command {
Commands::Config {
tokens,
manual_commands,
auto_commands,
display_commands,
} => {
println!("Tokens: {:?}", tokens);
println!("Manual Commands: {:?}", manual_commands);
println!("Auto Commands: {:?}", auto_commands);
println!("Display Commands: {:?}", display_commands);
}
// ...
}
With these changes, the Config
command has become more versatile and functional, allowing users to customize their experience as they wish, just like enjoying a burger with their favorite toppings.
And that's it! ๐ We've successfully added new options to the Config
command, making it more flexible and powerful for users. In the next part, we will be adding the implementation for these configuration options.
Cover: Bhupesh
Subscribe to my newsletter
Read articles from Priyam Srivastava directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Priyam Srivastava
Priyam Srivastava
Tech-savvy developer with experience in deep learning research, web development, and Android app creation. Skilled in Python, Java, and TypeScript, with a passion for open-source projects and hackathon victories.