diff --git a/src/functions/api.rs b/src/functions/api.rs index d0c373c2..efc983db 100644 --- a/src/functions/api.rs +++ b/src/functions/api.rs @@ -85,18 +85,32 @@ pub async fn get_function_by_slug( client: &ApiClient, project_id: &str, slug: &str, + version: Option<&str>, ) -> Result> { - let pid = escape_sql(project_id); - let slug = escape_sql(slug); - let query = format!("SELECT * FROM project_functions('{pid}') WHERE slug = '{slug}'"); - let response = client.btql(&query).await?; + let query = FunctionListQuery { + project_id: Some(project_id.to_string()), + slug: Some(slug.to_string()), + version: version.map(ToOwned::to_owned), + ..Default::default() + }; + let page = list_functions_page(client, &query).await?; + let Some(raw) = page.objects.into_iter().next() else { + return Ok(None); + }; - Ok(response.data.into_iter().next()) + serde_json::from_value(raw) + .map(Some) + .context("unexpected function response shape") } -pub async fn get_function_by_id(client: &ApiClient, id: &str) -> Result> { +pub async fn get_function_by_id( + client: &ApiClient, + id: &str, + version: Option<&str>, +) -> Result> { let query = FunctionListQuery { id: Some(id.to_string()), + version: version.map(ToOwned::to_owned), ..Default::default() }; let page = list_functions_page(client, &query).await?; diff --git a/src/functions/delete.rs b/src/functions/delete.rs index 9879b8c2..36fc7f0b 100644 --- a/src/functions/delete.rs +++ b/src/functions/delete.rs @@ -22,7 +22,7 @@ pub async fn run( let project_id = &ctx.project.id; let function = match slug { - Some(s) => api::get_function_by_slug(&ctx.client, project_id, s) + Some(s) => api::get_function_by_slug(&ctx.client, project_id, s, None) .await? .ok_or_else(|| anyhow!("{} with slug '{s}' not found", label(ft)))?, None => { diff --git a/src/functions/mod.rs b/src/functions/mod.rs index 4b5e3b2e..d055c231 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -433,6 +433,9 @@ pub struct ViewArgs { /// Function id #[arg(long = "id", env = "BT_FUNCTIONS_VIEW_ID")] id: Option, + /// Version selector. + #[arg(long, env = "BT_FUNCTIONS_VIEW_VERSION")] + version: Option, /// Open in browser #[arg(long)] web: bool, @@ -610,11 +613,29 @@ pub async fn run_typed(base: BaseArgs, args: FunctionArgs, kind: FunctionTypeFil 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 + view::run_by_id( + &auth_ctx, + id, + v.version.as_deref(), + 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 + view::run( + &ctx, + slug, + v.version.as_deref(), + base.json, + v.web, + base.verbose, + ft, + ) + .await } }, command => { @@ -641,11 +662,29 @@ pub async fn run(base: BaseArgs, args: FunctionsArgs) -> Result<()> { 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 + view::run_by_id( + &auth_ctx, + id, + v.inner.version.as_deref(), + 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 + view::run( + &ctx, + slug, + v.inner.version.as_deref(), + base.json, + v.inner.web, + base.verbose, + ft, + ) + .await } } } diff --git a/src/functions/view.rs b/src/functions/view.rs index 9caad589..f40735bd 100644 --- a/src/functions/view.rs +++ b/src/functions/view.rs @@ -18,6 +18,7 @@ use super::{AuthContext, FunctionTypeFilter, ResolvedContext}; pub async fn run( ctx: &ResolvedContext, slug: Option<&str>, + version: Option<&str>, json: bool, web: bool, verbose: bool, @@ -27,7 +28,7 @@ pub async fn run( let function = match slug { Some(s) => with_spinner( &format!("Loading {}...", label(ft)), - api::get_function_by_slug(&ctx.client, project_id, s), + api::get_function_by_slug(&ctx.client, project_id, s, version), ) .await? .ok_or_else(|| anyhow!("{} with slug '{s}' not found", label(ft)))?, @@ -39,7 +40,28 @@ pub async fn run( label_plural(ft), ); } - select_function_interactive(&ctx.client, project_id, ft).await? + let selected = select_function_interactive(&ctx.client, project_id, ft).await?; + if let Some(version) = version { + with_spinner( + &format!("Loading {}...", label(ft)), + api::get_function_by_slug( + &ctx.client, + project_id, + &selected.slug, + Some(version), + ), + ) + .await? + .ok_or_else(|| { + anyhow!( + "{} with slug '{}' not found at version {version}", + label(ft), + selected.slug + ) + })? + } else { + selected + } } }; @@ -58,6 +80,7 @@ pub async fn run( pub async fn run_by_id( ctx: &AuthContext, id: &str, + version: Option<&str>, json: bool, web: bool, verbose: bool, @@ -65,7 +88,7 @@ pub async fn run_by_id( ) -> Result<()> { let function = with_spinner( &format!("Loading {}...", label(ft)), - api::get_function_by_id(&ctx.client, id), + api::get_function_by_id(&ctx.client, id, version), ) .await? .ok_or_else(|| anyhow!("{} with id '{id}' not found", label(ft)))?; diff --git a/tests/functions.rs b/tests/functions.rs index ca01af1c..99c4d7fa 100644 --- a/tests/functions.rs +++ b/tests/functions.rs @@ -178,6 +178,7 @@ fn sanitized_env_keys() -> &'static [&'static str] { "BT_FUNCTIONS_PUSH_TSCONFIG", "BT_FUNCTIONS_PUSH_EXTERNAL_PACKAGES", "BT_FUNCTIONS_VIEW_ID", + "BT_FUNCTIONS_VIEW_VERSION", "BT_FUNCTIONS_PULL_OUTPUT_DIR", "BT_FUNCTIONS_PULL_PROJECT_ID", "BT_FUNCTIONS_PULL_PROJECT_NAME", @@ -598,6 +599,22 @@ fn functions_pull_help_includes_expected_flags() { assert!(stdout.contains("--language")); } +#[test] +fn functions_view_help_includes_expected_flags() { + let output = Command::new(bt_binary_path()) + .arg("functions") + .arg("view") + .arg("--help") + .output() + .expect("run bt functions view --help"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("--id")); + assert!(stdout.contains("--version")); + assert!(stdout.contains("BT_FUNCTIONS_VIEW_VERSION")); +} + #[test] fn functions_pull_accepts_id_and_slug_together() { let output = Command::new(bt_binary_path()) @@ -2293,6 +2310,7 @@ async fn functions_view_by_positional_id_does_not_require_project_context() { .env_remove("BRAINTRUST_PROFILE") .env_remove("BRAINTRUST_DEFAULT_PROJECT") .env_remove("BT_FUNCTIONS_VIEW_ID") + .env_remove("BT_FUNCTIONS_VIEW_VERSION") .output() .expect("run bt functions view fn_123"); @@ -2322,6 +2340,157 @@ async fn functions_view_by_positional_id_does_not_require_project_context() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn functions_view_by_id_passes_version() { + 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": "0000000000000007" + })); + + 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", + "--id", + "fn_123", + "--version", + "0000000000000007", + ]) + .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") + .env_remove("BT_FUNCTIONS_VIEW_VERSION") + .output() + .expect("run bt functions view --id fn_123 --version"); + + server.stop().await; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + panic!("mock view by id at version 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["_xact_id"].as_str(), Some("0000000000000007")); + + let requests = state.requests.lock().expect("requests lock").clone(); + assert!( + requests + .iter() + .any(|entry| entry == "/v1/function?ids=fn_123&version=0000000000000007"), + "view request should include the version selector, 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_view_by_slug_passes_version() { + let state = Arc::new(MockServerState::default()); + state + .projects + .lock() + .expect("projects lock") + .push(MockProject { + id: "proj_mock".to_string(), + name: "mock-project".to_string(), + org_id: "org_mock".to_string(), + }); + 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": "0000000000000007" + })); + + 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", + "doc-search", + "--version", + "0000000000000007", + ]) + .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_DEFAULT_PROJECT", "mock-project") + .env("BRAINTRUST_NO_COLOR", "1") + .env("BRAINTRUST_NO_INPUT", "1") + .env_remove("BRAINTRUST_PROFILE") + .env_remove("BT_FUNCTIONS_VIEW_ID") + .env_remove("BT_FUNCTIONS_VIEW_VERSION") + .output() + .expect("run bt functions view doc-search --version"); + + server.stop().await; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + panic!("mock view by slug at version failed:\n{stderr}"); + } + + let function: Value = serde_json::from_slice(&output.stdout).expect("parse function JSON"); + assert_eq!(function["slug"].as_str(), Some("doc-search")); + assert_eq!(function["_xact_id"].as_str(), Some("0000000000000007")); + + let requests = state.requests.lock().expect("requests lock").clone(); + assert!( + requests.iter().any(|entry| entry + == "/v1/function?project_id=proj_mock&slug=doc-search&version=0000000000000007"), + "view request should include project, slug, and version selectors, got {requests:?}" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn functions_pull_works_against_mock_api() { let state = Arc::new(MockServerState::default());