use std::{collections::BTreeMap, fs, path::PathBuf};
use clap::{Args, Parser, Subcommand};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use uuid::Uuid;
const API_BASE: &str = "https://todo.ashrhmn.com/api/v1";
#[derive(Parser)]
#[command(name = "taskr")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Login(LoginArgs),
List(ListArgs),
Add(AddArgs),
Done(NumberArgs),
Undone(NumberArgs),
Workspace {
#[command(subcommand)]
command: WorkspaceCommand,
},
Token {
#[command(subcommand)]
command: TokenCommand,
},
}
#[derive(Args)]
struct LoginArgs {
#[arg(long)]
token: String,
}
#[derive(Args)]
struct ListArgs {
workspace: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
checked: bool,
}
#[derive(Args)]
struct AddArgs {
#[arg(short = 'w', long = "workspace", alias = "bucket", short_alias = 'b')]
workspace: Option<String>,
#[arg(short = 'd', long = "description")]
description: Option<String>,
#[arg(short = 't', long = "time")]
due: Option<String>,
title: Vec<String>,
}
#[derive(Args)]
struct NumberArgs {
number: i32,
}
#[derive(Subcommand)]
enum WorkspaceCommand {
Create { name: String },
List,
Rename { slug: String, name: String },
Archive { slug: String },
}
#[derive(Subcommand)]
enum TokenCommand {
Create { name: String },
List,
Revoke { token: String },
Rotate { token: String },
}
#[derive(Debug, Serialize, Deserialize)]
struct CliConfig {
token: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct ApiResponse<T> {
data: T,
}
#[derive(Debug, Serialize, Deserialize)]
struct CreateTodoRequest {
title: String,
description: Option<String>,
workspace: Option<String>,
due: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct CreateTokenRequest {
name: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct CreateWorkspaceRequest {
name: String,
slug: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct UpdateWorkspaceRequest {
name: Option<String>,
slug: Option<String>,
archived: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
struct CreatedToken {
token: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct TokenInfo {
id: Uuid,
name: String,
revoked: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct Workspace {
slug: String,
name: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct TodoItem {
number: i32,
title: String,
workspace: Option<String>,
due_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[tokio::main]
async fn main() {
if let Err(error) = run().await {
eprintln!("{error}");
std::process::exit(1);
}
}
async fn run() -> Result<(), String> {
let cli = Cli::parse();
match cli.command {
Command::Login(args) => {
save_config(&CliConfig { token: args.token })?;
println!("Logged in.");
Ok(())
}
Command::List(args) => {
let config = load_config()?;
let client = Client::new(config.token);
let mut query = Vec::new();
if args.all {
query.push(("all", "true".to_string()));
} else if let Some(workspace) = args.workspace {
query.push(("workspace", workspace));
} else {
query.push(("unassigned", "true".to_string()));
}
if args.checked {
query.push(("include_checked", "true".to_string()));
}
let todos: Vec<TodoItem> = client.get_query("/todos", &query).await?;
print_todos(&todos);
Ok(())
}
Command::Add(args) => {
if args.title.is_empty() {
return Err("Todo title is required".to_string());
}
let config = load_config()?;
let client = Client::new(config.token);
let todo: TodoItem = client
.post(
"/todos",
&CreateTodoRequest {
title: args.title.join(" "),
description: args.description,
workspace: args.workspace,
due: args.due,
},
)
.await?;
println!("#{} {}", todo.number, todo.title);
Ok(())
}
Command::Done(args) => set_checked(args.number, true).await,
Command::Undone(args) => set_checked(args.number, false).await,
Command::Workspace { command } => workspace_command(command).await,
Command::Token { command } => token_command(command).await,
}
}
async fn set_checked(number: i32, checked: bool) -> Result<(), String> {
let config = load_config()?;
let client = Client::new(config.token);
let path = if checked {
format!("/todos/{number}/check")
} else {
format!("/todos/{number}/uncheck")
};
let todo: TodoItem = client.post_empty(&path).await?;
println!("#{} {}", todo.number, todo.title);
Ok(())
}
async fn workspace_command(command: WorkspaceCommand) -> Result<(), String> {
let config = load_config()?;
let client = Client::new(config.token);
match command {
WorkspaceCommand::Create { name } => {
let workspace: Workspace = client
.post("/workspaces", &CreateWorkspaceRequest { name, slug: None })
.await?;
println!("{} - {}", workspace.slug, workspace.name);
}
WorkspaceCommand::List => {
let workspaces: Vec<Workspace> = client.get("/workspaces").await?;
for workspace in workspaces {
println!("{} - {}", workspace.slug, workspace.name);
}
}
WorkspaceCommand::Rename { slug, name } => {
let workspace: Workspace = client
.patch(
&format!("/workspaces/{slug}"),
&UpdateWorkspaceRequest {
name: Some(name),
slug: None,
archived: None,
},
)
.await?;
println!("{} - {}", workspace.slug, workspace.name);
}
WorkspaceCommand::Archive { slug } => {
let workspace: Workspace = client.delete(&format!("/workspaces/{slug}")).await?;
println!("Archived {}", workspace.slug);
}
}
Ok(())
}
async fn token_command(command: TokenCommand) -> Result<(), String> {
let config = load_config()?;
let client = Client::new(config.token);
match command {
TokenCommand::Create { name } => {
let token: CreatedToken = client.post("/tokens", &CreateTokenRequest { name }).await?;
println!("{}", token.token);
}
TokenCommand::List => {
let tokens: Vec<TokenInfo> = client.get("/tokens").await?;
for token in tokens {
let suffix = if token.revoked { " (revoked)" } else { "" };
println!("{} {}{}", token.id, token.name, suffix);
}
}
TokenCommand::Revoke { token } => {
let _: () = client.delete(&format!("/tokens/{token}")).await?;
println!("Token revoked.");
}
TokenCommand::Rotate { token } => {
let token: CreatedToken = client
.post_empty(&format!("/tokens/{token}/rotate"))
.await?;
println!("{}", token.token);
}
}
Ok(())
}
struct Client {
token: String,
http: reqwest::Client,
}
impl Client {
fn new(token: String) -> Self {
Self {
token,
http: reqwest::Client::new(),
}
}
async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, String> {
self.request(self.http.get(format!("{API_BASE}{path}")))
.await
}
async fn get_query<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, String)],
) -> Result<T, String> {
self.request(self.http.get(format!("{API_BASE}{path}")).query(query))
.await
}
async fn post<T: DeserializeOwned, B: Serialize>(
&self,
path: &str,
body: &B,
) -> Result<T, String> {
self.request(self.http.post(format!("{API_BASE}{path}")).json(body))
.await
}
async fn post_empty<T: DeserializeOwned>(&self, path: &str) -> Result<T, String> {
self.request(self.http.post(format!("{API_BASE}{path}")))
.await
}
async fn patch<T: DeserializeOwned, B: Serialize>(
&self,
path: &str,
body: &B,
) -> Result<T, String> {
self.request(self.http.patch(format!("{API_BASE}{path}")).json(body))
.await
}
async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, String> {
self.request(self.http.delete(format!("{API_BASE}{path}")))
.await
}
async fn request<T: DeserializeOwned>(
&self,
request: reqwest::RequestBuilder,
) -> Result<T, String> {
let response = request
.bearer_auth(&self.token)
.send()
.await
.map_err(|error| error.to_string())?;
let status = response.status();
let text = response.text().await.map_err(|error| error.to_string())?;
if status == StatusCode::NO_CONTENT {
return serde_json::from_str("null").map_err(|error| error.to_string());
}
if !status.is_success() {
return Err(text);
}
let body: ApiResponse<T> =
serde_json::from_str(&text).map_err(|error| error.to_string())?;
Ok(body.data)
}
}
fn print_todos(todos: &[TodoItem]) {
if todos.is_empty() {
println!("No open todos.");
return;
}
let mut groups: BTreeMap<String, Vec<&TodoItem>> = BTreeMap::new();
for todo in todos {
groups
.entry(
todo.workspace
.clone()
.unwrap_or_else(|| "unassigned".to_string()),
)
.or_default()
.push(todo);
}
for (workspace, todos) in groups {
println!("[{workspace}]");
for todo in todos {
let due = todo
.due_at
.map(|date| format!(" due {}", date.format("%Y-%m-%d %H:%M UTC")))
.unwrap_or_default();
println!("#{} {}{}", todo.number, todo.title, due);
}
}
}
fn config_path() -> Result<PathBuf, String> {
let dir = dirs::config_dir()
.ok_or_else(|| "Cannot find config directory".to_string())?
.join("taskr");
Ok(dir.join("config.json"))
}
fn save_config(config: &CliConfig) -> Result<(), String> {
let path = config_path()?;
let parent = path
.parent()
.ok_or_else(|| "Invalid config path".to_string())?;
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
let body = serde_json::to_string_pretty(config).map_err(|error| error.to_string())?;
fs::write(path, body).map_err(|error| error.to_string())
}
fn load_config() -> Result<CliConfig, String> {
let path = config_path()?;
let body = fs::read_to_string(path)
.map_err(|_| "Run `taskr login --token <token>` first".to_string())?;
serde_json::from_str(&body).map_err(|error| error.to_string())
}