From 7cdd7267f5d6a82e36cecfb372ca5cdbcb2a9566 Mon Sep 17 00:00:00 2001 From: Justin Smestad Date: Wed, 8 Apr 2026 00:11:42 -0600 Subject: [PATCH 1/4] Add reachability window create/update/delete commands --- README.md | 6 +- src/commands/windows.rs | 404 +++++++++++++++++++++++++++++++++++----- src/main.rs | 203 +++++++++++++++++++- tests/integration.rs | 17 ++ 4 files changed, 576 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 2870373..3e2e0bb 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ hd auth hd status hd availability hd windows +hd windows create --label "Focus" --mode busy --days "Mon-Fri" --start 09:00:00 --end 11:30:00 # Set yourself to busy for 2 hours hd busy 2h @@ -57,7 +58,10 @@ hd watch | `hd auth` | Authenticate via Device Flow (browser-based) | | `hd status` | Show your current availability | | `hd availability [--at ]` | Show availability resolution and next transition | -| `hd windows` | List configured reachability windows | +| `hd windows [list]` | List configured reachability windows | +| `hd windows create ...` | Create a reachability window | +| `hd windows update ...` | Update a reachability window | +| `hd windows delete ` | Delete a reachability window | | `hd whoami` | Show your authenticated identity | | `hd busy [duration]` | Set mode to busy | | `hd online` | Set mode to online | diff --git a/src/commands/windows.rs b/src/commands/windows.rs index 9c61064..cc57db2 100644 --- a/src/commands/windows.rs +++ b/src/commands/windows.rs @@ -1,4 +1,5 @@ -use anyhow::Result; +use anyhow::{bail, Result}; +use serde_json::Value; use crate::auth; use crate::client::GraphQLClient; @@ -24,10 +25,85 @@ query { } "#; -pub async fn run(api_url: &str, json: bool) -> Result<()> { +const CREATE_WINDOW_MUTATION: &str = r#" +mutation CreateReachabilityWindow($input: ReachabilityWindowInput!) { + createReachabilityWindow(input: $input) { + id + label + mode + days + startTime + endTime + alertsPolicy + autoActivate + priority + status + statusEmoji + statusText + snooze + } +} +"#; + +const UPDATE_WINDOW_MUTATION: &str = r#" +mutation UpdateReachabilityWindow($id: ID!, $input: ReachabilityWindowUpdateInput!) { + updateReachabilityWindow(id: $id, input: $input) { + id + label + mode + days + startTime + endTime + alertsPolicy + autoActivate + priority + status + statusEmoji + statusText + snooze + } +} +"#; + +const DELETE_WINDOW_MUTATION: &str = r#" +mutation DeleteReachabilityWindow($id: ID!) { + deleteReachabilityWindow(id: $id) { + id + label + mode + days + startTime + endTime + alertsPolicy + autoActivate + priority + status + statusEmoji + statusText + snooze + } +} +"#; + +#[derive(Clone, Debug, Default)] +pub struct WindowInputArgs { + pub label: Option, + pub mode: Option, + pub days: Option, + pub start: Option, + pub end: Option, + pub alerts_policy: Option, + pub priority: Option, + pub auto_activate: Option, + pub snooze: Option, + pub status: Option, + pub status_emoji: Option, + pub status_text: Option, +} + +pub async fn list(api_url: &str, json: bool) -> Result<()> { let token = auth::require_token()?; let client = GraphQLClient::new(api_url, &token); - let data = client.execute(WINDOWS_QUERY, None).await?; if json { @@ -53,61 +129,291 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { } for window in windows { - let id = window["id"].as_str().unwrap_or("-"); - let label = window["label"].as_str().unwrap_or("Unnamed"); - let mode = window["mode"].as_str().unwrap_or("UNKNOWN").to_uppercase(); - let days = window["days"].as_str().unwrap_or("-"); - let start = window["startTime"].as_str().unwrap_or("-"); - let end = window["endTime"].as_str().unwrap_or("-"); - let policy = window["alertsPolicy"].as_str().unwrap_or("-"); - let priority = window["priority"].as_i64().unwrap_or_default(); - let auto_activate = window["autoActivate"].as_bool().unwrap_or(false); - let status = window["status"].as_bool().unwrap_or(false); - let emoji = window["statusEmoji"].as_str().unwrap_or(""); - let status_text = window["statusText"].as_str().unwrap_or(""); + print_window(window); + } + Ok(()) +} + +pub async fn create(api_url: &str, args: WindowInputArgs, json: bool) -> Result<()> { + require_create_fields(&args)?; + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let input = build_input(args); + let variables = serde_json::json!({ "input": input }); + let data = client + .execute(CREATE_WINDOW_MUTATION, Some(variables)) + .await?; + + if json { println!( - " {} {} ({})", - format::styled_dimmed("•"), - format::styled_bold(label), - format::color_mode(&mode) - ); - println!( - " {} {} {}-{}", - format::styled_dimmed("Window:"), - days, - start, - end + "{}", + serde_json::to_string_pretty(&data["createReachabilityWindow"])? ); + return Ok(()); + } + + println!(); + println!(" {} Window created", format::styled_green_bold("✓")); + println!(); + print_window(&data["createReachabilityWindow"]); + Ok(()) +} + +pub async fn update(api_url: &str, id: &str, args: WindowInputArgs, json: bool) -> Result<()> { + if all_fields_empty(&args) { + bail!("No updates provided. Pass at least one field to update."); + } + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let input = build_input(args); + let variables = serde_json::json!({ "id": id, "input": input }); + let data = client + .execute(UPDATE_WINDOW_MUTATION, Some(variables)) + .await?; + + if json { println!( - " {} {}", - format::styled_dimmed("Alerts:"), - policy.to_lowercase().replace('_', " ") + "{}", + serde_json::to_string_pretty(&data["updateReachabilityWindow"])? ); + return Ok(()); + } + + println!(); + println!(" {} Window updated", format::styled_green_bold("✓")); + println!(); + print_window(&data["updateReachabilityWindow"]); + Ok(()) +} + +pub async fn delete(api_url: &str, id: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let variables = serde_json::json!({ "id": id }); + let data = client + .execute(DELETE_WINDOW_MUTATION, Some(variables)) + .await?; + + if json { println!( - " {} {} {} {} {} {}", - format::styled_dimmed("Priority:"), - priority, - format::styled_dimmed("Auto:"), - auto_activate, - format::styled_dimmed("Status:"), - status + "{}", + serde_json::to_string_pretty(&data["deleteReachabilityWindow"])? ); - if !emoji.is_empty() || !status_text.is_empty() { - println!( - " {} {} {}", - format::styled_dimmed("Message:"), - emoji, - status_text - ); - } + return Ok(()); + } + + let window = &data["deleteReachabilityWindow"]; + let label = window["label"].as_str().unwrap_or("Unnamed"); + + println!(); + println!( + " {} Deleted window {}", + format::styled_green_bold("✓"), + format::styled_bold(label) + ); + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(id) + ); + println!(); + + Ok(()) +} + +fn require_create_fields(args: &WindowInputArgs) -> Result<()> { + if args.label.is_none() + || args.mode.is_none() + || args.days.is_none() + || args.start.is_none() + || args.end.is_none() + { + bail!("Create requires --label, --mode, --days, --start, and --end."); + } + Ok(()) +} + +fn all_fields_empty(args: &WindowInputArgs) -> bool { + args.label.is_none() + && args.mode.is_none() + && args.days.is_none() + && args.start.is_none() + && args.end.is_none() + && args.alerts_policy.is_none() + && args.priority.is_none() + && args.auto_activate.is_none() + && args.snooze.is_none() + && args.status.is_none() + && args.status_emoji.is_none() + && args.status_text.is_none() +} + +fn build_input(args: WindowInputArgs) -> Value { + let mut input = serde_json::json!({}); + + if let Some(label) = args.label { + input["label"] = serde_json::json!(label); + } + if let Some(mode) = args.mode { + input["mode"] = serde_json::json!(normalize_mode(&mode)); + } + if let Some(days) = args.days { + input["days"] = serde_json::json!(days); + } + if let Some(start) = args.start { + input["startTime"] = serde_json::json!(start); + } + if let Some(end) = args.end { + input["endTime"] = serde_json::json!(end); + } + if let Some(policy) = args.alerts_policy { + input["alertsPolicy"] = serde_json::json!(normalize_alerts_policy(&policy)); + } + if let Some(priority) = args.priority { + input["priority"] = serde_json::json!(priority); + } + if let Some(auto_activate) = args.auto_activate { + input["autoActivate"] = serde_json::json!(auto_activate); + } + if let Some(snooze) = args.snooze { + input["snooze"] = serde_json::json!(snooze); + } + if let Some(status) = args.status { + input["status"] = serde_json::json!(status); + } + if let Some(status_emoji) = args.status_emoji { + input["statusEmoji"] = serde_json::json!(status_emoji); + } + if let Some(status_text) = args.status_text { + input["statusText"] = serde_json::json!(status_text); + } + + input +} + +fn normalize_mode(mode: &str) -> String { + mode.trim().replace('-', "_").to_uppercase() +} + +fn normalize_alerts_policy(policy: &str) -> String { + policy.trim().replace('-', "_").to_uppercase() +} + +fn print_window(window: &Value) { + let id = window["id"].as_str().unwrap_or("-"); + let label = window["label"].as_str().unwrap_or("Unnamed"); + let mode = window["mode"].as_str().unwrap_or("UNKNOWN").to_uppercase(); + let days = window["days"].as_str().unwrap_or("-"); + let start = window["startTime"].as_str().unwrap_or("-"); + let end = window["endTime"].as_str().unwrap_or("-"); + let policy = window["alertsPolicy"].as_str().unwrap_or("-"); + let priority = window["priority"].as_i64().unwrap_or_default(); + let auto_activate = window["autoActivate"].as_bool().unwrap_or(false); + let status = window["status"].as_bool().unwrap_or(false); + let emoji = window["statusEmoji"].as_str().unwrap_or(""); + let status_text = window["statusText"].as_str().unwrap_or(""); + + println!( + " {} {} ({})", + format::styled_dimmed("•"), + format::styled_bold(label), + format::color_mode(&mode) + ); + println!( + " {} {} {}-{}", + format::styled_dimmed("Window:"), + days, + start, + end + ); + println!( + " {} {}", + format::styled_dimmed("Alerts:"), + policy.to_lowercase().replace('_', " ") + ); + println!( + " {} {} {} {} {} {}", + format::styled_dimmed("Priority:"), + priority, + format::styled_dimmed("Auto:"), + auto_activate, + format::styled_dimmed("Status:"), + status + ); + if !emoji.is_empty() || !status_text.is_empty() { println!( - " {} {}", - format::styled_dimmed("ID:"), - format::styled_dimmed(id) + " {} {} {}", + format::styled_dimmed("Message:"), + emoji, + status_text ); - println!(); } + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(id) + ); + println!(); +} - Ok(()) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_input_normalizes_enums_and_maps_fields() { + let args = WindowInputArgs { + label: Some("Focus".to_string()), + mode: Some("busy".to_string()), + days: Some("Mon-Fri".to_string()), + start: Some("09:00:00".to_string()), + end: Some("17:00:00".to_string()), + alerts_policy: Some("do_not_disturb".to_string()), + priority: Some(10), + auto_activate: Some(true), + snooze: Some(false), + status: Some(true), + status_emoji: Some("🎧".to_string()), + status_text: Some("Deep work".to_string()), + }; + + let input = build_input(args); + + assert_eq!(input["mode"], "BUSY"); + assert_eq!(input["alertsPolicy"], "DO_NOT_DISTURB"); + assert_eq!(input["startTime"], "09:00:00"); + assert_eq!(input["endTime"], "17:00:00"); + assert_eq!(input["statusEmoji"], "🎧"); + } + + #[test] + fn create_requires_core_fields() { + let args = WindowInputArgs::default(); + assert!(require_create_fields(&args).is_err()); + + let args = WindowInputArgs { + label: Some("Focus".to_string()), + mode: Some("busy".to_string()), + days: Some("Mon-Fri".to_string()), + start: Some("09:00:00".to_string()), + end: Some("17:00:00".to_string()), + ..WindowInputArgs::default() + }; + assert!(require_create_fields(&args).is_ok()); + } + + #[test] + fn all_fields_empty_detects_changes() { + assert!(all_fields_empty(&WindowInputArgs::default())); + assert!(!all_fields_empty(&WindowInputArgs { + priority: Some(5), + ..WindowInputArgs::default() + })); + } } diff --git a/src/main.rs b/src/main.rs index afd0245..6f54e52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,8 +40,11 @@ enum Commands { at: Option, }, - /// List configured reachability windows - Windows, + /// Manage reachability windows + Windows { + #[command(subcommand)] + action: Option, + }, /// Show your authenticated identity Whoami, @@ -172,6 +175,123 @@ enum Commands { }, } +#[derive(Subcommand)] +enum WindowAction { + /// List configured windows + List, + + /// Create a reachability window + Create { + /// Window label + #[arg(long)] + label: String, + + /// Mode (online, busy, limited, offline) + #[arg(long, value_parser = ["online", "busy", "limited", "offline"])] + mode: String, + + /// Days expression (for example: Mon-Fri) + #[arg(long)] + days: String, + + /// Start time (HH:MM:SS) + #[arg(long)] + start: String, + + /// End time (HH:MM:SS) + #[arg(long)] + end: String, + + /// Alerts policy (off, interruptable, do_not_disturb, take_a_number, after_hours) + #[arg(long, value_parser = ["off", "interruptable", "do_not_disturb", "take_a_number", "after_hours"])] + alerts_policy: Option, + + /// Priority (higher wins) + #[arg(long)] + priority: Option, + + /// Auto activate this window + #[arg(long)] + auto_activate: Option, + + /// Enable snooze for this window + #[arg(long)] + snooze: Option, + + /// Set status enabled/disabled for this window + #[arg(long)] + status: Option, + + /// Optional status emoji + #[arg(long)] + status_emoji: Option, + + /// Optional status text + #[arg(long)] + status_text: Option, + }, + + /// Update a reachability window + Update { + /// Window id + id: String, + + /// Window label + #[arg(long)] + label: Option, + + /// Mode (online, busy, limited, offline) + #[arg(long, value_parser = ["online", "busy", "limited", "offline"])] + mode: Option, + + /// Days expression (for example: Mon-Fri) + #[arg(long)] + days: Option, + + /// Start time (HH:MM:SS) + #[arg(long)] + start: Option, + + /// End time (HH:MM:SS) + #[arg(long)] + end: Option, + + /// Alerts policy (off, interruptable, do_not_disturb, take_a_number, after_hours) + #[arg(long, value_parser = ["off", "interruptable", "do_not_disturb", "take_a_number", "after_hours"])] + alerts_policy: Option, + + /// Priority (higher wins) + #[arg(long)] + priority: Option, + + /// Auto activate this window + #[arg(long)] + auto_activate: Option, + + /// Enable snooze for this window + #[arg(long)] + snooze: Option, + + /// Set status enabled/disabled for this window + #[arg(long)] + status: Option, + + /// Optional status emoji + #[arg(long)] + status_emoji: Option, + + /// Optional status text + #[arg(long)] + status_text: Option, + }, + + /// Delete a reachability window + Delete { + /// Window id + id: String, + }, +} + #[derive(Subcommand)] enum HookAction { /// Install git hooks in the current repository @@ -267,7 +387,82 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { Commands::Auth => commands::auth::run(&api_url).await, Commands::Status => commands::status::run(&api_url, json).await, Commands::Availability { at } => commands::availability::run(&api_url, at, json).await, - Commands::Windows => commands::windows::run(&api_url, json).await, + Commands::Windows { action } => match action { + None | Some(WindowAction::List) => commands::windows::list(&api_url, json).await, + Some(WindowAction::Create { + label, + mode, + days, + start, + end, + alerts_policy, + priority, + auto_activate, + snooze, + status, + status_emoji, + status_text, + }) => { + commands::windows::create( + &api_url, + commands::windows::WindowInputArgs { + label: Some(label), + mode: Some(mode), + days: Some(days), + start: Some(start), + end: Some(end), + alerts_policy, + priority, + auto_activate, + snooze, + status, + status_emoji, + status_text, + }, + json, + ) + .await + } + Some(WindowAction::Update { + id, + label, + mode, + days, + start, + end, + alerts_policy, + priority, + auto_activate, + snooze, + status, + status_emoji, + status_text, + }) => { + commands::windows::update( + &api_url, + &id, + commands::windows::WindowInputArgs { + label, + mode, + days, + start, + end, + alerts_policy, + priority, + auto_activate, + snooze, + status, + status_emoji, + status_text, + }, + json, + ) + .await + } + Some(WindowAction::Delete { id }) => { + commands::windows::delete(&api_url, &id, json).await + } + }, Commands::Whoami => commands::whoami::run(&api_url, json).await, Commands::Busy { duration } => commands::mode::run(&api_url, "BUSY", duration, json).await, Commands::Online => commands::mode::run(&api_url, "ONLINE", None, json).await, @@ -353,7 +548,7 @@ fn command_name(cmd: &Commands) -> &'static str { Commands::Auth => "auth", Commands::Status => "status", Commands::Availability { .. } => "availability", - Commands::Windows => "windows", + Commands::Windows { .. } => "windows", Commands::Whoami => "whoami", Commands::Busy { .. } => "busy", Commands::Online => "online", diff --git a/tests/integration.rs b/tests/integration.rs index 04b5a1f..ec0d216 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -79,6 +79,23 @@ fn subcommand_help_works() { } } +#[test] +fn windows_subcommand_help_works() { + for cmd in &[ + ["windows", "list"], + ["windows", "create"], + ["windows", "update"], + ["windows", "delete"], + ] { + Command::cargo_bin("hd") + .unwrap() + .args([cmd[0], cmd[1], "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Usage:")); + } +} + #[test] fn completions_generates_output() { for shell in &["bash", "zsh", "fish"] { From f636f22e150b56a42d60535227f00012a007f114 Mon Sep 17 00:00:00 2001 From: Justin Smestad Date: Wed, 8 Apr 2026 00:22:32 -0600 Subject: [PATCH 2/4] Expose digest, presets CRUD, autoresponder, and verdict settings commands --- README.md | 18 +- src/commands/autoresponder.rs | 122 +++++++++++++ src/commands/digest.rs | 148 ++++++++++++++++ src/commands/interrupt.rs | 61 +++++++ src/commands/mod.rs | 5 + src/commands/presets.rs | 296 ++++++++++++++++++++++++++----- src/commands/proposals.rs | 89 ++++++++++ src/commands/verdict_settings.rs | 92 ++++++++++ src/main.rs | 250 ++++++++++++++++++++++++-- tests/integration.rs | 66 +++++++ 10 files changed, 1083 insertions(+), 64 deletions(-) create mode 100644 src/commands/autoresponder.rs create mode 100644 src/commands/digest.rs create mode 100644 src/commands/interrupt.rs create mode 100644 src/commands/proposals.rs create mode 100644 src/commands/verdict_settings.rs diff --git a/README.md b/README.md index 3e2e0bb..9b378aa 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ hd status hd availability hd windows hd windows create --label "Focus" --mode busy --days "Mon-Fri" --start 09:00:00 --end 11:30:00 +hd presets create --name "Deep Focus" --alerts do_not_disturb --presence on_keys --duration 90 +hd digest list --latest 10 +hd autoresponder get # Set yourself to busy for 2 hours hd busy 2h @@ -62,14 +65,25 @@ hd watch | `hd windows create ...` | Create a reachability window | | `hd windows update ...` | Update a reachability window | | `hd windows delete ` | Delete a reachability window | +| `hd presets [list]` | List available presets | +| `hd presets create ...` | Create a preset | +| `hd presets update ...` | Update a preset | +| `hd presets delete ` | Delete a preset | +| `hd preset "name"` | Activate a preset | +| `hd digest [list] [--latest N]` | List digest summaries | +| `hd digest dismiss ` | Dismiss a digest entry | +| `hd autoresponder get` | Show auto-responder settings | +| `hd autoresponder set ...` | Update busy/limited/offline auto-response text | +| `hd verdict-settings get` | Show verdict settings | +| `hd verdict-settings set --mode-thresholds ''` | Update verdict mode thresholds | +| `hd proposals [--latest N] [--verdict approved\|deferred]` | List recent proposals | +| `hd interrupt ` | Evaluate if an interrupt is allowed | | `hd whoami` | Show your authenticated identity | | `hd busy [duration]` | Set mode to busy | | `hd online` | Set mode to online | | `hd offline` | Set mode to offline | | `hd limited [duration]` | Set mode to limited | | `hd verdict "desc"` | Submit a task proposal and get a verdict | -| `hd presets` | List available presets | -| `hd preset "name"` | Activate a preset | | `hd watch` | Live-updating status dashboard | | `hd doctor` | Check CLI health and connectivity | | `hd update` | Self-update to the latest version | diff --git a/src/commands/autoresponder.rs b/src/commands/autoresponder.rs new file mode 100644 index 0000000..633aea1 --- /dev/null +++ b/src/commands/autoresponder.rs @@ -0,0 +1,122 @@ +use anyhow::{bail, Result}; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const AUTO_RESPONDER_QUERY: &str = r#" +query { + autoResponderSettings { + id + busyText + limitedText + offlineText + updatedAt + } +} +"#; + +const UPDATE_AUTO_RESPONDER_MUTATION: &str = r#" +mutation UpdateAutoResponderSettings($busyText: String, $limitedText: String, $offlineText: String) { + updateAutoResponderSettings(busyText: $busyText, limitedText: $limitedText, offlineText: $offlineText) { + id + busyText + limitedText + offlineText + updatedAt + } +} +"#; + +pub async fn get(api_url: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data = client.execute(AUTO_RESPONDER_QUERY, None).await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data["autoResponderSettings"])? + ); + return Ok(()); + } + + let settings = &data["autoResponderSettings"]; + println!(); + println!(" {}", format::styled_bold("Auto-responder")); + println!(); + println!( + " {} {}", + format::styled_dimmed("Busy:"), + settings["busyText"].as_str().unwrap_or("-") + ); + println!( + " {} {}", + format::styled_dimmed("Limited:"), + settings["limitedText"].as_str().unwrap_or("-") + ); + println!( + " {} {}", + format::styled_dimmed("Offline:"), + settings["offlineText"].as_str().unwrap_or("-") + ); + println!(); + Ok(()) +} + +pub async fn set( + api_url: &str, + busy_text: Option, + limited_text: Option, + offline_text: Option, + json: bool, +) -> Result<()> { + if busy_text.is_none() && limited_text.is_none() && offline_text.is_none() { + bail!("No updates provided. Pass at least one of --busy-text, --limited-text, or --offline-text."); + } + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let variables = serde_json::json!({ + "busyText": busy_text, + "limitedText": limited_text, + "offlineText": offline_text, + }); + let data = client + .execute(UPDATE_AUTO_RESPONDER_MUTATION, Some(variables)) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data["updateAutoResponderSettings"])? + ); + return Ok(()); + } + + println!(); + println!( + " {} Auto-responder updated", + format::styled_green_bold("✓") + ); + println!(); + let settings = &data["updateAutoResponderSettings"]; + println!( + " {} {}", + format::styled_dimmed("Busy:"), + settings["busyText"].as_str().unwrap_or("-") + ); + println!( + " {} {}", + format::styled_dimmed("Limited:"), + settings["limitedText"].as_str().unwrap_or("-") + ); + println!( + " {} {}", + format::styled_dimmed("Offline:"), + settings["offlineText"].as_str().unwrap_or("-") + ); + println!(); + Ok(()) +} diff --git a/src/commands/digest.rs b/src/commands/digest.rs new file mode 100644 index 0000000..cce6040 --- /dev/null +++ b/src/commands/digest.rs @@ -0,0 +1,148 @@ +use anyhow::Result; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const DIGEST_QUERY: &str = r#" +query DigestSummaries($latest: Int) { + digestSummaries(latest: $latest) { + id + action + actorLabel + actorRef + channelRef + sourceType + entryCount + firstEventAt + lastEventAt + events { + description + insertedAt + } + } +} +"#; + +const DISMISS_DIGEST_MUTATION: &str = r#" +mutation DismissDigestEntry($id: ID!) { + dismissDigestEntry(id: $id) { + id + action + actorLabel + entryCount + sourceType + } +} +"#; + +pub async fn list(api_url: &str, latest: Option, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let variables = serde_json::json!({ "latest": latest }); + let data = client.execute(DIGEST_QUERY, Some(variables)).await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data["digestSummaries"])? + ); + return Ok(()); + } + + let entries = data["digestSummaries"] + .as_array() + .ok_or_else(|| anyhow::anyhow!("No digest summaries found"))?; + + println!(); + println!(" {}", format::styled_bold("Digest Summaries")); + println!(); + + if entries.is_empty() { + println!(" {}", format::styled_dimmed("No digest entries.")); + println!(); + return Ok(()); + } + + for entry in entries { + let id = entry["id"].as_str().unwrap_or("-"); + let action = entry["action"].as_str().unwrap_or("-"); + let actor = entry["actorLabel"] + .as_str() + .or_else(|| entry["actorRef"].as_str()) + .unwrap_or("Unknown"); + let count = entry["entryCount"].as_i64().unwrap_or_default(); + let source = entry["sourceType"].as_str().unwrap_or("-"); + + println!( + " {} {} ({})", + format::styled_dimmed("•"), + format::styled_bold(actor), + action.to_lowercase() + ); + println!( + " {} {} {} {}", + format::styled_dimmed("Entries:"), + count, + format::styled_dimmed("Source:"), + source.to_lowercase() + ); + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(id) + ); + + if let Some(events) = entry["events"].as_array() { + for event in events.iter().take(2) { + if let Some(description) = event["description"].as_str() { + println!( + " {} {}", + format::styled_dimmed("-"), + format::styled_dimmed(description) + ); + } + } + } + + println!(); + } + + Ok(()) +} + +pub async fn dismiss(api_url: &str, id: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let data = client + .execute( + DISMISS_DIGEST_MUTATION, + Some(serde_json::json!({ "id": id })), + ) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data["dismissDigestEntry"])? + ); + return Ok(()); + } + + let entry = &data["dismissDigestEntry"]; + println!(); + println!( + " {} Dismissed digest entry", + format::styled_green_bold("✓") + ); + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(entry["id"].as_str().unwrap_or(id)) + ); + println!(); + + Ok(()) +} diff --git a/src/commands/interrupt.rs b/src/commands/interrupt.rs new file mode 100644 index 0000000..a24465b --- /dev/null +++ b/src/commands/interrupt.rs @@ -0,0 +1,61 @@ +use anyhow::Result; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const EVALUATE_INTERRUPT_QUERY: &str = r#" +query EvaluateInterrupt($handle: String) { + evaluateInterrupt(handle: $handle) { + allowed + reason + autoResponse + } +} +"#; + +pub async fn evaluate(api_url: &str, handle: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let data = client + .execute( + EVALUATE_INTERRUPT_QUERY, + Some(serde_json::json!({ "handle": handle })), + ) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data["evaluateInterrupt"])? + ); + return Ok(()); + } + + let result = &data["evaluateInterrupt"]; + let allowed = result["allowed"].as_bool().unwrap_or(false); + + println!(); + println!(" {} {}", format::styled_dimmed("Handle:"), handle); + println!( + " {} {}", + format::styled_dimmed("Allowed:"), + if allowed { + format::styled_green_bold("yes") + } else { + format::styled_yellow_bold("no") + } + ); + if let Some(reason) = result["reason"].as_str() { + println!(" {} {}", format::styled_dimmed("Reason:"), reason); + } + if let Some(response) = result["autoResponse"].as_str() { + if !response.is_empty() { + println!(" {} {}", format::styled_dimmed("Auto-response:"), response); + } + } + println!(); + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 80ed7c3..9ede480 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,16 +1,21 @@ pub mod alias; pub mod auth; +pub mod autoresponder; pub mod availability; pub mod calibration_cmd; +pub mod digest; pub mod doctor; pub mod hooks; +pub mod interrupt; pub mod mode; pub mod outcome; pub mod presets; +pub mod proposals; pub mod status; pub mod telemetry_cmd; pub mod update; pub mod verdict; +pub mod verdict_settings; pub mod watch; pub mod whoami; pub mod windows; diff --git a/src/commands/presets.rs b/src/commands/presets.rs index 4f427ed..b56c5f7 100644 --- a/src/commands/presets.rs +++ b/src/commands/presets.rs @@ -1,4 +1,5 @@ -use anyhow::Result; +use anyhow::{bail, Result}; +use serde_json::Value; use crate::auth; use crate::client::GraphQLClient; @@ -12,6 +13,7 @@ query { alerts presence duration + status statusEmoji statusText } @@ -30,7 +32,57 @@ mutation ApplyPreset($id: ID!) { } "#; -pub async fn run(api_url: &str, json: bool) -> Result<()> { +const CREATE_PRESET_MUTATION: &str = r#" +mutation CreatePreset($input: PresetInput!) { + createPreset(input: $input) { + id + name + alerts + presence + duration + status + statusEmoji + statusText + } +} +"#; + +const UPDATE_PRESET_MUTATION: &str = r#" +mutation UpdatePreset($id: ID!, $input: PresetInput!) { + updatePreset(id: $id, input: $input) { + id + name + alerts + presence + duration + status + statusEmoji + statusText + } +} +"#; + +const DELETE_PRESET_MUTATION: &str = r#" +mutation DeletePreset($id: ID!) { + deletePreset(id: $id) { + id + name + } +} +"#; + +#[derive(Clone, Debug, Default)] +pub struct PresetInputArgs { + pub name: Option, + pub alerts: Option, + pub presence: Option, + pub duration: Option, + pub status: Option, + pub status_emoji: Option, + pub status_text: Option, +} + +pub async fn list(api_url: &str, json: bool) -> Result<()> { let token = auth::require_token()?; let client = GraphQLClient::new(api_url, &token); @@ -47,7 +99,7 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { if presets.is_empty() { println!(); - println!(" No presets configured. Create presets in the HeadsDown app."); + println!(" No presets configured. Create one with `hd presets create`."); println!(); return Ok(()); } @@ -57,46 +109,7 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { println!(); for preset in presets { - let name = preset["name"].as_str().unwrap_or("Unknown"); - let alerts = preset["alerts"].as_str().unwrap_or(""); - let presence = preset["presence"].as_str().unwrap_or(""); - let emoji = preset["statusEmoji"].as_str().unwrap_or(""); - let status_text = preset["statusText"].as_str().unwrap_or(""); - - // Format the preset name with any emoji - let display_name = if !emoji.is_empty() { - format!("{} {}", emoji, name) - } else { - name.to_string() - }; - - print!( - " {} {}", - format::styled_dimmed("•"), - format::styled_bold(&display_name) - ); - - // Show duration if set - if let Some(duration) = preset["duration"].as_i64() { - print!(" ({})", format::format_duration(duration)); - } - - println!(); - - // Show details on second line - let mut details = Vec::new(); - if !alerts.is_empty() { - details.push(format!("alerts: {}", format_enum_value(alerts))); - } - if !presence.is_empty() { - details.push(format!("presence: {}", format_enum_value(presence))); - } - if !status_text.is_empty() { - details.push(format!("\"{}\"", status_text)); - } - if !details.is_empty() { - println!(" {}", format::styled_dimmed(&details.join(" · "))); - } + print_preset(preset); } println!(); @@ -114,13 +127,11 @@ pub async fn activate(api_url: &str, name_or_id: &str, json: bool) -> Result<()> let token = auth::require_token()?; let client = GraphQLClient::new(api_url, &token); - // First, list presets to find the matching one let data = client.execute(PRESETS_QUERY, None).await?; let presets = data["presets"] .as_array() .ok_or_else(|| anyhow::anyhow!("No presets found"))?; - // Find by name (case-insensitive) or ID let preset = presets .iter() .find(|p| { @@ -166,11 +177,9 @@ pub async fn activate(api_url: &str, name_or_id: &str, json: bool) -> Result<()> format::color_mode(&mode) ); - // Show duration if present if let Some(expires_str) = contract["expiresAt"].as_str() { if let Ok(expires_at) = expires_str.parse::>() { - let now = chrono::Utc::now(); - let remaining = expires_at.signed_duration_since(now); + let remaining = expires_at.signed_duration_since(chrono::Utc::now()); if remaining.num_minutes() > 0 { print!(" for {}", format::format_duration(remaining.num_minutes())); } @@ -183,7 +192,196 @@ pub async fn activate(api_url: &str, name_or_id: &str, json: bool) -> Result<()> Ok(()) } -/// Convert SCREAMING_SNAKE_CASE enum values to readable text. +pub async fn create(api_url: &str, args: PresetInputArgs, json: bool) -> Result<()> { + if args.name.is_none() { + bail!("Create requires --name."); + } + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let input = build_input(args); + let data = client + .execute( + CREATE_PRESET_MUTATION, + Some(serde_json::json!({ "input": input })), + ) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&data["createPreset"])?); + return Ok(()); + } + + println!(); + println!(" {} Preset created", format::styled_green_bold("✓")); + println!(); + print_preset(&data["createPreset"]); + Ok(()) +} + +pub async fn update(api_url: &str, id: &str, args: PresetInputArgs, json: bool) -> Result<()> { + if all_fields_empty(&args) { + bail!("No updates provided. Pass at least one field to update."); + } + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let input = build_input(args); + let data = client + .execute( + UPDATE_PRESET_MUTATION, + Some(serde_json::json!({ "id": id, "input": input })), + ) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&data["updatePreset"])?); + return Ok(()); + } + + println!(); + println!(" {} Preset updated", format::styled_green_bold("✓")); + println!(); + print_preset(&data["updatePreset"]); + Ok(()) +} + +pub async fn delete(api_url: &str, id: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data = client + .execute( + DELETE_PRESET_MUTATION, + Some(serde_json::json!({ "id": id })), + ) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&data["deletePreset"])?); + return Ok(()); + } + + println!(); + println!( + " {} Deleted preset {}", + format::styled_green_bold("✓"), + format::styled_bold(data["deletePreset"]["name"].as_str().unwrap_or("Unknown")) + ); + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(id) + ); + println!(); + Ok(()) +} + +fn build_input(args: PresetInputArgs) -> Value { + let mut input = serde_json::json!({}); + + if let Some(name) = args.name { + input["name"] = serde_json::json!(name); + } + if let Some(alerts) = args.alerts { + input["alerts"] = serde_json::json!(normalize_enum(&alerts)); + } + if let Some(presence) = args.presence { + input["presence"] = serde_json::json!(normalize_enum(&presence)); + } + if let Some(duration) = args.duration { + input["duration"] = serde_json::json!(duration); + } + if let Some(status) = args.status { + input["status"] = serde_json::json!(status); + } + if let Some(status_emoji) = args.status_emoji { + input["statusEmoji"] = serde_json::json!(status_emoji); + } + if let Some(status_text) = args.status_text { + input["statusText"] = serde_json::json!(status_text); + } + + input +} + +fn all_fields_empty(args: &PresetInputArgs) -> bool { + args.name.is_none() + && args.alerts.is_none() + && args.presence.is_none() + && args.duration.is_none() + && args.status.is_none() + && args.status_emoji.is_none() + && args.status_text.is_none() +} + +fn normalize_enum(input: &str) -> String { + input.trim().replace('-', "_").to_uppercase() +} + +fn print_preset(preset: &Value) { + let id = preset["id"].as_str().unwrap_or("-"); + let name = preset["name"].as_str().unwrap_or("Unknown"); + let alerts = preset["alerts"].as_str().unwrap_or(""); + let presence = preset["presence"].as_str().unwrap_or(""); + let emoji = preset["statusEmoji"].as_str().unwrap_or(""); + let status_text = preset["statusText"].as_str().unwrap_or(""); + + let display_name = if !emoji.is_empty() { + format!("{} {}", emoji, name) + } else { + name.to_string() + }; + + print!( + " {} {}", + format::styled_dimmed("•"), + format::styled_bold(&display_name) + ); + if let Some(duration) = preset["duration"].as_i64() { + print!(" ({})", format::format_duration(duration)); + } + println!(); + + let mut details = Vec::new(); + if !alerts.is_empty() { + details.push(format!("alerts: {}", format_enum_value(alerts))); + } + if !presence.is_empty() { + details.push(format!("presence: {}", format_enum_value(presence))); + } + if !status_text.is_empty() { + details.push(format!("\"{}\"", status_text)); + } + if !details.is_empty() { + println!(" {}", format::styled_dimmed(&details.join(" · "))); + } + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(id) + ); +} + fn format_enum_value(s: &str) -> String { s.to_lowercase().replace('_', " ") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_enum_maps_to_upper_snake_case() { + assert_eq!(normalize_enum("do_not_disturb"), "DO_NOT_DISTURB"); + assert_eq!(normalize_enum("take-a-number"), "TAKE_A_NUMBER"); + } + + #[test] + fn update_requires_at_least_one_field() { + assert!(all_fields_empty(&PresetInputArgs::default())); + assert!(!all_fields_empty(&PresetInputArgs { + name: Some("Focus".to_string()), + ..PresetInputArgs::default() + })); + } +} diff --git a/src/commands/proposals.rs b/src/commands/proposals.rs new file mode 100644 index 0000000..3c55d39 --- /dev/null +++ b/src/commands/proposals.rs @@ -0,0 +1,89 @@ +use anyhow::Result; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const PROPOSALS_QUERY: &str = r#" +query Proposals($latest: Int, $verdict: VerdictDecision) { + proposals(latest: $latest, verdict: $verdict) { + id + description + estimatedFiles + estimatedMinutes + model + framework + verdict + verdictReason + insertedAt + } +} +"#; + +pub async fn list( + api_url: &str, + latest: Option, + verdict: Option, + json: bool, +) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let verdict = verdict.map(|v| v.to_uppercase()); + let variables = serde_json::json!({ "latest": latest, "verdict": verdict }); + let data = client.execute(PROPOSALS_QUERY, Some(variables)).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&data["proposals"])?); + return Ok(()); + } + + let proposals = data["proposals"] + .as_array() + .ok_or_else(|| anyhow::anyhow!("No proposals found"))?; + + println!(); + println!(" {}", format::styled_bold("Recent proposals")); + println!(); + + if proposals.is_empty() { + println!(" {}", format::styled_dimmed("No proposals found.")); + println!(); + return Ok(()); + } + + for proposal in proposals { + let id = proposal["id"].as_str().unwrap_or("-"); + let desc = proposal["description"].as_str().unwrap_or("-"); + let decision = proposal["verdict"] + .as_str() + .unwrap_or("UNKNOWN") + .to_uppercase(); + let reason = proposal["verdictReason"].as_str().unwrap_or("-"); + let files = proposal["estimatedFiles"].as_i64().unwrap_or_default(); + let minutes = proposal["estimatedMinutes"].as_i64().unwrap_or_default(); + + println!( + " {} {}", + format::styled_dimmed("•"), + format::styled_bold(desc) + ); + println!( + " {} {} {} ~{} files / ~{} min", + format::styled_dimmed("Verdict:"), + format::color_verdict(&decision), + format::styled_dimmed("Scope:"), + files, + minutes + ); + println!(" {} {}", format::styled_dimmed("Reason:"), reason); + println!( + " {} {}", + format::styled_dimmed("ID:"), + format::styled_dimmed(id) + ); + println!(); + } + + Ok(()) +} diff --git a/src/commands/verdict_settings.rs b/src/commands/verdict_settings.rs new file mode 100644 index 0000000..17979bb --- /dev/null +++ b/src/commands/verdict_settings.rs @@ -0,0 +1,92 @@ +use anyhow::{Context, Result}; +use serde_json::Value; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const VERDICT_SETTINGS_QUERY: &str = r#" +query { + verdictSettings { + id + modeThresholds + updatedAt + } +} +"#; + +const UPDATE_VERDICT_SETTINGS_MUTATION: &str = r#" +mutation UpdateVerdictSettings($modeThresholds: JSON) { + updateVerdictSettings(modeThresholds: $modeThresholds) { + id + modeThresholds + updatedAt + } +} +"#; + +pub async fn get(api_url: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data = client.execute(VERDICT_SETTINGS_QUERY, None).await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data["verdictSettings"])? + ); + return Ok(()); + } + + let settings = &data["verdictSettings"]; + println!(); + println!(" {}", format::styled_bold("Verdict settings")); + println!(); + println!( + " {} {}", + format::styled_dimmed("ID:"), + settings["id"].as_str().unwrap_or("-") + ); + println!(" {}", format::styled_dimmed("Mode thresholds:")); + println!( + "{}", + serde_json::to_string_pretty(&settings["modeThresholds"])? + ); + println!(); + Ok(()) +} + +pub async fn set(api_url: &str, mode_thresholds: &str, json: bool) -> Result<()> { + let parsed: Value = + serde_json::from_str(mode_thresholds).context("mode_thresholds must be valid JSON")?; + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data = client + .execute( + UPDATE_VERDICT_SETTINGS_MUTATION, + Some(serde_json::json!({ "modeThresholds": parsed })), + ) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data["updateVerdictSettings"])? + ); + return Ok(()); + } + + println!(); + println!( + " {} Verdict settings updated", + format::styled_green_bold("✓") + ); + println!(); + println!( + "{}", + serde_json::to_string_pretty(&data["updateVerdictSettings"]["modeThresholds"])? + ); + println!(); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 6f54e52..2ad2275 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,53 @@ enum Commands { action: Option, }, + /// Manage preset configurations + Presets { + #[command(subcommand)] + action: Option, + }, + + /// Apply a preset by name or ID + Preset { + /// Preset name or ID + name: String, + }, + + /// Manage digest summaries + Digest { + #[command(subcommand)] + action: Option, + }, + + /// Manage auto-responder text + Autoresponder { + #[command(subcommand)] + action: Option, + }, + + /// Manage verdict threshold settings + VerdictSettings { + #[command(subcommand)] + action: Option, + }, + + /// List recent task proposals + Proposals { + /// Number of latest proposals to fetch + #[arg(long)] + latest: Option, + + /// Filter by verdict decision (approved, deferred) + #[arg(long, value_parser = ["approved", "deferred"])] + verdict: Option, + }, + + /// Evaluate whether interrupting someone is allowed + Interrupt { + /// User handle to evaluate + handle: String, + }, + /// Show your authenticated identity Whoami, @@ -85,15 +132,6 @@ enum Commands { model: Option, }, - /// List available presets - Presets, - - /// Activate a preset by name or ID - Preset { - /// Preset name or ID - name: String, - }, - /// Live-updating status dashboard Watch, @@ -292,6 +330,94 @@ enum WindowAction { }, } +#[derive(Subcommand)] +enum PresetsAction { + /// List configured presets + List, + + /// Create a preset + Create { + #[arg(long)] + name: String, + #[arg(long)] + alerts: Option, + #[arg(long)] + presence: Option, + #[arg(long)] + duration: Option, + #[arg(long)] + status: Option, + #[arg(long)] + status_emoji: Option, + #[arg(long)] + status_text: Option, + }, + + /// Update a preset by id + Update { + id: String, + #[arg(long)] + name: Option, + #[arg(long)] + alerts: Option, + #[arg(long)] + presence: Option, + #[arg(long)] + duration: Option, + #[arg(long)] + status: Option, + #[arg(long)] + status_emoji: Option, + #[arg(long)] + status_text: Option, + }, + + /// Delete a preset by id + Delete { id: String }, +} + +#[derive(Subcommand)] +enum DigestAction { + /// List recent digest summaries + List { + /// Number of latest summaries to fetch + #[arg(long)] + latest: Option, + }, + + /// Dismiss a digest entry by id + Dismiss { id: String }, +} + +#[derive(Subcommand)] +enum AutoResponderAction { + /// Show current auto-responder settings + Get, + + /// Update auto-responder text templates + Set { + #[arg(long)] + busy_text: Option, + #[arg(long)] + limited_text: Option, + #[arg(long)] + offline_text: Option, + }, +} + +#[derive(Subcommand)] +enum VerdictSettingsAction { + /// Show current verdict settings + Get, + + /// Update mode thresholds JSON + Set { + /// JSON object for mode thresholds + #[arg(long)] + mode_thresholds: String, + }, +} + #[derive(Subcommand)] enum HookAction { /// Install git hooks in the current repository @@ -463,6 +589,101 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { commands::windows::delete(&api_url, &id, json).await } }, + Commands::Presets { action } => match action { + None | Some(PresetsAction::List) => commands::presets::list(&api_url, json).await, + Some(PresetsAction::Create { + name, + alerts, + presence, + duration, + status, + status_emoji, + status_text, + }) => { + commands::presets::create( + &api_url, + commands::presets::PresetInputArgs { + name: Some(name), + alerts, + presence, + duration, + status, + status_emoji, + status_text, + }, + json, + ) + .await + } + Some(PresetsAction::Update { + id, + name, + alerts, + presence, + duration, + status, + status_emoji, + status_text, + }) => { + commands::presets::update( + &api_url, + &id, + commands::presets::PresetInputArgs { + name, + alerts, + presence, + duration, + status, + status_emoji, + status_text, + }, + json, + ) + .await + } + Some(PresetsAction::Delete { id }) => { + commands::presets::delete(&api_url, &id, json).await + } + }, + Commands::Preset { name } => commands::presets::activate(&api_url, &name, json).await, + Commands::Digest { action } => match action { + None | Some(DigestAction::List { latest: None }) => { + commands::digest::list(&api_url, None, json).await + } + Some(DigestAction::List { latest }) => { + commands::digest::list(&api_url, latest, json).await + } + Some(DigestAction::Dismiss { id }) => { + commands::digest::dismiss(&api_url, &id, json).await + } + }, + Commands::Autoresponder { action } => match action { + None | Some(AutoResponderAction::Get) => { + commands::autoresponder::get(&api_url, json).await + } + Some(AutoResponderAction::Set { + busy_text, + limited_text, + offline_text, + }) => { + commands::autoresponder::set(&api_url, busy_text, limited_text, offline_text, json) + .await + } + }, + Commands::VerdictSettings { action } => match action { + None | Some(VerdictSettingsAction::Get) => { + commands::verdict_settings::get(&api_url, json).await + } + Some(VerdictSettingsAction::Set { mode_thresholds }) => { + commands::verdict_settings::set(&api_url, &mode_thresholds, json).await + } + }, + Commands::Proposals { latest, verdict } => { + commands::proposals::list(&api_url, latest, verdict, json).await + } + Commands::Interrupt { handle } => { + commands::interrupt::evaluate(&api_url, &handle, json).await + } Commands::Whoami => commands::whoami::run(&api_url, json).await, Commands::Busy { duration } => commands::mode::run(&api_url, "BUSY", duration, json).await, Commands::Online => commands::mode::run(&api_url, "ONLINE", None, json).await, @@ -476,8 +697,6 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { minutes, model, } => commands::verdict::run(&api_url, &description, files, minutes, model, json).await, - Commands::Presets => commands::presets::run(&api_url, json).await, - Commands::Preset { name } => commands::presets::activate(&api_url, &name, json).await, Commands::Watch => commands::watch::run(&api_url).await, Commands::Doctor => commands::doctor::run(&api_url, json).await, Commands::Update => commands::update::run().await, @@ -549,14 +768,19 @@ fn command_name(cmd: &Commands) -> &'static str { Commands::Status => "status", Commands::Availability { .. } => "availability", Commands::Windows { .. } => "windows", + Commands::Presets { .. } => "presets", + Commands::Preset { .. } => "preset", + Commands::Digest { .. } => "digest", + Commands::Autoresponder { .. } => "autoresponder", + Commands::VerdictSettings { .. } => "verdict-settings", + Commands::Proposals { .. } => "proposals", + Commands::Interrupt { .. } => "interrupt", Commands::Whoami => "whoami", Commands::Busy { .. } => "busy", Commands::Online => "online", Commands::Offline => "offline", Commands::Limited { .. } => "limited", Commands::Verdict { .. } => "verdict", - Commands::Presets => "presets", - Commands::Preset { .. } => "preset", Commands::Watch => "watch", Commands::Doctor => "doctor", Commands::Update => "update", diff --git a/tests/integration.rs b/tests/integration.rs index ec0d216..fd925ad 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -52,6 +52,11 @@ fn subcommand_help_works() { "status", "availability", "windows", + "digest", + "autoresponder", + "verdict-settings", + "proposals", + "interrupt", "whoami", "busy", "online", @@ -96,6 +101,67 @@ fn windows_subcommand_help_works() { } } +#[test] +fn presets_subcommand_help_works() { + for cmd in &[ + ["presets", "list"], + ["presets", "create"], + ["presets", "update"], + ["presets", "delete"], + ] { + Command::cargo_bin("hd") + .unwrap() + .args([cmd[0], cmd[1], "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Usage:")); + } +} + +#[test] +fn digest_subcommand_help_works() { + for cmd in &[ + ["digest", "list"], + ["digest", "dismiss"], + ["autoresponder", "get"], + ["autoresponder", "set"], + ["verdict-settings", "get"], + ["verdict-settings", "set"], + ] { + Command::cargo_bin("hd") + .unwrap() + .args([cmd[0], cmd[1], "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Usage:")); + } +} + +#[test] +fn windows_update_without_fields_fails_with_helpful_message() { + Command::cargo_bin("hd") + .unwrap() + .args(["windows", "update", "window_123"]) + .assert() + .failure() + .stderr(predicate::str::contains("No updates provided")); +} + +#[test] +fn windows_create_missing_required_args_fails_at_parse() { + Command::cargo_bin("hd") + .unwrap() + .args([ + "windows", "create", "--label", "Focus", "--mode", "busy", "--days", "Mon-Fri", + "--start", "09:00:00", + ]) + .assert() + .failure() + .stderr(predicate::str::contains( + "required arguments were not provided", + )); +} + #[test] fn completions_generates_output() { for shell in &["bash", "zsh", "fish"] { From d1863a8af93429f10b9dc68b45e54b7146597225 Mon Sep 17 00:00:00 2001 From: Justin Smestad Date: Tue, 21 Apr 2026 10:48:43 -0600 Subject: [PATCH 3/4] Align CLI with latest backend contract and add parity hardening --- .github/workflows/contract-smoke.yml | 20 + README.md | 22 +- src/auth.rs | 143 +++---- src/client.rs | 62 +++- src/commands/availability.rs | 108 +++--- src/commands/grants.rs | 334 +++++++++++++++++ src/commands/mod.rs | 2 + src/commands/override_cmd.rs | 223 +++++++++++ src/commands/presets.rs | 335 ++++------------- src/commands/status.rs | 99 +++-- src/commands/verdict.rs | 70 +++- src/commands/verdict_settings.rs | 127 +++++-- src/commands/windows.rs | 214 ++++++++--- src/contract/availability.rs | 44 +++ src/contract/mod.rs | 1 + src/main.rs | 256 +++++++++---- tests/backend_parity.rs | 536 +++++++++++++++++++++++++++ tests/graphql_contract.rs | 31 ++ tests/integration.rs | 37 +- 19 files changed, 2089 insertions(+), 575 deletions(-) create mode 100644 .github/workflows/contract-smoke.yml create mode 100644 src/commands/grants.rs create mode 100644 src/commands/override_cmd.rs create mode 100644 src/contract/availability.rs create mode 100644 src/contract/mod.rs create mode 100644 tests/backend_parity.rs diff --git a/.github/workflows/contract-smoke.yml b/.github/workflows/contract-smoke.yml new file mode 100644 index 0000000..4319a6e --- /dev/null +++ b/.github/workflows/contract-smoke.yml @@ -0,0 +1,20 @@ +name: Contract Smoke + +on: + pull_request: + branches: [main] + push: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + contract-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --test graphql_contract --verbose + - run: cargo test --test backend_parity --verbose diff --git a/README.md b/README.md index 9b378aa..50ab322 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,10 @@ hd status hd availability hd windows hd windows create --label "Focus" --mode busy --days "Mon-Fri" --start 09:00:00 --end 11:30:00 -hd presets create --name "Deep Focus" --alerts do_not_disturb --presence on_keys --duration 90 hd digest list --latest 10 hd autoresponder get +hd grants list-active +hd override get # Set yourself to busy for 2 hours hd busy 2h @@ -46,6 +47,14 @@ hd online # Submit a task for verdict hd verdict "refactor auth module" --files 5 --minutes 30 +# Tune verdict settings +hd verdict-settings set --default-wrap-up-mode wrap_up --wrap-up-threshold-minutes 25 +hd verdict-settings set --thresholds '{"online":{"maxFiles":8,"maxEstimatedMinutes":90}}' + +# Delegation grants and temporary overrides +hd grants create --scope workspace --workspace-ref "$PWD" --permissions preset_apply +hd override set --mode busy --duration-minutes 30 --reason "heads-down coding" + # List and activate presets hd presets hd preset "Focusing" @@ -62,22 +71,21 @@ hd watch | `hd status` | Show your current availability | | `hd availability [--at ]` | Show availability resolution and next transition | | `hd windows [list]` | List configured reachability windows | -| `hd windows create ...` | Create a reachability window | +| `hd windows create ...` | Create a reachability window (supports days like `Mon-Fri` or `Mon,Wed,Fri`) | | `hd windows update ...` | Update a reachability window | | `hd windows delete ` | Delete a reachability window | | `hd presets [list]` | List available presets | -| `hd presets create ...` | Create a preset | -| `hd presets update ...` | Update a preset | -| `hd presets delete ` | Delete a preset | | `hd preset "name"` | Activate a preset | | `hd digest [list] [--latest N]` | List digest summaries | | `hd digest dismiss ` | Dismiss a digest entry | | `hd autoresponder get` | Show auto-responder settings | | `hd autoresponder set ...` | Update busy/limited/offline auto-response text | | `hd verdict-settings get` | Show verdict settings | -| `hd verdict-settings set --mode-thresholds ''` | Update verdict mode thresholds | +| `hd verdict-settings set --thresholds ''` | Update verdict threshold settings | | `hd proposals [--latest N] [--verdict approved\|deferred]` | List recent proposals | | `hd interrupt ` | Evaluate if an interrupt is allowed | +| `hd grants [list-active\|list\|create\|revoke\|revoke-many]` | Manage delegation grants | +| `hd override [get\|set\|clear]` | Manage temporary availability overrides | | `hd whoami` | Show your authenticated identity | | `hd busy [duration]` | Set mode to busy | | `hd online` | Set mode to online | @@ -156,7 +164,7 @@ The CLI uses [Device Flow](https://www.rfc-editor.org/rfc/rfc8628) authenticatio 4. Approve the request 5. The CLI stores your API key locally -Credentials are stored at `~/.config/headsdown/credentials` (XDG-compliant, respects `$XDG_CONFIG_HOME`). +Credentials are stored at `~/.config/headsdown/credentials.json` (XDG-compliant, respects `$XDG_CONFIG_HOME`). A legacy `credentials` token file is still read for backwards compatibility. ## Configuration diff --git a/src/auth.rs b/src/auth.rs index 963c325..11df39c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,27 +1,55 @@ use anyhow::{bail, Context, Result}; use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; -/// Returns the path to the credentials file, respecting XDG_CONFIG_HOME. -fn credentials_path() -> Result { - let dir = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - PathBuf::from(xdg).join("headsdown") +#[derive(Deserialize, Serialize)] +struct JsonCredentials { + #[serde(rename = "apiKey")] + api_key: String, + #[serde(rename = "createdAt")] + created_at: String, + label: Option, +} + +/// Returns the path to the config directory, respecting XDG_CONFIG_HOME. +fn config_dir() -> Result { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + Ok(PathBuf::from(xdg).join("headsdown")) } else if let Some(proj) = ProjectDirs::from("app", "headsdown", "headsdown") { - proj.config_dir().to_path_buf() + Ok(proj.config_dir().to_path_buf()) } else { bail!("Could not determine config directory"); - }; + } +} + +fn legacy_credentials_path() -> Result { + Ok(config_dir()?.join("credentials")) +} - Ok(dir.join("credentials")) +fn json_credentials_path() -> Result { + Ok(config_dir()?.join("credentials.json")) } -/// Load the stored API key, if any. +/// Load the stored API key, if any. Supports both legacy plain-token credentials and modern credentials.json. pub fn load_token() -> Result> { - let path = credentials_path()?; - if path.exists() { - let contents = fs::read_to_string(&path) - .with_context(|| format!("Failed to read {}", path.display()))?; + let json_path = json_credentials_path()?; + if json_path.exists() { + let contents = fs::read_to_string(&json_path) + .with_context(|| format!("Failed to read {}", json_path.display()))?; + if let Ok(parsed) = serde_json::from_str::(&contents) { + let token = parsed.api_key.trim().to_string(); + if !token.is_empty() { + return Ok(Some(token)); + } + } + } + + let legacy_path = legacy_credentials_path()?; + if legacy_path.exists() { + let contents = fs::read_to_string(&legacy_path) + .with_context(|| format!("Failed to read {}", legacy_path.display()))?; let token = contents.trim().to_string(); if token.is_empty() { Ok(None) @@ -33,15 +61,32 @@ pub fn load_token() -> Result> { } } -/// Store the API key to the credentials file. +/// Store the API key to credentials.json (modern format) and legacy credentials for backwards compatibility. pub fn store_token(token: &str) -> Result<()> { - let path = credentials_path()?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create directory {}", parent.display()))?; - } + let dir = config_dir()?; + fs::create_dir_all(&dir) + .with_context(|| format!("Failed to create directory {}", dir.display()))?; + + let trimmed = token.trim(); - // Write with restrictive permissions (owner read/write only) + let json_path = json_credentials_path()?; + let json_payload = JsonCredentials { + api_key: trimmed.to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + label: Some("HeadsDown CLI".to_string()), + }; + write_secure( + &json_path, + &(serde_json::to_string_pretty(&json_payload)? + "\n"), + )?; + + let legacy_path = legacy_credentials_path()?; + write_secure(&legacy_path, trimmed)?; + + Ok(()) +} + +fn write_secure(path: &PathBuf, contents: &str) -> Result<()> { #[cfg(unix)] { use std::io::Write; @@ -51,14 +96,14 @@ pub fn store_token(token: &str) -> Result<()> { .create(true) .truncate(true) .mode(0o600) - .open(&path) + .open(path) .with_context(|| format!("Failed to write {}", path.display()))?; - file.write_all(token.as_bytes())?; + file.write_all(contents.as_bytes())?; } #[cfg(not(unix))] { - fs::write(&path, token).with_context(|| format!("Failed to write {}", path.display()))?; + fs::write(path, contents).with_context(|| format!("Failed to write {}", path.display()))?; } Ok(()) @@ -103,33 +148,16 @@ mod tests { #[test] #[serial] - fn store_creates_parent_directories() { - with_temp_config(|| { - // Temp dir has no headsdown/ subdir yet - store_token("hd_test").unwrap(); - assert!(credentials_path().unwrap().exists()); - }); - } - - #[test] - #[serial] - fn load_returns_none_for_empty_file() { - with_temp_config(|| { - let path = credentials_path().unwrap(); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, "").unwrap(); - assert_eq!(load_token().unwrap(), None); - }); - } - - #[test] - #[serial] - fn load_returns_none_for_whitespace_only_file() { + fn load_reads_json_credentials_when_present() { with_temp_config(|| { - let path = credentials_path().unwrap(); + let path = json_credentials_path().unwrap(); fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, " \n ").unwrap(); - assert_eq!(load_token().unwrap(), None); + fs::write( + &path, + r#"{"apiKey":"hd_from_json","createdAt":"2026-01-01T00:00:00Z"}"#, + ) + .unwrap(); + assert_eq!(load_token().unwrap(), Some("hd_from_json".to_string())); }); } @@ -142,24 +170,6 @@ mod tests { }); } - #[test] - #[serial] - fn require_token_returns_token_when_present() { - with_temp_config(|| { - store_token("hd_xyz").unwrap(); - assert_eq!(require_token().unwrap(), "hd_xyz"); - }); - } - - #[test] - #[serial] - fn store_empty_string_then_load_returns_none() { - with_temp_config(|| { - store_token("").unwrap(); - assert_eq!(load_token().unwrap(), None); - }); - } - #[cfg(unix)] #[test] #[serial] @@ -167,8 +177,7 @@ mod tests { use std::os::unix::fs::PermissionsExt; with_temp_config(|| { store_token("hd_secret").unwrap(); - let path = credentials_path().unwrap(); - let metadata = fs::metadata(&path).unwrap(); + let metadata = fs::metadata(json_credentials_path().unwrap()).unwrap(); let mode = metadata.permissions().mode() & 0o777; assert_eq!(mode, 0o600, "Credentials file should be owner-only (0600)"); }); diff --git a/src/client.rs b/src/client.rs index c149290..4008dd5 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,5 +1,6 @@ use anyhow::{bail, Context, Result}; use reqwest::Client; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::time::Duration; @@ -10,6 +11,7 @@ pub struct GraphQLClient { client: Client, endpoint: String, token: String, + actor_context: Option, } #[derive(Serialize)] @@ -35,6 +37,15 @@ const INITIAL_BACKOFF_MS: u64 = 500; impl GraphQLClient { pub fn new(api_url: &str, token: &str) -> Self { + let workspace_ref = std::env::current_dir() + .ok() + .map(|path| path.display().to_string()); + let actor_context = serde_json::json!({ + "source": "headsdown-cli", + "agentId": "headsdown-cli", + "workspaceRef": workspace_ref, + }); + Self { client: Client::builder() .timeout(Duration::from_secs(30)) @@ -42,6 +53,7 @@ impl GraphQLClient { .unwrap_or_default(), endpoint: format!("{}/graphql", api_url), token: token.to_string(), + actor_context: Some(actor_context.to_string()), } } @@ -81,12 +93,28 @@ impl GraphQLClient { Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Request failed after retries"))) } + /// Execute a GraphQL query/mutation and deserialize the data payload into a typed structure. + pub async fn execute_typed( + &self, + query: &str, + variables: Option, + ) -> Result { + let data = self.execute(query, variables).await?; + serde_json::from_value(data).context("Failed to decode API response shape") + } + async fn send_request(&self, request: &GraphQLRequest) -> Result { - let response = self + let mut request_builder = self .client .post(&self.endpoint) .header("Authorization", format!("Bearer {}", self.token)) - .header("Content-Type", "application/json") + .header("Content-Type", "application/json"); + + if let Some(actor_context) = &self.actor_context { + request_builder = request_builder.header("x-headsdown-actor-context", actor_context); + } + + let response = request_builder .json(request) .send() .await @@ -242,4 +270,34 @@ mod tests { let err = client.execute("query { fail }", None).await.unwrap_err(); assert!(err.to_string().contains("No data returned")); } + + #[tokio::test] + async fn execute_typed_deserializes_data_payload() { + #[derive(Deserialize)] + struct TypedResponse { + profile: Profile, + } + + #[derive(Deserialize)] + struct Profile { + name: String, + } + + let (server, client) = setup().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": {"profile": {"name": "Alice"}} + }))) + .expect(1) + .mount(&server) + .await; + + let data: TypedResponse = client + .execute_typed("query { profile { name } }", None) + .await + .unwrap(); + assert_eq!(data.profile.name, "Alice"); + } } diff --git a/src/commands/availability.rs b/src/commands/availability.rs index f9b7035..b3d074a 100644 --- a/src/commands/availability.rs +++ b/src/commands/availability.rs @@ -1,8 +1,10 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use crate::auth; use crate::client::GraphQLClient; +use crate::contract::availability::{format_days, AvailabilityResolution}; use crate::format; const AVAILABILITY_QUERY: &str = r#" @@ -30,74 +32,82 @@ query Availability($at: DateTime) { } "#; +#[derive(Deserialize, Serialize)] +struct AvailabilityResponse { + availability: Option, +} + pub async fn run(api_url: &str, at: Option, json: bool) -> Result<()> { let token = auth::require_token()?; let client = GraphQLClient::new(api_url, &token); let variables = serde_json::json!({ "at": at }); - let data = client.execute(AVAILABILITY_QUERY, Some(variables)).await?; + let data: AvailabilityResponse = client + .execute_typed(AVAILABILITY_QUERY, Some(variables)) + .await?; + let availability = data + .availability + .ok_or_else(|| anyhow!("No availability found"))?; if json { - println!("{}", serde_json::to_string_pretty(&data["availability"])?); + println!("{}", serde_json::to_string_pretty(&availability)?); return Ok(()); } - let availability = &data["availability"]; - println!(); println!(" {}", format::styled_bold("Availability")); println!(); - if let Some(in_hours) = availability["inReachableHours"].as_bool() { - let state = if in_hours { - "Reachable now" - } else { - "Not reachable now" - }; - println!(" {} {}", format::styled_dimmed("State:"), state); - } - - if let Some(label) = availability["activeWindow"]["label"].as_str() { - let mode = availability["activeWindow"]["mode"] - .as_str() - .unwrap_or("UNKNOWN") - .to_uppercase(); - let days = availability["activeWindow"]["days"].as_str().unwrap_or("-"); - let start = availability["activeWindow"]["startTime"] - .as_str() - .unwrap_or("-"); - let end = availability["activeWindow"]["endTime"] - .as_str() - .unwrap_or("-"); + if let Some(in_hours) = availability.in_reachable_hours { println!( - " {} {} ({})", - format::styled_dimmed("Active:"), - label, - format::color_mode(&mode) - ); - println!( - " {} {} {}-{}", - format::styled_dimmed("Hours:"), - days, - start, - end + " {} {}", + format::styled_dimmed("State:"), + if in_hours { + "Reachable now" + } else { + "Not reachable now" + } ); } - if let Some(label) = availability["nextWindow"]["label"].as_str() { - let mode = availability["nextWindow"]["mode"] - .as_str() - .unwrap_or("UNKNOWN") - .to_uppercase(); - println!( - " {} {} ({})", - format::styled_dimmed("Next window:"), - label, - format::color_mode(&mode) - ); + if let Some(active_window) = availability.active_window { + if let Some(label) = active_window.label { + let mode = active_window + .mode + .unwrap_or_else(|| "UNKNOWN".to_string()) + .to_uppercase(); + println!( + " {} {} ({})", + format::styled_dimmed("Active:"), + label, + format::color_mode(&mode) + ); + println!( + " {} {} {}-{}", + format::styled_dimmed("Hours:"), + format_days(active_window.days.as_ref()), + active_window.start_time.unwrap_or_else(|| "-".to_string()), + active_window.end_time.unwrap_or_else(|| "-".to_string()) + ); + } + } + + if let Some(next_window) = availability.next_window { + if let Some(label) = next_window.label { + let mode = next_window + .mode + .unwrap_or_else(|| "UNKNOWN".to_string()) + .to_uppercase(); + println!( + " {} {} ({})", + format::styled_dimmed("Next window:"), + label, + format::color_mode(&mode) + ); + } } - if let Some(next_transition) = availability["nextTransitionAt"].as_str() { + if let Some(next_transition) = availability.next_transition_at { if let Ok(next_at) = next_transition.parse::>() { println!( " {} {}", diff --git a/src/commands/grants.rs b/src/commands/grants.rs new file mode 100644 index 0000000..66fc3a9 --- /dev/null +++ b/src/commands/grants.rs @@ -0,0 +1,334 @@ +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const ACTIVE_GRANTS_QUERY: &str = r#" +query ActiveDelegationGrants { + activeDelegationGrants { + id + scope + sessionId + workspaceRef + agentId + permissions + source + expiresAt + revokedAt + expiredAt + insertedAt + } +} +"#; + +const GRANTS_QUERY: &str = r#" +query DelegationGrants($filter: DelegationGrantFilterInput) { + delegationGrants(filter: $filter) { + id + scope + sessionId + workspaceRef + agentId + permissions + source + expiresAt + revokedAt + expiredAt + insertedAt + } +} +"#; + +const CREATE_GRANT_MUTATION: &str = r#" +mutation CreateDelegationGrant($input: DelegationGrantInput!) { + createDelegationGrant(input: $input) { + id + scope + sessionId + workspaceRef + agentId + permissions + source + expiresAt + revokedAt + expiredAt + insertedAt + } +} +"#; + +const REVOKE_GRANT_MUTATION: &str = r#" +mutation RevokeDelegationGrant($id: ID!) { + revokeDelegationGrant(id: $id) { + id + scope + expiresAt + revokedAt + } +} +"#; + +const REVOKE_MANY_MUTATION: &str = r#" +mutation RevokeDelegationGrants($filter: DelegationGrantFilterInput) { + revokeDelegationGrants(filter: $filter) { + revokedCount + } +} +"#; + +#[derive(Clone, Debug, Default)] +pub struct GrantsFilterArgs { + pub active: Option, + pub scope: Option, + pub session_id: Option, + pub workspace_ref: Option, + pub agent_id: Option, + pub source: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct CreateGrantArgs { + pub scope: Option, + pub session_id: Option, + pub workspace_ref: Option, + pub agent_id: Option, + pub permissions: Vec, + pub duration_minutes: Option, + pub expires_at: Option, + pub source: Option, +} + +#[derive(Deserialize, Serialize)] +struct DelegationGrant { + id: String, + scope: String, + #[serde(rename = "expiresAt")] + expires_at: Option, + permissions: Option>, +} + +#[derive(Deserialize)] +struct ActiveGrantsResponse { + #[serde(rename = "activeDelegationGrants")] + active_delegation_grants: Vec, +} + +#[derive(Deserialize)] +struct GrantsResponse { + #[serde(rename = "delegationGrants")] + delegation_grants: Vec, +} + +#[derive(Deserialize)] +struct CreateGrantResponse { + #[serde(rename = "createDelegationGrant")] + create_delegation_grant: DelegationGrant, +} + +#[derive(Deserialize, Serialize)] +struct RevokedGrant { + id: String, + scope: String, +} + +#[derive(Deserialize)] +struct RevokeGrantResponse { + #[serde(rename = "revokeDelegationGrant")] + revoke_delegation_grant: RevokedGrant, +} + +#[derive(Deserialize, Serialize)] +struct RevokeManyResult { + #[serde(rename = "revokedCount")] + revoked_count: i64, +} + +#[derive(Deserialize)] +struct RevokeManyResponse { + #[serde(rename = "revokeDelegationGrants")] + revoke_delegation_grants: RevokeManyResult, +} + +pub async fn list_active(api_url: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data: ActiveGrantsResponse = client.execute_typed(ACTIVE_GRANTS_QUERY, None).await?; + output_grants(&data.active_delegation_grants, json) +} + +pub async fn list(api_url: &str, filter: GrantsFilterArgs, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let variables = serde_json::json!({ + "filter": { + "active": filter.active, + "scope": filter.scope.map(|v| v.to_uppercase()), + "sessionId": filter.session_id, + "workspaceRef": filter.workspace_ref, + "agentId": filter.agent_id, + "source": filter.source, + } + }); + + let data: GrantsResponse = client.execute_typed(GRANTS_QUERY, Some(variables)).await?; + output_grants(&data.delegation_grants, json) +} + +pub async fn create(api_url: &str, args: CreateGrantArgs, json: bool) -> Result<()> { + let scope = args + .scope + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--scope is required for grants create"))? + .to_uppercase(); + + if args.permissions.is_empty() { + bail!("--permissions is required for grants create"); + } + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let variables = serde_json::json!({ + "input": { + "scope": scope, + "sessionId": args.session_id, + "workspaceRef": args.workspace_ref, + "agentId": args.agent_id, + "permissions": args.permissions.into_iter().map(|v| v.to_uppercase()).collect::>(), + "durationMinutes": args.duration_minutes, + "expiresAt": args.expires_at, + "source": args.source.unwrap_or_else(|| "hd".to_string()), + } + }); + + let data: CreateGrantResponse = client + .execute_typed(CREATE_GRANT_MUTATION, Some(variables)) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data.create_delegation_grant)? + ); + return Ok(()); + } + + println!(); + println!(" {} Grant created", format::styled_green_bold("✓")); + println!( + " {} {}", + format::styled_dimmed("ID:"), + data.create_delegation_grant.id + ); + println!( + " {} {}", + format::styled_dimmed("Scope:"), + data.create_delegation_grant.scope + ); + println!(); + Ok(()) +} + +pub async fn revoke(api_url: &str, id: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data: RevokeGrantResponse = client + .execute_typed(REVOKE_GRANT_MUTATION, Some(serde_json::json!({ "id": id }))) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data.revoke_delegation_grant)? + ); + return Ok(()); + } + + println!(); + println!(" {} Grant revoked", format::styled_green_bold("✓")); + println!(" {} {}", format::styled_dimmed("ID:"), id); + println!(); + Ok(()) +} + +pub async fn revoke_many(api_url: &str, filter: GrantsFilterArgs, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let variables = serde_json::json!({ + "filter": { + "active": filter.active, + "scope": filter.scope.map(|v| v.to_uppercase()), + "sessionId": filter.session_id, + "workspaceRef": filter.workspace_ref, + "agentId": filter.agent_id, + "source": filter.source, + } + }); + + let data: RevokeManyResponse = client + .execute_typed(REVOKE_MANY_MUTATION, Some(variables)) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data.revoke_delegation_grants)? + ); + return Ok(()); + } + + println!(); + println!( + " {} Revoked {} grants", + format::styled_green_bold("✓"), + data.revoke_delegation_grants.revoked_count + ); + println!(); + Ok(()) +} + +fn output_grants(grants: &[DelegationGrant], json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(grants)?); + return Ok(()); + } + + println!(); + println!(" {}", format::styled_bold("Delegation Grants")); + println!(); + + if grants.is_empty() { + println!(" {}", format::styled_dimmed("No grants found.")); + println!(); + return Ok(()); + } + + for grant in grants { + println!( + " {} {}", + format::styled_dimmed("•"), + format::styled_bold(&grant.id) + ); + println!(" {} {}", format::styled_dimmed("Scope:"), grant.scope); + println!( + " {} {}", + format::styled_dimmed("Expires:"), + grant.expires_at.clone().unwrap_or_else(|| "-".to_string()) + ); + + if let Some(perms) = &grant.permissions { + let joined = perms.join(", "); + if !joined.is_empty() { + println!(" {} {}", format::styled_dimmed("Permissions:"), joined); + } + } + + println!(); + } + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9ede480..a984af9 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,10 +5,12 @@ pub mod availability; pub mod calibration_cmd; pub mod digest; pub mod doctor; +pub mod grants; pub mod hooks; pub mod interrupt; pub mod mode; pub mod outcome; +pub mod override_cmd; pub mod presets; pub mod proposals; pub mod status; diff --git a/src/commands/override_cmd.rs b/src/commands/override_cmd.rs new file mode 100644 index 0000000..80866cb --- /dev/null +++ b/src/commands/override_cmd.rs @@ -0,0 +1,223 @@ +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; + +use crate::auth; +use crate::client::GraphQLClient; +use crate::format; + +const ACTIVE_OVERRIDE_QUERY: &str = r#" +query ActiveAvailabilityOverride { + activeAvailabilityOverride { + id + mode + reason + source + expiresAt + cancelledAt + expiredAt + insertedAt + updatedAt + } +} +"#; + +const CREATE_OVERRIDE_MUTATION: &str = r#" +mutation CreateAvailabilityOverride($input: AvailabilityOverrideInput!) { + createAvailabilityOverride(input: $input) { + id + mode + reason + source + expiresAt + cancelledAt + expiredAt + insertedAt + updatedAt + } +} +"#; + +const CANCEL_OVERRIDE_MUTATION: &str = r#" +mutation CancelAvailabilityOverride($id: ID!, $reason: String, $source: String) { + cancelAvailabilityOverride(id: $id, reason: $reason, source: $source) { + id + mode + reason + source + expiresAt + cancelledAt + expiredAt + insertedAt + updatedAt + } +} +"#; + +#[derive(Deserialize)] +struct ActiveOverrideResponse { + #[serde(rename = "activeAvailabilityOverride")] + active_availability_override: Option, +} + +#[derive(Deserialize)] +struct CreateOverrideResponse { + #[serde(rename = "createAvailabilityOverride")] + create_availability_override: AvailabilityOverride, +} + +#[derive(Deserialize)] +struct CancelOverrideResponse { + #[serde(rename = "cancelAvailabilityOverride")] + cancel_availability_override: AvailabilityOverride, +} + +#[derive(Deserialize, Serialize, Clone)] +struct AvailabilityOverride { + id: String, + mode: String, + #[serde(rename = "expiresAt")] + expires_at: Option, + #[serde(rename = "cancelledAt")] + cancelled_at: Option, +} + +pub async fn get(api_url: &str, json: bool) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data: ActiveOverrideResponse = client.execute_typed(ACTIVE_OVERRIDE_QUERY, None).await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data.active_availability_override)? + ); + return Ok(()); + } + + println!(); + let current = if let Some(value) = data.active_availability_override { + value + } else { + println!(" {}", format::styled_dimmed("No active override.")); + println!(); + return Ok(()); + }; + + println!(" {}", format::styled_bold("Active Override")); + println!(" {} {}", format::styled_dimmed("ID:"), current.id); + println!(" {} {}", format::styled_dimmed("Mode:"), current.mode); + println!( + " {} {}", + format::styled_dimmed("Expires:"), + current.expires_at.unwrap_or_else(|| "-".to_string()) + ); + println!(); + Ok(()) +} + +pub async fn set( + api_url: &str, + mode: Option, + duration_minutes: Option, + expires_at: Option, + reason: Option, + json: bool, +) -> Result<()> { + let mode = mode.ok_or_else(|| anyhow::anyhow!("--mode is required for override set"))?; + + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + let data: CreateOverrideResponse = client + .execute_typed( + CREATE_OVERRIDE_MUTATION, + Some(serde_json::json!({ + "input": { + "mode": mode.to_uppercase(), + "durationMinutes": duration_minutes, + "expiresAt": expires_at, + "reason": reason, + "source": "hd", + } + })), + ) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data.create_availability_override)? + ); + return Ok(()); + } + + println!(); + println!(" {} Override set", format::styled_green_bold("✓")); + println!( + " {} {}", + format::styled_dimmed("Mode:"), + data.create_availability_override.mode + ); + println!(); + Ok(()) +} + +pub async fn clear( + api_url: &str, + id: Option, + reason: Option, + json: bool, +) -> Result<()> { + let token = auth::require_token()?; + let client = GraphQLClient::new(api_url, &token); + + let override_id = if let Some(value) = id { + value + } else { + let data: ActiveOverrideResponse = + client.execute_typed(ACTIVE_OVERRIDE_QUERY, None).await?; + let active = if let Some(value) = data.active_availability_override { + value + } else { + if json { + println!("{}", serde_json::json!({ "override": null })); + } else { + println!(); + println!( + " {}", + format::styled_dimmed("No active override to clear.") + ); + println!(); + } + return Ok(()); + }; + active.id + }; + + if override_id.is_empty() { + bail!("Override id is required"); + } + + let data: CancelOverrideResponse = client + .execute_typed( + CANCEL_OVERRIDE_MUTATION, + Some(serde_json::json!({ + "id": override_id, + "reason": reason, + "source": "hd", + })), + ) + .await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&data.cancel_availability_override)? + ); + return Ok(()); + } + + println!(); + println!(" {} Override cleared", format::styled_green_bold("✓")); + println!(); + Ok(()) +} diff --git a/src/commands/presets.rs b/src/commands/presets.rs index b56c5f7..7332eb4 100644 --- a/src/commands/presets.rs +++ b/src/commands/presets.rs @@ -1,5 +1,5 @@ -use anyhow::{bail, Result}; -use serde_json::Value; +use anyhow::Result; +use serde::{Deserialize, Serialize}; use crate::auth; use crate::client::GraphQLClient; @@ -10,12 +10,12 @@ query { presets { id name - alerts - presence - duration status statusEmoji statusText + duration + insertedAt + updatedAt } } "#; @@ -23,83 +23,66 @@ query { const APPLY_PRESET_MUTATION: &str = r#" mutation ApplyPreset($id: ID!) { applyPreset(id: $id) { - mode - expiresAt - duration - statusText - statusEmoji - } -} -"#; - -const CREATE_PRESET_MUTATION: &str = r#" -mutation CreatePreset($input: PresetInput!) { - createPreset(input: $input) { id - name - alerts - presence - duration + mode status statusEmoji statusText + autoRespond + lock + duration + ruleSetType + ruleSetParams + expiresAt + insertedAt } } "#; -const UPDATE_PRESET_MUTATION: &str = r#" -mutation UpdatePreset($id: ID!, $input: PresetInput!) { - updatePreset(id: $id, input: $input) { - id - name - alerts - presence - duration - status - statusEmoji - statusText - } +#[derive(Deserialize)] +struct PresetsResponse { + presets: Vec, } -"#; -const DELETE_PRESET_MUTATION: &str = r#" -mutation DeletePreset($id: ID!) { - deletePreset(id: $id) { - id - name - } +#[derive(Deserialize, Serialize, Clone)] +struct Preset { + id: String, + name: String, + #[serde(rename = "statusEmoji")] + status_emoji: Option, + #[serde(rename = "statusText")] + status_text: Option, + duration: Option, +} + +#[derive(Deserialize)] +struct ApplyPresetResponse { + #[serde(rename = "applyPreset")] + apply_preset: AppliedPreset, } -"#; -#[derive(Clone, Debug, Default)] -pub struct PresetInputArgs { - pub name: Option, - pub alerts: Option, - pub presence: Option, - pub duration: Option, - pub status: Option, - pub status_emoji: Option, - pub status_text: Option, +#[derive(Deserialize, Serialize)] +struct AppliedPreset { + id: String, + mode: String, + #[serde(rename = "expiresAt")] + expires_at: Option, } pub async fn list(api_url: &str, json: bool) -> Result<()> { let token = auth::require_token()?; let client = GraphQLClient::new(api_url, &token); - let data = client.execute(PRESETS_QUERY, None).await?; + let data: PresetsResponse = client.execute_typed(PRESETS_QUERY, None).await?; if json { - println!("{}", serde_json::to_string_pretty(&data["presets"])?); + println!("{}", serde_json::to_string_pretty(&data.presets)?); return Ok(()); } - let presets = data["presets"] - .as_array() - .ok_or_else(|| anyhow::anyhow!("No presets found"))?; - - if presets.is_empty() { + if data.presets.is_empty() { println!(); - println!(" No presets configured. Create one with `hd presets create`."); + println!(" No presets configured."); println!(); return Ok(()); } @@ -108,7 +91,7 @@ pub async fn list(api_url: &str, json: bool) -> Result<()> { println!(" {}", format::styled_bold("Available Presets")); println!(); - for preset in presets { + for preset in &data.presets { print_preset(preset); } @@ -127,20 +110,15 @@ pub async fn activate(api_url: &str, name_or_id: &str, json: bool) -> Result<()> let token = auth::require_token()?; let client = GraphQLClient::new(api_url, &token); - let data = client.execute(PRESETS_QUERY, None).await?; - let presets = data["presets"] - .as_array() - .ok_or_else(|| anyhow::anyhow!("No presets found"))?; + let data: PresetsResponse = client.execute_typed(PRESETS_QUERY, None).await?; - let preset = presets + let preset = data + .presets .iter() - .find(|p| { - let pname = p["name"].as_str().unwrap_or(""); - let pid = p["id"].as_str().unwrap_or(""); - pname.eq_ignore_ascii_case(name_or_id) || pid == name_or_id - }) + .find(|p| p.name.eq_ignore_ascii_case(name_or_id) || p.id == name_or_id) + .cloned() .ok_or_else(|| { - let names: Vec<&str> = presets.iter().filter_map(|p| p["name"].as_str()).collect(); + let names: Vec = data.presets.iter().map(|p| p.name.clone()).collect(); anyhow::anyhow!( "Preset '{}' not found. Available: {}", name_or_id, @@ -148,36 +126,27 @@ pub async fn activate(api_url: &str, name_or_id: &str, json: bool) -> Result<()> ) })?; - let preset_id = preset["id"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("Preset missing ID"))?; - let preset_name = preset["name"].as_str().unwrap_or(name_or_id); - - let variables = serde_json::json!({ "id": preset_id }); - let data = client - .execute(APPLY_PRESET_MUTATION, Some(variables)) + let variables = serde_json::json!({ "id": preset.id }); + let applied: ApplyPresetResponse = client + .execute_typed(APPLY_PRESET_MUTATION, Some(variables)) .await?; if json { - println!("{}", serde_json::to_string_pretty(&data["applyPreset"])?); + println!("{}", serde_json::to_string_pretty(&applied.apply_preset)?); return Ok(()); } - let contract = &data["applyPreset"]; - let mode = contract["mode"] - .as_str() - .unwrap_or("UNKNOWN") - .to_uppercase(); + let mode = applied.apply_preset.mode.to_uppercase(); println!(); print!( " {} Applied preset \"{}\" - now {}", format::styled_green_bold("✓"), - format::styled_bold(preset_name), + format::styled_bold(&preset.name), format::color_mode(&mode) ); - if let Some(expires_str) = contract["expiresAt"].as_str() { + if let Some(expires_str) = applied.apply_preset.expires_at { if let Ok(expires_at) = expires_str.parse::>() { let remaining = expires_at.signed_duration_since(chrono::Utc::now()); if remaining.num_minutes() > 0 { @@ -192,144 +161,11 @@ pub async fn activate(api_url: &str, name_or_id: &str, json: bool) -> Result<()> Ok(()) } -pub async fn create(api_url: &str, args: PresetInputArgs, json: bool) -> Result<()> { - if args.name.is_none() { - bail!("Create requires --name."); - } - - let token = auth::require_token()?; - let client = GraphQLClient::new(api_url, &token); - let input = build_input(args); - let data = client - .execute( - CREATE_PRESET_MUTATION, - Some(serde_json::json!({ "input": input })), - ) - .await?; - - if json { - println!("{}", serde_json::to_string_pretty(&data["createPreset"])?); - return Ok(()); - } - - println!(); - println!(" {} Preset created", format::styled_green_bold("✓")); - println!(); - print_preset(&data["createPreset"]); - Ok(()) -} - -pub async fn update(api_url: &str, id: &str, args: PresetInputArgs, json: bool) -> Result<()> { - if all_fields_empty(&args) { - bail!("No updates provided. Pass at least one field to update."); - } - - let token = auth::require_token()?; - let client = GraphQLClient::new(api_url, &token); - let input = build_input(args); - let data = client - .execute( - UPDATE_PRESET_MUTATION, - Some(serde_json::json!({ "id": id, "input": input })), - ) - .await?; - - if json { - println!("{}", serde_json::to_string_pretty(&data["updatePreset"])?); - return Ok(()); - } - - println!(); - println!(" {} Preset updated", format::styled_green_bold("✓")); - println!(); - print_preset(&data["updatePreset"]); - Ok(()) -} - -pub async fn delete(api_url: &str, id: &str, json: bool) -> Result<()> { - let token = auth::require_token()?; - let client = GraphQLClient::new(api_url, &token); - let data = client - .execute( - DELETE_PRESET_MUTATION, - Some(serde_json::json!({ "id": id })), - ) - .await?; - - if json { - println!("{}", serde_json::to_string_pretty(&data["deletePreset"])?); - return Ok(()); - } - - println!(); - println!( - " {} Deleted preset {}", - format::styled_green_bold("✓"), - format::styled_bold(data["deletePreset"]["name"].as_str().unwrap_or("Unknown")) - ); - println!( - " {} {}", - format::styled_dimmed("ID:"), - format::styled_dimmed(id) - ); - println!(); - Ok(()) -} - -fn build_input(args: PresetInputArgs) -> Value { - let mut input = serde_json::json!({}); - - if let Some(name) = args.name { - input["name"] = serde_json::json!(name); - } - if let Some(alerts) = args.alerts { - input["alerts"] = serde_json::json!(normalize_enum(&alerts)); - } - if let Some(presence) = args.presence { - input["presence"] = serde_json::json!(normalize_enum(&presence)); - } - if let Some(duration) = args.duration { - input["duration"] = serde_json::json!(duration); - } - if let Some(status) = args.status { - input["status"] = serde_json::json!(status); - } - if let Some(status_emoji) = args.status_emoji { - input["statusEmoji"] = serde_json::json!(status_emoji); - } - if let Some(status_text) = args.status_text { - input["statusText"] = serde_json::json!(status_text); - } - - input -} - -fn all_fields_empty(args: &PresetInputArgs) -> bool { - args.name.is_none() - && args.alerts.is_none() - && args.presence.is_none() - && args.duration.is_none() - && args.status.is_none() - && args.status_emoji.is_none() - && args.status_text.is_none() -} - -fn normalize_enum(input: &str) -> String { - input.trim().replace('-', "_").to_uppercase() -} - -fn print_preset(preset: &Value) { - let id = preset["id"].as_str().unwrap_or("-"); - let name = preset["name"].as_str().unwrap_or("Unknown"); - let alerts = preset["alerts"].as_str().unwrap_or(""); - let presence = preset["presence"].as_str().unwrap_or(""); - let emoji = preset["statusEmoji"].as_str().unwrap_or(""); - let status_text = preset["statusText"].as_str().unwrap_or(""); - - let display_name = if !emoji.is_empty() { - format!("{} {}", emoji, name) +fn print_preset(preset: &Preset) { + let display_name = if let Some(emoji) = &preset.status_emoji { + format!("{} {}", emoji, preset.name) } else { - name.to_string() + preset.name.clone() }; print!( @@ -337,51 +173,40 @@ fn print_preset(preset: &Value) { format::styled_dimmed("•"), format::styled_bold(&display_name) ); - if let Some(duration) = preset["duration"].as_i64() { + if let Some(duration) = preset.duration { print!(" ({})", format::format_duration(duration)); } println!(); - let mut details = Vec::new(); - if !alerts.is_empty() { - details.push(format!("alerts: {}", format_enum_value(alerts))); - } - if !presence.is_empty() { - details.push(format!("presence: {}", format_enum_value(presence))); - } - if !status_text.is_empty() { - details.push(format!("\"{}\"", status_text)); - } - if !details.is_empty() { - println!(" {}", format::styled_dimmed(&details.join(" · "))); + if let Some(status_text) = &preset.status_text { + if !status_text.is_empty() { + println!( + " {}", + format::styled_dimmed(&format!("\"{}\"", status_text)) + ); + } } println!( " {} {}", format::styled_dimmed("ID:"), - format::styled_dimmed(id) + format::styled_dimmed(&preset.id) ); } -fn format_enum_value(s: &str) -> String { - s.to_lowercase().replace('_', " ") -} - #[cfg(test)] mod tests { - use super::*; - - #[test] - fn normalize_enum_maps_to_upper_snake_case() { - assert_eq!(normalize_enum("do_not_disturb"), "DO_NOT_DISTURB"); - assert_eq!(normalize_enum("take-a-number"), "TAKE_A_NUMBER"); - } - #[test] - fn update_requires_at_least_one_field() { - assert!(all_fields_empty(&PresetInputArgs::default())); - assert!(!all_fields_empty(&PresetInputArgs { - name: Some("Focus".to_string()), - ..PresetInputArgs::default() - })); + fn preset_lookup_is_case_insensitive() { + let list = serde_json::json!([ + {"id":"1","name":"Focus"}, + {"id":"2","name":"Meetings"} + ]); + let presets = list.as_array().unwrap(); + + let found = presets + .iter() + .find(|p| p["name"].as_str().unwrap().eq_ignore_ascii_case("focus")) + .unwrap(); + assert_eq!(found["id"], "1"); } } diff --git a/src/commands/status.rs b/src/commands/status.rs index 5bfb443..298e3d1 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -1,8 +1,10 @@ use anyhow::Result; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use crate::auth; use crate::client::GraphQLClient; +use crate::contract::availability::AvailabilityResolution; use crate::format; const STATUS_QUERY: &str = r#" @@ -33,32 +35,60 @@ query { } "#; +#[derive(Deserialize, Serialize)] +struct StatusResponse { + #[serde(rename = "activeContract")] + active_contract: Option, + availability: Option, + profile: Option, +} + +#[derive(Deserialize, Serialize)] +struct ActiveContract { + mode: Option, + #[serde(rename = "statusText")] + status_text: Option, + #[serde(rename = "statusEmoji")] + status_emoji: Option, + #[serde(rename = "expiresAt")] + expires_at: Option, + lock: Option, +} + +#[derive(Deserialize, Serialize)] +struct Profile { + name: Option, +} + pub async fn run(api_url: &str, json: bool) -> Result<()> { let token = auth::require_token()?; let client = GraphQLClient::new(api_url, &token); - let data = client.execute(STATUS_QUERY, None).await?; + let data: StatusResponse = client.execute_typed(STATUS_QUERY, None).await?; if json { println!("{}", serde_json::to_string_pretty(&data)?); return Ok(()); } - let contract = &data["activeContract"]; - let availability = &data["availability"]; - let profile = &data["profile"]; - - let name = profile["name"].as_str().unwrap_or("Unknown"); - let mode = contract["mode"] - .as_str() - .unwrap_or("UNKNOWN") + let contract = data.active_contract; + let availability = data.availability; + let profile = data.profile; + + let name = profile + .and_then(|p| p.name) + .unwrap_or_else(|| "Unknown".to_string()); + let mode = contract + .as_ref() + .and_then(|c| c.mode.clone()) + .unwrap_or_else(|| "UNKNOWN".to_string()) .to_uppercase(); println!(); println!( " {} {}", format::styled_bold("●"), - format::styled_bold(name) + format::styled_bold(&name) ); println!(); println!( @@ -67,47 +97,52 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { format::color_mode(&mode) ); - // Status text - if let Some(emoji) = contract["statusEmoji"].as_str() { - if let Some(text) = contract["statusText"].as_str() { + if let Some(text) = contract.as_ref().and_then(|c| c.status_text.clone()) { + if let Some(emoji) = contract.as_ref().and_then(|c| c.status_emoji.clone()) { println!(" {} {} {}", format::styled_dimmed("Status:"), emoji, text); + } else { + println!(" {} {}", format::styled_dimmed("Status:"), text); } - } else if let Some(text) = contract["statusText"].as_str() { - println!(" {} {}", format::styled_dimmed("Status:"), text); } - // Duration / expires at - if let Some(expires_str) = contract["expiresAt"].as_str() { + if let Some(expires_str) = contract.as_ref().and_then(|c| c.expires_at.clone()) { if let Ok(expires_at) = expires_str.parse::>() { - let now = Utc::now(); - let remaining = expires_at.signed_duration_since(now); - + let remaining = expires_at.signed_duration_since(Utc::now()); if remaining.num_minutes() > 0 { - let formatted = format::format_duration(remaining.num_minutes()); println!( " {} {} remaining (until {})", format::styled_dimmed("Time:"), - format::styled_bold(&formatted), + format::styled_bold(&format::format_duration(remaining.num_minutes())), expires_at.format("%l:%M %p").to_string().trim() ); } } } - if let Some(in_hours) = availability["inReachableHours"].as_bool() { - let state = if in_hours { - "Reachable now" - } else { - "Not reachable now" - }; - println!(" {} {}", format::styled_dimmed("Availability:"), state); + if let Some(in_hours) = availability.as_ref().and_then(|a| a.in_reachable_hours) { + println!( + " {} {}", + format::styled_dimmed("Availability:"), + if in_hours { + "Reachable now" + } else { + "Not reachable now" + } + ); } - if let Some(label) = availability["activeWindow"]["label"].as_str() { + if let Some(label) = availability + .as_ref() + .and_then(|a| a.active_window.as_ref()) + .and_then(|w| w.label.clone()) + { println!(" {} {}", format::styled_dimmed("Window:"), label); } - if let Some(next_transition) = availability["nextTransitionAt"].as_str() { + if let Some(next_transition) = availability + .as_ref() + .and_then(|a| a.next_transition_at.clone()) + { if let Ok(next_at) = next_transition.parse::>() { println!( " {} {}", @@ -117,7 +152,7 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { } } - if contract["lock"].as_bool() == Some(true) { + if contract.as_ref().and_then(|c| c.lock) == Some(true) { println!( " {} {}", format::styled_dimmed("Lock:"), diff --git a/src/commands/verdict.rs b/src/commands/verdict.rs index 3b332f2..606051a 100644 --- a/src/commands/verdict.rs +++ b/src/commands/verdict.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use serde::{Deserialize, Serialize}; use crate::auth; use crate::client::GraphQLClient; @@ -8,15 +9,48 @@ const SUBMIT_PROPOSAL_MUTATION: &str = r#" mutation SubmitProposal($input: ProposalInput!) { submitProposal(input: $input) { decision - policy - policyStatus reason proposalId evaluatedAt + wrapUpGuidance { + active + deadlineAt + remainingMinutes + profile + source + reason + hints + thresholdMinutes + selectedMode + } } } "#; +#[derive(Deserialize)] +struct SubmitProposalResponse { + #[serde(rename = "submitProposal")] + submit_proposal: Verdict, +} + +#[derive(Deserialize, Serialize)] +struct Verdict { + decision: String, + reason: String, + #[serde(rename = "proposalId")] + proposal_id: String, + #[serde(rename = "wrapUpGuidance")] + wrap_up_guidance: Option, +} + +#[derive(Deserialize, Serialize)] +struct WrapUpGuidance { + #[serde(rename = "selectedMode")] + selected_mode: Option, + #[serde(rename = "remainingMinutes")] + remaining_minutes: Option, +} + pub async fn run( api_url: &str, description: &str, @@ -45,23 +79,18 @@ pub async fn run( } let variables = serde_json::json!({ "input": input }); - let data = client - .execute(SUBMIT_PROPOSAL_MUTATION, Some(variables)) + let data: SubmitProposalResponse = client + .execute_typed(SUBMIT_PROPOSAL_MUTATION, Some(variables)) .await?; if json { - println!("{}", serde_json::to_string_pretty(&data["submitProposal"])?); + println!("{}", serde_json::to_string_pretty(&data.submit_proposal)?); return Ok(()); } - let verdict = &data["submitProposal"]; - let decision = verdict["decision"] - .as_str() - .unwrap_or("UNKNOWN") - .to_uppercase(); - let policy = verdict["policy"].as_str().unwrap_or("UNKNOWN"); - let policy_status = verdict["policyStatus"].as_str().unwrap_or("UNKNOWN"); - let reason = verdict["reason"].as_str().unwrap_or("No reason provided"); + let verdict = data.submit_proposal; + let decision = verdict.decision.to_uppercase(); + let reason = verdict.reason; println!(); println!( @@ -70,10 +99,21 @@ pub async fn run( format::color_verdict(&decision) ); println!(); - println!(" {} {}", format::styled_dimmed("Policy:"), policy); - println!(" {} {}", format::styled_dimmed("State:"), policy_status); println!(" {} {}", format::styled_dimmed("Reason:"), reason); + if let Some(wrap_up_guidance) = verdict.wrap_up_guidance { + if let Some(mode) = wrap_up_guidance.selected_mode { + println!(" {} {}", format::styled_dimmed("Delivery mode:"), mode); + } + if let Some(minutes) = wrap_up_guidance.remaining_minutes { + println!( + " {} {} min", + format::styled_dimmed("Attention window:"), + minutes + ); + } + } + // Show the proposal details println!(); println!(" {} {}", format::styled_dimmed("Task:"), description); diff --git a/src/commands/verdict_settings.rs b/src/commands/verdict_settings.rs index 17979bb..9d4d9c9 100644 --- a/src/commands/verdict_settings.rs +++ b/src/commands/verdict_settings.rs @@ -1,4 +1,5 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::auth; @@ -9,70 +10,148 @@ const VERDICT_SETTINGS_QUERY: &str = r#" query { verdictSettings { id - modeThresholds + thresholds { + online { + maxFiles + maxEstimatedMinutes + } + busy { + maxFiles + maxEstimatedMinutes + } + limited { + maxFiles + maxEstimatedMinutes + } + offline { + maxFiles + maxEstimatedMinutes + } + } + defaultWrapUpMode + wrapUpThresholdMinutes updatedAt } } "#; const UPDATE_VERDICT_SETTINGS_MUTATION: &str = r#" -mutation UpdateVerdictSettings($modeThresholds: JSON) { - updateVerdictSettings(modeThresholds: $modeThresholds) { +mutation UpdateVerdictSettings($thresholds: VerdictModeThresholdsInput, $defaultWrapUpMode: WrapUpMode, $wrapUpThresholdMinutes: Int) { + updateVerdictSettings(thresholds: $thresholds, defaultWrapUpMode: $defaultWrapUpMode, wrapUpThresholdMinutes: $wrapUpThresholdMinutes) { id - modeThresholds + thresholds { + online { + maxFiles + maxEstimatedMinutes + } + busy { + maxFiles + maxEstimatedMinutes + } + limited { + maxFiles + maxEstimatedMinutes + } + offline { + maxFiles + maxEstimatedMinutes + } + } + defaultWrapUpMode + wrapUpThresholdMinutes updatedAt } } "#; +#[derive(Deserialize)] +struct VerdictSettingsResponse { + #[serde(rename = "verdictSettings")] + verdict_settings: VerdictSettings, +} + +#[derive(Deserialize)] +struct UpdateVerdictSettingsResponse { + #[serde(rename = "updateVerdictSettings")] + update_verdict_settings: VerdictSettings, +} + +#[derive(Deserialize, Serialize)] +struct VerdictSettings { + id: String, + thresholds: Value, + #[serde(rename = "defaultWrapUpMode")] + default_wrap_up_mode: String, + #[serde(rename = "wrapUpThresholdMinutes")] + wrap_up_threshold_minutes: i64, + #[serde(rename = "updatedAt")] + updated_at: String, +} + pub async fn get(api_url: &str, json: bool) -> Result<()> { let token = auth::require_token()?; let client = GraphQLClient::new(api_url, &token); - let data = client.execute(VERDICT_SETTINGS_QUERY, None).await?; + let data: VerdictSettingsResponse = client.execute_typed(VERDICT_SETTINGS_QUERY, None).await?; if json { - println!( - "{}", - serde_json::to_string_pretty(&data["verdictSettings"])? - ); + println!("{}", serde_json::to_string_pretty(&data.verdict_settings)?); return Ok(()); } - let settings = &data["verdictSettings"]; + let settings = data.verdict_settings; println!(); println!(" {}", format::styled_bold("Verdict settings")); println!(); + println!(" {} {}", format::styled_dimmed("ID:"), settings.id); println!( " {} {}", - format::styled_dimmed("ID:"), - settings["id"].as_str().unwrap_or("-") + format::styled_dimmed("Default wrap-up mode:"), + settings.default_wrap_up_mode ); - println!(" {}", format::styled_dimmed("Mode thresholds:")); println!( - "{}", - serde_json::to_string_pretty(&settings["modeThresholds"])? + " {} {}", + format::styled_dimmed("Wrap-up threshold:"), + format!("{} min", settings.wrap_up_threshold_minutes) ); + println!(" {}", format::styled_dimmed("Thresholds:")); + println!("{}", serde_json::to_string_pretty(&settings.thresholds)?); println!(); Ok(()) } -pub async fn set(api_url: &str, mode_thresholds: &str, json: bool) -> Result<()> { - let parsed: Value = - serde_json::from_str(mode_thresholds).context("mode_thresholds must be valid JSON")?; +pub async fn set( + api_url: &str, + thresholds: Option<&str>, + default_wrap_up_mode: Option<&str>, + wrap_up_threshold_minutes: Option, + json: bool, +) -> Result<()> { + if thresholds.is_none() && default_wrap_up_mode.is_none() && wrap_up_threshold_minutes.is_none() + { + bail!("No updates provided. Pass at least one of --thresholds, --default-wrap-up-mode, or --wrap-up-threshold-minutes."); + } + + let parsed_thresholds: Option = thresholds + .map(|raw| serde_json::from_str(raw).context("thresholds must be valid JSON")) + .transpose()?; let token = auth::require_token()?; let client = GraphQLClient::new(api_url, &token); - let data = client - .execute( + let data: UpdateVerdictSettingsResponse = client + .execute_typed( UPDATE_VERDICT_SETTINGS_MUTATION, - Some(serde_json::json!({ "modeThresholds": parsed })), + Some(serde_json::json!({ + "thresholds": parsed_thresholds, + "defaultWrapUpMode": default_wrap_up_mode.map(|v| v.to_uppercase()), + "wrapUpThresholdMinutes": wrap_up_threshold_minutes, + })), ) .await?; if json { println!( "{}", - serde_json::to_string_pretty(&data["updateVerdictSettings"])? + serde_json::to_string_pretty(&data.update_verdict_settings)? ); return Ok(()); } @@ -85,7 +164,7 @@ pub async fn set(api_url: &str, mode_thresholds: &str, json: bool) -> Result<()> println!(); println!( "{}", - serde_json::to_string_pretty(&data["updateVerdictSettings"]["modeThresholds"])? + serde_json::to_string_pretty(&data.update_verdict_settings)? ); println!(); Ok(()) diff --git a/src/commands/windows.rs b/src/commands/windows.rs index cc57db2..e373f2f 100644 --- a/src/commands/windows.rs +++ b/src/commands/windows.rs @@ -1,8 +1,10 @@ -use anyhow::{bail, Result}; +use anyhow::{anyhow, bail, Result}; +use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::auth; use crate::client::GraphQLClient; +use crate::contract::availability::{format_days, DaysField}; use crate::format; const WINDOWS_QUERY: &str = r#" @@ -101,34 +103,68 @@ pub struct WindowInputArgs { pub status_text: Option, } +#[derive(Deserialize)] +struct WindowsResponse { + #[serde(rename = "reachabilityWindows")] + reachability_windows: Vec, +} + +#[derive(Deserialize)] +struct WindowMutationResponse { + #[serde(rename = "createReachabilityWindow")] + create_reachability_window: Option, + #[serde(rename = "updateReachabilityWindow")] + update_reachability_window: Option, + #[serde(rename = "deleteReachabilityWindow")] + delete_reachability_window: Option, +} + +#[derive(Deserialize, Serialize)] +struct ReachabilityWindow { + id: String, + label: String, + mode: String, + days: Option, + #[serde(rename = "startTime")] + start_time: Option, + #[serde(rename = "endTime")] + end_time: Option, + #[serde(rename = "alertsPolicy")] + alerts_policy: Option, + #[serde(rename = "autoActivate")] + auto_activate: Option, + priority: Option, + status: Option, + #[serde(rename = "statusEmoji")] + status_emoji: Option, + #[serde(rename = "statusText")] + status_text: Option, +} + pub async fn list(api_url: &str, json: bool) -> Result<()> { let token = auth::require_token()?; let client = GraphQLClient::new(api_url, &token); - let data = client.execute(WINDOWS_QUERY, None).await?; + let data: WindowsResponse = client.execute_typed(WINDOWS_QUERY, None).await?; if json { println!( "{}", - serde_json::to_string_pretty(&data["reachabilityWindows"])? + serde_json::to_string_pretty(&data.reachability_windows)? ); return Ok(()); } - let windows = data["reachabilityWindows"] - .as_array() - .ok_or_else(|| anyhow::anyhow!("No reachability windows found"))?; - println!(); println!(" {}", format::styled_bold("Reachability Windows")); println!(); - if windows.is_empty() { + if data.reachability_windows.is_empty() { println!(" {}", format::styled_dimmed("No windows configured")); println!(); return Ok(()); } - for window in windows { + for window in &data.reachability_windows { print_window(window); } @@ -143,22 +179,22 @@ pub async fn create(api_url: &str, args: WindowInputArgs, json: bool) -> Result< let input = build_input(args); let variables = serde_json::json!({ "input": input }); - let data = client - .execute(CREATE_WINDOW_MUTATION, Some(variables)) + let data: WindowMutationResponse = client + .execute_typed(CREATE_WINDOW_MUTATION, Some(variables)) .await?; + let window = data + .create_reachability_window + .ok_or_else(|| anyhow!("Missing createReachabilityWindow in response"))?; if json { - println!( - "{}", - serde_json::to_string_pretty(&data["createReachabilityWindow"])? - ); + println!("{}", serde_json::to_string_pretty(&window)?); return Ok(()); } println!(); println!(" {} Window created", format::styled_green_bold("✓")); println!(); - print_window(&data["createReachabilityWindow"]); + print_window(&window); Ok(()) } @@ -172,22 +208,22 @@ pub async fn update(api_url: &str, id: &str, args: WindowInputArgs, json: bool) let input = build_input(args); let variables = serde_json::json!({ "id": id, "input": input }); - let data = client - .execute(UPDATE_WINDOW_MUTATION, Some(variables)) + let data: WindowMutationResponse = client + .execute_typed(UPDATE_WINDOW_MUTATION, Some(variables)) .await?; + let window = data + .update_reachability_window + .ok_or_else(|| anyhow!("Missing updateReachabilityWindow in response"))?; if json { - println!( - "{}", - serde_json::to_string_pretty(&data["updateReachabilityWindow"])? - ); + println!("{}", serde_json::to_string_pretty(&window)?); return Ok(()); } println!(); println!(" {} Window updated", format::styled_green_bold("✓")); println!(); - print_window(&data["updateReachabilityWindow"]); + print_window(&window); Ok(()) } @@ -196,26 +232,23 @@ pub async fn delete(api_url: &str, id: &str, json: bool) -> Result<()> { let client = GraphQLClient::new(api_url, &token); let variables = serde_json::json!({ "id": id }); - let data = client - .execute(DELETE_WINDOW_MUTATION, Some(variables)) + let data: WindowMutationResponse = client + .execute_typed(DELETE_WINDOW_MUTATION, Some(variables)) .await?; + let window = data + .delete_reachability_window + .ok_or_else(|| anyhow!("Missing deleteReachabilityWindow in response"))?; if json { - println!( - "{}", - serde_json::to_string_pretty(&data["deleteReachabilityWindow"])? - ); + println!("{}", serde_json::to_string_pretty(&window)?); return Ok(()); } - let window = &data["deleteReachabilityWindow"]; - let label = window["label"].as_str().unwrap_or("Unnamed"); - println!(); println!( " {} Deleted window {}", format::styled_green_bold("✓"), - format::styled_bold(label) + format::styled_bold(&window.label) ); println!( " {} {}", @@ -264,7 +297,7 @@ fn build_input(args: WindowInputArgs) -> Value { input["mode"] = serde_json::json!(normalize_mode(&mode)); } if let Some(days) = args.days { - input["days"] = serde_json::json!(days); + input["days"] = serde_json::json!(normalize_days_input(&days)); } if let Some(start) = args.start { input["startTime"] = serde_json::json!(start); @@ -305,30 +338,82 @@ fn normalize_alerts_policy(policy: &str) -> String { policy.trim().replace('-', "_").to_uppercase() } -fn print_window(window: &Value) { - let id = window["id"].as_str().unwrap_or("-"); - let label = window["label"].as_str().unwrap_or("Unnamed"); - let mode = window["mode"].as_str().unwrap_or("UNKNOWN").to_uppercase(); - let days = window["days"].as_str().unwrap_or("-"); - let start = window["startTime"].as_str().unwrap_or("-"); - let end = window["endTime"].as_str().unwrap_or("-"); - let policy = window["alertsPolicy"].as_str().unwrap_or("-"); - let priority = window["priority"].as_i64().unwrap_or_default(); - let auto_activate = window["autoActivate"].as_bool().unwrap_or(false); - let status = window["status"].as_bool().unwrap_or(false); - let emoji = window["statusEmoji"].as_str().unwrap_or(""); - let status_text = window["statusText"].as_str().unwrap_or(""); +fn normalize_days_input(input: &str) -> Vec { + let normalized = input.trim(); + + if normalized.contains('-') { + let parts: Vec<&str> = normalized.split('-').collect(); + if parts.len() == 2 { + let start = normalize_day(parts[0]); + let end = normalize_day(parts[1]); + let ordered = vec![ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY", + ]; + if let (Some(start_idx), Some(end_idx)) = ( + ordered.iter().position(|d| d == &start), + ordered.iter().position(|d| d == &end), + ) { + return if start_idx <= end_idx { + ordered[start_idx..=end_idx] + .iter() + .map(|d| d.to_string()) + .collect() + } else { + ordered[start_idx..] + .iter() + .chain(ordered[..=end_idx].iter()) + .map(|d| d.to_string()) + .collect() + }; + } + } + } + + normalized + .split(',') + .map(normalize_day) + .collect::>() +} + +fn normalize_day(day: &str) -> String { + match day.trim().to_lowercase().as_str() { + "mon" | "monday" => "MONDAY".to_string(), + "tue" | "tues" | "tuesday" => "TUESDAY".to_string(), + "wed" | "wednesday" => "WEDNESDAY".to_string(), + "thu" | "thur" | "thurs" | "thursday" => "THURSDAY".to_string(), + "fri" | "friday" => "FRIDAY".to_string(), + "sat" | "saturday" => "SATURDAY".to_string(), + "sun" | "sunday" => "SUNDAY".to_string(), + other => other.replace('-', "_").to_uppercase(), + } +} + +fn print_window(window: &ReachabilityWindow) { + let mode = window.mode.to_uppercase(); + let days = format_days(window.days.as_ref()); + let start = window.start_time.clone().unwrap_or_else(|| "-".to_string()); + let end = window.end_time.clone().unwrap_or_else(|| "-".to_string()); + let policy = window + .alerts_policy + .clone() + .unwrap_or_else(|| "-".to_string()); println!( " {} {} ({})", format::styled_dimmed("•"), - format::styled_bold(label), + format::styled_bold(&window.label), format::color_mode(&mode) ); println!( " {} {} {}-{}", format::styled_dimmed("Window:"), - days, + &days, start, end ); @@ -340,24 +425,26 @@ fn print_window(window: &Value) { println!( " {} {} {} {} {} {}", format::styled_dimmed("Priority:"), - priority, + window.priority.unwrap_or_default(), format::styled_dimmed("Auto:"), - auto_activate, + window.auto_activate.unwrap_or(false), format::styled_dimmed("Status:"), - status + window.status.unwrap_or(false) ); - if !emoji.is_empty() || !status_text.is_empty() { + if window.status_emoji.as_deref().unwrap_or("") != "" + || window.status_text.as_deref().unwrap_or("") != "" + { println!( " {} {} {}", format::styled_dimmed("Message:"), - emoji, - status_text + window.status_emoji.clone().unwrap_or_default(), + window.status_text.clone().unwrap_or_default() ); } println!( " {} {}", format::styled_dimmed("ID:"), - format::styled_dimmed(id) + format::styled_dimmed(&window.id) ); println!(); } @@ -416,4 +503,19 @@ mod tests { ..WindowInputArgs::default() })); } + + #[test] + fn normalize_days_input_supports_ranges_and_lists() { + assert_eq!(normalize_days_input("Mon-Fri").len(), 5); + assert_eq!( + normalize_days_input("Mon,Wed,Fri"), + vec!["MONDAY", "WEDNESDAY", "FRIDAY"] + ); + } + + #[test] + fn format_days_reads_array_shape() { + let value = DaysField::List(vec!["MONDAY".to_string(), "TUESDAY".to_string()]); + assert_eq!(format_days(Some(&value)), "monday,tuesday"); + } } diff --git a/src/contract/availability.rs b/src/contract/availability.rs new file mode 100644 index 0000000..1fa0aeb --- /dev/null +++ b/src/contract/availability.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone)] +pub struct AvailabilityResolution { + #[serde(rename = "inReachableHours")] + pub in_reachable_hours: Option, + #[serde(rename = "nextTransitionAt")] + pub next_transition_at: Option, + #[serde(rename = "activeWindow")] + pub active_window: Option, + #[serde(rename = "nextWindow")] + pub next_window: Option, +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct AvailabilityWindow { + pub id: Option, + pub label: Option, + pub mode: Option, + #[serde(rename = "startTime")] + pub start_time: Option, + #[serde(rename = "endTime")] + pub end_time: Option, + pub days: Option, +} + +#[derive(Deserialize, Serialize, Clone)] +#[serde(untagged)] +pub enum DaysField { + List(Vec), + Single(String), +} + +pub fn format_days(days: Option<&DaysField>) -> String { + match days { + Some(DaysField::List(day_list)) if !day_list.is_empty() => day_list + .iter() + .map(|d| d.to_lowercase()) + .collect::>() + .join(","), + Some(DaysField::Single(day)) => day.clone(), + _ => "-".to_string(), + } +} diff --git a/src/contract/mod.rs b/src/contract/mod.rs new file mode 100644 index 0000000..faca698 --- /dev/null +++ b/src/contract/mod.rs @@ -0,0 +1 @@ +pub mod availability; diff --git a/src/main.rs b/src/main.rs index 2ad2275..9fd1b2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod auth; mod client; mod commands; mod config; +mod contract; mod format; mod telemetry; @@ -52,6 +53,18 @@ enum Commands { action: Option, }, + /// Manage delegation grants + Grants { + #[command(subcommand)] + action: Option, + }, + + /// Manage temporary availability overrides + Override { + #[command(subcommand)] + action: Option, + }, + /// Apply a preset by name or ID Preset { /// Preset name or ID @@ -334,46 +347,93 @@ enum WindowAction { enum PresetsAction { /// List configured presets List, +} - /// Create a preset - Create { +#[derive(Subcommand)] +enum GrantsAction { + /// List active grants + ListActive, + + /// List grants with optional filters + List { #[arg(long)] - name: String, + active: Option, + #[arg(long, value_parser = ["session", "workspace", "agent"])] + scope: Option, #[arg(long)] - alerts: Option, + session_id: Option, #[arg(long)] - presence: Option, + workspace_ref: Option, #[arg(long)] - duration: Option, + agent_id: Option, #[arg(long)] - status: Option, + source: Option, + }, + + /// Create a grant + Create { + #[arg(long, value_parser = ["session", "workspace", "agent"])] + scope: String, #[arg(long)] - status_emoji: Option, + session_id: Option, #[arg(long)] - status_text: Option, + workspace_ref: Option, + #[arg(long)] + agent_id: Option, + #[arg(long, value_delimiter = ',')] + permissions: Vec, + #[arg(long)] + duration_minutes: Option, + #[arg(long)] + expires_at: Option, + #[arg(long)] + source: Option, }, - /// Update a preset by id - Update { - id: String, + /// Revoke one grant by id + Revoke { id: String }, + + /// Revoke many grants with optional filters + RevokeMany { #[arg(long)] - name: Option, + active: Option, + #[arg(long, value_parser = ["session", "workspace", "agent"])] + scope: Option, #[arg(long)] - alerts: Option, + session_id: Option, #[arg(long)] - presence: Option, + workspace_ref: Option, #[arg(long)] - duration: Option, + agent_id: Option, #[arg(long)] - status: Option, + source: Option, + }, +} + +#[derive(Subcommand)] +enum OverrideAction { + /// Get active override + Get, + + /// Set a temporary override + Set { + #[arg(long, value_parser = ["online", "busy", "limited", "offline"])] + mode: String, #[arg(long)] - status_emoji: Option, + duration_minutes: Option, #[arg(long)] - status_text: Option, + expires_at: Option, + #[arg(long)] + reason: Option, }, - /// Delete a preset by id - Delete { id: String }, + /// Clear active override (or specific id) + Clear { + #[arg(long)] + id: Option, + #[arg(long)] + reason: Option, + }, } #[derive(Subcommand)] @@ -410,11 +470,19 @@ enum VerdictSettingsAction { /// Show current verdict settings Get, - /// Update mode thresholds JSON + /// Update verdict settings Set { - /// JSON object for mode thresholds + /// JSON object for thresholds #[arg(long)] - mode_thresholds: String, + thresholds: Option, + + /// Default delivery mode near attention deadline (auto, wrap_up, full_depth) + #[arg(long, value_parser = ["auto", "wrap_up", "full_depth"])] + default_wrap_up_mode: Option, + + /// Minutes before attention deadline where wrap-up behavior activates + #[arg(long)] + wrap_up_threshold_minutes: Option, }, } @@ -591,58 +659,105 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { }, Commands::Presets { action } => match action { None | Some(PresetsAction::List) => commands::presets::list(&api_url, json).await, - Some(PresetsAction::Create { - name, - alerts, - presence, - duration, - status, - status_emoji, - status_text, + }, + Commands::Grants { action } => match action { + None | Some(GrantsAction::ListActive) => { + commands::grants::list_active(&api_url, json).await + } + Some(GrantsAction::List { + active, + scope, + session_id, + workspace_ref, + agent_id, + source, }) => { - commands::presets::create( + commands::grants::list( &api_url, - commands::presets::PresetInputArgs { - name: Some(name), - alerts, - presence, - duration, - status, - status_emoji, - status_text, + commands::grants::GrantsFilterArgs { + active, + scope, + session_id, + workspace_ref, + agent_id, + source, }, json, ) .await } - Some(PresetsAction::Update { - id, - name, - alerts, - presence, - duration, - status, - status_emoji, - status_text, + Some(GrantsAction::Create { + scope, + session_id, + workspace_ref, + agent_id, + permissions, + duration_minutes, + expires_at, + source, }) => { - commands::presets::update( + commands::grants::create( &api_url, - &id, - commands::presets::PresetInputArgs { - name, - alerts, - presence, - duration, - status, - status_emoji, - status_text, + commands::grants::CreateGrantArgs { + scope: Some(scope), + session_id, + workspace_ref, + agent_id, + permissions, + duration_minutes, + expires_at, + source, }, json, ) .await } - Some(PresetsAction::Delete { id }) => { - commands::presets::delete(&api_url, &id, json).await + Some(GrantsAction::Revoke { id }) => { + commands::grants::revoke(&api_url, &id, json).await + } + Some(GrantsAction::RevokeMany { + active, + scope, + session_id, + workspace_ref, + agent_id, + source, + }) => { + commands::grants::revoke_many( + &api_url, + commands::grants::GrantsFilterArgs { + active, + scope, + session_id, + workspace_ref, + agent_id, + source, + }, + json, + ) + .await + } + }, + Commands::Override { action } => match action { + None | Some(OverrideAction::Get) => commands::override_cmd::get(&api_url, json).await, + Some(OverrideAction::Set { + mode, + duration_minutes, + expires_at, + reason, + }) => { + commands::override_cmd::set( + &api_url, + Some(mode), + duration_minutes, + expires_at, + reason, + json, + ) + .await + } + Some(OverrideAction::Clear { id, reason }) => { + commands::override_cmd::clear(&api_url, id, reason, json).await } }, Commands::Preset { name } => commands::presets::activate(&api_url, &name, json).await, @@ -674,8 +789,19 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { None | Some(VerdictSettingsAction::Get) => { commands::verdict_settings::get(&api_url, json).await } - Some(VerdictSettingsAction::Set { mode_thresholds }) => { - commands::verdict_settings::set(&api_url, &mode_thresholds, json).await + Some(VerdictSettingsAction::Set { + thresholds, + default_wrap_up_mode, + wrap_up_threshold_minutes, + }) => { + commands::verdict_settings::set( + &api_url, + thresholds.as_deref(), + default_wrap_up_mode.as_deref(), + wrap_up_threshold_minutes, + json, + ) + .await } }, Commands::Proposals { latest, verdict } => { @@ -769,6 +895,8 @@ fn command_name(cmd: &Commands) -> &'static str { Commands::Availability { .. } => "availability", Commands::Windows { .. } => "windows", Commands::Presets { .. } => "presets", + Commands::Grants { .. } => "grants", + Commands::Override { .. } => "override", Commands::Preset { .. } => "preset", Commands::Digest { .. } => "digest", Commands::Autoresponder { .. } => "autoresponder", diff --git a/tests/backend_parity.rs b/tests/backend_parity.rs new file mode 100644 index 0000000..110731f --- /dev/null +++ b/tests/backend_parity.rs @@ -0,0 +1,536 @@ +use assert_cmd::Command; +use serde_json::Value; +use tempfile::TempDir; +use wiremock::matchers::{body_string_contains, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn prepare_auth_dir() -> TempDir { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join("headsdown"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join("credentials.json"), + r#"{"apiKey":"hd_test_token","createdAt":"2026-04-21T00:00:00Z"}"#, + ) + .unwrap(); + dir +} + +fn run_json(args: &[&str], auth_dir: &TempDir) -> Value { + let assert = Command::cargo_bin("hd") + .unwrap() + .args(args) + .env("XDG_CONFIG_HOME", auth_dir.path()) + .assert() + .success(); + + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + serde_json::from_str(&stdout).unwrap() +} + +#[tokio::test] +async fn verdict_json_matches_latest_submit_proposal_shape() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("mutation SubmitProposal")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "submitProposal": { + "decision": "APPROVED", + "reason": "Looks good", + "proposalId": "prop_123", + "evaluatedAt": "2026-04-21T16:00:00Z", + "wrapUpGuidance": { + "active": false, + "deadlineAt": null, + "remainingMinutes": null, + "profile": "NORMAL", + "source": "INACTIVE", + "reason": "Outside threshold", + "hints": [], + "thresholdMinutes": 30, + "selectedMode": "AUTO" + } + } + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth_dir = prepare_auth_dir(); + let json = run_json( + &[ + "--api-url", + &server.uri(), + "verdict", + "refactor auth module", + "--files", + "5", + "--minutes", + "30", + "--json", + ], + &auth_dir, + ); + + assert_eq!(json["decision"], "APPROVED"); + assert_eq!(json["proposalId"], "prop_123"); + assert_eq!(json["wrapUpGuidance"]["selectedMode"], "AUTO"); +} + +#[tokio::test] +async fn verdict_settings_get_json_matches_thresholds_shape() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("query")) + .and(body_string_contains("verdictSettings")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "verdictSettings": { + "id": "vs_1", + "thresholds": { + "online": {"maxFiles": 10, "maxEstimatedMinutes": 120}, + "busy": {"maxFiles": 3, "maxEstimatedMinutes": 45}, + "limited": {"maxFiles": 2, "maxEstimatedMinutes": 30}, + "offline": {"maxFiles": 0, "maxEstimatedMinutes": 0} + }, + "defaultWrapUpMode": "AUTO", + "wrapUpThresholdMinutes": 30, + "updatedAt": "2026-04-21T16:00:00Z" + } + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth_dir = prepare_auth_dir(); + let json = run_json( + &[ + "--api-url", + &server.uri(), + "verdict-settings", + "get", + "--json", + ], + &auth_dir, + ); + + assert_eq!(json["id"], "vs_1"); + assert_eq!(json["thresholds"]["busy"]["maxFiles"], 3); + assert_eq!(json["defaultWrapUpMode"], "AUTO"); +} + +#[tokio::test] +async fn verdict_settings_set_sends_new_shape_and_returns_payload() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("mutation UpdateVerdictSettings")) + .and(body_string_contains("\"thresholds\"")) + .and(body_string_contains("\"defaultWrapUpMode\":\"WRAP_UP\"")) + .and(body_string_contains("\"wrapUpThresholdMinutes\":25")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "updateVerdictSettings": { + "id": "vs_2", + "thresholds": { + "online": {"maxFiles": 8, "maxEstimatedMinutes": 90}, + "busy": {"maxFiles": 3, "maxEstimatedMinutes": 30}, + "limited": {"maxFiles": 2, "maxEstimatedMinutes": 20}, + "offline": {"maxFiles": 0, "maxEstimatedMinutes": 0} + }, + "defaultWrapUpMode": "WRAP_UP", + "wrapUpThresholdMinutes": 25, + "updatedAt": "2026-04-21T16:05:00Z" + } + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth_dir = prepare_auth_dir(); + let thresholds = r#"{"online":{"maxFiles":8,"maxEstimatedMinutes":90}}"#; + let json = run_json( + &[ + "--api-url", + &server.uri(), + "verdict-settings", + "set", + "--thresholds", + thresholds, + "--default-wrap-up-mode", + "wrap_up", + "--wrap-up-threshold-minutes", + "25", + "--json", + ], + &auth_dir, + ); + + assert_eq!(json["id"], "vs_2"); + assert_eq!(json["defaultWrapUpMode"], "WRAP_UP"); + assert_eq!(json["wrapUpThresholdMinutes"], 25); +} + +#[tokio::test] +async fn grants_list_active_json_works() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("query ActiveDelegationGrants")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "activeDelegationGrants": [ + { + "id": "grant_1", + "scope": "WORKSPACE", + "sessionId": null, + "workspaceRef": "/repo", + "agentId": "pi-agent", + "permissions": ["PRESET_APPLY"], + "source": "pi", + "expiresAt": "2026-04-21T20:00:00Z", + "revokedAt": null, + "expiredAt": null, + "insertedAt": "2026-04-21T15:00:00Z" + } + ] + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth_dir = prepare_auth_dir(); + let json = run_json( + &[ + "--api-url", + &server.uri(), + "grants", + "list-active", + "--json", + ], + &auth_dir, + ); + + assert_eq!(json.as_array().unwrap().len(), 1); + assert_eq!(json[0]["id"], "grant_1"); +} + +#[tokio::test] +async fn grants_create_and_revoke_many_mutations_work() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("mutation CreateDelegationGrant")) + .and(body_string_contains("\"scope\":\"WORKSPACE\"")) + .and(body_string_contains( + "\"permissions\":[\"PRESET_APPLY\",\"AVAILABILITY_OVERRIDE_CREATE\"]", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "createDelegationGrant": { + "id": "grant_2", + "scope": "WORKSPACE", + "sessionId": null, + "workspaceRef": "/repo", + "agentId": null, + "permissions": ["PRESET_APPLY", "AVAILABILITY_OVERRIDE_CREATE"], + "source": "hd", + "expiresAt": "2026-04-21T20:00:00Z", + "revokedAt": null, + "expiredAt": null, + "insertedAt": "2026-04-21T16:10:00Z" + } + } + }))) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("mutation RevokeDelegationGrants")) + .and(body_string_contains("\"scope\":\"WORKSPACE\"")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "revokeDelegationGrants": { + "revokedCount": 2 + } + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth_dir = prepare_auth_dir(); + let created = run_json( + &[ + "--api-url", + &server.uri(), + "grants", + "create", + "--scope", + "workspace", + "--workspace-ref", + "/repo", + "--permissions", + "preset_apply,availability_override_create", + "--json", + ], + &auth_dir, + ); + assert_eq!(created["id"], "grant_2"); + + let revoked = run_json( + &[ + "--api-url", + &server.uri(), + "grants", + "revoke-many", + "--scope", + "workspace", + "--json", + ], + &auth_dir, + ); + assert_eq!(revoked["revokedCount"], 2); +} + +#[tokio::test] +async fn override_get_set_clear_json_work() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("query ActiveAvailabilityOverride")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "activeAvailabilityOverride": { + "id": "ovr_1", + "mode": "BUSY", + "reason": "focus", + "source": "pi", + "expiresAt": "2026-04-21T18:00:00Z", + "cancelledAt": null, + "expiredAt": null, + "insertedAt": "2026-04-21T16:00:00Z", + "updatedAt": "2026-04-21T16:00:00Z" + } + } + }))) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("mutation CreateAvailabilityOverride")) + .and(body_string_contains("\"mode\":\"BUSY\"")) + .and(body_string_contains("\"durationMinutes\":30")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "createAvailabilityOverride": { + "id": "ovr_2", + "mode": "BUSY", + "reason": "focus", + "source": "hd", + "expiresAt": "2026-04-21T17:00:00Z", + "cancelledAt": null, + "expiredAt": null, + "insertedAt": "2026-04-21T16:20:00Z", + "updatedAt": "2026-04-21T16:20:00Z" + } + } + }))) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains("mutation CancelAvailabilityOverride")) + .and(body_string_contains("\"id\":\"ovr_2\"")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "cancelAvailabilityOverride": { + "id": "ovr_2", + "mode": "BUSY", + "reason": "done", + "source": "hd", + "expiresAt": "2026-04-21T17:00:00Z", + "cancelledAt": "2026-04-21T16:30:00Z", + "expiredAt": null, + "insertedAt": "2026-04-21T16:20:00Z", + "updatedAt": "2026-04-21T16:30:00Z" + } + } + }))) + .expect(1) + .mount(&server) + .await; + + let auth_dir = prepare_auth_dir(); + + let current = run_json( + &["--api-url", &server.uri(), "override", "get", "--json"], + &auth_dir, + ); + assert_eq!(current["id"], "ovr_1"); + + let created = run_json( + &[ + "--api-url", + &server.uri(), + "override", + "set", + "--mode", + "busy", + "--duration-minutes", + "30", + "--reason", + "focus", + "--json", + ], + &auth_dir, + ); + assert_eq!(created["id"], "ovr_2"); + + let cleared = run_json( + &[ + "--api-url", + &server.uri(), + "override", + "clear", + "--id", + "ovr_2", + "--reason", + "done", + "--json", + ], + &auth_dir, + ); + assert_eq!(cleared["id"], "ovr_2"); + assert_eq!(cleared["cancelledAt"], "2026-04-21T16:30:00Z"); +} + +#[tokio::test] +async fn migrated_commands_fail_fast_on_shape_mismatch() { + struct Case { + operation_hint: &'static str, + args: Vec<&'static str>, + response_data: Value, + } + + let cases = vec![ + Case { + operation_hint: "activeContract", + args: vec!["status", "--json"], + response_data: serde_json::json!({ + "activeContract": {"mode": 123, "statusText": null, "statusEmoji": null, "expiresAt": null, "lock": false}, + "availability": null, + "profile": null + }), + }, + Case { + operation_hint: "availability", + args: vec!["availability", "--json"], + response_data: serde_json::json!({ + "availability": {"inReachableHours": "yes", "nextTransitionAt": null, "activeWindow": null, "nextWindow": null} + }), + }, + Case { + operation_hint: "reachabilityWindows", + args: vec!["windows", "list", "--json"], + response_data: serde_json::json!({ + "reachabilityWindows": [{"id":"w1","label":"Focus","mode":7,"days":["MONDAY"],"startTime":"09:00:00","endTime":"17:00:00","alertsPolicy":"OFF","autoActivate":true,"priority":1,"status":false,"statusEmoji":null,"statusText":null,"snooze":false}] + }), + }, + Case { + operation_hint: "presets", + args: vec!["presets", "list", "--json"], + response_data: serde_json::json!({ + "presets": [{"id":"p1","name":99,"statusEmoji":null,"statusText":"Deep work","duration":30}] + }), + }, + Case { + operation_hint: "activeDelegationGrants", + args: vec!["grants", "list-active", "--json"], + response_data: serde_json::json!({ + "activeDelegationGrants": [{"id":"g1","scope":42,"expiresAt":null,"permissions":[]}] + }), + }, + Case { + operation_hint: "activeAvailabilityOverride", + args: vec!["override", "get", "--json"], + response_data: serde_json::json!({ + "activeAvailabilityOverride": {"id":"ovr_1","mode":1,"expiresAt":null,"cancelledAt":null} + }), + }, + Case { + operation_hint: "submitProposal", + args: vec![ + "verdict", + "refactor auth", + "--files", + "3", + "--minutes", + "20", + "--json", + ], + response_data: serde_json::json!({ + "submitProposal": {"decision":1,"reason":"ok","proposalId":"prop_1","wrapUpGuidance":null} + }), + }, + Case { + operation_hint: "verdictSettings", + args: vec!["verdict-settings", "get", "--json"], + response_data: serde_json::json!({ + "verdictSettings": {"id":"vs_1","thresholds":{},"defaultWrapUpMode":1,"wrapUpThresholdMinutes":30,"updatedAt":"2026-04-21T16:00:00Z"} + }), + }, + ]; + + for case in cases { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/graphql")) + .and(body_string_contains(case.operation_hint)) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"data": case.response_data})), + ) + .expect(1) + .mount(&server) + .await; + + let auth_dir = prepare_auth_dir(); + let mut args: Vec = vec!["--api-url".to_string(), server.uri()]; + args.extend(case.args.iter().map(|value| value.to_string())); + + let assert = Command::cargo_bin("hd") + .unwrap() + .args(&args) + .env("XDG_CONFIG_HOME", auth_dir.path()) + .assert() + .failure(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!( + stderr.contains("Failed to decode API response shape"), + "expected decode-shape failure for operation hint {}, got: {}", + case.operation_hint, + stderr + ); + } +} diff --git a/tests/graphql_contract.rs b/tests/graphql_contract.rs index 06ab071..cd33da6 100644 --- a/tests/graphql_contract.rs +++ b/tests/graphql_contract.rs @@ -17,3 +17,34 @@ fn command_queries_use_availability_root_field() { "availability query should reference availability root field" ); } + +#[test] +fn verdict_query_matches_latest_submit_proposal_shape() { + let src = include_str!("../src/commands/verdict.rs"); + assert!(src.contains("decision")); + assert!(src.contains("reason")); + assert!(src.contains("proposalId")); + assert!(src.contains("wrapUpGuidance")); + assert!(!src.contains("policyStatus")); +} + +#[test] +fn verdict_settings_query_uses_thresholds_shape() { + let src = include_str!("../src/commands/verdict_settings.rs"); + assert!(src.contains("thresholds")); + assert!(src.contains("defaultWrapUpMode")); + assert!(src.contains("wrapUpThresholdMinutes")); + assert!(!src.contains("modeThresholds")); +} + +#[test] +fn windows_and_availability_handle_days_arrays() { + let windows_src = include_str!("../src/commands/windows.rs"); + let availability_src = include_str!("../src/commands/availability.rs"); + let contract_src = include_str!("../src/contract/availability.rs"); + + assert!(windows_src.contains("normalize_days_input")); + assert!(windows_src.contains("DaysField")); + assert!(availability_src.contains("format_days(")); + assert!(contract_src.contains("enum DaysField")); +} diff --git a/tests/integration.rs b/tests/integration.rs index fd925ad..0490368 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -64,6 +64,8 @@ fn subcommand_help_works() { "limited", "verdict", "presets", + "grants", + "override", "preset", "watch", "doctor", @@ -103,11 +105,38 @@ fn windows_subcommand_help_works() { #[test] fn presets_subcommand_help_works() { + Command::cargo_bin("hd") + .unwrap() + .args(["presets", "list", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Usage:")); +} + +#[test] +fn grants_subcommand_help_works() { + for cmd in &[ + ["grants", "list-active"], + ["grants", "list"], + ["grants", "create"], + ["grants", "revoke"], + ["grants", "revoke-many"], + ] { + Command::cargo_bin("hd") + .unwrap() + .args([cmd[0], cmd[1], "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Usage:")); + } +} + +#[test] +fn override_subcommand_help_works() { for cmd in &[ - ["presets", "list"], - ["presets", "create"], - ["presets", "update"], - ["presets", "delete"], + ["override", "get"], + ["override", "set"], + ["override", "clear"], ] { Command::cargo_bin("hd") .unwrap() From 51c2b5cf35cea2ba3b0b2ff110352abb3fdc0fb6 Mon Sep 17 00:00:00 2001 From: Justin Smestad Date: Tue, 21 Apr 2026 11:00:28 -0600 Subject: [PATCH 4/4] Fix clippy warnings in verdict settings and windows formatting --- src/commands/verdict_settings.rs | 4 ++-- src/commands/windows.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/verdict_settings.rs b/src/commands/verdict_settings.rs index 9d4d9c9..fb70063 100644 --- a/src/commands/verdict_settings.rs +++ b/src/commands/verdict_settings.rs @@ -109,9 +109,9 @@ pub async fn get(api_url: &str, json: bool) -> Result<()> { settings.default_wrap_up_mode ); println!( - " {} {}", + " {} {} min", format::styled_dimmed("Wrap-up threshold:"), - format!("{} min", settings.wrap_up_threshold_minutes) + settings.wrap_up_threshold_minutes ); println!(" {}", format::styled_dimmed("Thresholds:")); println!("{}", serde_json::to_string_pretty(&settings.thresholds)?); diff --git a/src/commands/windows.rs b/src/commands/windows.rs index e373f2f..f0c410d 100644 --- a/src/commands/windows.rs +++ b/src/commands/windows.rs @@ -346,7 +346,7 @@ fn normalize_days_input(input: &str) -> Vec { if parts.len() == 2 { let start = normalize_day(parts[0]); let end = normalize_day(parts[1]); - let ordered = vec![ + let ordered = [ "MONDAY", "TUESDAY", "WEDNESDAY", @@ -431,8 +431,8 @@ fn print_window(window: &ReachabilityWindow) { format::styled_dimmed("Status:"), window.status.unwrap_or(false) ); - if window.status_emoji.as_deref().unwrap_or("") != "" - || window.status_text.as_deref().unwrap_or("") != "" + if !window.status_emoji.as_deref().unwrap_or("").is_empty() + || !window.status_text.as_deref().unwrap_or("").is_empty() { println!( " {} {} {}",