smarana 0.3.2

An extensible note taking system for typst.
use config::Config;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;

/// Global configuration (lives in XDG_CONFIG_HOME/smarana/config.toml).
#[derive(Debug, Deserialize, Default)]
pub struct GlobalConfig {
    #[serde(default)]
    pub notebook: String,
}

/// A single alias entry.
#[derive(Debug, Deserialize)]
pub struct Alias {
    pub cmd: String,
    #[serde(default)]
    pub desc: Option<String>,
}

/// Core application configuration
#[derive(Debug, Deserialize)]
pub struct SmaranaConfig {
    #[serde(default = "default_editor")]
    pub editor: String,
    #[serde(default = "default_shell")]
    pub shell: String,
    #[serde(default = "default_title")]
    pub default_title: String,
    #[serde(default = "default_filename_gen")]
    pub filename_gen: String,
}

impl Default for SmaranaConfig {
    fn default() -> Self {
        Self {
            editor: default_editor(),
            shell: default_shell(),
            default_title: default_title(),
            filename_gen: default_filename_gen(),
        }
    }
}

fn default_editor() -> String {
    std::env::var("EDITOR").unwrap_or_else(|_| "nvim".to_string())
}

fn default_shell() -> String {
    "/bin/bash".to_string()
}

fn default_title() -> String {
    "Untitled Note".to_string()
}

fn default_filename_gen() -> String {
    "tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]\\+/-/g;s/^-//;s/-$//' | sed \"s/^/$(date +%F)-/\"".to_string()
}

/// Frontmatter logic configuration
#[derive(Debug, Deserialize)]
pub struct FrontmatterConfig {
    pub template: String,
    #[serde(flatten)]
    pub variables: HashMap<String, String>,
}

impl Default for FrontmatterConfig {
    fn default() -> Self {
        Self {
            template: "default.typ".to_string(),
            variables: HashMap::new(),
        }
    }
}

/// Notebook configuration (lives in <notebook>/.smarana/config.toml).
#[derive(Debug, Deserialize, Default)]
pub struct AppConfig {
    #[serde(default)]
    pub smarana: SmaranaConfig,
    #[serde(default)]
    pub frontmatter: FrontmatterConfig,
    #[serde(default)]
    pub alias: HashMap<String, Alias>,
}

impl GlobalConfig {
    /// Load the global config from XDG_CONFIG_HOME/smarana/config.toml.
    pub fn load() -> Self {
        let path = global_config_path();

        if !path.exists() {
            return GlobalConfig::default();
        }

        let settings = Config::builder()
            .add_source(config::File::from(path).required(false))
            .build();

        match settings {
            Ok(cfg) => cfg.try_deserialize().unwrap_or_else(|e| {
                eprintln!("Warning: failed to parse global config: {e}");
                GlobalConfig::default()
            }),
            Err(e) => {
                eprintln!("Warning: failed to load global config: {e}");
                GlobalConfig::default()
            }
        }
    }

    /// Returns the absolute notebook path, expanding `$HOME` if present.
    pub fn notebook_path(&self) -> Option<PathBuf> {
        if self.notebook.is_empty() {
            return None;
        }
        let expanded = expand_home_var(&self.notebook);
        Some(PathBuf::from(expanded))
    }
}

impl AppConfig {
    /// Load notebook configuration from `<notebook>/.smarana/config.toml`.
    pub fn load() -> Self {
        let path = notebook_config_path();

        if !path.exists() {
            return AppConfig::default();
        }

        let settings = Config::builder()
            .add_source(config::File::from(path).required(false))
            .build();

        match settings {
            Ok(cfg) => cfg.try_deserialize().unwrap_or_else(|e| {
                eprintln!("Warning: failed to parse notebook config: {e}");
                AppConfig::default()
            }),
            Err(e) => {
                eprintln!("Warning: failed to load notebook config: {e}");
                AppConfig::default()
            }
        }
    }

    /// Print all registered alias names (with descriptions if set).
    pub fn list_aliases(&self) {
        if self.alias.is_empty() {
            println!("No aliases configured.");
            return;
        }

        let mut aliases: Vec<_> = self.alias.iter().collect();
        aliases.sort_by_key(|(k, _)| (*k).clone());

        for (name, alias) in aliases {
            if let Some(desc) = &alias.desc {
                println!("{name} - {desc}");
            } else {
                println!("{name}");
            }
        }
    }

    /// Run an alias by name. Returns `true` if the alias was found and executed.
    pub fn run_alias(&self, name: &str) -> bool {
        if let Some(alias) = self.alias.get(name) {
            let status = Command::new("sh")
                .arg("-c")
                .arg(&alias.cmd)
                .status();

            match status {
                Ok(s) => {
                    if !s.success() {
                        std::process::exit(s.code().unwrap_or(1));
                    }
                }
                Err(e) => {
                    eprintln!("Failed to run alias '{name}': {e}");
                    std::process::exit(1);
                }
            }
            true
        } else {
            false
        }
    }
}

/// Returns the global config path: XDG_CONFIG_HOME/smarana/config.toml
pub fn global_config_path() -> PathBuf {
    let config_home = std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| {
        let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
        format!("{home}/.config")
    });
    PathBuf::from(config_home)
        .join("smarana")
        .join("config.toml")
}

/// Expand `$HOME` in a path string to the actual home directory.
pub fn expand_home_var(path: &str) -> String {
    if let Ok(home) = std::env::var("HOME") {
        path.replace("$HOME", &home)
    } else {
        path.to_string()
    }
}

/// Returns the notebook config path: <notebook>/.smarana/config.toml
/// Resolves the notebook directory from the global config.
pub fn notebook_config_path() -> PathBuf {
    let global = GlobalConfig::load();
    if let Some(notebook) = global.notebook_path() {
        notebook.join(".smarana").join("config.toml")
    } else {
        // Fallback: try CWD/.smarana/config.toml
        let mut path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
        path.push(".smarana");
        path.push("config.toml");
        path
    }
}

/// Open the notebook config file with configured editor.
pub fn open_config() {
    let path = notebook_config_path();

    if !path.exists() {
        eprintln!(
            "No notebook config found at: {}\nRun `sma -i` to initialize a notebook first.",
            path.display()
        );
        return;
    }

    let app_config = AppConfig::load();
    let editor = app_config.smarana.editor;

    let status = Command::new(&editor)
        .arg(&path)
        .status();

    match status {
        Ok(s) if !s.success() => {
            eprintln!("Editor exited with non-zero status");
        }
        Err(e) => {
            eprintln!("Failed to open editor '{editor}': {e}");
        }
        _ => {}
    }
}