Skip to main content

asana_cli/cli/
mod.rs

1//! Command-line interface entry points for the Asana CLI.
2
3mod custom_field;
4mod project;
5mod section;
6mod tag;
7mod task;
8mod user;
9mod workspace;
10
11use crate::api::{ApiClient, ApiError, AuthToken};
12use crate::config::Config;
13use anyhow::{Context, Result, anyhow};
14use clap::{Parser, Subcommand};
15use custom_field::CustomFieldCommand;
16use project::ProjectCommand;
17use secrecy::SecretString;
18use section::SectionCommand;
19use serde_json::Value;
20use tag::TagCommand;
21use task::TaskCommand;
22use tftio_cli_common::{
23    AgentCapability, AgentSurfaceSpec, CommandSelector, DoctorCheck, DoctorChecks, FatalCliError,
24    FlagSelector, JsonOutput, LicenseType, MetaCommand, RepoInfo, StandardCommand, ToolSpec,
25    map_standard_command, run_cli_from, workspace_tool,
26};
27use tokio::runtime::Builder as RuntimeBuilder;
28use tracing::debug;
29use user::UserCommand;
30use workspace::WorkspaceCommand;
31
32const VERSION: &str = match option_env!("CARGO_PKG_VERSION") {
33    Some(version) => version,
34    None => "unknown",
35};
36
37const CONFIG_COMMAND: CommandSelector = CommandSelector::new(&["config"]);
38const TASK_COMMAND: CommandSelector = CommandSelector::new(&["task"]);
39const PROJECT_COMMAND: CommandSelector = CommandSelector::new(&["project"]);
40const SECTION_COMMAND: CommandSelector = CommandSelector::new(&["section"]);
41const TAG_COMMAND: CommandSelector = CommandSelector::new(&["tag"]);
42const CUSTOM_FIELD_COMMAND: CommandSelector = CommandSelector::new(&["custom-field"]);
43const WORKSPACE_COMMAND: CommandSelector = CommandSelector::new(&["workspace"]);
44const USER_COMMAND: CommandSelector = CommandSelector::new(&["user"]);
45
46const CONFIG_TOKEN_FLAG: FlagSelector = FlagSelector::new(&["config", "set", "token"], "token");
47const CONFIG_WORKSPACE_FLAG: FlagSelector =
48    FlagSelector::new(&["config", "set", "workspace"], "workspace");
49const TASK_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["task"], "workspace");
50const TASK_PARENT_FLAG: FlagSelector = FlagSelector::new(&["task", "list"], "parent");
51const PROJECT_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["project"], "workspace");
52const SECTION_PROJECT_FLAG: FlagSelector = FlagSelector::new(&["section"], "project");
53const TAG_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["tag"], "workspace");
54const CUSTOM_FIELD_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["custom-field"], "workspace");
55const USER_WORKSPACE_FLAG: FlagSelector = FlagSelector::new(&["user"], "workspace");
56
57const MANAGE_CONFIG_CAPABILITY: AgentCapability = AgentCapability::new(
58    "manage-config",
59    "Read or update persisted Asana CLI configuration",
60    &[CONFIG_COMMAND],
61    &[CONFIG_TOKEN_FLAG, CONFIG_WORKSPACE_FLAG],
62)
63.with_examples(&[
64    "asana-cli config get",
65    "asana-cli config set token --token <PAT>",
66    "asana-cli config set workspace --workspace <GID>",
67    "asana-cli config set workspace --clear-workspace",
68    "asana-cli config set assignee --assignee <USER>",
69    "asana-cli config test",
70])
71.with_output("prints confirmation lines or redacted stored configuration values")
72.with_constraints("writes use the configured config home and config test calls the Asana API")
73.with_when_to_use(
74    "the user needs to configure or inspect the asana-cli personal access token or default workspace",
75)
76.with_when_not_to_use(
77    "the user is performing operational Asana work; use the task, project, or workspace capabilities instead",
78);
79
80const MANAGE_TASKS_CAPABILITY: AgentCapability = AgentCapability::new(
81    "manage-tasks",
82    "Create, inspect, and update Asana tasks",
83    &[TASK_COMMAND],
84    &[TASK_WORKSPACE_FLAG, TASK_PARENT_FLAG],
85)
86.with_examples(&[
87    "asana-cli task list --workspace <GID>",
88    "asana-cli task list --parent <TASK>",
89    "asana-cli task show <TASK>",
90    "asana-cli task create --workspace <GID> --name <NAME>",
91    "asana-cli task update <TASK> --completed true",
92    "asana-cli task update <TASK> --parent <TASK2>",
93    "asana-cli task update <TASK> --clear-due-on",
94    "asana-cli task delete <TASK>",
95    "asana-cli task search --workspace <GID> --query <TEXT>",
96    "asana-cli task <TASK> tags add --tag <TAG>",
97    "asana-cli task <TASK> tags list",
98    "asana-cli task <TASK> projects add --project <PROJECT>",
99    "asana-cli task <TASK> followers add --follower <USER>",
100    "asana-cli task <TASK> depends-on add --dependency <TASK2>",
101    "asana-cli task <TASK> blocks list",
102    "asana-cli task <TASK> comments create --text <BODY>",
103    "asana-cli task <TASK> comments list",
104    "asana-cli task <TASK> attachments create --file <PATH>",
105    "asana-cli task <TASK> attachments download <ATTACHMENT> --output <PATH>",
106    "asana-cli task <TASK> move-to-section <SECTION>",
107])
108.with_output("prints task tables, summaries, or JSON payloads produced by task commands")
109.with_constraints("task commands require a stored personal access token and valid task identifiers")
110.with_when_to_use("the user wants to list, inspect, create, or update Asana tasks")
111.with_when_not_to_use(
112    "the user is asking about projects, sections, tags, or workspaces rather than tasks",
113);
114
115const MANAGE_PROJECTS_CAPABILITY: AgentCapability = AgentCapability::new(
116    "manage-projects",
117    "Inspect and manage Asana projects",
118    &[PROJECT_COMMAND],
119    &[PROJECT_WORKSPACE_FLAG],
120)
121.with_examples(&[
122    "asana-cli project list --workspace <GID>",
123    "asana-cli project show <PROJECT>",
124    "asana-cli project create --workspace <GID> --name <NAME> --color light-green",
125    "asana-cli project update <PROJECT> --archived true",
126    "asana-cli project update <PROJECT> --clear-due-on",
127    "asana-cli project delete <PROJECT>",
128    "asana-cli project <PROJECT> members list",
129    "asana-cli project <PROJECT> members add <USER>",
130    "asana-cli project <PROJECT> members update --member <USER> --role commenter",
131])
132.with_output("prints project listings, detail blocks, and mutation confirmations")
133.with_constraints("project commands require API-authenticated access to the target workspace")
134.with_when_to_use("the user wants to list, inspect, or modify Asana projects")
135.with_when_not_to_use(
136    "the user is asking about tasks within a project, sections, or workspace-level metadata",
137);
138
139const MANAGE_SECTIONS_CAPABILITY: AgentCapability = AgentCapability::new(
140    "manage-sections",
141    "List or modify sections within Asana projects",
142    &[SECTION_COMMAND],
143    &[SECTION_PROJECT_FLAG],
144)
145.with_examples(&[
146    "asana-cli section list --project <PROJECT>",
147    "asana-cli section show <SECTION>",
148    "asana-cli section create --project <PROJECT> --name <NAME>",
149    "asana-cli section update <SECTION> --name <NAME>",
150    "asana-cli section delete <SECTION>",
151    "asana-cli section <SECTION> tasks list",
152])
153.with_output("prints section records and success messages from section operations")
154.with_constraints("section commands operate inside a project and require a resolvable project gid")
155.with_when_to_use("the user wants to list or modify sections inside a known Asana project")
156.with_when_not_to_use(
157    "the user does not have a project gid or wants to operate on tasks rather than sections",
158);
159
160const MANAGE_TAGS_CAPABILITY: AgentCapability = AgentCapability::new(
161    "manage-tags",
162    "Inspect and maintain Asana tags",
163    &[TAG_COMMAND],
164    &[TAG_WORKSPACE_FLAG],
165)
166.with_examples(&[
167    "asana-cli tag list --workspace <GID>",
168    "asana-cli tag show <TAG>",
169    "asana-cli tag create --workspace <GID> --name <NAME> --color dark-blue",
170    "asana-cli tag update <TAG> --name <NAME>",
171    "asana-cli tag update <TAG> --clear-notes",
172    "asana-cli tag delete <TAG>",
173])
174.with_output("prints tag collections, tag detail records, or mutation confirmations")
175.with_constraints("tag commands require workspace access and valid tag identifiers")
176.with_when_to_use("the user wants to inspect or maintain Asana tags within a workspace")
177.with_when_not_to_use(
178    "the user wants to add or remove tags on a specific task; use the task capability for that",
179);
180
181const MANAGE_CUSTOM_FIELDS_CAPABILITY: AgentCapability = AgentCapability::new(
182    "manage-custom-fields",
183    "Inspect and manage Asana custom fields",
184    &[CUSTOM_FIELD_COMMAND],
185    &[CUSTOM_FIELD_WORKSPACE_FLAG],
186)
187.with_examples(&[
188    "asana-cli custom-field list --workspace <GID>",
189    "asana-cli custom-field show <FIELD>",
190])
191.with_output("prints custom field definitions and update confirmations")
192.with_constraints("custom field commands require workspace-scoped API access")
193.with_when_to_use(
194    "the user wants to inspect or update the custom field schema for an Asana workspace",
195)
196.with_when_not_to_use(
197    "the user wants to set custom field values on a task; use the task capability for that",
198);
199
200const MANAGE_WORKSPACES_CAPABILITY: AgentCapability = AgentCapability::new(
201    "manage-workspaces",
202    "Inspect available Asana workspaces",
203    &[WORKSPACE_COMMAND],
204    &[],
205)
206.with_examples(&[
207    "asana-cli workspace list",
208    "asana-cli workspace show <WORKSPACE>",
209])
210.with_output("prints workspace listings or detail records from the API")
211.with_constraints("workspace commands require a valid stored personal access token")
212.with_when_to_use("the user wants to list available Asana workspaces or inspect a workspace by gid")
213.with_when_not_to_use(
214    "the user wants to operate on entities inside a workspace rather than enumerate workspaces",
215);
216
217const MANAGE_USERS_CAPABILITY: AgentCapability = AgentCapability::new(
218    "manage-users",
219    "Inspect Asana users and memberships",
220    &[USER_COMMAND],
221    &[USER_WORKSPACE_FLAG],
222)
223.with_examples(&[
224    "asana-cli user show me",
225    "asana-cli user show <USER>",
226    "asana-cli user list --workspace <GID>",
227])
228.with_output("prints user records, user lists, and membership-related summaries")
229.with_constraints("user commands require API-authenticated access to the target workspace")
230.with_when_to_use("the user wants to inspect Asana users or memberships in a workspace")
231.with_when_not_to_use("the user wants to assign tasks to users; use the task capability for that");
232
233const ASANA_AGENT_SURFACE: AgentSurfaceSpec = AgentSurfaceSpec::new(&[
234    MANAGE_CONFIG_CAPABILITY,
235    MANAGE_TASKS_CAPABILITY,
236    MANAGE_PROJECTS_CAPABILITY,
237    MANAGE_SECTIONS_CAPABILITY,
238    MANAGE_TAGS_CAPABILITY,
239    MANAGE_CUSTOM_FIELDS_CAPABILITY,
240    MANAGE_WORKSPACES_CAPABILITY,
241    MANAGE_USERS_CAPABILITY,
242]);
243
244struct AsanaCliDoctor;
245
246impl DoctorChecks for AsanaCliDoctor {
247    fn repo_info() -> RepoInfo {
248        RepoInfo::new("tftio-stuff", "tools")
249    }
250
251    fn current_version() -> &'static str {
252        VERSION
253    }
254
255    fn tool_checks(&self) -> Vec<DoctorCheck> {
256        crate::doctor::tool_specific_checks()
257    }
258}
259
260const TOOL_SPEC: ToolSpec = workspace_tool(
261    "asana-cli",
262    "Asana CLI",
263    VERSION,
264    LicenseType::MIT,
265    false,
266    true,
267)
268.with_agent_surface(&ASANA_AGENT_SURFACE);
269
270#[derive(Parser, Debug)]
271#[command(name = "asana-cli")]
272#[command(about = "An interface to the Asana API")]
273#[command(version = VERSION)]
274struct Cli {
275    /// Subcommand to execute.
276    #[command(subcommand)]
277    command: Commands,
278}
279
280#[derive(Subcommand, Debug)]
281enum Commands {
282    /// Shared metadata commands.
283    Meta {
284        /// Shared metadata command to execute.
285        #[command(subcommand)]
286        command: MetaCommand,
287    },
288    /// Manage persisted configuration.
289    Config {
290        #[command(subcommand)]
291        command: ConfigCommand,
292    },
293    /// Task operations.
294    Task {
295        #[command(subcommand)]
296        command: Box<TaskCommand>,
297    },
298    /// Project operations.
299    Project {
300        #[command(subcommand)]
301        command: Box<ProjectCommand>,
302    },
303    /// Section operations.
304    Section {
305        #[command(subcommand)]
306        command: Box<SectionCommand>,
307    },
308    /// Tag operations.
309    Tag {
310        #[command(subcommand)]
311        command: Box<TagCommand>,
312    },
313    /// Custom field operations.
314    #[command(name = "custom-field")]
315    CustomField {
316        #[command(subcommand)]
317        command: Box<CustomFieldCommand>,
318    },
319    /// Workspace operations.
320    Workspace {
321        #[command(subcommand)]
322        command: Box<WorkspaceCommand>,
323    },
324    /// User operations.
325    User {
326        #[command(subcommand)]
327        command: Box<UserCommand>,
328    },
329}
330
331#[derive(Subcommand, Debug)]
332enum ConfigCommand {
333    /// Store configuration values.
334    Set {
335        #[command(subcommand)]
336        command: ConfigSetCommand,
337    },
338    /// Display the current configuration (token redacted).
339    Get,
340    /// Validate the stored Personal Access Token against the Asana API.
341    Test,
342}
343
344#[derive(Subcommand, Debug)]
345enum ConfigSetCommand {
346    /// Store the Personal Access Token.
347    Token {
348        /// Personal Access Token value; omit to be prompted securely.
349        #[arg(long)]
350        token: Option<String>,
351    },
352    /// Store the default workspace gid.
353    Workspace {
354        /// Workspace gid to use when none is supplied on the command line.
355        #[arg(long, value_name = "GID")]
356        workspace: Option<String>,
357        /// Clear the stored default workspace.
358        #[arg(long = "clear-workspace")]
359        clear_workspace: bool,
360    },
361    /// Store the default assignee identifier.
362    Assignee {
363        /// Identifier (email or gid) that should replace the `me` alias.
364        #[arg(long, value_name = "ID")]
365        assignee: Option<String>,
366        /// Clear the stored default assignee.
367        #[arg(long = "clear-assignee")]
368        clear_assignee: bool,
369    },
370    /// Store the default project identifier.
371    Project {
372        /// Project gid to use when none is supplied on the command line.
373        #[arg(long, value_name = "GID")]
374        project: Option<String>,
375        /// Clear the stored default project.
376        #[arg(long = "clear-project")]
377        clear_project: bool,
378    },
379}
380
381/// Binary entrypoint for the Asana CLI.
382///
383/// Pre-processes argv to translate the subject-first instance dispatch shape
384/// (e.g. `task <TASK> tags add --tag <TAG>`) into the verb-first shape that
385/// the underlying clap structures parse, then delegates to the shared
386/// [`run_cli_from`] helper.
387#[must_use]
388pub fn run_cli_entrypoint() -> i32 {
389    let env = process_env();
390    let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
391    let argv = rewrite_subject_first(argv);
392    run_cli_from::<Cli, _, AsanaCliDoctor, _, _>(
393        &TOOL_SPEC,
394        &env,
395        argv,
396        &AsanaCliDoctor,
397        |cli| metadata_command(&cli.command),
398        |cli| {
399            run_domain(cli).map_err(|err| {
400                tracing::error!(error = %err, "command execution failed");
401                FatalCliError::new("asana-cli", JsonOutput::Text, err.to_string())
402            })
403        },
404    )
405}
406
407/// Read process-edge environment values once at the binary edge.
408#[allow(
409    clippy::disallowed_methods,
410    reason = "agent token / HOME read once at the process edge (REPO_INVARIANTS.md #5)"
411)]
412fn process_env() -> tftio_cli_common::ProcessEnv {
413    tftio_cli_common::ProcessEnv {
414        agent: tftio_cli_common::AgentModeContext::from_tokens(
415            std::env::var(tftio_cli_common::AGENT_TOKEN_ENV).ok(),
416            std::env::var(tftio_cli_common::AGENT_TOKEN_EXPECTED_ENV).ok(),
417        ),
418        home: std::env::var_os("HOME").map(std::path::PathBuf::from),
419    }
420}
421
422/// Rewrite a subject-first invocation into the verb-first form clap parses.
423///
424/// Examples:
425/// - `task T1 tags add --tag X`        → `task tags add T1 --tag X`
426/// - `task T1 move-to-section S1`      → `task move-to-section T1 S1`
427/// - `task T1 comments show C1`        → `task comments show C1` (TASK dropped:
428///   the inner verb addresses the comment by its own gid)
429/// - `project P1 members add U1`        → `project members add P1 U1`
430/// - `section S1 tasks list`            → `section tasks list S1`
431///
432/// Verb-first invocations (`task list ...`, `project show P1`, etc.) and the
433/// metadata commands (`version`, `doctor`, `--help`, …) pass through unchanged.
434fn rewrite_subject_first(mut argv: Vec<std::ffi::OsString>) -> Vec<std::ffi::OsString> {
435    if argv.len() < 4 {
436        return argv;
437    }
438    let top_owned: String = match argv.get(1).and_then(|a| a.to_str()) {
439        Some(value) => value.to_string(),
440        None => return argv,
441    };
442    let top = top_owned.as_str();
443    let direct: &[&str] = match top {
444        "task" => &[
445            "list",
446            "show",
447            "create",
448            "update",
449            "delete",
450            "search",
451            "create-batch",
452            "update-batch",
453            "complete-batch",
454            "help",
455            "--help",
456            "-h",
457        ],
458        "project" | "section" => &[
459            "list", "show", "create", "update", "delete", "help", "--help", "-h",
460        ],
461        _ => return argv,
462    };
463    let instance: &[&str] = match top {
464        "task" => &[
465            "tags",
466            "projects",
467            "followers",
468            "depends-on",
469            "blocks",
470            "comments",
471            "attachments",
472            "move-to-section",
473        ],
474        "project" => &["members"],
475        "section" => &["tasks"],
476        _ => return argv,
477    };
478
479    let token_owned: String = match argv.get(2).and_then(|a| a.to_str()) {
480        Some(value) => value.to_string(),
481        None => return argv,
482    };
483    let token = token_owned.as_str();
484    if direct.contains(&token) || token.starts_with('-') {
485        return argv;
486    }
487    let verb_owned: String = match argv.get(3).and_then(|a| a.to_str()) {
488        Some(value) => value.to_string(),
489        None => return argv,
490    };
491    let verb = verb_owned.as_str();
492    if !instance.contains(&verb) {
493        return argv;
494    }
495
496    let sub_owned: Option<String> = argv.get(4).and_then(|v| v.to_str()).map(str::to_string);
497    let drop_subject = matches!(
498        (top, verb, sub_owned.as_deref()),
499        ("task", "comments", Some("show" | "update" | "delete"))
500            | ("task", "attachments", Some("show" | "download" | "delete"))
501    );
502
503    let subject = argv.remove(2);
504    if drop_subject {
505        return argv;
506    }
507
508    // Where to insert the subject in the verb-first argv:
509    // - task <verb> <sub> <SUBJECT> ...                   → position 4
510    // - task move-to-section <SUBJECT> <SECTION> ...      → position 3
511    // - project members <sub> <SUBJECT> ...               → position 4
512    // - section tasks list <SUBJECT>                      → position 4
513    let insert_at = match (top, verb) {
514        ("task", "move-to-section") => 3,
515        _ => 4,
516    };
517    if insert_at <= argv.len() {
518        argv.insert(insert_at, subject);
519    } else {
520        argv.push(subject);
521    }
522    argv
523}
524
525fn run_domain(cli: Cli) -> Result<i32> {
526    debug!(?cli, "parsed CLI arguments");
527
528    let mut config = Config::load(&crate::config::EnvInputs::from_env())?;
529    debug!(
530        config_path = %config.path().display(),
531        "configuration handle prepared"
532    );
533
534    let exit_code = match cli.command {
535        Commands::Meta { .. } => unreachable!("metadata commands are routed before dispatch"),
536        Commands::Config { command } => {
537            handle_config_command(command, &mut config)?;
538            0
539        }
540        Commands::Task { command } => {
541            task::handle_task_command(*command, &config)?;
542            0
543        }
544        Commands::Project { command } => {
545            handle_project_command(*command, &config)?;
546            0
547        }
548        Commands::Section { command } => {
549            handle_section_command(*command, &config)?;
550            0
551        }
552        Commands::Tag { command } => {
553            handle_tag_command(*command, &config)?;
554            0
555        }
556        Commands::CustomField { command } => {
557            handle_custom_field_command(*command, &config)?;
558            0
559        }
560        Commands::Workspace { command } => {
561            handle_workspace_command(*command, &config)?;
562            0
563        }
564        Commands::User { command } => {
565            handle_user_command(*command, &config)?;
566            0
567        }
568    };
569
570    Ok(exit_code)
571}
572
573fn metadata_command(command: &Commands) -> Option<StandardCommand> {
574    match command {
575        Commands::Meta { command } => Some(map_standard_command(command, JsonOutput::Text)),
576        Commands::Config { .. }
577        | Commands::Task { .. }
578        | Commands::Project { .. }
579        | Commands::Section { .. }
580        | Commands::Tag { .. }
581        | Commands::CustomField { .. }
582        | Commands::Workspace { .. }
583        | Commands::User { .. } => None,
584    }
585}
586
587fn handle_config_command(command: ConfigCommand, config: &mut Config) -> Result<()> {
588    match command {
589        ConfigCommand::Set { command } => handle_config_set(command, config),
590        ConfigCommand::Get => {
591            handle_config_get(config);
592            Ok(())
593        }
594        ConfigCommand::Test => handle_config_test(config),
595    }
596}
597
598fn handle_config_set(command: ConfigSetCommand, config: &mut Config) -> Result<()> {
599    match command {
600        ConfigSetCommand::Token { token } => {
601            let value = match token {
602                Some(value) => value,
603                None => rpassword::prompt_password("Enter Personal Access Token: ")
604                    .context("failed to read token from prompt")?,
605            };
606
607            if value.trim().is_empty() {
608                return Err(anyhow!("token value cannot be empty"));
609            }
610
611            let secret = SecretString::new(value.into());
612            config
613                .store_personal_access_token(&secret)
614                .context("failed to store Personal Access Token")?;
615            println!("Personal Access Token stored in configuration file.");
616            Ok(())
617        }
618        ConfigSetCommand::Workspace {
619            workspace,
620            clear_workspace,
621        } => {
622            if clear_workspace {
623                config
624                    .set_default_workspace(None)
625                    .context("failed to clear default workspace")?;
626                println!("Default workspace cleared.");
627                return Ok(());
628            }
629
630            let value = workspace
631                .as_deref()
632                .map(str::trim)
633                .filter(|value| !value.is_empty())
634                .ok_or_else(|| anyhow!("provide --workspace <gid> or use --clear"))?;
635
636            config
637                .set_default_workspace(Some(value.to_string()))
638                .context("failed to store default workspace")?;
639            println!("Default workspace stored in configuration file.");
640            Ok(())
641        }
642        ConfigSetCommand::Assignee {
643            assignee,
644            clear_assignee,
645        } => {
646            if clear_assignee {
647                config
648                    .set_default_assignee(None)
649                    .context("failed to clear default assignee")?;
650                println!("Default assignee cleared.");
651                return Ok(());
652            }
653
654            let value = assignee
655                .as_deref()
656                .map(str::trim)
657                .filter(|value| !value.is_empty())
658                .ok_or_else(|| anyhow!("provide --assignee <id> or use --clear"))?;
659
660            config
661                .set_default_assignee(Some(value.to_string()))
662                .context("failed to store default assignee")?;
663            println!("Default assignee stored in configuration file.");
664            Ok(())
665        }
666        ConfigSetCommand::Project {
667            project,
668            clear_project,
669        } => {
670            if clear_project {
671                config
672                    .set_default_project(None)
673                    .context("failed to clear default project")?;
674                println!("Default project cleared.");
675                return Ok(());
676            }
677
678            let value = project
679                .as_deref()
680                .map(str::trim)
681                .filter(|value| !value.is_empty())
682                .ok_or_else(|| anyhow!("provide --project <gid> or use --clear"))?;
683
684            config
685                .set_default_project(Some(value.to_string()))
686                .context("failed to store default project")?;
687            println!("Default project stored in configuration file.");
688            Ok(())
689        }
690    }
691}
692
693fn handle_config_get(config: &Config) {
694    println!("Configuration file: {}", config.path().display());
695    println!("API base URL: {}", config.effective_api_base_url());
696    println!(
697        "Default workspace: {}",
698        config
699            .default_workspace()
700            .filter(|workspace| !workspace.is_empty())
701            .unwrap_or("not set")
702    );
703    println!(
704        "Default assignee: {}",
705        config
706            .default_assignee()
707            .filter(|assignee| !assignee.is_empty())
708            .unwrap_or("not set")
709    );
710    println!(
711        "Default project: {}",
712        config
713            .default_project()
714            .filter(|project| !project.is_empty())
715            .unwrap_or("not set")
716    );
717
718    if let Some(_token) = config.personal_access_token() {
719        let status = if config.environment_token_available() {
720            "provided via environment variable"
721        } else if config.has_persisted_token() {
722            "stored in configuration file"
723        } else {
724            "available"
725        };
726        println!("Personal Access Token: {status}");
727    } else {
728        println!("Personal Access Token: not set");
729    }
730}
731
732fn handle_config_test(config: &Config) -> Result<()> {
733    let client = build_api_client(config)?;
734
735    let runtime = RuntimeBuilder::new_current_thread()
736        .enable_all()
737        .build()
738        .context("failed to initialise async runtime")?;
739
740    runtime.block_on(async move {
741        match client.get_current_user().await {
742            Ok(payload) => {
743                let user_name = payload
744                    .get("data")
745                    .and_then(|data| data.get("name"))
746                    .and_then(Value::as_str)
747                    .unwrap_or("unknown user");
748                println!("Personal Access Token validated for {user_name}.");
749                Ok(())
750            }
751            Err(ApiError::Authentication(_)) => Err(anyhow!(
752                "authentication failed; verify your Personal Access Token"
753            )),
754            Err(ApiError::RateLimited { retry_after, .. }) => Err(anyhow!(
755                "Asana rate limited the request. Retry after {:.1} seconds",
756                retry_after.as_secs_f32()
757            )),
758            Err(ApiError::Offline { .. }) => Err(anyhow!(
759                "offline mode enabled; disable offline mode to contact Asana"
760            )),
761            Err(err) => Err(anyhow!(err)),
762        }
763    })
764}
765
766pub(super) fn build_api_client(config: &Config) -> Result<ApiClient> {
767    let token = config.personal_access_token().ok_or_else(|| {
768        anyhow!("no Personal Access Token found; run `asana-cli config set token`")
769    })?;
770
771    let auth_token = AuthToken::new(token);
772    let cache_dir = config.cache_dir().to_path_buf();
773
774    let client = ApiClient::builder(auth_token)
775        .base_url(config.effective_api_base_url().to_string())
776        .cache_dir(cache_dir)
777        .build()?;
778
779    Ok(client)
780}
781
782fn handle_project_command(command: ProjectCommand, config: &Config) -> Result<()> {
783    project::handle_project_command(command, config)
784}
785
786fn handle_section_command(command: SectionCommand, config: &Config) -> Result<()> {
787    section::execute_section_command(command, config)
788}
789
790fn handle_tag_command(command: TagCommand, config: &Config) -> Result<()> {
791    tag::handle_tag_command(command, config)
792}
793
794fn handle_custom_field_command(command: CustomFieldCommand, config: &Config) -> Result<()> {
795    custom_field::handle_custom_field_command(command, config)
796}
797
798fn handle_workspace_command(command: WorkspaceCommand, config: &Config) -> Result<()> {
799    workspace::handle_workspace_command(command, config)
800}
801
802fn handle_user_command(command: UserCommand, config: &Config) -> Result<()> {
803    user::handle_user_command(command, config)
804}