pushit 0.1.0

A small, cross-platform command-line tool for sending push notifications.
use std::collections::BTreeSet;
use std::fs;

use crate::cli::{ProfileAddArgs, ProfileCmd, ServiceKind};
use crate::config::Config;
use crate::error::{Error, Result};
use crate::paths::{self, Tier};
use crate::profile::{MessageDefaults, Profile, ServiceConfig};
use crate::service::pushover::PushoverConfig;

pub fn run(cmd: ProfileCmd) -> Result<()> {
    match cmd {
        ProfileCmd::Add(args) => add(args),
        ProfileCmd::List => list(),
        ProfileCmd::Show { name, system } => show(&name, tier_from(system)),
        ProfileCmd::Remove { name, system } => remove(&name, tier_from(system)),
        ProfileCmd::Use { name, system } => use_default(&name, tier_from(system)),
        ProfileCmd::Path { name, system } => path(name.as_deref(), tier_from(system)),
    }
}

fn tier_from(system: bool) -> Tier {
    if system { Tier::System } else { Tier::User }
}

fn add(args: ProfileAddArgs) -> Result<()> {
    paths::validate_profile_name(&args.name)?;
    let tier = tier_from(args.system);

    let service = match args.service {
        ServiceKind::Pushover => ServiceConfig::Pushover(PushoverConfig {
            token: args.token.expect("clap enforces --token for pushover"),
            user_key: args.user_key.expect("clap enforces --user-key for pushover"),
        }),
    };

    let profile = Profile {
        name: args.name.clone(),
        service,
        defaults: MessageDefaults {
            title: args.title,
            priority: args.priority,
            sound: args.sound,
            device: args.device,
            url: args.url,
            url_title: args.url_title,
        },
    };
    profile.save(tier, false)?;
    println!("created profile '{}' ({})", args.name, tier.label());

    if tier == Tier::System
        && paths::profile_file(Tier::User, &args.name)?.exists()
    {
        eprintln!(
            "warning: user profile '{}' shadows this system profile",
            args.name
        );
    }
    Ok(())
}

fn list() -> Result<()> {
    let user_default = Config::load_tier(Tier::User)?.default_profile;
    let system_default = Config::load_tier(Tier::System)?.default_profile;
    let user = Profile::list_tier(Tier::User)?;
    let system = Profile::list_tier(Tier::System)?;
    let user_set: BTreeSet<&str> = user.iter().map(String::as_str).collect();

    println!("user:");
    if user.is_empty() {
        println!("  (none)");
    } else {
        for name in &user {
            let mut tags: Vec<&str> = Vec::new();
            if user_default.as_deref() == Some(name) {
                tags.push("default");
            }
            print_entry(name, &tags);
        }
    }

    println!("system:");
    if system.is_empty() {
        println!("  (none)");
    } else {
        for name in &system {
            let mut tags: Vec<&str> = Vec::new();
            if system_default.as_deref() == Some(name) {
                tags.push("system default");
            }
            if user_set.contains(name.as_str()) {
                tags.push("shadowed");
            }
            print_entry(name, &tags);
        }
    }
    Ok(())
}

fn print_entry(name: &str, tags: &[&str]) {
    if tags.is_empty() {
        println!("  {name}");
    } else {
        println!("  {name} ({})", tags.join(", "));
    }
}

fn show(name: &str, tier: Tier) -> Result<()> {
    let path = match tier {
        Tier::System => paths::profile_file(Tier::System, name)?,
        Tier::User => {
            let (_, p) = paths::find_profile_file(name)?
                .ok_or_else(|| Error::ProfileNotFound(name.to_string()))?;
            p
        }
    };
    let body = fs::read_to_string(&path).map_err(|e| match e.kind() {
        std::io::ErrorKind::NotFound => Error::ProfileNotFound(name.to_string()),
        _ => Error::Io(e),
    })?;
    print!("{body}");
    if !body.ends_with('\n') {
        println!();
    }
    Ok(())
}

fn remove(name: &str, tier: Tier) -> Result<()> {
    Profile::remove(tier, name)?;
    let mut config = Config::load_tier(tier)?;
    if config.default_profile.as_deref() == Some(name) {
        config.default_profile = None;
        config.save_tier(tier)?;
    }
    println!("removed profile '{name}' ({})", tier.label());

    if tier == Tier::User
        && paths::profile_file(Tier::System, name)?.exists()
    {
        println!("system profile '{name}' is now active");
    }
    Ok(())
}

fn use_default(name: &str, tier: Tier) -> Result<()> {
    let _ = Profile::load(name)?;
    let mut config = Config::load_tier(tier)?;
    config.default_profile = Some(name.to_string());
    config.save_tier(tier)?;
    println!("{} default profile is now '{name}'", tier.label());
    Ok(())
}

fn path(name: Option<&str>, tier: Tier) -> Result<()> {
    let p = match name {
        Some(n) => Profile::path_for(tier, n)?,
        None => paths::profiles_dir(tier)?,
    };
    println!("{}", p.display());
    Ok(())
}