Journey in Rust: Expanding Our ChatGPT API Command Line Tool - Part 4

Journey in Rust: Expanding Our ChatGPT API Command Line Tool - Part 4

ยท

7 min read

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 a commit subcommand that allows users to commit changes to a repository. The commit 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.

  1. tokens flag: This flag allows users to set a custom value for the maximum number of tokens the AI can generate in a response.

  2. manual_commands flag: With this flag, users can supply their own set of system commands, giving them more control over the AI's behavior.

  3. auto_commands flag: This flag enables us to determine what all commands you have it is best for the popular operating systems.

  4. 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

๐Ÿ”— Repository: termoil

ย