mod client;
mod error;
mod render;
use clap::{Args, Parser, Subcommand, error::ErrorKind};
use error::AppError;
use render::OutputFormat;
use serde::Deserialize;
use std::{
env, fs,
io::{self, Read},
process,
};
use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};
#[derive(Parser)]
#[command(
name = "hevy-rs",
version,
about = "A command-line client for the Hevy API"
)]
struct Cli {
#[arg(long, env = "HEVY_API_KEY", hide_env_values = true, global = true)]
api_key: Option<String>,
#[arg(long, value_enum, default_value_t = OutputFormat::Text, global = true)]
format: OutputFormat,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
User {
#[command(subcommand)]
command: UserCommand,
},
Workouts {
#[command(subcommand)]
command: WorkoutCommand,
},
Routines {
#[command(subcommand)]
command: RoutineCommand,
},
ExerciseTemplates {
#[command(subcommand)]
command: ExerciseTemplateCommand,
},
RoutineFolders {
#[command(subcommand)]
command: RoutineFolderCommand,
},
ExerciseHistory {
#[command(subcommand)]
command: ExerciseHistoryCommand,
},
BodyMeasurements {
#[command(subcommand)]
command: BodyMeasurementCommand,
},
}
#[derive(Subcommand)]
enum UserCommand {
Get,
}
#[derive(Subcommand)]
enum RoutineCommand {
List(PaginationArgs),
Get { routine_id: String },
Create(MutationArgs),
Update {
routine_id: String,
#[command(flatten)]
mutation: MutationArgs,
},
}
#[derive(Subcommand)]
enum ExerciseTemplateCommand {
List(ExerciseTemplatePaginationArgs),
Get { exercise_template_id: String },
Create(MutationArgs),
}
#[derive(Subcommand)]
enum RoutineFolderCommand {
List(PaginationArgs),
Get { folder_id: String },
Create(MutationArgs),
}
#[derive(Subcommand)]
enum ExerciseHistoryCommand {
Get {
exercise_template_id: String,
#[arg(long, value_parser = parse_iso8601)]
start: Option<String>,
#[arg(long, value_parser = parse_iso8601)]
end: Option<String>,
},
}
#[derive(Subcommand)]
enum BodyMeasurementCommand {
List(PaginationArgs),
Get {
#[arg(value_parser = parse_date)]
date: String,
},
Create(MutationArgs),
Update {
#[arg(value_parser = parse_date)]
date: String,
#[command(flatten)]
mutation: MutationArgs,
},
}
#[derive(Subcommand)]
enum WorkoutCommand {
List(PaginationArgs),
Count,
Get { workout_id: String },
Events {
#[command(flatten)]
pagination: PaginationArgs,
#[arg(long, value_parser = parse_iso8601)]
since: Option<String>,
},
Create(MutationArgs),
Update {
workout_id: String,
#[command(flatten)]
mutation: MutationArgs,
},
}
#[derive(Args)]
struct MutationArgs {
#[arg(long)]
data: String,
#[arg(long)]
dry_run: bool,
#[arg(long)]
yes: bool,
}
#[derive(Args)]
struct PaginationArgs {
#[arg(long, value_parser = clap::value_parser!(u32).range(1..), conflicts_with = "all")]
page: Option<u32>,
#[arg(long, value_parser = clap::value_parser!(u32).range(1..=10))]
page_size: Option<u32>,
#[arg(long)]
all: bool,
}
#[derive(Args)]
struct ExerciseTemplatePaginationArgs {
#[arg(long, value_parser = clap::value_parser!(u32).range(1..), conflicts_with = "all")]
page: Option<u32>,
#[arg(long, value_parser = clap::value_parser!(u32).range(1..=100))]
page_size: Option<u32>,
#[arg(long)]
all: bool,
}
#[derive(Deserialize)]
struct Config {
api_key: Option<String>,
}
fn main() {
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(error)
if matches!(
error.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
) =>
{
error.print().expect("help output is writable");
process::exit(0);
}
Err(error) => exit_with(
AppError::invocation(error.to_string()),
if requests_json_output() {
OutputFormat::Json
} else {
OutputFormat::Text
},
),
};
let format = cli.format;
let result = match cli.command {
Command::User {
command: UserCommand::Get,
} => resolve_api_key(cli.api_key).and_then(|api_key| client::get_user(&api_key)),
Command::Workouts { command } => match command {
WorkoutCommand::List(pagination) => resolve_api_key(cli.api_key).and_then(|api_key| {
client::list_workouts(
&api_key,
client::Pagination {
page: pagination.page,
page_size: pagination.page_size,
all: pagination.all,
},
)
}),
WorkoutCommand::Count => {
resolve_api_key(cli.api_key).and_then(|api_key| client::get_workout_count(&api_key))
}
WorkoutCommand::Get { workout_id } => resolve_api_key(cli.api_key)
.and_then(|api_key| client::get_workout(&api_key, &workout_id)),
WorkoutCommand::Events { pagination, since } => {
resolve_api_key(cli.api_key).and_then(|api_key| {
client::list_workout_events(
&api_key,
client::Pagination {
page: pagination.page,
page_size: pagination.page_size,
all: pagination.all,
},
since.as_deref(),
)
})
}
WorkoutCommand::Create(mutation) => {
execute_mutation(cli.api_key, "workouts", "workout", None, mutation)
}
WorkoutCommand::Update {
workout_id,
mutation,
} => execute_mutation(
cli.api_key,
"workouts",
"workout",
Some(workout_id),
mutation,
),
},
Command::ExerciseTemplates { command } => match command {
ExerciseTemplateCommand::List(pagination) => {
resolve_api_key(cli.api_key).and_then(|api_key| {
client::list_exercise_templates(
&api_key,
client::Pagination {
page: pagination.page,
page_size: pagination.page_size,
all: pagination.all,
},
)
})
}
ExerciseTemplateCommand::Get {
exercise_template_id,
} => resolve_api_key(cli.api_key)
.and_then(|api_key| client::get_exercise_template(&api_key, &exercise_template_id)),
ExerciseTemplateCommand::Create(mutation) => execute_mutation(
cli.api_key,
"exercise_templates",
"exercise template",
None,
mutation,
),
},
Command::RoutineFolders { command } => match command {
RoutineFolderCommand::List(pagination) => {
resolve_api_key(cli.api_key).and_then(|api_key| {
client::list_routine_folders(
&api_key,
client::Pagination {
page: pagination.page,
page_size: pagination.page_size,
all: pagination.all,
},
)
})
}
RoutineFolderCommand::Get { folder_id } => resolve_api_key(cli.api_key)
.and_then(|api_key| client::get_routine_folder(&api_key, &folder_id)),
RoutineFolderCommand::Create(mutation) => execute_mutation(
cli.api_key,
"routine_folders",
"routine folder",
None,
mutation,
),
},
Command::ExerciseHistory { command } => match command {
ExerciseHistoryCommand::Get {
exercise_template_id,
start,
end,
} => resolve_api_key(cli.api_key).and_then(|api_key| {
client::get_exercise_history(
&api_key,
&exercise_template_id,
start.as_deref(),
end.as_deref(),
)
}),
},
Command::BodyMeasurements { command } => match command {
BodyMeasurementCommand::List(pagination) => {
resolve_api_key(cli.api_key).and_then(|api_key| {
client::list_body_measurements(
&api_key,
client::Pagination {
page: pagination.page,
page_size: pagination.page_size,
all: pagination.all,
},
)
})
}
BodyMeasurementCommand::Get { date } => resolve_api_key(cli.api_key)
.and_then(|api_key| client::get_body_measurement(&api_key, &date)),
BodyMeasurementCommand::Create(mutation) => execute_mutation(
cli.api_key,
"body_measurements",
"body measurement",
None,
mutation,
),
BodyMeasurementCommand::Update { date, mutation } => execute_mutation(
cli.api_key,
"body_measurements",
"body measurement",
Some(date),
mutation,
),
},
Command::Routines { command } => match command {
RoutineCommand::List(pagination) => resolve_api_key(cli.api_key).and_then(|api_key| {
client::list_routines(
&api_key,
client::Pagination {
page: pagination.page,
page_size: pagination.page_size,
all: pagination.all,
},
)
}),
RoutineCommand::Get { routine_id } => resolve_api_key(cli.api_key)
.and_then(|api_key| client::get_routine(&api_key, &routine_id)),
RoutineCommand::Create(mutation) => {
execute_mutation(cli.api_key, "routines", "routine", None, mutation)
}
RoutineCommand::Update {
routine_id,
mutation,
} => execute_mutation(
cli.api_key,
"routines",
"routine",
Some(routine_id),
mutation,
),
},
};
match result {
Ok(user) => render::success(&user, format),
Err(error) => exit_with(error, format),
}
}
fn execute_mutation(
explicit_api_key: Option<String>,
resource_path: &str,
resource_name: &str,
resource_id: Option<String>,
mutation: MutationArgs,
) -> Result<serde_json::Value, AppError> {
if mutation.dry_run && mutation.yes {
return Err(AppError::invocation(
"--dry-run cannot be combined with --yes.",
));
}
let payload = read_payload(&mutation.data)?;
if mutation.dry_run {
return Ok(dry_run_output(
resource_path,
resource_name,
resource_id.as_deref(),
payload,
));
}
let api_key = resolve_api_key(explicit_api_key)?;
match (resource_path, resource_id) {
("workouts", Some(workout_id)) => client::update_workout(&api_key, &workout_id, &payload),
("workouts", None) => client::create_workout(&api_key, &payload),
("routines", Some(routine_id)) => client::update_routine(&api_key, &routine_id, &payload),
("routines", None) => client::create_routine(&api_key, &payload),
("exercise_templates", None) => client::create_exercise_template(&api_key, &payload),
("routine_folders", None) => client::create_routine_folder(&api_key, &payload),
("body_measurements", Some(date)) => {
client::update_body_measurement(&api_key, &date, &payload)
}
("body_measurements", None) => client::create_body_measurement(&api_key, &payload),
_ => unreachable!("all mutation resources are known"),
}
}
fn read_payload(source: &str) -> Result<serde_json::Value, AppError> {
let content = if source == "-" {
let mut content = String::new();
io::stdin().read_to_string(&mut content).map_err(|_| {
AppError::invocation("Could not read JSON payload from standard input.")
})?;
content
} else if let Some(path) = source.strip_prefix('@') {
fs::read_to_string(path)
.map_err(|_| AppError::invocation("Could not read the JSON payload file."))?
} else {
source.to_owned()
};
serde_json::from_str(&content)
.map_err(|_| AppError::invocation("--data must contain valid JSON."))
}
fn dry_run_output(
resource_path: &str,
resource_name: &str,
resource_id: Option<&str>,
payload: serde_json::Value,
) -> serde_json::Value {
let (method, path, affected_resource) = match resource_id {
Some(resource_id) => (
"PUT",
format!("/v1/{resource_path}/{resource_id}"),
resource_id.to_owned(),
),
None => (
"POST",
format!("/v1/{resource_path}"),
format!("new {resource_name}"),
),
};
serde_json::json!({
"dry_run": true,
"affected_resource": affected_resource,
"request": {
"method": method,
"path": path,
"body": redact_secrets(payload),
}
})
}
fn redact_secrets(value: serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(fields) => fields
.into_iter()
.map(|(name, value)| {
let value = if is_secret_field(&name) {
serde_json::Value::String("[REDACTED]".to_owned())
} else {
redact_secrets(value)
};
(name, value)
})
.collect(),
serde_json::Value::Array(items) => items.into_iter().map(redact_secrets).collect(),
value => value,
}
}
fn is_secret_field(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"api_key" | "apikey" | "authorization" | "password" | "token" | "secret"
)
}
fn exit_with(error: AppError, format: OutputFormat) -> ! {
render::error(&error, format);
process::exit(error.exit_code);
}
fn requests_json_output() -> bool {
let arguments: Vec<_> = env::args().collect();
arguments.iter().any(|argument| argument == "--format=json")
|| arguments
.windows(2)
.any(|arguments| arguments == ["--format", "json"])
}
fn parse_date(value: &str) -> Result<String, String> {
let format = time::format_description::parse_borrowed::<3>("[year]-[month]-[day]")
.expect("the date format description is valid");
Date::parse(value, &format)
.map(|_| value.to_owned())
.map_err(|_| "must be a valid date in YYYY-MM-DD form, such as 2025-01-15".to_owned())
}
fn parse_iso8601(value: &str) -> Result<String, String> {
OffsetDateTime::parse(value, &Rfc3339)
.map(|_| value.to_owned())
.map_err(|_| {
"must be an ISO-8601 timestamp with an offset, such as 2025-01-01T00:00:00Z".to_owned()
})
}
fn resolve_api_key(explicit_key: Option<String>) -> Result<String, AppError> {
explicit_key
.filter(|key| !key.trim().is_empty())
.or_else(|| env::var("HEVY_API_KEY").ok().filter(|key| !key.trim().is_empty()))
.or_else(config_api_key)
.ok_or_else(|| {
AppError::authentication(
"No API key was provided. Set HEVY_API_KEY, use --api-key, or configure hevy/config.toml.",
)
})
}
fn config_api_key() -> Option<String> {
let path = config_directory()?.join("hevy").join("config.toml");
let content = fs::read_to_string(path).ok()?;
toml::from_str::<Config>(&content)
.ok()?
.api_key
.filter(|key| !key.trim().is_empty())
}
fn config_directory() -> Option<std::path::PathBuf> {
env::var_os("HEVY_CONFIG_DIR")
.map(std::path::PathBuf::from)
.or_else(dirs::config_dir)
}