durthang 0.1.0

A modern, terminal-based MUD client with TLS, GMCP, automap, aliases, triggers, and a sidebar panel system
// Copyright (c) 2026 Raimo Geisel
// SPDX-License-Identifier: GPL-3.0-only
//
// Durthang 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, version 3.  See <https://www.gnu.org/licenses/gpl-3.0.html>.

//! Configuration module — data model, TOML persistence and OS-keyring credential helpers.
//!
//! The TOML config file contains the list of [`Server`] entries and their
//! associated [`Character`] records.  Sensitive credentials (passwords) are
//! **never** written to the config file; they are stored in the OS keyring via
//! the [`keyring`] crate.  The keyring entry key follows the pattern
//! `"durthang/<server_id>/<login_name>"`.
//!
//! # Structure
//!
//! ```text
//! Config
//! ├── servers: Vec<Server>
//! └── characters: Vec<Character>
//!     ├── aliases: Vec<Alias>
//!     ├── triggers: Vec<Trigger>
//!     └── sidebar: SidebarLayout
//!         └── panels: Vec<PanelConfig>
//! ```
//!
//! # File location
//!
//! Defaults to `$XDG_CONFIG_HOME/durthang/config.toml` (typically
//! `~/.config/durthang/config.toml`).

use serde::{Deserialize, Serialize};
use std::{
    fs,
    path::{Path, PathBuf},
};
use uuid::Uuid;

// ---------------------------------------------------------------------------
// Aliases & Triggers
// ---------------------------------------------------------------------------

/// A command alias: replaces `name` (or a line starting with `name`) with
/// `expansion` before the line is sent to the server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Alias {
    pub name: String,
    pub expansion: String,
}

/// A trigger: when an incoming line matches `pattern`, optionally re-colour
/// it and/or auto-send a command back to the server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trigger {
    pub id: String,
    /// A regular expression matched against the raw (ANSI-stripped) line.
    pub pattern: String,
    /// Named colour applied as foreground when the line matches
    /// (e.g. `"red"`, `"yellow"`, `"cyan"`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub color: Option<String>,
    /// If set, this command is automatically sent to the server when a match
    /// is found.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub send: Option<String>,
}

impl Trigger {
    #[allow(dead_code)]
    pub fn new(pattern: impl Into<String>) -> Self {
        Self {
            id: Uuid::new_v4().to_string(),
            pattern: pattern.into(),
            color: None,
            send: None,
        }
    }
}

// ---------------------------------------------------------------------------
// Sidebar layout
// ---------------------------------------------------------------------------

/// Identifies one of the sidebar panels.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PanelKind {
    Automap,
    Notes,
    /// Legacy — silently filtered out when loading old configs.
    #[doc(hidden)]
    CharSheet,
    /// Legacy — silently filtered out when loading old configs.
    #[doc(hidden)]
    Paperdoll,
    /// Legacy — silently filtered out when loading old configs.
    #[doc(hidden)]
    Inventory,
}

impl PanelKind {
    #[allow(dead_code)]
    pub fn label(&self) -> &'static str {
        match self {
            PanelKind::Automap => "Automap",
            PanelKind::Notes => "Notes",
            _ => "Legacy",
        }
    }

    /// Short label used in the sidebar tab bar.
    pub fn short_label(&self) -> &'static str {
        match self {
            PanelKind::Automap => "Map",
            PanelKind::Notes => "Notes",
            _ => "---",
        }
    }
}

/// Which sidebar column a panel is assigned to.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SidebarSide {
    Left,
    Right,
}

/// Per-panel configuration: sidebar assignment and relative height.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PanelConfig {
    pub kind: PanelKind,
    /// Which sidebar column.  `None` = not displayed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub side: Option<SidebarSide>,
    /// Relative height expressed as a percentage (1–100).
    /// Panels that share a sidebar column have their values normalised to fill it.
    #[serde(default = "default_panel_height_pct")]
    pub height_pct: u8,
}

/// Default relative height for a panel that does not declare its own value.
fn default_panel_height_pct() -> u8 {
    50
}
/// Default visibility state of the right sidebar column.
fn default_right_visible() -> bool {
    true
}
/// Default width in terminal characters of the right sidebar column.
fn default_right_width() -> u16 {
    26
}

/// Return the default panel layout: Automap on the right at 35 % height,
/// Notes on the right at 65 % height.
fn default_panels() -> Vec<PanelConfig> {
    vec![
        PanelConfig {
            kind: PanelKind::Automap,
            side: Some(SidebarSide::Right),
            height_pct: 35,
        },
        PanelConfig {
            kind: PanelKind::Notes,
            side: Some(SidebarSide::Right),
            height_pct: 65,
        },
    ]
}

/// Per-character sidebar layout persisted in the config file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SidebarLayout {
    /// Whether the right sidebar column is shown.
    #[serde(default = "default_right_visible")]
    pub right_visible: bool,
    /// Width of the right sidebar column in terminal characters.
    #[serde(default = "default_right_width")]
    pub right_width: u16,
    /// Panel configurations (kind, side assignment, relative height).
    #[serde(default = "default_panels")]
    pub panels: Vec<PanelConfig>,
    /// User-created notes shown in the Notes panel.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub notes: Vec<String>,
}

impl Default for SidebarLayout {
    fn default() -> Self {
        Self {
            right_visible: true,
            right_width: 26,
            panels: default_panels(),
            notes: Vec::new(),
        }
    }
}

// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------

/// A MUD server entry stored in the config file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Server {
    pub id: String,
    pub name: String,
    pub host: String,
    pub port: u16,
    /// Whether to use TLS for this server connection.
    #[serde(default)]
    pub tls: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub notes: Option<String>,
}

impl Server {
    /// Create a new [`Server`] with a randomly generated UUID id, TLS
    /// disabled, and all optional fields set to `None`.
    pub fn new(name: impl Into<String>, host: impl Into<String>, port: u16) -> Self {
        Self {
            id: Uuid::new_v4().to_string(),
            name: name.into(),
            host: host.into(),
            port,
            tls: false,
            notes: None,
        }
    }
}

/// A character belonging to a server.
/// The actual password is stored in the OS keyring, never here.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Character {
    pub id: String,
    /// Display name shown in the UI.
    pub name: String,
    pub server_id: String,
    /// The username typed at the MUD's login prompt.
    /// When absent the character's `name` is used instead.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub login: Option<String>,
    /// Optional human-readable reminder — never the actual password.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub password_hint: Option<String>,
    /// Free-form notes (e.g. race, class, level).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub notes: Option<String>,
    /// Command aliases stored per character.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub aliases: Vec<Alias>,
    /// Trigger rules stored per character.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub triggers: Vec<Trigger>,
    /// Sidebar layout for this character.
    #[serde(default)]
    pub sidebar: SidebarLayout,
}

impl Character {
    /// Create a new [`Character`] with a randomly generated UUID id and all
    /// optional fields (`login`, `password_hint`, `notes`) set to `None`.
    /// Aliases and triggers start empty; the sidebar uses its [`Default`] layout.
    pub fn new(name: impl Into<String>, server_id: impl Into<String>) -> Self {
        Self {
            id: Uuid::new_v4().to_string(),
            name: name.into(),
            server_id: server_id.into(),
            login: None,
            password_hint: None,
            notes: None,
            aliases: Vec::new(),
            triggers: Vec::new(),
            sidebar: SidebarLayout::default(),
        }
    }

    /// Returns the login name to use at the MUD prompt (falls back to `name`).
    pub fn effective_login(&self) -> &str {
        self.login.as_deref().unwrap_or(&self.name)
    }
}

// ---------------------------------------------------------------------------
// Root config object
// ---------------------------------------------------------------------------

/// Root configuration — serialised to / deserialised from TOML.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Config {
    #[serde(default)]
    pub servers: Vec<Server>,
    #[serde(default)]
    pub characters: Vec<Character>,
}

impl Config {
    /// Resolve the default config file path using XDG or
    /// fall back to `~/.config/durthang/config.toml`.
    pub fn default_path() -> PathBuf {
        let base = std::env::var("XDG_CONFIG_HOME")
            .map(PathBuf::from)
            .unwrap_or_else(|_| {
                let home = std::env::var("HOME").expect("HOME environment variable not set");
                PathBuf::from(home).join(".config")
            });
        base.join("durthang").join("config.toml")
    }

    /// Load config from `path`.
    /// Returns a default `Config` if the file does not exist yet.
    pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
        if !path.exists() {
            return Ok(Self::default());
        }
        let contents = fs::read_to_string(path)?;
        Ok(toml::from_str(&contents)?)
    }

    /// Persist the config to `path`, creating parent directories as needed.
    pub fn save(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        let contents = toml::to_string_pretty(self)?;
        fs::write(path, contents)?;
        Ok(())
    }

    /// Look up a server by id.
    #[allow(dead_code)]
    pub fn server_by_id(&self, id: &str) -> Option<&Server> {
        self.servers.iter().find(|s| s.id == id)
    }

    /// Return all characters that belong to a given server.
    pub fn characters_for_server(&self, server_id: &str) -> Vec<&Character> {
        self.characters
            .iter()
            .filter(|c| c.server_id == server_id)
            .collect()
    }
}

// ---------------------------------------------------------------------------
// Keyring helpers
// ---------------------------------------------------------------------------

/// Service name under which all Durthang credentials are stored in the keyring.
const KEYRING_SERVICE: &str = "durthang";

/// Build a keyring [`Entry`](keyring::Entry) for the given server/character combination.
///
/// The account key is formatted as `"<server_id>/<character_name>"` so that
/// multiple characters on the same server each get their own entry.
fn keyring_entry(server_id: &str, character_name: &str) -> keyring::Result<keyring::Entry> {
    let account = format!("{server_id}/{character_name}");
    keyring::Entry::new(KEYRING_SERVICE, &account)
}

/// Store a password for the given character in the OS keyring.
pub fn store_password(
    server_id: &str,
    character_name: &str,
    password: &str,
) -> keyring::Result<()> {
    keyring_entry(server_id, character_name)?.set_password(password)
}

/// Retrieve the stored password for a character from the OS keyring.
/// Returns `None` if no entry exists yet.
pub fn get_password(server_id: &str, character_name: &str) -> keyring::Result<Option<String>> {
    match keyring_entry(server_id, character_name)?.get_password() {
        Ok(pw) => Ok(Some(pw)),
        Err(keyring::Error::NoEntry) => Ok(None),
        Err(e) => Err(e),
    }
}

/// Remove the stored password for a character from the OS keyring.
pub fn delete_password(server_id: &str, character_name: &str) -> keyring::Result<()> {
    keyring_entry(server_id, character_name)?.delete_credential()
}