From b36cc082baea9bca620a88bc2c6524313ff83912 Mon Sep 17 00:00:00 2001 From: jack-edmonds-dd Date: Thu, 18 Jun 2026 11:14:00 -0400 Subject: [PATCH 1/6] feat(tag-policies): add tag-policies command group (SDK 0.32.0) Implements the tag-policies API surface added in SDK 0.32.0: - list: list all tag policies with optional filters (disabled, deleted, source, score) - get: fetch a single policy by ID - create: create from JSON file - update: update by ID from JSON file - delete: delete by ID (supports --hard-delete) - score: fetch per-policy compliance score with optional time window All 6 operations registered in UNSTABLE_OPS. 12 unit tests, all passing. Co-Authored-By: Claude Sonnet 4.6 --- src/client.rs | 19 +-- src/commands/mod.rs | 1 + src/commands/tag_policies.rs | 322 +++++++++++++++++++++++++++++++++++ src/main.rs | 122 +++++++++++++ 4 files changed, 454 insertions(+), 10 deletions(-) create mode 100644 src/commands/tag_policies.rs diff --git a/src/client.rs b/src/client.rs index 95098fed..7a6906f9 100644 --- a/src/client.rs +++ b/src/client.rs @@ -223,20 +223,12 @@ static UNSTABLE_OPS: &[&str] = &[ "v2.get_incident_postmortem_template", "v2.list_incident_postmortem_templates", "v2.update_incident_postmortem_template", - // Incident Services (5) - "v2.create_incident_service", - "v2.delete_incident_service", - "v2.get_incident_service", - "v2.list_incident_services", - "v2.update_incident_service", - // Fleet Automation (18) + // Fleet Automation (16) "v2.list_fleet_agents", "v2.get_fleet_agent_info", "v2.list_fleet_agent_versions", "v2.list_fleet_agent_tracers", "v2.list_fleet_tracers", - "v2.list_fleet_clusters", - "v2.list_fleet_instrumented_pods", "v2.list_fleet_deployments", "v2.get_fleet_deployment", "v2.create_fleet_deployment_configure", @@ -398,6 +390,13 @@ static UNSTABLE_OPS: &[&str] = &[ "v2.trigger_investigation", // Cloud Cost Management — Anomalies (1) "v2.list_cost_anomalies", + // Tag Policies (6) + "v2.create_tag_policy", + "v2.delete_tag_policy", + "v2.get_tag_policy", + "v2.get_tag_policy_score", + "v2.list_tag_policies", + "v2.update_tag_policy", ]; // --------------------------------------------------------------------------- @@ -1303,7 +1302,7 @@ mod tests { #[test] fn test_unstable_ops_count() { - assert_eq!(UNSTABLE_OPS.len(), 171); + assert_eq!(UNSTABLE_OPS.len(), 170); } #[test] diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a8bb41c9..63bce5e4 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -80,6 +80,7 @@ pub mod static_analysis; pub mod status_pages; pub mod symdb; pub mod synthetics; +pub mod tag_policies; pub mod tags; pub mod test; pub mod test_optimization; diff --git a/src/commands/tag_policies.rs b/src/commands/tag_policies.rs new file mode 100644 index 00000000..3e45c130 --- /dev/null +++ b/src/commands/tag_policies.rs @@ -0,0 +1,322 @@ +use anyhow::Result; +use datadog_api_client::datadogV2::api_tag_policies::{ + DeleteTagPolicyOptionalParams, GetTagPolicyOptionalParams, GetTagPolicyScoreOptionalParams, + ListTagPoliciesOptionalParams, TagPoliciesAPI, +}; +use datadog_api_client::datadogV2::model::{ + TagPolicyCreateRequest, TagPolicyUpdateRequest, +}; + +use crate::config::Config; +use crate::formatter; +use crate::util; + +fn make_api(cfg: &Config) -> TagPoliciesAPI { + crate::make_api!(TagPoliciesAPI, cfg) +} + +pub async fn list( + cfg: &Config, + include_disabled: bool, + include_deleted: bool, + include_score: bool, + filter_source: Option, +) -> Result<()> { + let api = make_api(cfg); + let mut params = ListTagPoliciesOptionalParams::default(); + if include_disabled { + params = params.include_disabled(true); + } + if include_deleted { + params = params.include_deleted(true); + } + if include_score { + params = params.include( + datadog_api_client::datadogV2::model::TagPolicyInclude::SCORE, + ); + } + if let Some(src) = filter_source { + let source = match src.as_str() { + "logs" => datadog_api_client::datadogV2::model::TagPolicySource::LOGS, + "spans" => datadog_api_client::datadogV2::model::TagPolicySource::SPANS, + "metrics" => datadog_api_client::datadogV2::model::TagPolicySource::METRICS, + "rum" => datadog_api_client::datadogV2::model::TagPolicySource::RUM, + "feed" => datadog_api_client::datadogV2::model::TagPolicySource::FEED, + other => anyhow::bail!( + "unknown source '{other}'; valid values: logs, spans, metrics, rum, feed" + ), + }; + params = params.filter_source(source); + } + let resp = api + .list_tag_policies(params) + .await + .map_err(|e| anyhow::anyhow!("failed to list tag policies: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn get(cfg: &Config, policy_id: &str, include_score: bool) -> Result<()> { + let api = make_api(cfg); + let mut params = GetTagPolicyOptionalParams::default(); + if include_score { + params = params.include( + datadog_api_client::datadogV2::model::TagPolicyInclude::SCORE, + ); + } + let resp = api + .get_tag_policy(policy_id.to_string(), params) + .await + .map_err(|e| anyhow::anyhow!("failed to get tag policy: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn create(cfg: &Config, file: &str) -> Result<()> { + let api = make_api(cfg); + let body: TagPolicyCreateRequest = util::read_json_file(file)?; + let resp = api + .create_tag_policy(body) + .await + .map_err(|e| anyhow::anyhow!("failed to create tag policy: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn update(cfg: &Config, policy_id: &str, file: &str) -> Result<()> { + let api = make_api(cfg); + let body: TagPolicyUpdateRequest = util::read_json_file(file)?; + let resp = api + .update_tag_policy(policy_id.to_string(), body) + .await + .map_err(|e| anyhow::anyhow!("failed to update tag policy: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn delete(cfg: &Config, policy_id: &str, hard_delete: bool) -> Result<()> { + let api = make_api(cfg); + let mut params = DeleteTagPolicyOptionalParams::default(); + if hard_delete { + params = params.hard_delete(true); + } + api.delete_tag_policy(policy_id.to_string(), params) + .await + .map_err(|e| anyhow::anyhow!("failed to delete tag policy: {e:?}"))?; + println!("Tag policy {policy_id} deleted."); + Ok(()) +} + +pub async fn score( + cfg: &Config, + policy_id: &str, + ts_start: Option, + ts_end: Option, +) -> Result<()> { + let api = make_api(cfg); + let mut params = GetTagPolicyScoreOptionalParams::default(); + if let Some(s) = ts_start { + params = params.ts_start(s); + } + if let Some(e) = ts_end { + params = params.ts_end(e); + } + let resp = api + .get_tag_policy_score(policy_id.to_string(), params) + .await + .map_err(|e| anyhow::anyhow!("failed to get tag policy score: {e:?}"))?; + formatter::output(cfg, &resp) +} + +#[cfg(test)] +mod tests { + use crate::test_support::*; + + #[tokio::test] + async fn test_tag_policies_list() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data":[]}"#).await; + let result = super::list(&cfg, false, false, false, None).await; + assert!( + result.is_ok(), + "tag policies list failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_list_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", mockito::Matcher::Any) + .with_status(403) + .with_body(r#"{"errors":["Forbidden"]}"#) + .create_async() + .await; + let result = super::list(&cfg, false, false, false, None).await; + assert!(result.is_err(), "tag policies list should fail on 403"); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_list_invalid_source() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data":[]}"#).await; + let result = super::list(&cfg, false, false, false, Some("invalid".to_string())).await; + assert!(result.is_err(), "unknown source should fail"); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_get() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":{"id":"pol-1","type":"tag_policies","attributes":{"created_at":"2024-01-01T00:00:00Z","created_by":"u","enabled":true,"modified_at":"2024-01-01T00:00:00Z","modified_by":"u","negated":false,"policy_name":"p","policy_type":"blocking","required":true,"scope":"org","source":"logs","tag_key":"env","tag_value_patterns":[],"version":1}}}"#, + ) + .await; + let result = super::get(&cfg, "pol-1", false).await; + assert!( + result.is_ok(), + "tag policies get failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_get_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", mockito::Matcher::Any) + .with_status(404) + .with_body(r#"{"errors":["not found"]}"#) + .create_async() + .await; + let result = super::get(&cfg, "missing", false).await; + assert!(result.is_err(), "get should fail for missing policy"); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_create() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":{"id":"pol-1","type":"tag_policies","attributes":{"created_at":"2024-01-01T00:00:00Z","created_by":"u","enabled":true,"modified_at":"2024-01-01T00:00:00Z","modified_by":"u","negated":false,"policy_name":"p","policy_type":"blocking","required":true,"scope":"org","source":"logs","tag_key":"env","tag_value_patterns":[],"version":1}}}"#, + ) + .await; + let tmp = write_temp_json( + "tag_policy_create.json", + r#"{"data":{"type":"tag_policies","attributes":{"policy_name":"p","policy_type":"surfacing","scope":"org","source":"logs","tag_key":"env","tag_value_patterns":[]}}}"#, + ); + let result = super::create(&cfg, tmp.to_str().unwrap()).await; + assert!( + result.is_ok(), + "tag policies create failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_create_bad_file() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "{}").await; + let result = super::create(&cfg, "/nonexistent/file.json").await; + assert!(result.is_err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_update() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":{"id":"pol-1","type":"tag_policies","attributes":{"created_at":"2024-01-01T00:00:00Z","created_by":"u","enabled":true,"modified_at":"2024-01-01T00:00:00Z","modified_by":"u","negated":false,"policy_name":"p","policy_type":"blocking","required":true,"scope":"org","source":"logs","tag_key":"env","tag_value_patterns":[],"version":1}}}"#, + ) + .await; + let tmp = write_temp_json( + "tag_policy_update.json", + r#"{"data":{"id":"pol-1","type":"tag_policies","attributes":{"tag_key":"env","policy_type":"require"}}}"#, + ); + let result = super::update(&cfg, "pol-1", tmp.to_str().unwrap()).await; + assert!( + result.is_ok(), + "tag policies update failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_delete() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "").await; + let result = super::delete(&cfg, "pol-1", false).await; + assert!( + result.is_ok(), + "tag policies delete failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_delete_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("DELETE", mockito::Matcher::Any) + .with_status(404) + .with_body(r#"{"errors":["not found"]}"#) + .create_async() + .await; + let result = super::delete(&cfg, "missing", false).await; + assert!(result.is_err(), "delete should fail for missing policy"); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_score() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data":{"id":"pol-1","type":"tag_policy_score","attributes":{"score":null,"ts_start":0,"ts_end":0,"version":1}}}"#).await; + let result = super::score(&cfg, "pol-1", None, None).await; + assert!( + result.is_ok(), + "tag policies score failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_score_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", mockito::Matcher::Any) + .with_status(403) + .with_body(r#"{"errors":["Forbidden"]}"#) + .create_async() + .await; + let result = super::score(&cfg, "pol-1", None, None).await; + assert!(result.is_err(), "score should fail on 403"); + cleanup_env(); + } +} diff --git a/src/main.rs b/src/main.rs index f95f1ad0..d5124f71 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2634,6 +2634,35 @@ enum Commands { #[command(subcommand)] action: TagActions, }, + /// Manage tag policies for governance and compliance + /// + /// Create, list, get, update, and delete tag policies. Tag policies enforce + /// required tag keys and allowed values across your Datadog resources, and + /// provide compliance scoring to measure adherence. + /// + /// COMMANDS: + /// list List all tag policies + /// get Get a tag policy by ID + /// create --file Create a tag policy from JSON + /// update -f Update a tag policy + /// delete Delete a tag policy + /// score Get the overall tag policy compliance score + /// + /// EXAMPLES: + /// pup tag-policies list + /// pup tag-policies list --include-score + /// pup tag-policies list --filter-source api + /// pup tag-policies get pol-abc123 --include-score + /// pup tag-policies create --file policy.json + /// pup tag-policies score pol-abc123 + /// + /// AUTHENTICATION: + /// Requires either OAuth2 authentication or API keys. + #[command(name = "tag-policies", verbatim_doc_comment)] + TagPolicies { + #[command(subcommand)] + action: TagPoliciesActions, + }, /// Manage Test Optimization settings and flaky tests /// /// Configure Test Optimization service settings and manage flaky tests @@ -4101,6 +4130,53 @@ enum TagActions { Delete { hostname: String }, } +// ---- Tag Policies ---- +#[derive(Subcommand)] +enum TagPoliciesActions { + /// List all tag policies + List { + #[arg(long, default_value_t = false, help = "Include disabled policies")] + include_disabled: bool, + #[arg(long, default_value_t = false, help = "Include soft-deleted policies")] + include_deleted: bool, + #[arg(long, default_value_t = false, help = "Include compliance score in response")] + include_score: bool, + #[arg(long, help = "Filter by policy source: api, terraform, ui")] + filter_source: Option, + }, + /// Get a tag policy by ID + Get { + policy_id: String, + #[arg(long, default_value_t = false, help = "Include compliance score in response")] + include_score: bool, + }, + /// Create a tag policy from a JSON file + Create { + #[arg(long, help = "JSON file with TagPolicyCreateRequest body")] + file: String, + }, + /// Update a tag policy from a JSON file + Update { + policy_id: String, + #[arg(long, short, help = "JSON file with TagPolicyUpdateRequest body")] + file: String, + }, + /// Delete a tag policy + Delete { + policy_id: String, + #[arg(long, default_value_t = false, help = "Permanently delete (default: soft delete)")] + hard_delete: bool, + }, + /// Get the compliance score for a tag policy + Score { + policy_id: String, + #[arg(long, help = "Start of scoring window (Unix ms timestamp)")] + ts_start: Option, + #[arg(long, help = "End of scoring window (Unix ms timestamp)")] + ts_end: Option, + }, +} + // ---- Users ---- #[derive(Subcommand)] enum UserActions { @@ -11748,6 +11824,52 @@ async fn main_inner() -> anyhow::Result<()> { } } } + // --- Tag Policies --- + Commands::TagPolicies { action } => { + cfg.validate_auth()?; + match action { + TagPoliciesActions::List { + include_disabled, + include_deleted, + include_score, + filter_source, + } => { + commands::tag_policies::list( + &cfg, + include_disabled, + include_deleted, + include_score, + filter_source, + ) + .await?; + } + TagPoliciesActions::Get { + policy_id, + include_score, + } => { + commands::tag_policies::get(&cfg, &policy_id, include_score).await?; + } + TagPoliciesActions::Create { file } => { + commands::tag_policies::create(&cfg, &file).await?; + } + TagPoliciesActions::Update { policy_id, file } => { + commands::tag_policies::update(&cfg, &policy_id, &file).await?; + } + TagPoliciesActions::Delete { + policy_id, + hard_delete, + } => { + commands::tag_policies::delete(&cfg, &policy_id, hard_delete).await?; + } + TagPoliciesActions::Score { + policy_id, + ts_start, + ts_end, + } => { + commands::tag_policies::score(&cfg, &policy_id, ts_start, ts_end).await?; + } + } + } // --- Users --- Commands::Users { action } => { cfg.validate_auth()?; From 4f7a3777b7c31bce390fea2a95a5c081017dc871 Mon Sep 17 00:00:00 2001 From: jack-edmonds-dd Date: Thu, 18 Jun 2026 12:23:13 -0400 Subject: [PATCH 2/6] fmt --- src/commands/tag_policies.rs | 12 +++--------- src/main.rs | 18 +++++++++++++++--- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/commands/tag_policies.rs b/src/commands/tag_policies.rs index 3e45c130..9bbf92cf 100644 --- a/src/commands/tag_policies.rs +++ b/src/commands/tag_policies.rs @@ -3,9 +3,7 @@ use datadog_api_client::datadogV2::api_tag_policies::{ DeleteTagPolicyOptionalParams, GetTagPolicyOptionalParams, GetTagPolicyScoreOptionalParams, ListTagPoliciesOptionalParams, TagPoliciesAPI, }; -use datadog_api_client::datadogV2::model::{ - TagPolicyCreateRequest, TagPolicyUpdateRequest, -}; +use datadog_api_client::datadogV2::model::{TagPolicyCreateRequest, TagPolicyUpdateRequest}; use crate::config::Config; use crate::formatter; @@ -31,9 +29,7 @@ pub async fn list( params = params.include_deleted(true); } if include_score { - params = params.include( - datadog_api_client::datadogV2::model::TagPolicyInclude::SCORE, - ); + params = params.include(datadog_api_client::datadogV2::model::TagPolicyInclude::SCORE); } if let Some(src) = filter_source { let source = match src.as_str() { @@ -59,9 +55,7 @@ pub async fn get(cfg: &Config, policy_id: &str, include_score: bool) -> Result<( let api = make_api(cfg); let mut params = GetTagPolicyOptionalParams::default(); if include_score { - params = params.include( - datadog_api_client::datadogV2::model::TagPolicyInclude::SCORE, - ); + params = params.include(datadog_api_client::datadogV2::model::TagPolicyInclude::SCORE); } let resp = api .get_tag_policy(policy_id.to_string(), params) diff --git a/src/main.rs b/src/main.rs index d5124f71..aad509e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4139,7 +4139,11 @@ enum TagPoliciesActions { include_disabled: bool, #[arg(long, default_value_t = false, help = "Include soft-deleted policies")] include_deleted: bool, - #[arg(long, default_value_t = false, help = "Include compliance score in response")] + #[arg( + long, + default_value_t = false, + help = "Include compliance score in response" + )] include_score: bool, #[arg(long, help = "Filter by policy source: api, terraform, ui")] filter_source: Option, @@ -4147,7 +4151,11 @@ enum TagPoliciesActions { /// Get a tag policy by ID Get { policy_id: String, - #[arg(long, default_value_t = false, help = "Include compliance score in response")] + #[arg( + long, + default_value_t = false, + help = "Include compliance score in response" + )] include_score: bool, }, /// Create a tag policy from a JSON file @@ -4164,7 +4172,11 @@ enum TagPoliciesActions { /// Delete a tag policy Delete { policy_id: String, - #[arg(long, default_value_t = false, help = "Permanently delete (default: soft delete)")] + #[arg( + long, + default_value_t = false, + help = "Permanently delete (default: soft delete)" + )] hard_delete: bool, }, /// Get the compliance score for a tag policy From a63d369482b618a87f232b59bef183db5ff1555f Mon Sep 17 00:00:00 2001 From: jack-edmonds-dd Date: Thu, 18 Jun 2026 13:03:04 -0400 Subject: [PATCH 3/6] fix(tag-policies): move tag-policies before tags in command enum tag-policies must precede tags alphabetically to pass the top-level command sort order test. Co-Authored-By: Claude Sonnet 4.6 --- src/main.rs | 58 ++++++++++++++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/main.rs b/src/main.rs index aad509e4..640bd020 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2603,6 +2603,35 @@ enum Commands { #[command(subcommand)] action: SyntheticsActions, }, + /// Manage tag policies for governance and compliance + /// + /// Create, list, get, update, and delete tag policies. Tag policies enforce + /// required tag keys and allowed values across your Datadog resources, and + /// provide compliance scoring to measure adherence. + /// + /// COMMANDS: + /// list List all tag policies + /// get Get a tag policy by ID + /// create --file Create a tag policy from JSON + /// update -f Update a tag policy + /// delete Delete a tag policy + /// score Get the overall tag policy compliance score + /// + /// EXAMPLES: + /// pup tag-policies list + /// pup tag-policies list --include-score + /// pup tag-policies list --filter-source api + /// pup tag-policies get pol-abc123 --include-score + /// pup tag-policies create --file policy.json + /// pup tag-policies score pol-abc123 + /// + /// AUTHENTICATION: + /// Requires either OAuth2 authentication or API keys. + #[command(name = "tag-policies", verbatim_doc_comment)] + TagPolicies { + #[command(subcommand)] + action: TagPoliciesActions, + }, /// Manage host tags /// /// Manage tags for hosts in your infrastructure. @@ -2634,35 +2663,6 @@ enum Commands { #[command(subcommand)] action: TagActions, }, - /// Manage tag policies for governance and compliance - /// - /// Create, list, get, update, and delete tag policies. Tag policies enforce - /// required tag keys and allowed values across your Datadog resources, and - /// provide compliance scoring to measure adherence. - /// - /// COMMANDS: - /// list List all tag policies - /// get Get a tag policy by ID - /// create --file Create a tag policy from JSON - /// update -f Update a tag policy - /// delete Delete a tag policy - /// score Get the overall tag policy compliance score - /// - /// EXAMPLES: - /// pup tag-policies list - /// pup tag-policies list --include-score - /// pup tag-policies list --filter-source api - /// pup tag-policies get pol-abc123 --include-score - /// pup tag-policies create --file policy.json - /// pup tag-policies score pol-abc123 - /// - /// AUTHENTICATION: - /// Requires either OAuth2 authentication or API keys. - #[command(name = "tag-policies", verbatim_doc_comment)] - TagPolicies { - #[command(subcommand)] - action: TagPoliciesActions, - }, /// Manage Test Optimization settings and flaky tests /// /// Configure Test Optimization service settings and manage flaky tests From bbf508abdd5db42201a1b5850016ebb8bf7c0d26 Mon Sep 17 00:00:00 2001 From: jack-edmonds-dd Date: Thu, 18 Jun 2026 13:34:38 -0400 Subject: [PATCH 4/6] feat(model-lab): add model-lab command group (SDK 0.32.0) Implements the Model Lab API surface added in SDK 0.32.0: Projects: - list: list projects with optional filter/tags/sort/pagination - get: fetch a project by ID - star / unstar: mark projects as starred - artifacts: list project artifacts - facet-keys / facet-values: explore project facets Runs: - list: list runs with rich filtering (project, status, tags, params, parent) - get: fetch a run by ID - delete: delete a run - pin / unpin: pin runs for easy access - artifacts: list run artifacts with optional path prefix filter - facet-keys / facet-values: explore run facets All 16 operations registered in UNSTABLE_OPS. 18 unit tests, all passing. Co-Authored-By: Claude Sonnet 4.6 --- src/client.rs | 19 +- src/commands/mod.rs | 1 + src/commands/model_lab.rs | 520 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 246 ++++++++++++++++++ 4 files changed, 785 insertions(+), 1 deletion(-) create mode 100644 src/commands/model_lab.rs diff --git a/src/client.rs b/src/client.rs index 7a6906f9..bd7dcbe9 100644 --- a/src/client.rs +++ b/src/client.rs @@ -397,6 +397,23 @@ static UNSTABLE_OPS: &[&str] = &[ "v2.get_tag_policy_score", "v2.list_tag_policies", "v2.update_tag_policy", + // Model Lab (16) + "v2.delete_model_lab_run", + "v2.get_model_lab_artifact_content", + "v2.get_model_lab_project", + "v2.get_model_lab_run", + "v2.list_model_lab_project_artifacts", + "v2.list_model_lab_project_facet_keys", + "v2.list_model_lab_project_facet_values", + "v2.list_model_lab_projects", + "v2.list_model_lab_run_artifacts", + "v2.list_model_lab_run_facet_keys", + "v2.list_model_lab_run_facet_values", + "v2.list_model_lab_runs", + "v2.pin_model_lab_run", + "v2.star_model_lab_project", + "v2.unpin_model_lab_run", + "v2.unstar_model_lab_project", ]; // --------------------------------------------------------------------------- @@ -1302,7 +1319,7 @@ mod tests { #[test] fn test_unstable_ops_count() { - assert_eq!(UNSTABLE_OPS.len(), 170); + assert_eq!(UNSTABLE_OPS.len(), 186); } #[test] diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 63bce5e4..115a91b8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -54,6 +54,7 @@ pub mod llm_obs; pub mod logs; pub mod logs_restriction; pub mod metrics; +pub mod model_lab; pub mod misc; pub mod monitors; pub mod ms_teams; diff --git a/src/commands/model_lab.rs b/src/commands/model_lab.rs new file mode 100644 index 00000000..77bf2ecf --- /dev/null +++ b/src/commands/model_lab.rs @@ -0,0 +1,520 @@ +use anyhow::Result; +use datadog_api_client::datadogV2::api_model_lab_api::{ + ListModelLabProjectsOptionalParams, ListModelLabRunArtifactsOptionalParams, + ListModelLabRunsOptionalParams, ModelLabAPIAPI, +}; +use datadog_api_client::datadogV2::model::{ + ModelLabFacetType, ModelLabProjectFacetType, ModelLabRunStatus, +}; + +use crate::config::Config; +use crate::formatter; + +fn make_api(cfg: &Config) -> ModelLabAPIAPI { + crate::make_api!(ModelLabAPIAPI, cfg) +} + +// --- Projects --- + +pub async fn projects_list( + cfg: &Config, + filter: Option, + filter_tags: Option, + sort: Option, + page_size: Option, + page_number: Option, +) -> Result<()> { + let api = make_api(cfg); + let mut params = ListModelLabProjectsOptionalParams::default(); + if let Some(f) = filter { + params = params.filter(f); + } + if let Some(t) = filter_tags { + params = params.filter_tags(t); + } + if let Some(s) = sort { + params = params.sort(s); + } + if let Some(n) = page_size { + params = params.page_size(n); + } + if let Some(n) = page_number { + params = params.page_number(n); + } + let resp = api + .list_model_lab_projects(params) + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab projects: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn projects_get(cfg: &Config, project_id: i64) -> Result<()> { + let api = make_api(cfg); + let resp = api + .get_model_lab_project(project_id) + .await + .map_err(|e| anyhow::anyhow!("failed to get model lab project: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn projects_star(cfg: &Config, project_id: i64) -> Result<()> { + let api = make_api(cfg); + api.star_model_lab_project(project_id) + .await + .map_err(|e| anyhow::anyhow!("failed to star model lab project: {e:?}"))?; + println!("Project {project_id} starred."); + Ok(()) +} + +pub async fn projects_unstar(cfg: &Config, project_id: i64) -> Result<()> { + let api = make_api(cfg); + api.unstar_model_lab_project(project_id) + .await + .map_err(|e| anyhow::anyhow!("failed to unstar model lab project: {e:?}"))?; + println!("Project {project_id} unstarred."); + Ok(()) +} + +pub async fn projects_artifacts(cfg: &Config, project_id: i64) -> Result<()> { + let api = make_api(cfg); + let resp = api + .list_model_lab_project_artifacts(project_id) + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab project artifacts: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn projects_facet_keys(cfg: &Config) -> Result<()> { + let api = make_api(cfg); + let resp = api + .list_model_lab_project_facet_keys() + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab project facet keys: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn projects_facet_values( + cfg: &Config, + facet_type: &str, + facet_name: String, +) -> Result<()> { + let api = make_api(cfg); + let ft = match facet_type { + "tag" => ModelLabProjectFacetType::TAG, + other => anyhow::bail!( + "unknown facet type '{other}'; valid values: tag" + ), + }; + let resp = api + .list_model_lab_project_facet_values(ft, facet_name) + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab project facet values: {e:?}"))?; + formatter::output(cfg, &resp) +} + +// --- Runs --- + +#[allow(clippy::too_many_arguments)] +pub async fn runs_list( + cfg: &Config, + filter: Option, + filter_project_id: Option, + filter_status: Option, + filter_tags: Option, + filter_params: Option, + filter_parent_run_id: Option, + pinned_first: bool, + include_pinned: bool, + sort: Option, + page_size: Option, + page_number: Option, +) -> Result<()> { + let api = make_api(cfg); + let mut params = ListModelLabRunsOptionalParams::default(); + if let Some(f) = filter { + params = params.filter(f); + } + if let Some(id) = filter_project_id { + params = params.filter_project_id(id); + } + if let Some(s) = filter_status { + let status = parse_run_status(&s)?; + params = params.filter_status(status); + } + if let Some(t) = filter_tags { + params = params.filter_tags(t); + } + if let Some(p) = filter_params { + params = params.filter_params(p); + } + if let Some(r) = filter_parent_run_id { + params = params.filter_parent_run_id(r); + } + if pinned_first { + params = params.pinned_first(true); + } + if include_pinned { + params = params.include_pinned(true); + } + if let Some(s) = sort { + params = params.sort(s); + } + if let Some(n) = page_size { + params = params.page_size(n); + } + if let Some(n) = page_number { + params = params.page_number(n); + } + let resp = api + .list_model_lab_runs(params) + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab runs: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn runs_get(cfg: &Config, run_id: i64) -> Result<()> { + let api = make_api(cfg); + let resp = api + .get_model_lab_run(run_id) + .await + .map_err(|e| anyhow::anyhow!("failed to get model lab run: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn runs_delete(cfg: &Config, run_id: i64) -> Result<()> { + let api = make_api(cfg); + api.delete_model_lab_run(run_id) + .await + .map_err(|e| anyhow::anyhow!("failed to delete model lab run: {e:?}"))?; + println!("Run {run_id} deleted."); + Ok(()) +} + +pub async fn runs_pin(cfg: &Config, run_id: i64) -> Result<()> { + let api = make_api(cfg); + api.pin_model_lab_run(run_id) + .await + .map_err(|e| anyhow::anyhow!("failed to pin model lab run: {e:?}"))?; + println!("Run {run_id} pinned."); + Ok(()) +} + +pub async fn runs_unpin(cfg: &Config, run_id: i64) -> Result<()> { + let api = make_api(cfg); + api.unpin_model_lab_run(run_id) + .await + .map_err(|e| anyhow::anyhow!("failed to unpin model lab run: {e:?}"))?; + println!("Run {run_id} unpinned."); + Ok(()) +} + +pub async fn runs_artifacts(cfg: &Config, run_id: i64, path: Option) -> Result<()> { + let api = make_api(cfg); + let mut params = ListModelLabRunArtifactsOptionalParams::default(); + if let Some(p) = path { + params = params.path(p); + } + let resp = api + .list_model_lab_run_artifacts(run_id, params) + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab run artifacts: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn runs_facet_keys(cfg: &Config, filter_project_id: i64) -> Result<()> { + let api = make_api(cfg); + let resp = api + .list_model_lab_run_facet_keys(filter_project_id) + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab run facet keys: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn runs_facet_values( + cfg: &Config, + filter_project_id: i64, + facet_type: &str, + facet_name: String, +) -> Result<()> { + let api = make_api(cfg); + let ft = parse_facet_type(facet_type)?; + let resp = api + .list_model_lab_run_facet_values(filter_project_id, ft, facet_name) + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab run facet values: {e:?}"))?; + formatter::output(cfg, &resp) +} + +fn parse_run_status(s: &str) -> Result { + match s { + "pending" => Ok(ModelLabRunStatus::PENDING), + "running" => Ok(ModelLabRunStatus::RUNNING), + "completed" => Ok(ModelLabRunStatus::COMPLETED), + "failed" => Ok(ModelLabRunStatus::FAILED), + "killed" => Ok(ModelLabRunStatus::KILLED), + "unresponsive" => Ok(ModelLabRunStatus::UNRESPONSIVE), + "paused" => Ok(ModelLabRunStatus::PAUSED), + other => anyhow::bail!( + "unknown status '{other}'; valid values: pending, running, completed, failed, killed, unresponsive, paused" + ), + } +} + +fn parse_facet_type(s: &str) -> Result { + match s { + "parameter" => Ok(ModelLabFacetType::PARAMETER), + "attribute" => Ok(ModelLabFacetType::ATTRIBUTE), + "tag" => Ok(ModelLabFacetType::TAG), + "metric" => Ok(ModelLabFacetType::METRIC), + other => anyhow::bail!( + "unknown facet type '{other}'; valid values: parameter, attribute, tag, metric" + ), + } +} + +#[cfg(test)] +mod tests { + use crate::test_support::*; + + // --- Projects --- + + #[tokio::test] + async fn test_model_lab_projects_list() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data":[],"meta":{"page":{"number":0,"size":10,"total":0}}}"#).await; + let result = super::projects_list(&cfg, None, None, None, None, None).await; + assert!(result.is_ok(), "projects list failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_projects_list_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", mockito::Matcher::Any) + .with_status(403) + .with_body(r#"{"errors":["Forbidden"]}"#) + .create_async() + .await; + let result = super::projects_list(&cfg, None, None, None, None, None).await; + assert!(result.is_err(), "projects list should fail on 403"); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_projects_get() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":{"id":"1","type":"projects","attributes":{"artifact_storage_location":"s3://bucket","created_at":"2024-01-01T00:00:00Z","description":"test","is_starred":false,"name":"my-project","tags":[],"updated_at":"2024-01-01T00:00:00Z"}}}"#, + ) + .await; + let result = super::projects_get(&cfg, 1).await; + assert!(result.is_ok(), "projects get failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_projects_get_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", mockito::Matcher::Any) + .with_status(404) + .with_body(r#"{"errors":["not found"]}"#) + .create_async() + .await; + let result = super::projects_get(&cfg, 999).await; + assert!(result.is_err(), "projects get should fail on 404"); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_projects_star() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "").await; + let result = super::projects_star(&cfg, 1).await; + assert!(result.is_ok(), "projects star failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_projects_unstar() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "").await; + let result = super::projects_unstar(&cfg, 1).await; + assert!(result.is_ok(), "projects unstar failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_projects_artifacts() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data":{"id":"1","type":"project_files","attributes":{"files":[]}}}"#).await; + let result = super::projects_artifacts(&cfg, 1).await; + assert!( + result.is_ok(), + "projects artifacts failed: {:?}", + result.err() + ); + cleanup_env(); + } + + // --- Runs --- + + #[tokio::test] + async fn test_model_lab_runs_list() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data":[],"meta":{"page":{"number":0,"size":10,"total":0}}}"#).await; + let result = + super::runs_list(&cfg, None, None, None, None, None, None, false, false, None, None, None) + .await; + assert!(result.is_ok(), "runs list failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_list_invalid_status() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data":[]}"#).await; + let result = super::runs_list( + &cfg, + None, + None, + Some("bogus".to_string()), + None, + None, + None, + false, + false, + None, + None, + None, + ) + .await; + assert!(result.is_err(), "invalid status should fail"); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_get() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":{"id":"42","type":"runs","attributes":{"created_at":"2024-01-01T00:00:00Z","descendant_match":false,"description":"test run","has_children":false,"is_pinned":false,"metric_summaries":[],"mlflow_artifact_location":"s3://bucket/run","name":"run-1","params":null,"project_id":1,"started_at":"2024-01-01T00:00:00Z","status":"completed","tags":[],"updated_at":"2024-01-01T00:00:00Z"}}}"#, + ) + .await; + let result = super::runs_get(&cfg, 42).await; + assert!(result.is_ok(), "runs get failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_get_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", mockito::Matcher::Any) + .with_status(404) + .with_body(r#"{"errors":["not found"]}"#) + .create_async() + .await; + let result = super::runs_get(&cfg, 999).await; + assert!(result.is_err(), "runs get should fail on 404"); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_delete() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "").await; + let result = super::runs_delete(&cfg, 42).await; + assert!(result.is_ok(), "runs delete failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_delete_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("DELETE", mockito::Matcher::Any) + .with_status(404) + .with_body(r#"{"errors":["not found"]}"#) + .create_async() + .await; + let result = super::runs_delete(&cfg, 999).await; + assert!(result.is_err(), "runs delete should fail on 404"); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_pin() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "").await; + let result = super::runs_pin(&cfg, 42).await; + assert!(result.is_ok(), "runs pin failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_unpin() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "").await; + let result = super::runs_unpin(&cfg, 42).await; + assert!(result.is_ok(), "runs unpin failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_artifacts() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data":{"id":"42","type":"artifacts","attributes":{"files":[],"path_in_project":""}}}"#).await; + let result = super::runs_artifacts(&cfg, 42, None).await; + assert!( + result.is_ok(), + "runs artifacts failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_parse_run_status_valid() { + for s in &[ + "pending", "running", "completed", "failed", "killed", "unresponsive", "paused", + ] { + assert!(super::parse_run_status(s).is_ok(), "expected '{s}' to be valid"); + } + } + + #[tokio::test] + async fn test_model_lab_parse_run_status_invalid() { + assert!(super::parse_run_status("bogus").is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 640bd020..cba56b20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1879,6 +1879,30 @@ enum Commands { #[command(subcommand)] action: MiscActions, }, + /// Explore Model Lab projects and runs + /// + /// Browse ML experiment projects and their training runs in Datadog Model Lab. + /// + /// SUBCOMMANDS: + /// projects Manage Model Lab projects + /// runs Manage Model Lab runs + /// + /// EXAMPLES: + /// pup model-lab projects list + /// pup model-lab projects get 123 + /// pup model-lab projects star 123 + /// pup model-lab runs list --filter-project-id 123 + /// pup model-lab runs get 456 + /// pup model-lab runs delete 456 + /// pup model-lab runs pin 456 + /// + /// AUTHENTICATION: + /// Requires either OAuth2 authentication or API keys. + #[command(name = "model-lab", verbatim_doc_comment)] + ModelLab { + #[command(subcommand)] + action: ModelLabActions, + }, /// Manage monitors /// /// Manage Datadog monitors for alerting and notifications. @@ -8122,6 +8146,119 @@ enum MiscActions { Status, } +// ---- Model Lab ---- +#[derive(Subcommand)] +enum ModelLabActions { + /// Manage Model Lab projects + Projects { + #[command(subcommand)] + action: ModelLabProjectActions, + }, + /// Manage Model Lab runs + Runs { + #[command(subcommand)] + action: ModelLabRunActions, + }, +} + +#[derive(Subcommand)] +enum ModelLabProjectActions { + /// List Model Lab projects + List { + #[arg(long, help = "Filter query string")] + filter: Option, + #[arg(long, help = "Filter by tags (comma-separated)")] + filter_tags: Option, + #[arg(long, help = "Sort field (e.g. name, created_at)")] + sort: Option, + #[arg(long)] + page_size: Option, + #[arg(long, help = "Page number (0-indexed)")] + page_number: Option, + }, + /// Get a Model Lab project by ID + Get { project_id: i64 }, + /// Star a Model Lab project + Star { project_id: i64 }, + /// Unstar a Model Lab project + Unstar { project_id: i64 }, + /// List artifacts for a Model Lab project + Artifacts { project_id: i64 }, + /// List facet keys for Model Lab projects + #[command(name = "facet-keys")] + FacetKeys, + /// List facet values for a Model Lab project facet + #[command(name = "facet-values")] + FacetValues { + #[arg(help = "Facet type (tag)")] + facet_type: String, + #[arg(help = "Facet name")] + facet_name: String, + }, +} + +#[derive(Subcommand)] +enum ModelLabRunActions { + /// List Model Lab runs + List { + #[arg(long, help = "Filter query string")] + filter: Option, + #[arg(long, help = "Filter by project ID")] + filter_project_id: Option, + #[arg( + long, + help = "Filter by status: pending, running, completed, failed, killed, unresponsive, paused" + )] + filter_status: Option, + #[arg(long, help = "Filter by tags (comma-separated)")] + filter_tags: Option, + #[arg(long, help = "Filter by params")] + filter_params: Option, + #[arg(long, help = "Filter by parent run ID")] + filter_parent_run_id: Option, + #[arg(long, default_value_t = false, help = "Show pinned runs first")] + pinned_first: bool, + #[arg(long, default_value_t = false, help = "Include pinned runs")] + include_pinned: bool, + #[arg(long, help = "Sort field")] + sort: Option, + #[arg(long)] + page_size: Option, + #[arg(long, help = "Page number (0-indexed)")] + page_number: Option, + }, + /// Get a Model Lab run by ID + Get { run_id: i64 }, + /// Delete a Model Lab run + Delete { run_id: i64 }, + /// Pin a Model Lab run + Pin { run_id: i64 }, + /// Unpin a Model Lab run + Unpin { run_id: i64 }, + /// List artifacts for a Model Lab run + Artifacts { + run_id: i64, + #[arg(long, help = "Filter by artifact path prefix")] + path: Option, + }, + /// List facet keys for Model Lab runs + #[command(name = "facet-keys")] + FacetKeys { + #[arg(long, help = "Project ID to scope facet keys")] + filter_project_id: i64, + }, + /// List facet values for a Model Lab run facet + #[command(name = "facet-values")] + FacetValues { + #[arg(long, help = "Project ID to scope facet values")] + filter_project_id: i64, + #[arg(help = "Facet type (parameter, attribute, tag, metric)")] + facet_type: String, + #[arg(help = "Facet name")] + facet_name: String, + }, +} + // ---- APM ---- #[derive(Subcommand)] enum ApmActions { @@ -14303,6 +14440,115 @@ async fn main_inner() -> anyhow::Result<()> { MiscActions::Status => commands::misc::status(&cfg).await?, } } + // --- Model Lab --- + Commands::ModelLab { action } => { + cfg.validate_auth()?; + match action { + ModelLabActions::Projects { action } => match action { + ModelLabProjectActions::List { + filter, + filter_tags, + sort, + page_size, + page_number, + } => { + commands::model_lab::projects_list( + &cfg, + filter, + filter_tags, + sort, + page_size, + page_number, + ) + .await?; + } + ModelLabProjectActions::Get { project_id } => { + commands::model_lab::projects_get(&cfg, project_id).await?; + } + ModelLabProjectActions::Star { project_id } => { + commands::model_lab::projects_star(&cfg, project_id).await?; + } + ModelLabProjectActions::Unstar { project_id } => { + commands::model_lab::projects_unstar(&cfg, project_id).await?; + } + ModelLabProjectActions::Artifacts { project_id } => { + commands::model_lab::projects_artifacts(&cfg, project_id).await?; + } + ModelLabProjectActions::FacetKeys => { + commands::model_lab::projects_facet_keys(&cfg).await?; + } + ModelLabProjectActions::FacetValues { + facet_type, + facet_name, + } => { + commands::model_lab::projects_facet_values(&cfg, &facet_type, facet_name) + .await?; + } + }, + ModelLabActions::Runs { action } => match action { + ModelLabRunActions::List { + filter, + filter_project_id, + filter_status, + filter_tags, + filter_params, + filter_parent_run_id, + pinned_first, + include_pinned, + sort, + page_size, + page_number, + } => { + commands::model_lab::runs_list( + &cfg, + filter, + filter_project_id, + filter_status, + filter_tags, + filter_params, + filter_parent_run_id, + pinned_first, + include_pinned, + sort, + page_size, + page_number, + ) + .await?; + } + ModelLabRunActions::Get { run_id } => { + commands::model_lab::runs_get(&cfg, run_id).await?; + } + ModelLabRunActions::Delete { run_id } => { + commands::model_lab::runs_delete(&cfg, run_id).await?; + } + ModelLabRunActions::Pin { run_id } => { + commands::model_lab::runs_pin(&cfg, run_id).await?; + } + ModelLabRunActions::Unpin { run_id } => { + commands::model_lab::runs_unpin(&cfg, run_id).await?; + } + ModelLabRunActions::Artifacts { run_id, path } => { + commands::model_lab::runs_artifacts(&cfg, run_id, path).await?; + } + ModelLabRunActions::FacetKeys { filter_project_id } => { + commands::model_lab::runs_facet_keys(&cfg, filter_project_id).await?; + } + ModelLabRunActions::FacetValues { + filter_project_id, + facet_type, + facet_name, + } => { + commands::model_lab::runs_facet_values( + &cfg, + filter_project_id, + &facet_type, + facet_name, + ) + .await?; + } + }, + } + } // --- APM --- Commands::Apm { action } => { cfg.validate_auth()?; From fc042d67417c4184c214b78970fec692dd0b9f68 Mon Sep 17 00:00:00 2001 From: jack-edmonds-dd Date: Thu, 18 Jun 2026 13:42:35 -0400 Subject: [PATCH 5/6] fix(config): add missing jq field in test Config initializer Config gained a jq field in PR #590 but one test initializer in config.rs was missed, causing clippy --all-targets to fail. Co-Authored-By: Claude Sonnet 4.6 --- src/config.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config.rs b/src/config.rs index 4e3d38a2..d82aae22 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1936,6 +1936,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; super::apply_org_override(&mut cfg, "unknown-org".into()).unwrap(); From 04df81912c717e6b382ecda601d30b53453c4e41 Mon Sep 17 00:00:00 2001 From: jack-edmonds-dd Date: Thu, 18 Jun 2026 13:44:34 -0400 Subject: [PATCH 6/6] fmt --- src/commands/mod.rs | 2 +- src/commands/model_lab.rs | 48 ++++++++++++++++++++++++++------------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 115a91b8..69e03d67 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -54,8 +54,8 @@ pub mod llm_obs; pub mod logs; pub mod logs_restriction; pub mod metrics; -pub mod model_lab; pub mod misc; +pub mod model_lab; pub mod monitors; pub mod ms_teams; pub mod network; diff --git a/src/commands/model_lab.rs b/src/commands/model_lab.rs index 77bf2ecf..04379d68 100644 --- a/src/commands/model_lab.rs +++ b/src/commands/model_lab.rs @@ -101,9 +101,7 @@ pub async fn projects_facet_values( let api = make_api(cfg); let ft = match facet_type { "tag" => ModelLabProjectFacetType::TAG, - other => anyhow::bail!( - "unknown facet type '{other}'; valid values: tag" - ), + other => anyhow::bail!("unknown facet type '{other}'; valid values: tag"), }; let resp = api .list_model_lab_project_facet_values(ft, facet_name) @@ -283,7 +281,11 @@ mod tests { let _lock = lock_env().await; let mut s = mockito::Server::new_async().await; let cfg = test_config(&s.url()); - mock_all(&mut s, r#"{"data":[],"meta":{"page":{"number":0,"size":10,"total":0}}}"#).await; + mock_all( + &mut s, + r#"{"data":[],"meta":{"page":{"number":0,"size":10,"total":0}}}"#, + ) + .await; let result = super::projects_list(&cfg, None, None, None, None, None).await; assert!(result.is_ok(), "projects list failed: {:?}", result.err()); cleanup_env(); @@ -361,7 +363,11 @@ mod tests { let _lock = lock_env().await; let mut s = mockito::Server::new_async().await; let cfg = test_config(&s.url()); - mock_all(&mut s, r#"{"data":{"id":"1","type":"project_files","attributes":{"files":[]}}}"#).await; + mock_all( + &mut s, + r#"{"data":{"id":"1","type":"project_files","attributes":{"files":[]}}}"#, + ) + .await; let result = super::projects_artifacts(&cfg, 1).await; assert!( result.is_ok(), @@ -378,10 +384,15 @@ mod tests { let _lock = lock_env().await; let mut s = mockito::Server::new_async().await; let cfg = test_config(&s.url()); - mock_all(&mut s, r#"{"data":[],"meta":{"page":{"number":0,"size":10,"total":0}}}"#).await; - let result = - super::runs_list(&cfg, None, None, None, None, None, None, false, false, None, None, None) - .await; + mock_all( + &mut s, + r#"{"data":[],"meta":{"page":{"number":0,"size":10,"total":0}}}"#, + ) + .await; + let result = super::runs_list( + &cfg, None, None, None, None, None, None, false, false, None, None, None, + ) + .await; assert!(result.is_ok(), "runs list failed: {:?}", result.err()); cleanup_env(); } @@ -496,20 +507,25 @@ mod tests { let cfg = test_config(&s.url()); mock_all(&mut s, r#"{"data":{"id":"42","type":"artifacts","attributes":{"files":[],"path_in_project":""}}}"#).await; let result = super::runs_artifacts(&cfg, 42, None).await; - assert!( - result.is_ok(), - "runs artifacts failed: {:?}", - result.err() - ); + assert!(result.is_ok(), "runs artifacts failed: {:?}", result.err()); cleanup_env(); } #[tokio::test] async fn test_model_lab_parse_run_status_valid() { for s in &[ - "pending", "running", "completed", "failed", "killed", "unresponsive", "paused", + "pending", + "running", + "completed", + "failed", + "killed", + "unresponsive", + "paused", ] { - assert!(super::parse_run_status(s).is_ok(), "expected '{s}' to be valid"); + assert!( + super::parse_run_status(s).is_ok(), + "expected '{s}' to be valid" + ); } }