mod api;
mod cli;
mod config;
mod constants;
mod models;
mod output;
mod utils;
use std::env;
use std::process::ExitCode;
use clap::Parser;
use api::{
AuthHandler, CreateProjectRequest, CreateTaskRequest, TickTickClient, UpdateProjectRequest,
UpdateTaskRequest,
};
use cli::project::ProjectCommands;
use cli::subtask::SubtaskCommands;
use cli::task::TaskCommands;
use cli::{Cli, Commands};
use config::{Config, TokenStorage};
use constants::{ENV_CLIENT_ID, ENV_CLIENT_SECRET};
use models::{ChecklistItemRequest, Priority, Status};
use output::json::{
JsonResponse, ProjectData, ProjectListData, SubtaskListData, TaskData, TaskListData,
VersionData,
};
use output::text;
use output::OutputFormat;
use utils::date_parser::parse_date;
const APP_NAME: &str = env!("CARGO_PKG_NAME");
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
#[tokio::main]
async fn main() -> ExitCode {
let _ = dotenvy::dotenv();
let cli = Cli::parse();
let format = if cli.json {
OutputFormat::Json
} else {
OutputFormat::Text
};
let result = run_command(cli.command, format, cli.quiet).await;
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
if !cli.quiet {
eprintln!("{}", e);
}
ExitCode::FAILURE
}
}
}
async fn run_command(command: Commands, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
match command {
Commands::Init => cmd_init(format, quiet).await,
Commands::Reset { force } => cmd_reset(force, format, quiet),
Commands::Version => cmd_version(format, quiet),
Commands::Project(cmd) => cmd_project(cmd, format, quiet).await,
Commands::Task(cmd) => cmd_task(cmd, format, quiet).await,
Commands::Subtask(cmd) => cmd_subtask(cmd, format, quiet).await,
}
}
async fn cmd_init(format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
if TokenStorage::exists()? {
let message =
"Already authenticated. Use 'tickrs reset' to clear credentials and re-authenticate.";
if !quiet {
output_message(format, message, "ALREADY_INITIALIZED")?;
}
return Ok(());
}
let client_id = env::var(ENV_CLIENT_ID).map_err(|_| {
anyhow::anyhow!(
"Missing {} environment variable. Set it to your TickTick OAuth client ID.",
ENV_CLIENT_ID
)
})?;
let client_secret = env::var(ENV_CLIENT_SECRET).map_err(|_| {
anyhow::anyhow!(
"Missing {} environment variable. Set it to your TickTick OAuth client secret.",
ENV_CLIENT_SECRET
)
})?;
let auth = AuthHandler::new(client_id, client_secret);
let (auth_url, _) = auth.get_auth_url()?;
if !quiet && format == OutputFormat::Text {
println!("Opening browser for TickTick authorization...");
println!();
println!("If the browser doesn't open, visit this URL:");
println!("{}", auth_url);
println!();
}
let token = auth.run_oauth_flow().await?;
TokenStorage::save(&token)?;
let config = Config::default();
config.save()?;
let message = "Authentication successful";
if !quiet {
output_message(format, message, "SUCCESS")?;
}
Ok(())
}
fn cmd_reset(force: bool, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
let token_exists = TokenStorage::exists()?;
let config_path = Config::config_path()?;
let config_exists = config_path.exists();
if !token_exists && !config_exists {
let message = "Nothing to reset - no configuration or token found";
if !quiet {
output_message(format, message, "NOTHING_TO_RESET")?;
}
return Ok(());
}
if !force && format == OutputFormat::Text {
println!("This will delete your stored credentials and configuration.");
println!("You will need to re-authenticate with 'tickrs init'.");
print!("Continue? [y/N] ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Aborted.");
return Ok(());
}
}
if token_exists {
TokenStorage::delete()?;
}
if config_exists {
Config::delete()?;
}
let message = "Configuration and credentials cleared";
if !quiet {
output_message(format, message, "SUCCESS")?;
}
Ok(())
}
fn cmd_version(format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
if quiet {
return Ok(());
}
match format {
OutputFormat::Json => {
let data = VersionData {
name: APP_NAME.to_string(),
version: APP_VERSION.to_string(),
};
let response = JsonResponse::success(data);
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!("{}", text::format_version(APP_NAME, APP_VERSION));
}
}
Ok(())
}
fn output_message(format: OutputFormat, message: &str, code: &str) -> anyhow::Result<()> {
match format {
OutputFormat::Json => {
let response = JsonResponse::success_with_message(serde_json::json!({}), message);
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
if code == "SUCCESS" {
println!("{}", text::format_success(message));
} else {
println!("{}", message);
}
}
}
Ok(())
}
async fn cmd_project(
cmd: ProjectCommands,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
match cmd {
ProjectCommands::List => cmd_project_list(format, quiet).await,
ProjectCommands::Show { id } => cmd_project_show(&id, format, quiet).await,
ProjectCommands::Use { name_or_id } => cmd_project_use(&name_or_id, format, quiet).await,
ProjectCommands::Create {
name,
color,
view_mode,
kind,
} => cmd_project_create(&name, color, view_mode, kind, format, quiet).await,
ProjectCommands::Update {
id,
name,
color,
closed,
} => cmd_project_update(&id, name, color, closed, format, quiet).await,
ProjectCommands::Delete { id, force } => {
cmd_project_delete(&id, force, format, quiet).await
}
}
}
async fn cmd_project_list(format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
let client = TickTickClient::new()?;
let projects = client.list_projects().await?;
if quiet {
return Ok(());
}
match format {
OutputFormat::Json => {
let data = ProjectListData { projects };
let response = JsonResponse::success(data);
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!("{}", text::format_project_list(&projects));
}
}
Ok(())
}
async fn cmd_project_show(id: &str, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
let client = TickTickClient::new()?;
let project = client.get_project(id).await?;
if quiet {
return Ok(());
}
match format {
OutputFormat::Json => {
let data = ProjectData { project };
let response = JsonResponse::success(data);
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!("{}", text::format_project_details(&project));
}
}
Ok(())
}
async fn cmd_project_use(
name_or_id: &str,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
let client = TickTickClient::new()?;
let projects = client.list_projects().await?;
let project = projects
.iter()
.find(|p| p.id == name_or_id || p.name.eq_ignore_ascii_case(name_or_id))
.ok_or_else(|| anyhow::anyhow!("Project not found: {}", name_or_id))?;
let mut config = Config::load()?;
config.default_project_id = Some(project.id.clone());
config.save()?;
if quiet {
return Ok(());
}
let message = format!("Default project set to '{}'", project.name);
match format {
OutputFormat::Json => {
let data = ProjectData {
project: project.clone(),
};
let response = JsonResponse::success_with_message(data, &message);
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!("{}", text::format_success(&message));
}
}
Ok(())
}
async fn cmd_project_create(
name: &str,
color: Option<String>,
view_mode: Option<String>,
kind: Option<String>,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
let client = TickTickClient::new()?;
let request = CreateProjectRequest {
name: name.to_string(),
color,
view_mode,
kind,
};
let project = client.create_project(&request).await?;
if quiet {
return Ok(());
}
match format {
OutputFormat::Json => {
let data = ProjectData { project };
let response = JsonResponse::success_with_message(data, "Project created successfully");
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!(
"{}",
text::format_success_with_id("Project created", &project.id)
);
}
}
Ok(())
}
async fn cmd_project_update(
id: &str,
name: Option<String>,
color: Option<String>,
closed: Option<bool>,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
let client = TickTickClient::new()?;
let request = UpdateProjectRequest {
name,
color,
closed,
view_mode: None,
};
let project = client.update_project(id, &request).await?;
if quiet {
return Ok(());
}
match format {
OutputFormat::Json => {
let data = ProjectData { project };
let response = JsonResponse::success_with_message(data, "Project updated successfully");
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!(
"{}",
text::format_success_with_id("Project updated", &project.id)
);
}
}
Ok(())
}
async fn cmd_project_delete(
id: &str,
force: bool,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
if !force && format == OutputFormat::Text {
print!("Delete project '{}'? [y/N] ", id);
std::io::Write::flush(&mut std::io::stdout())?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Aborted.");
return Ok(());
}
}
let client = TickTickClient::new()?;
client.delete_project(id).await?;
if quiet {
return Ok(());
}
let message = "Project deleted successfully";
match format {
OutputFormat::Json => {
let response = JsonResponse::success_with_message(serde_json::json!({}), message);
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!("{}", text::format_success(message));
}
}
Ok(())
}
async fn cmd_task(cmd: TaskCommands, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
match cmd {
TaskCommands::List {
project_id,
project_name,
priority,
tag,
status,
} => {
cmd_task_list(
project_id,
project_name,
priority,
tag,
status,
format,
quiet,
)
.await
}
TaskCommands::Show {
id,
project_id,
project_name,
} => cmd_task_show(&id, project_id, project_name, format, quiet).await,
TaskCommands::Create {
title,
project_id,
project_name,
content,
priority,
tags,
date,
start,
due,
all_day,
timezone,
items,
} => {
cmd_task_create(
&title,
project_id,
project_name,
content,
priority,
tags,
date,
start,
due,
all_day,
timezone,
items,
format,
quiet,
)
.await
}
TaskCommands::Update {
id,
project_id,
project_name,
title,
content,
priority,
tags,
date,
start,
due,
all_day,
timezone,
items,
} => {
cmd_task_update(
&id,
project_id,
project_name,
title,
content,
priority,
tags,
date,
start,
due,
all_day,
timezone,
items,
format,
quiet,
)
.await
}
TaskCommands::Delete {
id,
project_id,
project_name,
force,
} => cmd_task_delete(&id, project_id, project_name, force, format, quiet).await,
TaskCommands::Complete {
id,
project_id,
project_name,
} => cmd_task_complete(&id, project_id, project_name, format, quiet).await,
TaskCommands::Uncomplete {
id,
project_id,
project_name,
} => cmd_task_uncomplete(&id, project_id, project_name, format, quiet).await,
}
}
async fn resolve_project_name(name: &str) -> anyhow::Result<String> {
let client = TickTickClient::new()?;
let projects = client.list_projects().await?;
let project = projects
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
.ok_or_else(|| anyhow::anyhow!("Project not found: {}", name))?;
Ok(project.id.clone())
}
async fn get_project_id(
project_id: Option<String>,
project_name: Option<String>,
) -> anyhow::Result<String> {
match (project_id, project_name) {
(Some(_), Some(_)) => {
anyhow::bail!("Cannot specify both --project-id and --project-name")
}
(Some(id), None) => Ok(id),
(None, Some(name)) => resolve_project_name(&name).await,
(None, None) => {
let config = Config::load()?;
config.default_project_id.ok_or_else(|| {
anyhow::anyhow!(
"No project specified. Use --project-id, --project-name, or set a default with 'tickrs project use <name>'"
)
})
}
}
}
async fn cmd_task_list(
project_id: Option<String>,
project_name: Option<String>,
priority_filter: Option<Priority>,
tag_filter: Option<String>,
status_filter: Option<String>,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
let project_id = get_project_id(project_id, project_name).await?;
let client = TickTickClient::new()?;
let mut tasks = client.list_tasks(&project_id).await?;
if let Some(priority) = priority_filter {
tasks.retain(|t| t.priority == priority);
}
if let Some(ref tag) = tag_filter {
let tag_lower = tag.to_lowercase();
tasks.retain(|t| t.tags.iter().any(|tt| tt.to_lowercase() == tag_lower));
}
if let Some(ref status) = status_filter {
let status_lower = status.to_lowercase();
match status_lower.as_str() {
"complete" | "completed" | "done" => {
tasks.retain(|t| t.status == Status::Complete);
}
"incomplete" | "pending" | "open" => {
tasks.retain(|t| t.status == Status::Normal);
}
_ => {
anyhow::bail!(
"Invalid status filter: {}. Use 'complete' or 'incomplete'",
status
);
}
}
}
if quiet {
return Ok(());
}
match format {
OutputFormat::Json => {
let count = tasks.len();
let data = TaskListData { tasks, count };
let response = JsonResponse::success(data);
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!("{}", text::format_task_list(&tasks));
}
}
Ok(())
}
async fn cmd_task_show(
task_id: &str,
project_id: Option<String>,
project_name: Option<String>,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
let project_id = get_project_id(project_id, project_name).await?;
let client = TickTickClient::new()?;
let task = client.get_task(&project_id, task_id).await?;
if quiet {
return Ok(());
}
match format {
OutputFormat::Json => {
let data = TaskData { task };
let response = JsonResponse::success(data);
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!("{}", text::format_task_details(&task));
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn cmd_task_create(
title: &str,
project_id: Option<String>,
project_name: Option<String>,
content: Option<String>,
priority: Option<Priority>,
tags: Option<String>,
date: Option<String>,
start: Option<String>,
due: Option<String>,
all_day: bool,
timezone: Option<String>,
items: Option<String>,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
let project_id = get_project_id(project_id, project_name).await?;
let (start_date, due_date) = parse_task_dates(date, start, due)?;
let tags_vec = tags.map(|t| t.split(',').map(|s| s.trim().to_string()).collect());
let items_vec = items.map(|i| {
i.split(',')
.enumerate()
.map(|(idx, s)| ChecklistItemRequest::new(s.trim()).with_sort_order(idx as i64))
.collect()
});
let request = CreateTaskRequest {
title: title.to_string(),
project_id: project_id.clone(),
content,
is_all_day: if all_day { Some(true) } else { None },
start_date,
due_date,
priority: priority.map(|p| p.to_api_value()),
time_zone: timezone,
tags: tags_vec,
items: items_vec,
};
let client = TickTickClient::new()?;
let task = client.create_task(&request).await?;
if quiet {
return Ok(());
}
match format {
OutputFormat::Json => {
let data = TaskData { task };
let response = JsonResponse::success_with_message(data, "Task created successfully");
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!("{}", text::format_success_with_id("Task created", &task.id));
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn cmd_task_update(
task_id: &str,
project_id: Option<String>,
project_name: Option<String>,
title: Option<String>,
content: Option<String>,
priority: Option<Priority>,
tags: Option<String>,
date: Option<String>,
start: Option<String>,
due: Option<String>,
all_day: Option<bool>,
timezone: Option<String>,
items: Option<String>,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
let project_id = get_project_id(project_id, project_name).await?;
let (start_date, due_date) = parse_task_dates(date, start, due)?;
let tags_vec = tags.map(|t| t.split(',').map(|s| s.trim().to_string()).collect());
let items_vec = items.map(|i| {
i.split(',')
.enumerate()
.map(|(idx, s)| ChecklistItemRequest::new(s.trim()).with_sort_order(idx as i64))
.collect()
});
let request = UpdateTaskRequest {
id: task_id.to_string(),
project_id: project_id.clone(),
title,
content,
is_all_day: all_day,
start_date,
due_date,
priority: priority.map(|p| p.to_api_value()),
time_zone: timezone,
tags: tags_vec,
status: None,
items: items_vec,
};
let client = TickTickClient::new()?;
let task = client.update_task(task_id, &request).await?;
if quiet {
return Ok(());
}
match format {
OutputFormat::Json => {
let data = TaskData { task };
let response = JsonResponse::success_with_message(data, "Task updated successfully");
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!("{}", text::format_success_with_id("Task updated", &task.id));
}
}
Ok(())
}
async fn cmd_task_delete(
task_id: &str,
project_id: Option<String>,
project_name: Option<String>,
force: bool,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
let project_id = get_project_id(project_id, project_name).await?;
if !force && format == OutputFormat::Text {
print!("Delete task '{}'? [y/N] ", task_id);
std::io::Write::flush(&mut std::io::stdout())?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Aborted.");
return Ok(());
}
}
let client = TickTickClient::new()?;
client.delete_task(&project_id, task_id).await?;
if quiet {
return Ok(());
}
let message = "Task deleted successfully";
match format {
OutputFormat::Json => {
let response = JsonResponse::success_with_message(serde_json::json!({}), message);
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!("{}", text::format_success(message));
}
}
Ok(())
}
async fn cmd_task_complete(
task_id: &str,
project_id: Option<String>,
project_name: Option<String>,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
let project_id = get_project_id(project_id, project_name).await?;
let client = TickTickClient::new()?;
client.complete_task(&project_id, task_id).await?;
if quiet {
return Ok(());
}
let message = "Task marked as complete";
match format {
OutputFormat::Json => {
let response = JsonResponse::success_with_message(serde_json::json!({}), message);
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!("{}", text::format_success(message));
}
}
Ok(())
}
async fn cmd_task_uncomplete(
task_id: &str,
project_id: Option<String>,
project_name: Option<String>,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
let project_id = get_project_id(project_id, project_name).await?;
let client = TickTickClient::new()?;
let task = client.uncomplete_task(&project_id, task_id).await?;
if quiet {
return Ok(());
}
match format {
OutputFormat::Json => {
let data = TaskData { task };
let response = JsonResponse::success_with_message(data, "Task marked as incomplete");
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!("{}", text::format_success("Task marked as incomplete"));
}
}
Ok(())
}
fn parse_task_dates(
date: Option<String>,
start: Option<String>,
due: Option<String>,
) -> anyhow::Result<(Option<String>, Option<String>)> {
if let Some(date_str) = date {
let dt = parse_date(&date_str)?;
let formatted = dt.format("%Y-%m-%dT%H:%M:%S%z").to_string();
return Ok((Some(formatted.clone()), Some(formatted)));
}
let start_date = start
.map(|s| parse_date(&s).map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%z").to_string()))
.transpose()?;
let due_date = due
.map(|s| parse_date(&s).map(|dt| dt.format("%Y-%m-%dT%H:%M:%S%z").to_string()))
.transpose()?;
Ok((start_date, due_date))
}
async fn cmd_subtask(
cmd: SubtaskCommands,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
match cmd {
SubtaskCommands::List {
task_id,
project_id,
project_name,
} => cmd_subtask_list(&task_id, project_id, project_name, format, quiet).await,
}
}
async fn cmd_subtask_list(
task_id: &str,
project_id: Option<String>,
project_name: Option<String>,
format: OutputFormat,
quiet: bool,
) -> anyhow::Result<()> {
let project_id = get_project_id(project_id, project_name).await?;
let client = TickTickClient::new()?;
let task = client.get_task(&project_id, task_id).await?;
let subtasks = task.items;
if quiet {
return Ok(());
}
match format {
OutputFormat::Json => {
let count = subtasks.len();
let data = SubtaskListData { subtasks, count };
let response = JsonResponse::success(data);
println!("{}", response.to_json_string());
}
OutputFormat::Text => {
println!("{}", text::format_subtask_list(&subtasks));
}
}
Ok(())
}