#![forbid(unsafe_code)]
#![warn(missing_docs)]
use colored::Colorize;
use dialoguer::Editor;
use dialoguer::Input;
use home::home_dir;
use indicatif::{ProgressBar, ProgressStyle};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
mod theme;
use theme::TofuTheme;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ConfigFile {
pub provider: String,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Message {
role: String,
content: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KeysFile {
pub google: Option<String>,
pub openai: Option<String>,
pub anthropic: Option<String>,
}
#[derive(Debug)]
pub struct Config {
pub verbose: bool,
pub interactive: bool,
pub message: Option<String>,
pub stream: Option<bool>,
pub file: Option<ConfigFile>,
}
fn get_config_path() -> Result<PathBuf, Box<dyn Error>> {
let config_dir = if cfg!(windows) {
dirs::config_dir()
.ok_or("Could not determine config directory")?
.join("tofu")
} else {
home_dir()
.ok_or("Could not determine home directory")?
.join(".tofu")
};
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir)?;
}
Ok(config_dir.join("config.json"))
}
fn get_keys_path() -> Result<PathBuf, Box<dyn Error>> {
let config_dir = if cfg!(windows) {
dirs::config_dir()
.ok_or("Could not determine config directory")?
.join("tofu")
} else {
home_dir()
.ok_or("Could not determine home directory")?
.join(".tofu")
};
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir)?;
}
Ok(config_dir.join("keys.json"))
}
pub fn load_config(profile: Option<&str>) -> Result<ConfigFile, Box<dyn Error>> {
let config_path = get_config_path()?;
if !config_path.exists() {
let default_config = ConfigFile {
provider: String::from("pollinations"),
model: String::from("openai"),
stream: Some(true),
system_prompt: Some(String::from("You are a helpful assistant named Tofu.")),
};
let gemini_config = ConfigFile {
provider: String::from("google"),
model: String::from("gemini-2.5-flash"),
stream: None,
system_prompt: None,
};
let openai_config = ConfigFile {
provider: String::from("openai"),
model: String::from("gpt-5-mini"),
stream: None,
system_prompt: None,
};
let anthropic_config = ConfigFile {
provider: String::from("anthropic"),
model: String::from("claude-sonnet-4-6"),
stream: None,
system_prompt: None,
};
let ollama_config = ConfigFile {
provider: String::from("ollama"),
model: String::from("llama3"),
stream: None,
system_prompt: None,
};
let profiles_json = serde_json::json!({
"default": &default_config,
"gemini": &gemini_config,
"openai": &openai_config,
"anthropic": &anthropic_config,
"ollama": &ollama_config
});
let config_json = serde_json::to_string_pretty(&profiles_json)?;
std::fs::write(&config_path, config_json)?;
return Ok(default_config);
}
let config_content = fs::read_to_string(&config_path)?;
let root_value: serde_json::Value = serde_json::from_str(&config_content)
.map_err(|e| format!("Failed to parse config file: {}", e))?;
if let Some(obj) = root_value.as_object() {
let looks_like_legacy = obj.contains_key("provider")
|| obj.contains_key("model")
|| obj.contains_key("stream")
|| obj.contains_key("system_prompt");
if looks_like_legacy {
println!("WARNING: legacy config is deprecated");
let cfg: ConfigFile = serde_json::from_value(root_value)
.map_err(|e| format!("Failed to parse legacy config: {}", e))?;
if cfg.provider.is_empty() || cfg.model.is_empty() {
return Err("Invalid config: provider and model must not be empty".into());
}
return Ok(cfg);
}
let (selected_name, selected_value) = if let Some(name) = profile {
match obj.get(name) {
Some(v) => (name.to_string(), v.clone()),
None => {
let available = obj.keys().cloned().collect::<Vec<_>>().join(", ");
return Err(
format!("Profile '{}' not found. Available: {}", name, available).into(),
);
}
}
} else {
if let Some(v) = obj.get("default") {
(String::from("default"), v.clone())
} else {
match obj.iter().next() {
Some((k, v)) => (k.clone(), v.clone()),
None => return Err("Config file contains no profiles".into()),
}
}
};
let mut cfg: ConfigFile = serde_json::from_value(selected_value).map_err(|e| {
format!(
"Failed to parse selected profile '{}': {}",
selected_name, e
)
})?;
if selected_name != "default" {
if let Some(default_value) = obj.get("default") {
let default_cfg: ConfigFile = serde_json::from_value(default_value.clone())
.map_err(|e| format!("Failed to parse default profile: {}", e))?;
if cfg.stream.is_none() {
cfg.stream = default_cfg.stream;
}
if cfg.system_prompt.is_none() {
cfg.system_prompt = default_cfg.system_prompt;
}
}
}
if cfg.provider.is_empty() || cfg.model.is_empty() {
return Err("Invalid config: provider and model must not be empty".into());
}
return Ok(cfg);
}
Err("Invalid config: root must be a JSON object".into())
}
pub fn load_keys() -> Result<KeysFile, Box<dyn Error>> {
let keys_path = get_keys_path()?;
if !keys_path.exists() {
let default_keys = serde_json::json!({
"google": "",
"openai": "",
"anthropic": ""
});
let keys_json = serde_json::to_string_pretty(&default_keys)?;
std::fs::write(&keys_path, keys_json)?;
return Ok(KeysFile {
google: None,
openai: None,
anthropic: None,
});
}
let keys_content = fs::read_to_string(&keys_path)?;
let keys_json: serde_json::Value = serde_json::from_str(&keys_content)?;
let keys: KeysFile = serde_json::from_value(keys_json)?;
return Ok(keys);
}
pub fn open_config() -> Result<(), Box<dyn Error>> {
println!("Opening config file...");
let config_path = get_config_path()?;
if let Err(e) = load_config(None) {
eprintln!("Warning: {}", e);
eprintln!("Opening editor to fix the config file...");
}
let editor = std::env::var("EDITOR").unwrap_or_else(|_| {
if cfg!(windows) {
String::from("notepad")
} else {
String::from("nano")
}
});
let status = std::process::Command::new(editor)
.arg(&config_path)
.status()?;
if !status.success() {
return Err(format!("Editor exited with status: {}", status).into());
}
if let Err(e) = load_config(None) {
eprintln!("Warning: The config file is still invalid: {}", e);
eprintln!("Please fix the config file and try again.");
}
Ok(())
}
fn get_active_profile(current_config: &ConfigFile) -> Result<String, Box<dyn Error>> {
let config_path = get_config_path()?;
let config_content = fs::read_to_string(&config_path)?;
let root_value: serde_json::Value = serde_json::from_str(&config_content)
.map_err(|e| format!("Failed to parse config file: {}", e))?;
if let Some(obj) = root_value.as_object() {
for (profile_name, profile_value) in obj.iter() {
if let Ok(profile_config) = serde_json::from_value::<ConfigFile>(profile_value.clone())
{
if profile_config.provider == current_config.provider
&& profile_config.model == current_config.model
{
return Ok(profile_name.clone());
}
}
}
Ok("default".to_string())
} else {
Err("Invalid config format - expected JSON object".into())
}
}
fn list_profiles() -> Result<(), Box<dyn Error>> {
let path = get_config_path()?;
let config = fs::read_to_string(&path)?;
let root_value: serde_json::Value =
serde_json::from_str(&config).map_err(|e| format!("Failed to parse config file: {}", e))?;
println!("{}", "Available profiles:".bold());
if let Some(obj) = root_value.as_object() {
if obj.is_empty() {
println!(" No profiles found");
} else {
for key in obj.keys() {
println!(" {}", key);
}
}
} else {
eprintln!(" Invalid config format - expected JSON object");
}
Ok(())
}
pub fn open_keys() -> Result<(), Box<dyn Error>> {
println!("Opening keys file...");
let config_path = get_keys_path()?;
if let Err(e) = load_keys() {
eprintln!("Warning: {}", e);
eprintln!("Opening editor to fix the keys file...");
}
let editor = std::env::var("EDITOR").unwrap_or_else(|_| {
if cfg!(windows) {
String::from("notepad")
} else {
String::from("nano")
}
});
let status = std::process::Command::new(editor)
.arg(&config_path)
.status()?;
if !status.success() {
return Err(format!("Editor exited with status: {}", status).into());
}
Ok(())
}
pub async fn run(config: Config) -> Result<(), Box<dyn Error>> {
if config.verbose {
println!(
"Tofu v{} initialized (verbose mode)",
env!("CARGO_PKG_VERSION")
);
println!("{:#?}", config);
}
if config.interactive {
run_interactive(config).await
} else {
let message = config.message.as_ref().unwrap_or(&String::new()).clone();
send_message(&message, &config, vec![]).await?;
Ok(())
}
}
async fn run_interactive(mut config: Config) -> Result<(), Box<dyn Error>> {
let mut conversation_history = vec![];
println!(
"{}",
format!("Tofu {}", env!("CARGO_PKG_VERSION")).bold().blue()
);
println!(
"{}",
"Ctrl+C or /q to exit • /? for commands".italic().dimmed()
);
loop {
let input: Result<String, _> = Input::with_theme(&TofuTheme::default()).interact_text();
match input {
Ok(mut line) => {
line = line.trim().to_string();
if line.is_empty() {
continue;
}
if line.starts_with('/') || line.starts_with("'''") || line.starts_with("\"\"\"") {
let (should_exit, new_config, message_to_send) =
handle_command(line.as_str(), &mut conversation_history, &config)?;
if let Some(new_file_config) = new_config {
config.file = Some(new_file_config);
}
if should_exit {
break; }
if let Some(message) = message_to_send {
line = message;
} else {
continue; }
}
conversation_history.push(Message {
role: "user".to_string(),
content: line.to_string(),
});
if conversation_history.len() > 100 {
conversation_history.remove(1);
}
match send_message(line.as_str(), &config, conversation_history.clone()).await {
Ok(response_content) => {
conversation_history.push(Message {
role: "assistant".to_string(),
content: response_content.clone(),
});
}
Err(e) => {
if e.to_string().contains("localhost:11434") {
eprintln!("{}", "Error: Ollama server not running".red());
} else {
eprintln!("{}", format!("Error: {}", e).red());
}
if !conversation_history.is_empty() {
conversation_history.pop();
}
continue;
}
}
}
Err(e) => {
eprintln!("{}", format!("Error reading input: {}", e).red());
break;
}
}
}
Ok(())
}
fn handle_command(
command: &str,
conversation_history: &mut Vec<Message>,
config: &Config,
) -> Result<(bool, Option<ConfigFile>, Option<String>), Box<dyn Error>> {
match command {
"/exit" | "/quit" | "/q" | "/bye" => Ok((true, None, None)),
"/help" | "/h" | "/?" | "/commands" | "/cmds" => {
println!("{}", "Available commands:".bold());
println!(" /help, / - Show this help message");
println!(" /exit, /quit, /q - Exit the program");
println!(
" /profile [name] - Switch to a different config profile. If name not provided, lists profiles"
);
println!(
" /model <name>, /m <name> - Switch to a different model (without changing profile)"
);
println!(" /listprofiles, /lsp - List all available profiles");
println!(" /clear - Clear conversation history");
println!(" /keys - Open the API keys file");
println!(" /show, /info - Display profile & model info");
println!(" /multiline, /ml, // - Enter multiline input mode");
println!("* Most Ollama commands also work, such as \"\"\" and /bye.");
Ok((false, None, None))
}
"/clear" => {
conversation_history.clear();
println!("{}", "Conversation history cleared.".blue());
Ok((false, None, None))
}
"/keys" | "/key" | "/apikeys" | "/apikey" => {
open_keys()?;
Ok((false, None, None))
}
cmd if cmd.starts_with("/profile") || cmd.starts_with("/p") => {
let parts: Vec<&str> = command.split_whitespace().collect();
if parts.len() != 2 {
if let Err(e) = list_profiles() {
eprintln!("{}", format!("Error listing profiles: {}", e).red());
} else {
println!("Usage: /profile [profile_name]");
}
return Ok((false, None, None));
}
let profile_name = parts[1];
match load_config(Some(profile_name)) {
Ok(new_config) => {
println!(
"{}",
format!("Switched to profile '{}'", profile_name).green()
);
Ok((false, Some(new_config), None))
}
Err(e) => {
eprintln!(
"{}",
format!("Failed to switch to profile '{}': {}", profile_name, e).red()
);
Ok((false, None, None))
}
}
}
cmd if cmd.starts_with("/model") || cmd.starts_with("/m") => {
let parts: Vec<&str> = command.split_whitespace().collect();
if parts.len() != 2 {
eprintln!("{}", "Usage: /model <model_name>".red());
return Ok((false, None, None));
}
let new_model = parts[1];
if let Some(mut current_config) = config.file.clone() {
current_config.model = new_model.to_string();
println!("{}", format!("Switched to model '{}'", new_model).green());
Ok((false, Some(current_config), None))
} else {
eprintln!("{}", "No configuration loaded".red());
Ok((false, None, None))
}
}
"/show" | "/info" | "/s" | "/i" => {
match config.file.as_ref() {
Some(current_config) => match get_active_profile(current_config) {
Ok(profile) => println!("Profile: {}", profile),
Err(_) => println!("Profile: unknown"),
},
None => println!("Profile: unknown (no config loaded)"),
}
if let Some(current_config) = config.file.as_ref() {
println!("Model: {}", current_config.model);
}
Ok((false, None, None))
}
"/listprofiles" | "/lsp" => {
if let Err(e) = list_profiles() {
eprintln!("{}", format!("Error listing profiles: {}", e).red());
}
Ok((false, None, None))
}
"/multiline" | "/ml" | "//" | "'''" | "\"\"\"" => {
if let Some(multiline_input) = Editor::new().edit("").unwrap() {
if !multiline_input.trim().is_empty() {
println!("{}\n", multiline_input);
return Ok((false, None, Some(multiline_input)));
} else {
println!("{}", "Empty input - cancelled".yellow());
}
} else {
eprintln!("{}", "Cancelled".red());
}
Ok((false, None, None))
}
_ => {
eprintln!(
"{}",
format!(
"Unknown command: {}. Type /help for available commands.",
command
)
.red()
);
Ok((false, None, None))
}
}
}
async fn send_message(
_message: &str,
config: &Config,
history: Vec<Message>,
) -> Result<String, Box<dyn Error>> {
let spinner = ProgressBar::new_spinner();
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
spinner.set_style(
ProgressStyle::with_template("{spinner:.blue} {msg} {elapsed:.bold}")
.unwrap()
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
);
spinner.set_message("Thinking...");
let mut messages = vec![];
if let Some(file) = &config.file {
if let Some(system_prompt) = &file.system_prompt {
messages.push(serde_json::json!({ "role": "system", "content": system_prompt }));
}
}
for msg in history {
messages.push(serde_json::json!({ "role": msg.role, "content": msg.content }));
}
let body = if let Some(file) = &config.file {
serde_json::json!({
"model": file.model,
"messages": messages,
"stream": config.stream,
})
} else {
return Err("No configuration file found".to_string().into());
};
let client = Client::new();
if config.verbose {
dbg!(&body);
}
let (url, auth_header) = if let Some(file) = &config.file {
match file.provider.as_str() {
"pollinations" => (
"https://gen.pollinations.ai/v1/chat/completions",
Some("Bearer pk_y1jVZsGNdFYuc12n".to_string()), ),
"google" => (
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
Some(format!("Bearer {}", load_keys().unwrap().google.unwrap())),
),
"openai" => (
"https://api.openai.com/v1/chat/completions",
Some(format!("Bearer {}", load_keys().unwrap().openai.unwrap())),
),
"anthropic" => (
"https://api.anthropic.com/v1/chat/completions",
Some(format!(
"Bearer {}",
load_keys().unwrap().anthropic.unwrap()
)),
),
"ollama" => (
"http://localhost:11434/v1/chat/completions",
None, ),
provider => {
return Err(format!("Unsupported provider: {}", provider).into());
}
}
} else {
return Err("No configuration file found".to_string().into());
};
let mut request = client
.post(url)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&body)?);
if let Some(auth) = auth_header {
request = request.header("Authorization", auth);
}
if config
.file
.as_ref()
.map(|f| f.provider == "anthropic")
.unwrap_or(false)
{
request = request.header("anthropic-version", "2023-06-01");
}
let mut response = request.send().await?;
if !response.status().is_success() {
let error_msg = format!("Request failed with status: {}", response.status());
if config
.file
.as_ref()
.map(|f| f.provider == "ollama")
.unwrap_or(false)
{
let status = response.status().as_u16();
if status == 101 || status == 0 {
return Err("Ollama server is not running. Please make sure Ollama is installed and running on localhost:11434. You can install Ollama from https://ollama.ai/".into());
}
}
return Err(error_msg.into());
}
spinner.finish_and_clear();
if config.stream == Some(true) {
spinner.finish_and_clear();
let mut buffer = String::new();
let mut response_content = String::new();
let mut done = false;
while let Some(chunk) = response.chunk().await? {
let chunk_str = String::from_utf8_lossy(&chunk);
buffer.push_str(&chunk_str);
loop {
if let Some(newline_idx) = buffer.find('\n') {
let line = buffer[..newline_idx].trim_end_matches('\r').to_string();
buffer.drain(..=newline_idx);
if line.is_empty() {
continue;
}
if line.starts_with("data: ") {
let payload = line[6..].trim();
if payload == "[DONE]" {
done = true;
println!(); break;
} else {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(payload) {
if let Some(choices) = v.get("choices").and_then(|c| c.as_array()) {
for choice in choices {
if let Some(delta) = choice.get("delta") {
if let Some(content) =
delta.get("content").and_then(|c| c.as_str())
{
print!("{}", content);
let _ = std::io::stdout().flush();
response_content.push_str(content);
}
} else if let Some(content) = choice
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
{
print!("{}", content);
let _ = std::io::stdout().flush();
response_content.push_str(content);
}
}
}
}
}
} else if config.verbose {
eprintln!("{}", line);
}
} else {
break;
}
}
if done {
break;
}
}
Ok(response_content)
} else {
let response_text = response.text().await?;
let json: serde_json::Value = serde_json::from_str(&response_text)?;
let content = json["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("")
.replace("\\n", "\n")
.trim_matches('"')
.to_string();
spinner.finish_and_clear();
println!("\n{}\n", content);
Ok(content)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_run() {
let config = Config {
verbose: false,
interactive: false,
message: Some(String::from("Hello, world!")),
stream: Some(false),
file: Some(ConfigFile {
provider: String::from("pollinations"),
model: String::from("openai"),
stream: Some(false),
system_prompt: Some(String::from("You are a helpful assistant named Tofu.")),
}),
};
let result = run(config).await;
assert!(result.is_ok());
}
}