monothink 0.4.0

A totally non-agentic coding assistant
/*
 * This file is part of monothink.
 *
 * monothink is free software: you can redistribute it and/or modify it under the terms
 * of the GNU General Public License as published by the Free Software Foundation,
 * either version 3 of the License, or (at your option) any later version.
 *
 * monothink is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
 * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with Wavething.
 * If not, see <https://www.gnu.org/licenses/>.
 */

use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, fs::File, io::Read, path::Path};

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub enum EndpointApi {
    OpenAICompletions,
    #[default]
    OpenAIResponses,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub struct Auth {
    pub endpoint: BTreeMap<String, EndpointSecrets>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct EndpointSecrets {
    pub api_token: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Config {
    pub general: General,
    pub endpoint: BTreeMap<String, Endpoint>,
    #[serde(default)]
    pub model: BTreeMap<String, Model>,
    #[serde(default)]
    pub symbols: Symbols,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct General {
    pub primary_endpoint: String,
    #[serde(default = "default_max_tokens_per_iteration")]
    pub max_tokens: u64,
    #[serde(default = "default_max_iterations_per_request")]
    pub max_iterations_per_request: usize,
    #[serde(default = "default_max_dir_list_results")]
    pub max_list_dir_results: usize,
    #[serde(default = "default_max_search_text_results")]
    pub max_search_text_results: usize,
    #[serde(default = "default_max_tool_observations")]
    pub max_tool_observations: u64,
    #[serde(default = "default_min_reasoning_observations")]
    pub min_reasoning_observations: u64,
    #[serde(default = "default_view_mode")]
    pub view_mode: ViewMode,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Endpoint {
    pub base_url: String,
    pub default_model: String,
    #[serde(skip)]
    pub secrets: Option<EndpointSecrets>,
    #[serde(default)]
    pub api_type: EndpointApi,
    #[serde(default)]
    pub models: Vec<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Model {
    #[serde(default = "default_context_window")]
    pub context_window: u64,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Symbols {
    #[serde(default = "default_assistant_message_symbol")]
    pub assistant_message: String,
    #[serde(default = "default_assistant_reasoning_symbol")]
    pub assistant_reasoning: String,
    #[serde(default = "default_idle_indicator_symbol")]
    pub idle_indicator: String,
    #[serde(default = "default_active_indicator_symbols")]
    pub active_indicators: Vec<String>,
    #[serde(default = "default_tool_message_symbol")]
    pub tool_message: String,
    #[serde(default = "default_message_state_final_symbol")]
    pub message_state_final: String,
    #[serde(default = "default_message_state_intermediate_symbol")]
    pub message_state_intermediate: String,
    #[serde(default = "default_notification_debug_symbol")]
    pub message_notification_debug: String,
    #[serde(default = "default_notification_error_symbol")]
    pub message_notification_error: String,
    #[serde(default = "default_notification_info_symbol")]
    pub message_notification_info: String,
    #[serde(default = "default_notification_warning_symbol")]
    pub message_notification_warning: String,
    #[serde(default = "default_status_failed_symbol")]
    pub status_failed: String,
    #[serde(default = "default_status_success_symbol")]
    pub status_success: String,
    #[serde(default = "default_statusbar_leader_symbol")]
    pub statusbar_leader: String,
    #[serde(default = "default_statusbar_separator_symbol")]
    pub statusbar_separator: String,
    #[serde(default = "default_statusbar_reverse_separator_symbol")]
    pub statusbar_reverse_separator: String,
    #[serde(default = "default_system_message_symbol")]
    pub system_message: String,
    #[serde(default = "default_tokens_input_symbol")]
    pub input_tokens: String,
    #[serde(default = "default_tokens_output_symbol")]
    pub output_tokens: String,
    #[serde(default = "default_user_message_symbol")]
    pub user_message: String,
}

impl Default for Symbols {
    fn default() -> Self {
        Self {
            assistant_message: default_assistant_message_symbol(),
            assistant_reasoning: default_assistant_reasoning_symbol(),
            idle_indicator: default_idle_indicator_symbol(),
            active_indicators: default_active_indicator_symbols(),
            tool_message: default_tool_message_symbol(),
            message_state_final: default_message_state_final_symbol(),
            message_state_intermediate: default_message_state_intermediate_symbol(),
            message_notification_debug: default_notification_debug_symbol(),
            message_notification_error: default_notification_error_symbol(),
            message_notification_info: default_notification_info_symbol(),
            message_notification_warning: default_notification_warning_symbol(),
            system_message: default_system_message_symbol(),
            status_failed: default_status_failed_symbol(),
            status_success: default_status_success_symbol(),
            statusbar_leader: default_statusbar_leader_symbol(),
            statusbar_separator: default_statusbar_separator_symbol(),
            statusbar_reverse_separator: default_statusbar_reverse_separator_symbol(),
            input_tokens: default_tokens_input_symbol(),
            output_tokens: default_tokens_output_symbol(),
            user_message: default_user_message_symbol(),
        }
    }
}

#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub enum ViewMode {
    /// show everything (incl. debug system messages intended for developers)
    Debug,
    /// show everything
    Full,

    /// show collapsed tool calls, reasoning and all user and assistant messages
    Detailed,

    /// show collapesed tool calls and all user and final assistant messages
    Compact,

    /// only show user and final assistant messages
    Minimal,
}

impl Default for ViewMode {
    fn default() -> Self {
        default_view_mode()
    }
}

pub fn default_assistant_message_symbol() -> String {
    "🤖".to_string()
}

pub fn default_assistant_reasoning_symbol() -> String {
    "💭".to_string()
}

// emoji: đŸŸĸ
pub fn default_idle_indicator_symbol() -> String {
    "●".to_string()
}

// ideas for alternative configs:
// minimal (black hourglass & black bowtie): active_indicators = ["⧗", "⧓"]
// snake via braille: active_indicators = ["⠋", "⠙", "â š", "â ¸", "â ŧ", "â ´", "â Ļ", "â §", "⠇", "⠏"]
// watch emoji: active_indicators = ["🕛", "🕐", "🕒", "🕓", "🕕", "🕖", "🕙", "🕘"]
// spinning half-circle: active_indicators = ["◐", "◓", "◑", "◒"]
// progress bar: active_indicators = ["▏  ", "▎  ", "▍  ", "▌  ", "▋  ", "▊  ", "▉  ", "█  ", "█▏ ", "█▎ ", "█▍ ", "█▌ ", "█▋ ", "█▊ ", "█▉ ", "██ ", "██▏", "██▎", "██▍", "██▌", "██▋", "██▊", "██▉", "███"]
// fade in: active_indicators = [" ", "░", "▒", "▓", "█", "▓", "▒"]
pub fn default_active_indicator_symbols() -> Vec<String> {
    vec![
        "⠋".to_string(),
        "⠙".to_string(),
        "â š".to_string(),
        "â ¸".to_string(),
        "â ŧ".to_string(),
        "â ´".to_string(),
        "â Ļ".to_string(),
        "â §".to_string(),
        "⠇".to_string(),
        "⠏".to_string(),
    ]
}

pub fn default_context_window() -> u64 {
    128000
}

pub fn default_max_tokens_per_iteration() -> u64 {
    16000
}

fn default_max_iterations_per_request() -> usize {
    40
}

fn default_max_dir_list_results() -> usize {
    100
}

fn default_max_search_text_results() -> usize {
    100
}

fn default_max_tool_observations() -> u64 {
    5
}

fn default_min_reasoning_observations() -> u64 {
    10
}

pub fn default_message_state_final_symbol() -> String {
    "✅".to_string()
}

pub fn default_message_state_intermediate_symbol() -> String {
    "🕝".to_string()
}

pub fn default_notification_debug_symbol() -> String {
    "đŸĒ˛".to_string()
}

pub fn default_notification_error_symbol() -> String {
    "🛑".to_string()
}

pub fn default_notification_info_symbol() -> String {
    "â„šī¸".to_string()
}

pub fn default_notification_warning_symbol() -> String {
    "âš ī¸".to_string()
}

pub fn default_tool_message_symbol() -> String {
    "🔧".to_string()
}

pub fn default_status_failed_symbol() -> String {
    "❌".to_string()
}

pub fn default_status_success_symbol() -> String {
    "✅".to_string()
}

pub fn default_statusbar_leader_symbol() -> String {
    "".to_string()
}

pub fn default_statusbar_separator_symbol() -> String {
    "".to_string()
}

pub fn default_statusbar_reverse_separator_symbol() -> String {
    "î‚ŗ".to_string()
}

pub fn default_system_message_symbol() -> String {
    "âš™ī¸".to_string()
}

pub fn default_tokens_input_symbol() -> String {
    "⇑".to_string()
}

pub fn default_tokens_output_symbol() -> String {
    "⇓".to_string()
}

pub fn default_user_message_symbol() -> String {
    "🧑‍đŸ’ģ".to_string()
}

// use compact as default as it is not straining for the user while providing most information
// It is easy to switch modes at runtime if more insight is needed
fn default_view_mode() -> ViewMode {
    ViewMode::Compact
}

// @todo overall, make error nicer using with_message (e.g. which file was not found or which value was expected?)
// load config and secrets
pub fn load_config() -> Result<Config> {
    // load config from default path
    if let Some(proj_dirs) = ProjectDirs::from("org", "codemonk", "monothink") {
        let config_dir = proj_dirs.config_dir().to_path_buf();

        let mut config_file_dir = config_dir.clone();
        config_file_dir.push("config.toml");
        let config_file_path = config_file_dir.as_path();

        let mut auth_file_dir = config_dir.clone();
        auth_file_dir.push("auth.toml");
        let auth_file_path = auth_file_dir.as_path();

        let mut config = parse_config(config_file_path).with_context(|| {
            format!(
                "Error while parsing config file '{}'.",
                config_file_path.to_string_lossy()
            )
        })?;
        let auth = parse_auth(auth_file_path).with_context(|| {
            format!(
                "Error while parsing auth file '{}'.",
                auth_file_path.to_string_lossy()
            )
        })?;
        merge_config_with_auth(&mut config, &auth);

        Ok(config)
    } else {
        panic!("Could not retrieve a valid home directory from the operating system.");
    }
}

fn merge_config_with_auth(config: &mut Config, auth: &Auth) {
    for (key, val) in &auth.endpoint {
        if let Some(endpoint) = config.endpoint.get_mut(key) {
            endpoint.secrets = Some(val.to_owned());
        } else {
            // @todo if a key has no matching endpoint, print a warning
        }
        // @todo if a key is missing for a endpoint, print a warning
        // for this iterate over all endpoints in check if secrets is none after merge
    }
}

fn parse_auth(path: &Path) -> Result<Auth> {
    let mut file = File::open(path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;

    let auth: Auth = toml::from_str(&contents)?;
    // @todo if it fails, print an example
    Ok(auth)
}

fn parse_config(path: &Path) -> Result<Config> {
    let mut file = File::open(path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;

    let config: Config = toml::from_str(&contents)?;
    // @todo if it fails, print an example
    Ok(config)
}