Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions src/functions/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,32 @@ pub async fn get_function_by_slug(
client: &ApiClient,
project_id: &str,
slug: &str,
version: Option<&str>,
) -> Result<Option<Function>> {
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<Option<Function>> {
pub async fn get_function_by_id(
client: &ApiClient,
id: &str,
version: Option<&str>,
) -> Result<Option<Function>> {
let query = FunctionListQuery {
id: Some(id.to_string()),
version: version.map(ToOwned::to_owned),
..Default::default()
};
let page = list_functions_page(client, &query).await?;
Expand Down
2 changes: 1 addition & 1 deletion src/functions/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
47 changes: 43 additions & 4 deletions src/functions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,9 @@ pub struct ViewArgs {
/// Function id
#[arg(long = "id", env = "BT_FUNCTIONS_VIEW_ID")]
id: Option<String>,
/// Version selector.
#[arg(long, env = "BT_FUNCTIONS_VIEW_VERSION")]
version: Option<String>,
/// Open in browser
#[arg(long)]
web: bool,
Expand Down Expand Up @@ -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 => {
Expand All @@ -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
}
}
}
Expand Down
29 changes: 26 additions & 3 deletions src/functions/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)))?,
Expand All @@ -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
}
}
};

Expand All @@ -58,14 +80,15 @@ pub async fn run(
pub async fn run_by_id(
ctx: &AuthContext,
id: &str,
version: Option<&str>,
json: bool,
web: bool,
verbose: bool,
ft: Option<FunctionTypeFilter>,
) -> 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)))?;
Expand Down
169 changes: 169 additions & 0 deletions tests/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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());
Expand Down
Loading