diff --git a/AGENTS.md b/AGENTS.md index b8acf40a..0c838320 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,12 @@ - This repo is managed with `mise` and `pre-commit`; prefer using the repo-defined toolchain and hooks when running local validation. +## Privacy and Test Data + +- Do not add real customer, organization, profile, project, function, dataset, experiment, or user names/IDs to tests, fixtures, docs, examples, snapshots, or committed files. +- Use synthetic placeholders instead, for example `test-profile`, `test-org`, `test-project`, `fn_test_topic_map`, or UUIDs clearly marked as fake. +- If a user-provided command includes real identifiers, do not copy them into code or tests; translate them to synthetic values before writing files. + ## CLI Implementation Conventions - Follow existing resource-command patterns before adding new structure; `projects/` is a good reference for module layout and command dispatch. diff --git a/src/functions/api.rs b/src/functions/api.rs index e4e1f1db..d0c373c2 100644 --- a/src/functions/api.rs +++ b/src/functions/api.rs @@ -94,6 +94,21 @@ pub async fn get_function_by_slug( Ok(response.data.into_iter().next()) } +pub async fn get_function_by_id(client: &ApiClient, id: &str) -> Result> { + let query = FunctionListQuery { + id: Some(id.to_string()), + ..Default::default() + }; + let page = list_functions_page(client, &query).await?; + let Some(raw) = page.objects.into_iter().next() else { + return Ok(None); + }; + + serde_json::from_value(raw) + .map(Some) + .context("unexpected function response shape") +} + pub async fn invoke_function( client: &ApiClient, body: &serde_json::Value, diff --git a/src/functions/mod.rs b/src/functions/mod.rs index 2caf3d0e..4b5e3b2e 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -151,6 +151,14 @@ impl SlugArgs { .as_deref() .or(self.slug_flag.as_deref()) } + + fn slug_positional(&self) -> Option<&str> { + self.slug_positional.as_deref() + } + + fn slug_flag(&self) -> Option<&str> { + self.slug_flag.as_deref() + } } #[derive(Debug, Clone, Args)] @@ -158,6 +166,8 @@ impl SlugArgs { Examples: bt tools list bt tools view my-tool + bt tools view fn_123 + bt tools view --id fn_123 bt scorers list bt scorers delete my-scorer ")] @@ -183,6 +193,8 @@ enum FunctionCommands { Examples: bt functions list bt functions view my-function + bt functions view fn_123 + bt functions view --id fn_123 bt functions invoke my-function --input '{\"key\":\"value\"}' bt functions push --file ./functions bt functions pull --output-dir ./braintrust @@ -418,17 +430,43 @@ impl PullArgs { pub struct ViewArgs { #[command(flatten)] slug: SlugArgs, + /// Function id + #[arg(long = "id", env = "BT_FUNCTIONS_VIEW_ID")] + id: Option, /// Open in browser #[arg(long)] web: bool, } impl ViewArgs { - fn slug(&self) -> Option<&str> { - self.slug.slug() + fn selector(&self) -> Result> { + match ( + self.id.as_deref(), + self.slug.slug_positional(), + self.slug.slug_flag(), + ) { + (Some(_), Some(_), _) | (Some(_), _, Some(_)) => { + bail!("use either --id or a slug, not both") + } + (Some(id), None, None) => Ok(ViewSelector::Id(id)), + (None, Some(positional), None) if is_likely_function_id(positional) => { + Ok(ViewSelector::Id(positional)) + } + (None, positional, flag) => Ok(ViewSelector::Slug(positional.or(flag))), + } } } +fn is_likely_function_id(value: &str) -> bool { + value.starts_with("fn_") || value.starts_with("func_") +} + +#[derive(Debug)] +enum ViewSelector<'a> { + Id(&'a str), + Slug(Option<&'a str>), +} + #[derive(Debug, Clone, Args)] pub struct DeleteArgs { #[command(flatten)] @@ -567,15 +605,29 @@ pub(crate) async fn select_function_interactive( } pub async fn run_typed(base: BaseArgs, args: FunctionArgs, kind: FunctionTypeFilter) -> Result<()> { - let ctx = resolve_context(&base).await?; let ft = Some(kind); match args.command { - None | Some(FunctionCommands::List) => list::run(&ctx, base.json, ft).await, - Some(FunctionCommands::View(v)) => { - view::run(&ctx, v.slug(), base.json, v.web, base.verbose, ft).await + Some(FunctionCommands::View(v)) => match v.selector()? { + ViewSelector::Id(id) => { + let auth_ctx = resolve_auth_context(&base).await?; + view::run_by_id(&auth_ctx, id, base.json, v.web, base.verbose, ft).await + } + ViewSelector::Slug(slug) => { + let ctx = resolve_context(&base).await?; + view::run(&ctx, slug, base.json, v.web, base.verbose, ft).await + } + }, + command => { + let ctx = resolve_context(&base).await?; + match command { + None | Some(FunctionCommands::List) => list::run(&ctx, base.json, ft).await, + Some(FunctionCommands::Delete(d)) => delete::run(&ctx, d.slug(), d.force, ft).await, + Some(FunctionCommands::Invoke(i)) => invoke::run(&ctx, &i, base.json, ft).await, + Some(FunctionCommands::View(_)) => { + unreachable!("handled before context resolution") + } + } } - Some(FunctionCommands::Delete(d)) => delete::run(&ctx, d.slug(), d.force, ft).await, - Some(FunctionCommands::Invoke(i)) => invoke::run(&ctx, &i, base.json, ft).await, } } @@ -584,6 +636,19 @@ pub async fn run(base: BaseArgs, args: FunctionsArgs) -> Result<()> { match args.command { Some(FunctionsCommands::Push(push_args)) => push::run(base, push_args).await, Some(FunctionsCommands::Pull(pull_args)) => pull::run(base, pull_args).await, + Some(FunctionsCommands::View(v)) => { + let ft = v.function_type.or(function_type); + match v.inner.selector()? { + ViewSelector::Id(id) => { + let auth_ctx = resolve_auth_context(&base).await?; + view::run_by_id(&auth_ctx, id, base.json, v.inner.web, base.verbose, ft).await + } + ViewSelector::Slug(slug) => { + let ctx = resolve_context(&base).await?; + view::run(&ctx, slug, base.json, v.inner.web, base.verbose, ft).await + } + } + } command => { let ctx = resolve_context(&base).await?; match command { @@ -591,24 +656,15 @@ pub async fn run(base: BaseArgs, args: FunctionsArgs) -> Result<()> { Some(FunctionsCommands::List(la)) => { list::run(&ctx, base.json, la.function_type.or(function_type)).await } - Some(FunctionsCommands::View(v)) => { - view::run( - &ctx, - v.inner.slug(), - base.json, - v.inner.web, - base.verbose, - v.function_type.or(function_type), - ) - .await - } Some(FunctionsCommands::Delete(d)) => { delete::run(&ctx, d.slug(), d.force, d.function_type.or(function_type)).await } Some(FunctionsCommands::Invoke(i)) => { invoke::run(&ctx, &i.inner, base.json, i.function_type.or(function_type)).await } - Some(FunctionsCommands::Push(_)) | Some(FunctionsCommands::Pull(_)) => { + Some(FunctionsCommands::Push(_)) + | Some(FunctionsCommands::Pull(_)) + | Some(FunctionsCommands::View(_)) => { unreachable!("handled before context resolution") } } @@ -938,6 +994,58 @@ mod tests { assert_eq!(pull.slug_flag, vec!["a", "b", "c"]); } + #[test] + fn view_accepts_id_selector() { + let _guard = test_lock(); + let parsed = parse(&["functions", "view", "--id", "f1"]).expect("parse view"); + let FunctionsCommands::View(view) = parsed.command.expect("subcommand") else { + panic!("expected view command"); + }; + match view.inner.selector().expect("view selector") { + ViewSelector::Id(id) => assert_eq!(id, "f1"), + ViewSelector::Slug(_) => panic!("expected id selector"), + } + } + + #[test] + fn view_auto_detects_positional_function_id() { + let _guard = test_lock(); + for value in ["fn_123", "func_123"] { + let parsed = parse(&["functions", "view", value]).expect("parse view"); + let FunctionsCommands::View(view) = parsed.command.expect("subcommand") else { + panic!("expected view command"); + }; + match view.inner.selector().expect("view selector") { + ViewSelector::Id(id) => assert_eq!(id, value), + ViewSelector::Slug(_) => panic!("expected id selector for {value}"), + } + } + } + + #[test] + fn view_slug_flag_forces_slug_even_when_value_looks_like_id() { + let _guard = test_lock(); + let parsed = parse(&["functions", "view", "--slug", "fn_123"]).expect("parse view"); + let FunctionsCommands::View(view) = parsed.command.expect("subcommand") else { + panic!("expected view command"); + }; + match view.inner.selector().expect("view selector") { + ViewSelector::Slug(Some(slug)) => assert_eq!(slug, "fn_123"), + other => panic!("expected slug selector, got {other:?}"), + } + } + + #[test] + fn view_rejects_id_and_slug_together() { + let _guard = test_lock(); + let parsed = parse(&["functions", "view", "--id", "f1", "slug"]).expect("parse view"); + let FunctionsCommands::View(view) = parsed.command.expect("subcommand") else { + panic!("expected view command"); + }; + let err = view.inner.selector().expect_err("id and slug conflict"); + assert!(err.to_string().contains("either --id or a slug")); + } + #[test] fn function_selection_label_includes_slug_when_name_differs() { let function = Function { diff --git a/src/functions/view.rs b/src/functions/view.rs index ccd86af1..9caad589 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -10,9 +10,10 @@ use crate::ui::prompt_render::{ use crate::ui::{ is_interactive, print_command_status, print_with_pager, with_spinner, CommandStatus, }; +use crate::{http::ApiClient, projects::api as projects_api}; use super::{api, build_web_path, label, label_plural, select_function_interactive}; -use super::{FunctionTypeFilter, ResolvedContext}; +use super::{AuthContext, FunctionTypeFilter, ResolvedContext}; pub async fn run( ctx: &ResolvedContext, @@ -42,13 +43,65 @@ pub async fn run( } }; + render_function( + &ctx.client, + &ctx.app_url, + Some(&ctx.project.name), + &function, + json, + web, + verbose, + ) + .await +} + +pub async fn run_by_id( + ctx: &AuthContext, + id: &str, + json: bool, + web: bool, + verbose: bool, + ft: Option, +) -> Result<()> { + let function = with_spinner( + &format!("Loading {}...", label(ft)), + api::get_function_by_id(&ctx.client, id), + ) + .await? + .ok_or_else(|| anyhow!("{} with id '{id}' not found", label(ft)))?; + + render_function( + &ctx.client, + &ctx.app_url, + None, + &function, + json, + web, + verbose, + ) + .await +} + +async fn render_function( + client: &ApiClient, + app_url: &str, + project_name: Option<&str>, + function: &api::Function, + json: bool, + web: bool, + verbose: bool, +) -> Result<()> { if web { - let path = build_web_path(&function); + let path = build_web_path(function); + let project_name = match project_name { + Some(project_name) => project_name.to_string(), + None => resolve_project_name(client, &function.project_id).await?, + }; let url = format!( "{}/app/{}/p/{}/{}", - ctx.app_url.trim_end_matches('/'), - encode(ctx.client.org_name()), - encode(&ctx.project.name), + app_url.trim_end_matches('/'), + encode(client.org_name()), + encode(&project_name), path ); open::that(&url)?; @@ -266,12 +319,16 @@ pub async fn run( writeln!(output, " {name}")?; } } - let path = build_web_path(&function); + let path = build_web_path(function); + let project_name = match project_name { + Some(project_name) => project_name.to_string(), + None => resolve_project_name(client, &function.project_id).await?, + }; let url = format!( "{}/app/{}/p/{}/{}", - ctx.app_url.trim_end_matches('/'), - encode(ctx.client.org_name()), - encode(&ctx.project.name), + app_url.trim_end_matches('/'), + encode(client.org_name()), + encode(&project_name), path ); writeln!( @@ -321,6 +378,15 @@ pub async fn run( Ok(()) } +async fn resolve_project_name(client: &ApiClient, project_id: &str) -> Result { + let projects = with_spinner("Loading project...", projects_api::list_projects(client)).await?; + projects + .into_iter() + .find(|project| project.id == project_id) + .map(|project| project.name) + .ok_or_else(|| anyhow!("project '{project_id}' not found for function")) +} + fn render_prompt_value(output: &mut String, val: &serde_json::Value) -> Result<()> { if let Some(model) = val .get("options") diff --git a/src/setup/mod.rs b/src/setup/mod.rs index a904f214..dafd5263 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -4936,9 +4936,9 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())) } - fn env_test_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) + fn env_test_lock() -> &'static tokio::sync::Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) } #[cfg(unix)] @@ -6112,9 +6112,9 @@ mod tests { } } - #[test] - fn sync_setup_api_key_sets_base_and_process_env() { - let _guard = env_test_lock().lock().expect("lock env test"); + #[tokio::test] + async fn sync_setup_api_key_sets_base_and_process_env() { + let _guard = env_test_lock().lock().await; let previous_api_key = env::var_os("BRAINTRUST_API_KEY"); env::remove_var("BRAINTRUST_API_KEY"); @@ -6133,7 +6133,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn run_agent_invocation_sets_extra_env_for_program_launches() { - let _guard = env_test_lock().lock().expect("lock env test"); + let _guard = env_test_lock().lock().await; let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock") @@ -6186,7 +6186,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn run_agent_invocation_inherits_process_api_key_env() { - let _guard = env_test_lock().lock().expect("lock env test"); + let _guard = env_test_lock().lock().await; let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock") diff --git a/src/topics/api.rs b/src/topics/api.rs index ef80f759..0266095f 100644 --- a/src/topics/api.rs +++ b/src/topics/api.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, HashMap}; use anyhow::{bail, Result}; use chrono::{DateTime, Utc}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use urlencoding::encode; @@ -55,6 +55,18 @@ pub struct TopicMapConfigUpdate { pub topic_map_id: String, } +#[derive(Debug, Clone, Serialize)] +struct TopicMapReportUrlRequest<'a> { + function_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + version: Option<&'a str>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TopicMapReportUrl { + pub url: String, +} + #[derive(Debug, Clone, Serialize)] pub struct TopicsProjectSummary { pub id: String, @@ -820,6 +832,18 @@ pub fn topics_url(app_url: &str, org_name: &str, project_name: &str) -> String { ) } +pub async fn fetch_topic_map_report_url( + client: &ApiClient, + function_id: &str, + version: Option<&str>, +) -> Result { + let request = TopicMapReportUrlRequest { + function_id, + version, + }; + client.post("/topic-map-report-url", &request).await +} + fn topic_automation_object_id(project_id: &str, data_scope: Option<&Value>) -> Result { let data_scope_mapping = data_scope.and_then(Value::as_object); let scope_type = string_value(data_scope_mapping.and_then(|scope| scope.get("type"))); @@ -2229,6 +2253,29 @@ mod tests { ); } + #[test] + fn topic_map_report_url_request_matches_endpoint_shape() { + let without_version = serde_json::to_value(TopicMapReportUrlRequest { + function_id: "fn_123", + version: None, + }) + .expect("serialize report url request"); + assert_eq!(without_version, json!({ "function_id": "fn_123" })); + + let with_version = serde_json::to_value(TopicMapReportUrlRequest { + function_id: "fn_123", + version: Some("0000000000000001"), + }) + .expect("serialize report url request"); + assert_eq!( + with_version, + json!({ + "function_id": "fn_123", + "version": "0000000000000001", + }) + ); + } + #[test] fn backfill_time_range_supports_duration_strings_and_intervals() { assert_eq!( diff --git a/src/topics/mod.rs b/src/topics/mod.rs index 0e26ea79..35123027 100644 --- a/src/topics/mod.rs +++ b/src/topics/mod.rs @@ -1,5 +1,6 @@ use anyhow::Result; use clap::{Args, Subcommand}; +use std::path::PathBuf; use crate::{args::BaseArgs, project_context::resolve_project_command_context_with_auth_mode}; @@ -8,6 +9,7 @@ mod config; mod formatting; mod open; mod poke; +mod report; mod rewind; mod status; @@ -25,6 +27,8 @@ Examples: bt topics config delete bt topics config set --topic-window 1h --generation-cadence 1d bt topics config topic-map set Task --embedding-model brain-embedding-1 + bt topics report fn_123 + bt topics report fn_123 --version 0000000000000001 bt topics poke bt topics rewind 7d bt topics open @@ -44,6 +48,8 @@ enum TopicsCommands { Poke, /// Rewind recent Topics history and queue it to reprocess Rewind(RewindArgs), + /// Download a saved topic map report JSON file + Report(ReportArgs), /// Open the Topics page in the browser Open, } @@ -244,11 +250,49 @@ struct RewindArgs { topic_window: String, } +#[derive(Debug, Clone, Args)] +struct ReportArgs { + /// Topic map function ID + #[arg(value_name = "FUNCTION_ID")] + function_id_positional: Option, + + /// Topic map function ID + #[arg(long = "id", env = "BT_TOPICS_REPORT_FUNCTION_ID")] + id: Option, + + /// Specific topic map version/xact ID + #[arg(long, env = "BT_TOPICS_REPORT_VERSION")] + version: Option, + + /// Output file path. Omit to write the report JSON to stdout. + #[arg(long, env = "BT_TOPICS_REPORT_OUTPUT")] + output: Option, +} + +impl ReportArgs { + fn function_id(&self) -> Result<&str> { + match (self.function_id_positional.as_deref(), self.id.as_deref()) { + (Some(_), Some(_)) => { + anyhow::bail!("use either --id or a positional function id, not both") + } + (Some(id), None) | (None, Some(id)) => Ok(id), + (None, None) => { + anyhow::bail!("topic map function id required. Use: bt topics report ") + } + } + } +} + pub async fn run(base: BaseArgs, args: TopicsArgs) -> Result<()> { + if let Some(TopicsCommands::Report(report_args)) = args.command.as_ref() { + return report::run(&base, report_args, base.json).await; + } + let read_only = match args.command.as_ref() { None | Some(TopicsCommands::Status(_)) | Some(TopicsCommands::Open) => true, Some(TopicsCommands::Config(config_args)) => config_args.command.is_none(), Some(TopicsCommands::Poke) | Some(TopicsCommands::Rewind(_)) => false, + Some(TopicsCommands::Report(_)) => unreachable!("handled before project resolution"), }; let ctx = resolve_project_command_context_with_auth_mode(&base, read_only).await?; @@ -302,6 +346,7 @@ pub async fn run(base: BaseArgs, args: TopicsArgs) -> Result<()> { Some(TopicsCommands::Rewind(rewind_args)) => { rewind::run(&ctx, &rewind_args, base.json).await } + Some(TopicsCommands::Report(_)) => unreachable!("handled before project resolution"), Some(TopicsCommands::Open) => open::run(&ctx).await, } } @@ -337,6 +382,7 @@ mod tests { None | Some(TopicsCommands::Status(_)) | Some(TopicsCommands::Open) => true, Some(TopicsCommands::Config(config_args)) => config_args.command.is_none(), Some(TopicsCommands::Poke) | Some(TopicsCommands::Rewind(_)) => false, + Some(TopicsCommands::Report(_)) => true, } } @@ -357,6 +403,9 @@ mod tests { let parsed = parse(&["topics", "open"]).expect("parse"); assert!(topics_command_is_read_only(parsed.command.as_ref())); + + let parsed = parse(&["topics", "report", "fn_123"]).expect("parse"); + assert!(topics_command_is_read_only(parsed.command.as_ref())); } #[test] @@ -371,6 +420,46 @@ mod tests { assert!(!topics_command_is_read_only(parsed.command.as_ref())); } + #[test] + fn topics_report_parses_function_id_version_and_output() { + let parsed = parse(&[ + "topics", + "report", + "--id", + "fn_123", + "--version", + "0000000000000001", + "--output", + "report.json", + ]) + .expect("parse"); + + let Some(TopicsCommands::Report(args)) = parsed.command.as_ref() else { + panic!("expected report command"); + }; + assert_eq!(args.function_id().expect("function id"), "fn_123"); + assert_eq!(args.version.as_deref(), Some("0000000000000001")); + assert_eq!( + args.output.as_deref(), + Some(std::path::Path::new("report.json")) + ); + assert!(topics_command_is_read_only(parsed.command.as_ref())); + } + + #[test] + fn topics_report_accepts_id_flag_without_output() { + let parsed = parse(&["topics", "report", "--id", "fn_test_topic_map"]).expect("parse"); + + let Some(TopicsCommands::Report(args)) = parsed.command.as_ref() else { + panic!("expected report command"); + }; + assert_eq!( + args.function_id().expect("function id"), + "fn_test_topic_map" + ); + assert_eq!(args.output, None); + } + #[test] fn topics_config_view_uses_read_only_auth() { let parsed = parse(&["topics", "config"]).expect("parse"); diff --git a/src/topics/report.rs b/src/topics/report.rs new file mode 100644 index 00000000..c90731b2 --- /dev/null +++ b/src/topics/report.rs @@ -0,0 +1,99 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use serde::Serialize; + +use crate::{ + args::BaseArgs, + auth::login_read_only, + http::{self, ApiClient}, + ui::{print_command_status, with_spinner, CommandStatus}, + utils::write_text_atomic, +}; + +use super::{api, ReportArgs}; + +#[derive(Debug, Serialize)] +struct TopicMapReportDownload { + function_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + version: Option, + output: String, + bytes: usize, +} + +pub async fn run(base: &BaseArgs, args: &ReportArgs, json: bool) -> Result<()> { + let auth = login_read_only(base).await?; + let client = ApiClient::new(&auth)?; + let function_id = args.function_id()?; + + let report_url = with_spinner( + "Requesting topic map report URL...", + api::fetch_topic_map_report_url(&client, function_id, args.version.as_deref()), + ) + .await?; + + let report = with_spinner( + "Downloading topic map report...", + download_topic_map_report(&report_url.url), + ) + .await?; + + let Some(output) = args.output.as_deref() else { + print!("{report}"); + return Ok(()); + }; + + let output = resolve_output_path(output)?; + write_text_atomic(&output, &report) + .with_context(|| format!("failed to write report to {}", output.display()))?; + + let downloaded = TopicMapReportDownload { + function_id: function_id.to_string(), + version: args.version.clone(), + output: output.display().to_string(), + bytes: report.len(), + }; + + if json { + println!("{}", serde_json::to_string(&downloaded)?); + return Ok(()); + } + + print_command_status( + CommandStatus::Success, + &format!( + "Downloaded topic map report to {} ({} bytes)", + downloaded.output, downloaded.bytes + ), + ); + Ok(()) +} + +async fn download_topic_map_report(url: &str) -> Result { + let client = http::build_http_client(http::DEFAULT_HTTP_TIMEOUT) + .context("failed to build report download HTTP client")?; + let response = client + .get(url) + .send() + .await + .with_context(|| format!("failed to download topic map report from {url}"))?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + bail!("topic map report download failed ({status}): {body}"); + } + response + .text() + .await + .context("failed to read topic map report body") +} + +fn resolve_output_path(path: &Path) -> Result { + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + Ok(std::env::current_dir() + .context("failed to resolve current directory")? + .join(path)) +} diff --git a/tests/cli.rs b/tests/cli.rs index 425767aa..b30a31f5 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -79,6 +79,24 @@ fn setup_verbose_is_accepted_after_subcommand() { .success(); } +#[test] +fn topics_report_help_accepts_global_org_short_conflict_free() { + bt_command() + .args([ + "topics", + "report", + "--profile", + "test-profile", + "--id", + "fn_123", + "--help", + ]) + .assert() + .success() + .stdout(predicate::str::contains("--id")) + .stdout(predicate::str::contains("--output")); +} + #[test] fn status_quiet_and_verbose_conflict() { bt_command() diff --git a/tests/datasets.rs b/tests/datasets.rs index a0a7570e..a5e808dd 100644 --- a/tests/datasets.rs +++ b/tests/datasets.rs @@ -104,12 +104,16 @@ struct MockDataset { created: String, } +type MockDatasetRow = Map; +type MockDatasetRowsById = BTreeMap; +type MockDatasetRowsByDataset = BTreeMap; + #[derive(Debug)] struct MockServerState { requests: Mutex>, projects: Mutex>, datasets: Mutex>, - dataset_rows: Mutex>>>, + dataset_rows: Mutex, btql_dataset_id: Mutex>, } diff --git a/tests/functions.rs b/tests/functions.rs index 1b78fd8f..08e4fcee 100644 --- a/tests/functions.rs +++ b/tests/functions.rs @@ -177,6 +177,7 @@ fn sanitized_env_keys() -> &'static [&'static str] { "BT_FUNCTIONS_PUSH_REQUIREMENTS", "BT_FUNCTIONS_PUSH_TSCONFIG", "BT_FUNCTIONS_PUSH_EXTERNAL_PACKAGES", + "BT_FUNCTIONS_VIEW_ID", "BT_FUNCTIONS_PULL_OUTPUT_DIR", "BT_FUNCTIONS_PULL_PROJECT_ID", "BT_FUNCTIONS_PULL_PROJECT_NAME", @@ -2199,6 +2200,72 @@ exit 24 ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn functions_view_by_positional_id_does_not_require_project_context() { + let state = Arc::new(MockServerState::default()); + state + .pull_rows + .lock() + .expect("pull rows lock") + .push(serde_json::json!({ + "id": "fn_123", + "name": "Doc Search", + "slug": "doc-search", + "project_id": "proj_mock", + "description": "Search docs", + "function_type": "tool", + "function_data": { "type": "code", "data": { "type": "inline", "code": "export default async function handler() {}" } }, + "_xact_id": "0000000000000001" + })); + + let server = MockServer::start(state.clone()).await; + + let tmp = tempdir().expect("tempdir"); + let config_dir = tempdir().expect("config dir"); + + let output = Command::new(bt_binary_path()) + .current_dir(tmp.path()) + .args(["functions", "--json", "view", "fn_123"]) + .env("XDG_CONFIG_HOME", config_dir.path()) + .env("APPDATA", config_dir.path()) + .env("BRAINTRUST_API_KEY", "test-key") + .env("BRAINTRUST_ORG_NAME", "test-org") + .env("BRAINTRUST_API_URL", &server.base_url) + .env("BRAINTRUST_APP_URL", &server.base_url) + .env("BRAINTRUST_NO_COLOR", "1") + .env("BRAINTRUST_NO_INPUT", "1") + .env_remove("BRAINTRUST_PROFILE") + .env_remove("BRAINTRUST_DEFAULT_PROJECT") + .env_remove("BT_FUNCTIONS_VIEW_ID") + .output() + .expect("run bt functions view fn_123"); + + server.stop().await; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + panic!("mock view by id failed:\n{stderr}"); + } + + let function: Value = serde_json::from_slice(&output.stdout).expect("parse function JSON"); + assert_eq!(function["id"].as_str(), Some("fn_123")); + assert_eq!(function["slug"].as_str(), Some("doc-search")); + + let requests = state.requests.lock().expect("requests lock").clone(); + assert!( + requests + .iter() + .any(|entry| entry == "/v1/function?ids=fn_123"), + "view request should fetch the function by id, got {requests:?}" + ); + assert!( + !requests + .iter() + .any(|entry| entry.starts_with("/v1/project")), + "view by id should not resolve project context, got {requests:?}" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn functions_pull_works_against_mock_api() { let state = Arc::new(MockServerState::default());