onenote-cli 0.1.3

Read and capture Microsoft OneNote notes through its Windows desktop application
Documentation
use crate::{cli::InitArgs, error::Error};
use serde::{Deserialize, Serialize};
use std::{
    collections::BTreeMap,
    fs::{self, File, OpenOptions},
    io::{Read, Write},
    path::PathBuf,
};

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
    #[default]
    Desktop,
    Ssh,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Profile {
    pub backend: Backend,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub host: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub identity_file: Option<PathBuf>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub port: Option<u16>,
    pub read_only: bool,
}
impl Profile {
    pub fn from_args(args: &InitArgs) -> Result<Self, Error> {
        let mut profile = Self {
            backend: args.backend,
            host: args.host.clone(),
            identity_file: args.identity_file.clone(),
            port: args.port,
            read_only: args.read_only,
        };
        profile.validate()?;
        if let Some(path) = &profile.identity_file {
            let expanded = if let Ok(rest) = path.strip_prefix("~") {
                dirs::home_dir()
                    .ok_or_else(|| failure("Cannot locate home directory"))?
                    .join(rest)
            } else {
                path.clone()
            };
            let absolute = expanded.canonicalize().map_err(|e| {
                failure(format!(
                    "Cannot access identity file {}: {e}",
                    path.display()
                ))
            })?;
            if !absolute.is_file() {
                return Err(failure("Identity file must be a file"));
            }
            profile.identity_file = Some(absolute);
        }
        Ok(profile)
    }
    pub fn validate(&self) -> Result<(), Error> {
        match self.backend {
            Backend::Desktop
                if self.host.is_some() || self.identity_file.is_some() || self.port.is_some() =>
            {
                return Err(failure(
                    "--host, --identity-file and --port require --backend ssh",
                ));
            }
            Backend::Ssh => {
                let host = self.host.as_deref().ok_or_else(|| {
                    failure("SSH profiles require --host HOST (an SSH alias or user@host)")
                })?;
                if host.is_empty()
                    || host.len() > 255
                    || host.starts_with('-')
                    || host.starts_with('@')
                    || host.ends_with('@')
                    || host.matches('@').count() > 1
                    || !host
                        .bytes()
                        .all(|c| c.is_ascii_alphanumeric() || b"@._-:[]".contains(&c))
                {
                    return Err(failure(
                        "Invalid SSH destination; use a host alias or user@host without whitespace or shell syntax",
                    ));
                }
                if self.port == Some(0) {
                    return Err(failure("SSH port must be between 1 and 65535"));
                }
            }
            _ => (),
        }
        Ok(())
    }
    pub fn target(&self) -> &str {
        self.host.as_deref().unwrap_or("local Windows desktop")
    }
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
    pub version: u32,
    pub active_profile: Option<String>,
    pub profiles: BTreeMap<String, Profile>,
}
impl Default for Config {
    fn default() -> Self {
        Self {
            version: 1,
            active_profile: None,
            profiles: BTreeMap::new(),
        }
    }
}
fn failure(message: impl Into<String>) -> Error {
    Error::new("config_error", message)
}
pub fn profile_name(name: &str) -> Result<String, String> {
    if name.is_empty()
        || name.len() > 64
        || !name
            .bytes()
            .all(|c| c.is_ascii_alphanumeric() || b"_-.".contains(&c))
    {
        Err(
            "profile names must contain 1–64 ASCII letters, digits, dots, underscores or hyphens"
                .into(),
        )
    } else {
        Ok(name.into())
    }
}
pub fn path() -> Result<PathBuf, Error> {
    if let Some(path) = std::env::var_os("ONENOTE_CONFIG") {
        if path.is_empty() {
            return Err(failure("ONENOTE_CONFIG must not be empty"));
        }
        return Ok(PathBuf::from(path));
    }
    let base = std::env::var_os("XDG_CONFIG_HOME")
        .filter(|s| !s.is_empty())
        .map(PathBuf::from)
        .or_else(|| {
            if cfg!(windows) {
                dirs::config_dir()
            } else {
                dirs::home_dir().map(|p| p.join(".config"))
            }
        });
    Ok(base
        .ok_or_else(|| failure("Cannot locate configuration directory; set ONENOTE_CONFIG"))?
        .join("onenote/config.toml"))
}
pub struct Store {
    pub path: PathBuf,
}
impl Store {
    pub fn open() -> Result<Self, Error> {
        Ok(Self { path: path()? })
    }
    pub fn read(&self) -> Result<Option<Config>, Error> {
        let file = match File::open(&self.path) {
            Ok(f) => f,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(e) => return Err(failure(format!("Cannot read {}: {e}", self.path.display()))),
        };
        let mut contents = String::new();
        file.take(1024 * 1024 + 1)
            .read_to_string(&mut contents)
            .map_err(|e| failure(e.to_string()))?;
        if contents.len() > 1024 * 1024 {
            return Err(failure("Configuration exceeds 1 MiB"));
        }
        let config: Config = toml::from_str(&contents).map_err(|e| {
            failure(format!(
                "Invalid configuration in {}: {e}",
                self.path.display()
            ))
        })?;
        if config.version != 1 {
            return Err(failure(
                "Unsupported configuration version; expected version = 1",
            ));
        }
        for (name, profile) in &config.profiles {
            profile_name(name).map_err(failure)?;
            profile.validate()?;
        }
        if let Some(name) = &config.active_profile
            && !config.profiles.contains_key(name)
        {
            return Err(failure(format!(
                "Active profile '{name}' does not exist; repair {}",
                self.path.display()
            )));
        }
        Ok(Some(config))
    }
    pub fn resolve(&self, selected: Option<&str>) -> Result<(Option<String>, Profile), Error> {
        let config = self.read()?;
        let name = selected.or_else(|| config.as_ref().and_then(|c| c.active_profile.as_deref()));
        if let Some(name) = name {
            let profile = config
                .as_ref()
                .and_then(|c| c.profiles.get(name))
                .ok_or_else(|| {
                    failure(format!(
                        "Profile '{name}' does not exist; run `onenote init --profile {name}`"
                    ))
                })?;
            return Ok((Some(name.into()), profile.clone()));
        }
        if config.is_some() {
            return Err(failure(
                "No active profile; use `onenote profile use NAME` or `onenote init`",
            ));
        }
        Ok((None, Profile::default()))
    }
    pub fn update(
        &self,
        change: impl FnOnce(&mut Config) -> Result<(), Error>,
    ) -> Result<(), Error> {
        let parent = self
            .path
            .parent()
            .filter(|p| !p.as_os_str().is_empty())
            .unwrap_or(std::path::Path::new("."));
        fs::create_dir_all(parent)?;
        let mut options = OpenOptions::new();
        options.read(true).write(true).create(true).truncate(false);
        #[cfg(unix)]
        {
            use std::os::unix::fs::OpenOptionsExt;
            options.mode(0o600);
        }
        let lock = options.open(self.path.with_extension("toml.lock"))?;
        lock.try_lock().map_err(|_| {
            failure("Configuration is being updated by another process; retry shortly")
        })?;
        let mut config = self.read()?.unwrap_or_default();
        change(&mut config)?;
        let mut file = tempfile::NamedTempFile::new_in(parent)?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            file.as_file()
                .set_permissions(fs::Permissions::from_mode(0o600))?;
        }
        file.write_all(
            toml::to_string_pretty(&config)
                .map_err(|e| failure(e.to_string()))?
                .as_bytes(),
        )?;
        file.as_file().sync_all()?;
        file.persist(&self.path)
            .map_err(|e| failure(format!("Cannot save {}: {e}", self.path.display())))?;
        Ok(())
    }
    pub fn ensure_available(&self, name: &str, force: bool) -> Result<(), Error> {
        if !force && self.read()?.is_some_and(|c| c.profiles.contains_key(name)) {
            return Err(failure(format!(
                "Profile '{name}' already exists; use --force to replace its settings"
            )));
        }
        Ok(())
    }
    pub fn save(&self, name: &str, profile: Profile, force: bool) -> Result<(), Error> {
        self.update(|config| {
            if config.profiles.contains_key(name) && !force {
                return Err(failure(format!(
                    "Profile '{name}' already exists; use --force to replace its settings"
                )));
            }
            config.profiles.insert(name.into(), profile);
            config.active_profile = Some(name.into());
            Ok(())
        })
    }
}