use std::{
collections::BTreeMap,
fs,
io::{self, IsTerminal, Write},
path::{Path, PathBuf},
};
use clap::{Args, Parser, Subcommand};
use reqwest::StatusCode;
use rpassword::read_password;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use skim::prelude::*;
use uuid::Uuid;
const API_BASE: &str = "https://taskr.ashrhmn.com/api/v1";
#[derive(Parser)]
#[command(name = "taskr")]
struct Cli {
#[arg(long, global = true)]
debug: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Login(LoginArgs),
List(ListArgs),
Add(AddArgs),
Search(SearchArgs),
Done(TaskSelectorArgs),
Undone(TaskSelectorArgs),
Workspace {
#[command(subcommand)]
command: WorkspaceCommand,
},
Token {
#[command(subcommand)]
command: TokenCommand,
},
}
#[derive(Args)]
struct LoginArgs {
#[arg(long)]
token: Option<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 SearchArgs {
#[arg(short = 'w', long = "workspace", alias = "bucket", short_alias = 'b')]
workspace: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
checked: bool,
#[arg(long, default_value_t = 25)]
limit: i64,
query: Vec<String>,
}
#[derive(Args)]
struct TaskSelectorArgs {
selector: Vec<String>,
}
#[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 ErrorResponse {
message: Option<String>,
error: Option<String>,
code: Option<String>,
}
#[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,
description: Option<String>,
workspace: Option<String>,
due_at: Option<chrono::DateTime<chrono::Utc>>,
checked_at: Option<chrono::DateTime<chrono::Utc>>,
created_at: 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) => {
let token = login_token(args.token)?;
save_config(&CliConfig { token })?;
println!("Logged in.");
Ok(())
}
Command::List(args) => {
let config = load_config()?;
let client = Client::new(config.token, cli.debug);
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("Task title is required".to_string());
}
let config = load_config()?;
let client = Client::new(config.token, cli.debug);
let todo: TodoItem = client
.post(
"/todos",
&CreateTodoRequest {
title: args.title.join(" "),
description: args.description,
workspace: args.workspace,
due: args.due,
},
)
.await?;
println!("{}", todo.title);
Ok(())
}
Command::Search(args) => search_command(args, cli.debug).await,
Command::Done(args) => set_checked_from_selector(args.selector, true, cli.debug).await,
Command::Undone(args) => set_checked_from_selector(args.selector, false, cli.debug).await,
Command::Workspace { command } => workspace_command(command, cli.debug).await,
Command::Token { command } => token_command(command, cli.debug).await,
}
}
async fn set_checked(number: i32, checked: bool, debug: bool) -> Result<(), String> {
let config = load_config()?;
let client = Client::new(config.token, debug);
let path = if checked {
format!("/todos/{number}/check")
} else {
format!("/todos/{number}/uncheck")
};
let todo: TodoItem = client.post_empty(&path).await?;
let action = if checked {
"marked as done"
} else {
"marked as not done"
};
println!("“{}” was {action}.", todo.title);
Ok(())
}
async fn workspace_command(command: WorkspaceCommand, debug: bool) -> Result<(), String> {
let config = load_config()?;
let client = Client::new(config.token, debug);
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, debug: bool) -> Result<(), String> {
let config = load_config()?;
let client = Client::new(config.token, debug);
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(())
}
async fn search_command(args: SearchArgs, debug: bool) -> Result<(), String> {
let query = args.query.join(" ");
if query.trim().is_empty() {
return Err("Search query is required".to_string());
}
let config = load_config()?;
let client = Client::new(config.token, debug);
let todos = search_todos(
&client,
&query,
args.workspace.as_deref(),
args.all,
args.checked,
args.limit,
)
.await?;
print_todos(&todos);
Ok(())
}
async fn set_checked_from_selector(
selector: Vec<String>,
checked: bool,
debug: bool,
) -> Result<(), String> {
if let Some(number) = parse_number_selector(&selector)? {
return set_checked(number, checked, debug).await;
}
let config = load_config()?;
let client = Client::new(config.token, debug);
let query = selector.join(" ");
let mut candidates = if query.trim().is_empty() {
let query = if checked {
vec![("all", "true".to_string())]
} else {
vec![
("all", "true".to_string()),
("include_checked", "true".to_string()),
]
};
client.get_query::<Vec<TodoItem>>("/todos", &query).await?
} else {
search_todos(&client, &query, None, true, !checked, 50).await?
};
candidates.retain(|todo| todo.checked_at.is_some() != checked);
if candidates.is_empty() {
return Err("No matching tasks found.".to_string());
}
let number = if candidates.len() == 1 {
candidates[0].number
} else {
pick_todo(&candidates)?
};
set_checked(number, checked, debug).await
}
fn parse_number_selector(selector: &[String]) -> Result<Option<i32>, String> {
match selector {
[] => Ok(None),
[value] => Ok(value.parse::<i32>().ok()),
_ => Ok(None),
}
}
async fn search_todos(
client: &Client,
search: &str,
workspace: Option<&str>,
all: bool,
include_checked: bool,
limit: i64,
) -> Result<Vec<TodoItem>, String> {
let mut query = vec![
("q", search.to_string()),
("limit", limit.clamp(1, 100).to_string()),
];
if let Some(workspace) = workspace {
query.push(("workspace", workspace.to_string()));
}
if all {
query.push(("all", "true".to_string()));
}
if include_checked {
query.push(("include_checked", "true".to_string()));
}
client.get_query("/todos/search", &query).await
}
fn pick_todo(todos: &[TodoItem]) -> Result<i32, String> {
if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
return Err(
"Multiple tasks matched. Run in a terminal for interactive selection.".to_string(),
);
}
let options = SkimOptionsBuilder::default()
.height("70%")
.prompt("taskr> ")
.reverse(true)
.build()
.map_err(|error| error.to_string())?;
let base_labels = todos.iter().map(format_todo_selector).collect::<Vec<_>>();
let choices = todos
.iter()
.zip(&base_labels)
.map(|(todo, label)| {
let label = if base_labels
.iter()
.filter(|candidate| *candidate == label)
.count()
> 1
{
format!("{} · {label}", todo.created_at.format("%Y-%m-%d %H:%M"))
} else {
label.clone()
};
(label, todo.number)
})
.collect::<Vec<_>>();
let items = choices
.iter()
.map(|(label, _)| label.clone())
.collect::<Vec<_>>();
let output = Skim::run_items(options, items).map_err(|error| error.to_string())?;
if output.is_abort {
return Err("Task selection cancelled.".to_string());
}
let selected = output
.selected_items
.first()
.ok_or_else(|| "No task selected.".to_string())?;
let selected = selected.output();
choices
.iter()
.find(|(label, _)| label == selected.as_ref())
.map(|(_, number)| *number)
.ok_or_else(|| "Selected task is no longer available.".to_string())
}
struct Client {
token: String,
http: reqwest::Client,
debug: bool,
}
impl Client {
fn new(token: String, debug: bool) -> Self {
Self {
token,
http: reqwest::Client::new(),
debug,
}
}
async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, String> {
self.request(
"GET",
path,
None,
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(
"GET",
path,
Some(&format_query(query)),
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> {
let payload = serde_json::to_string(body).map_err(|error| error.to_string())?;
self.request(
"POST",
path,
Some(&payload),
self.http.post(format!("{API_BASE}{path}")).json(body),
)
.await
}
async fn post_empty<T: DeserializeOwned>(&self, path: &str) -> Result<T, String> {
self.request(
"POST",
path,
None,
self.http.post(format!("{API_BASE}{path}")),
)
.await
}
async fn patch<T: DeserializeOwned, B: Serialize>(
&self,
path: &str,
body: &B,
) -> Result<T, String> {
let payload = serde_json::to_string(body).map_err(|error| error.to_string())?;
self.request(
"PATCH",
path,
Some(&payload),
self.http.patch(format!("{API_BASE}{path}")).json(body),
)
.await
}
async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, String> {
self.request(
"DELETE",
path,
None,
self.http.delete(format!("{API_BASE}{path}")),
)
.await
}
async fn request<T: DeserializeOwned>(
&self,
method: &str,
path: &str,
payload: Option<&str>,
request: reqwest::RequestBuilder,
) -> Result<T, String> {
if self.debug {
eprintln!("[DEBUG] {method} {API_BASE}{path}");
if let Some(payload) = payload {
eprintln!("[DEBUG] Payload: {payload}");
}
}
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 self.debug {
eprintln!("[DEBUG] HTTP Status: {status}");
eprintln!("[DEBUG] Response: {text}");
}
if status == StatusCode::NO_CONTENT {
return serde_json::from_str("null").map_err(|error| error.to_string());
}
if !status.is_success() {
return Err(format_api_error(status, &text));
}
let body: ApiResponse<T> =
serde_json::from_str(&text).map_err(|error| error.to_string())?;
Ok(body.data)
}
}
fn format_query(query: &[(&str, String)]) -> String {
query
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join("&")
}
fn format_api_error(status: StatusCode, body: &str) -> String {
let api_message = serde_json::from_str::<ErrorResponse>(body)
.ok()
.and_then(|response| response.message.or(response.error))
.filter(|message| !message.is_empty());
let prefix = match status.as_u16() {
400 => "Bad request",
401 => "Invalid or missing token",
403 => "Token is not allowed to perform this action",
404 => "Not found",
429 => "Rate limit exceeded",
500..=599 => "Server error",
_ => return format!("Request failed with status {status}: {body}"),
};
match api_message {
Some(message) => format!("{prefix}: {message}"),
None => prefix.to_string(),
}
}
fn print_todos(todos: &[TodoItem]) {
if todos.is_empty() {
println!("No open tasks.");
return;
}
let mut top_level = Vec::new();
let mut groups: BTreeMap<String, Vec<&TodoItem>> = BTreeMap::new();
for todo in todos {
match &todo.workspace {
Some(workspace) => groups.entry(workspace.clone()).or_default().push(todo),
None => top_level.push(todo),
}
}
let has_top_level = !top_level.is_empty();
for todo in top_level {
println!("{}", format_todo_line(todo));
}
let mut printed_group = has_top_level;
for (workspace, todos) in groups {
if printed_group {
println!();
}
println!("[{workspace}]");
for todo in todos {
println!("{}", format_todo_line(todo));
}
printed_group = true;
}
}
fn format_todo_line(todo: &TodoItem) -> String {
let due = todo
.due_at
.map(|date| format!(" due {}", date.format("%Y-%m-%d %H:%M UTC")))
.unwrap_or_default();
let checked = if todo.checked_at.is_some() {
" [done]"
} else {
""
};
format!("{}{}{}", todo.title, due, checked)
}
fn format_todo_selector(todo: &TodoItem) -> String {
match todo.workspace.as_deref() {
Some(workspace) => format!("{} · {workspace}", format_todo_line(todo)),
None => format_todo_line(todo),
}
}
fn login_token(token: Option<String>) -> Result<String, String> {
let token = match token {
Some(token) => token,
None => {
print!("Enter your Taskr token: ");
io::stdout().flush().map_err(|error| error.to_string())?;
read_password().map_err(|error| error.to_string())?
}
};
let token = token.trim().to_string();
if token.is_empty() {
return Err("Token cannot be empty".to_string());
}
if !token.starts_with("taskr_") {
return Err("Invalid token".to_string());
}
Ok(token)
}
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())?;
set_permissions(parent, 0o700)?;
let body = serde_json::to_string_pretty(config).map_err(|error| error.to_string())?;
fs::write(&path, body).map_err(|error| error.to_string())?;
set_permissions(&path, 0o600)
}
fn load_config() -> Result<CliConfig, String> {
if let Ok(token) = std::env::var("TASKR_TOKEN") {
let token = token.trim().to_string();
if !token.is_empty() {
return Ok(CliConfig { token });
}
}
let path = config_path()?;
let body = fs::read_to_string(path)
.map_err(|_| "Run `taskr login` first or set TASKR_TOKEN.".to_string())?;
serde_json::from_str(&body).map_err(|error| error.to_string())
}
#[cfg(unix)]
fn set_permissions(path: &Path, mode: u32) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(|error| error.to_string())
}
#[cfg(not(unix))]
fn set_permissions(_path: &Path, _mode: u32) -> Result<(), String> {
Ok(())
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use super::{TodoItem, format_todo_line, format_todo_selector};
#[test]
fn task_output_hides_internal_number_and_top_level_label() {
let todo = TodoItem {
number: 42,
title: "Buy milk".to_string(),
description: None,
workspace: None,
due_at: None,
checked_at: None,
created_at: Utc::now(),
};
assert_eq!(format_todo_line(&todo), "Buy milk");
assert_eq!(format_todo_selector(&todo), "Buy milk");
assert!(!format_todo_line(&todo).contains("42"));
assert!(!format_todo_selector(&todo).contains("unassigned"));
}
}