note-to-self-cli 0.1.2

Encrypted local-first journaling CLI with sync and locked journals.
use anyhow::{anyhow, Context, Result};
use note_to_self_lib::{decode_bytes, encode_bytes, DerivedKeys};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    pub default_journal: String,
    pub editor: Option<String>,
    pub sync: Option<SyncConfig>,
    pub device_id: Uuid,
    pub username: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub account_keys: Option<CachedAccountKeys>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncConfig {
    pub server_url: String,
    pub username: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedAccountKeys {
    pub encryption_key: String,
    pub auth_key: String,
}

impl Config {
    pub fn new(
        username: Option<String>,
        default_journal: String,
        server_url: Option<String>,
    ) -> Self {
        let sync = server_url.map(|server_url| SyncConfig {
            server_url: normalize_server_url(&server_url),
            username: username.clone().unwrap_or_default(),
        });
        Self {
            default_journal,
            editor: None,
            sync,
            device_id: Uuid::now_v7(),
            username,
            account_keys: None,
        }
    }

    pub fn sync_username(&self) -> Option<&str> {
        self.sync
            .as_ref()
            .map(|sync| sync.username.as_str())
            .or(self.username.as_deref())
            .filter(|username| !username.is_empty())
    }

    pub fn set_account_keys(&mut self, keys: &DerivedKeys) {
        self.account_keys = Some(CachedAccountKeys {
            encryption_key: encode_bytes(&keys.encryption_key),
            auth_key: encode_bytes(&keys.auth_key),
        });
    }

    pub fn cached_account_keys(&self) -> Result<Option<DerivedKeys>> {
        let Some(keys) = &self.account_keys else {
            return Ok(None);
        };
        Ok(Some(DerivedKeys {
            encryption_key: decode_key(&keys.encryption_key, "cached encryption key")?,
            auth_key: decode_key(&keys.auth_key, "cached auth key")?,
        }))
    }
}

fn decode_key(encoded: &str, name: &str) -> Result<[u8; 32]> {
    let bytes = decode_bytes(encoded).with_context(|| format!("invalid {name}"))?;
    bytes
        .try_into()
        .map_err(|bytes: Vec<u8>| anyhow!("{name} has length {}, expected 32", bytes.len()))
}

pub fn note_home() -> Result<PathBuf> {
    if let Ok(path) = std::env::var("NOTE_TO_SELF_HOME").or_else(|_| std::env::var("JRNL2_HOME")) {
        return Ok(PathBuf::from(path));
    }
    let home = dirs::home_dir().ok_or_else(|| anyhow!("could not find home directory"))?;
    Ok(home.join(".note-to-self"))
}

pub fn config_path(root: &Path) -> PathBuf {
    root.join("config.toml")
}

pub fn load_config(root: &Path) -> Result<Option<Config>> {
    let path = config_path(root);
    if !path.exists() {
        return Ok(None);
    }
    let raw =
        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
    let config =
        toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?;
    Ok(Some(config))
}

pub fn save_config(root: &Path, config: &Config) -> Result<()> {
    fs::create_dir_all(root).with_context(|| format!("failed to create {}", root.display()))?;
    let raw = toml::to_string_pretty(config).context("failed to serialize config")?;
    let path = config_path(root);
    fs::write(&path, raw).context("failed to write config")?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut permissions = fs::metadata(&path)?.permissions();
        permissions.set_mode(0o600);
        fs::set_permissions(&path, permissions)
            .with_context(|| format!("failed to secure {}", path.display()))?;
    }
    Ok(())
}

pub fn normalize_server_url(server_url: &str) -> String {
    server_url.trim().trim_end_matches('/').to_string()
}

pub fn device_node_id(device_id: Uuid) -> u64 {
    let bytes = device_id.as_bytes();
    u64::from_be_bytes([
        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
    ])
}