duck/src/main.rs

142 lines
4.7 KiB
Rust

use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
#[derive(Parser)]
#[command(version)]
struct Cli {
/// Path to a config file.
///
/// See https://docs.rs/dirs/5.0.1/dirs/fn.config_dir.html for default
/// locations. Default location is using the directory from above with a
/// file name of "duck.toml".
#[arg(short, long)]
config: Option<PathBuf>,
/// The command to run. If not specified, generates a new Duck address.
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Deserialize, Serialize)]
struct Config {
key: String,
}
#[derive(Subcommand)]
enum Command {
/// Sets the API key in the config file.
///
/// You can get this key from https://duckduckgo.com/email. Open the network
/// inspector, click generate new address, and copy the Bearer token from
/// the Authorization header. That's the API key.
SetKey {
/// The API key to set in the config file.
key: String,
},
/// Generate a new Duck address.
#[command(aliases = ["new", "create", "add"])]
Generate,
/// Deactivates an existing Duck address.
#[command(aliases = ["disable", "delete", "remove"])]
Deactivate {
/// Address to deactivate. This can be just the email username; the
/// domain is not required.
address: String,
},
/// Reactivates an existing Duck address.
#[command(aliases = ["enable", "activate"])]
Reactivate {
/// Address to reactivate. This can be just the email username; the
/// domain is not required.
address: String,
},
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Response {
Address(String),
Active(bool),
Error(String),
}
fn config(path: &Path) -> Result<Config> {
let config = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == ErrorKind::NotFound => {
eprintln!("You are missing a config file at `{}`.", path.to_string_lossy());
eprintln!("Try running `duck set-key <api key>` first.");
anyhow::bail!("missing config file");
}
Err(e) => Err(e).context("could not read config file")?,
};
toml::from_str(&config)
.context("could not parse config file")
}
fn main() -> Result<()> {
let args = Cli::parse();
let command = args.command.unwrap_or(Command::Generate);
let config_path = args.config.or_else(|| dirs::config_dir().map(|c| c.join("duck.toml")))
.context("could not find a path for config, try using -c or --config")?;
match command {
Command::SetKey { key } => {
let config_str = toml::to_string(&Config {
key,
}).context("could not serialise config")?;
std::fs::write(config_path, config_str)
.context("could not write config")?;
println!("Config saved.");
}
Command::Generate => {
let config = config(&config_path)?;
let resp: Response = ureq::post("https://quack.duckduckgo.com/api/email/addresses")
.set("Authorization", &format!("Bearer {}", config.key))
.call()
.context("could not send request")?
.into_json()
.context("could not parse response")?;
match resp {
Response::Address(a) => println!("{a}@duck.com"),
Response::Active(_) => anyhow::bail!("unexpected response {resp:#?}"),
Response::Error(e) => anyhow::bail!("an error occurred: {e}"),
}
}
Command::Deactivate { address } => {
set_active(&config_path, &address, false)?;
}
Command::Reactivate { address } => {
set_active(&config_path, &address, true)?;
}
}
Ok(())
}
fn set_active(config_path: &Path, address: &str, active: bool) -> Result<()> {
let config = config(config_path)?;
let address = address.split('@').next()
.context("could not get first part of address")?;
let resp: Response = ureq::put(&format!("https://quack.duckduckgo.com/api/email/addresses/{address}?active={active}"))
.set("Authorization", &format!("Bearer {}", config.key))
.call()
.context("could not send request")?
.into_json()
.context("could not parse response")?;
match resp {
Response::Address(_) => anyhow::bail!("unexpected response {resp:#?}"),
Response::Active(a) => println!("{address}@duck.com is now {}", if a { "activated" } else { "deactivated" }),
Response::Error(e) => anyhow::bail!("an error occurred: {e}"),
}
Ok(())
}