Skip to content
Closed
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
97 changes: 97 additions & 0 deletions src/functions/api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use urlencoding::encode;

use crate::http::ApiClient;
use crate::resource_cmd::NamedResource;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Function {
pub id: String,
pub name: String,
#[serde(default)]
pub slug: String,
pub project_id: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub function_type: Option<String>,
#[serde(default)]
pub function_data: Option<serde_json::Value>,
}

impl Function {
pub fn display_type(&self) -> String {
if let Some(function_type) = self
.function_type
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
return function_type.to_string();
}

self.function_data
.as_ref()
.and_then(|fd| fd.get("type"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| "-".to_string())
}
}

impl NamedResource for Function {
fn name(&self) -> &str {
&self.name
}

fn description(&self) -> Option<&str> {
self.description.as_deref()
}

fn slug(&self) -> &str {
&self.slug
}

fn resource_type(&self) -> Option<String> {
Some(self.display_type())
}
}

#[derive(Debug, Deserialize)]
struct ListResponse {
objects: Vec<Function>,
}

pub async fn list_functions(client: &ApiClient, project: &str) -> Result<Vec<Function>> {
let path = format!(
"/v1/function?org_name={}&project_name={}",
encode(client.org_name()),
encode(project)
);
let list: ListResponse = client.get(&path).await?;

Ok(list.objects)
}

pub async fn get_function_by_slug(
client: &ApiClient,
project: &str,
slug: &str,
) -> Result<Option<Function>> {
let path = format!(
"/v1/function?org_name={}&project_name={}&slug={}",
encode(client.org_name()),
encode(project),
encode(slug)
);
let list: ListResponse = client.get(&path).await?;
Ok(list.objects.into_iter().next())
}

pub async fn delete_function(client: &ApiClient, function_id: &str) -> Result<()> {
let path = format!("/v1/function/{}", encode(function_id));
client.delete(&path).await
}
72 changes: 72 additions & 0 deletions src/functions/delete.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
use std::io::IsTerminal;

use anyhow::{anyhow, bail, Result};
use dialoguer::Confirm;

use crate::{
functions::api::{self, Function},
http::ApiClient,
resource_cmd::select_named_resource_interactive,
ui::{print_command_status, with_spinner, CommandStatus},
};

pub async fn run(client: &ApiClient, project: &str, slug: Option<&str>, force: bool) -> Result<()> {
if force && slug.is_none() {
bail!("slug required when using --force. Use: bt functions delete <slug> --force");
}

let function = match slug {
Some(s) => api::get_function_by_slug(client, project, s)
.await?
.ok_or_else(|| anyhow!("function with slug '{s}' not found"))?,
None => {
if !std::io::stdin().is_terminal() {
bail!("function slug required. Use: bt functions delete <slug>");
}
select_function_interactive(client, project).await?
}
};

if !force && std::io::stdin().is_terminal() {
let confirm = Confirm::new()
.with_prompt(format!(
"Delete function '{}' from {}?",
&function.name, project
))
.default(false)
.interact()?;

if !confirm {
return Ok(());
}
}

match with_spinner(
"Deleting function...",
api::delete_function(client, &function.id),
)
.await
{
Ok(_) => {
print_command_status(
CommandStatus::Success,
&format!("Deleted '{}'", function.name),
);
eprintln!("Run `bt functions list` to see remaining functions.");
Ok(())
}
Err(e) => {
print_command_status(
CommandStatus::Error,
&format!("Failed to delete '{}'", function.name),
);
Err(e)
}
}
}

pub async fn select_function_interactive(client: &ApiClient, project: &str) -> Result<Function> {
let functions =
with_spinner("Loading functions...", api::list_functions(client, project)).await?;
select_named_resource_interactive(functions, "no functions found", "Select function")
}
18 changes: 18 additions & 0 deletions src/functions/list.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use anyhow::Result;

use crate::{http::ApiClient, resource_cmd::print_named_resource_list, ui::with_spinner};

use super::api;

pub async fn run(client: &ApiClient, project: &str, org: &str, json: bool) -> Result<()> {
let functions =
with_spinner("Loading functions...", api::list_functions(client, project)).await?;

if json {
println!("{}", serde_json::to_string(&functions)?);
} else {
print_named_resource_list(&functions, "function", org, project, true)?;
}

Ok(())
}
81 changes: 81 additions & 0 deletions src/functions/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
use anyhow::Result;
use clap::{Args, Subcommand};

use crate::{args::BaseArgs, project_command::resolve_project_command_context};

mod api;
mod delete;
mod list;
mod view;

#[derive(Debug, Clone, Args)]
pub struct FunctionsArgs {
#[command(subcommand)]
command: Option<FunctionsCommands>,
}

#[derive(Debug, Clone, Subcommand)]
enum FunctionsCommands {
/// List all functions
List,
/// View a function's details
View(ViewArgs),
/// Delete a function
Delete(DeleteArgs),
}

#[derive(Debug, Clone, Args)]
pub struct ViewArgs {
/// Function slug (positional)
#[arg(value_name = "SLUG")]
slug_positional: Option<String>,

/// Function slug (flag)
#[arg(long = "slug", short = 's')]
slug_flag: Option<String>,
}

impl ViewArgs {
fn slug(&self) -> Option<&str> {
self.slug_positional
.as_deref()
.or(self.slug_flag.as_deref())
}
}

#[derive(Debug, Clone, Args)]
pub struct DeleteArgs {
/// Function slug (positional) of the function to delete
#[arg(value_name = "SLUG")]
slug_positional: Option<String>,

/// Function slug (flag) of the function to delete
#[arg(long = "slug", short = 's')]
slug_flag: Option<String>,

/// Skip confirmation prompt (requires slug)
#[arg(long, short = 'f')]
force: bool,
}

impl DeleteArgs {
fn slug(&self) -> Option<&str> {
self.slug_positional
.as_deref()
.or(self.slug_flag.as_deref())
}
}

pub async fn run(base: BaseArgs, args: FunctionsArgs) -> Result<()> {
let ctx = resolve_project_command_context(&base).await?;
let client = &ctx.client;
let project = &ctx.project;

match args.command {
None | Some(FunctionsCommands::List) => {
list::run(client, project, &ctx.login.login.org_name, base.json).await
}
Some(FunctionsCommands::View(f)) => view::run(client, project, f.slug(), base.json).await,
Some(FunctionsCommands::Delete(f)) => delete::run(client, project, f.slug(), f.force).await,
}
}
66 changes: 66 additions & 0 deletions src/functions/view.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
use std::fmt::Write as _;
use std::io::IsTerminal;

use anyhow::{anyhow, bail, Result};
use dialoguer::console;

use crate::http::ApiClient;
use crate::ui::{print_with_pager, with_spinner};

use super::api;
use super::delete::select_function_interactive;

pub async fn run(client: &ApiClient, project: &str, slug: Option<&str>, json: bool) -> Result<()> {
let function = match slug {
Some(s) => with_spinner(
"Loading function...",
api::get_function_by_slug(client, project, s),
)
.await?
.ok_or_else(|| anyhow!("function with slug '{s}' not found"))?,
None => {
if !std::io::stdin().is_terminal() {
bail!("function slug required. Use: bt functions view <slug>");
}
select_function_interactive(client, project).await?
}
};

if json {
println!("{}", serde_json::to_string(&function)?);
return Ok(());
}

let mut output = String::new();
writeln!(output, "Viewing {}", console::style(&function.name).bold())?;
writeln!(
output,
"{} {}",
console::style("Slug:").dim(),
function.slug
)?;
writeln!(
output,
"{} {}",
console::style("Type:").dim(),
function.display_type()
)?;

if let Some(desc) = function
.description
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
writeln!(output, "{} {}", console::style("Description:").dim(), desc)?;
}

if let Some(function_data) = &function.function_data {
writeln!(output)?;
writeln!(output, "{}", console::style("Function Data").bold())?;
writeln!(output, "{}", serde_json::to_string_pretty(function_data)?)?;
}

print_with_pager(&output)?;
Ok(())
}
6 changes: 6 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ mod args;
mod env;
#[cfg(unix)]
mod eval;
mod functions;
mod http;
mod login;
mod project_command;
mod projects;
mod prompts;
mod resource_cmd;
mod self_update;
mod setup;
mod sql;
Expand Down Expand Up @@ -41,6 +44,8 @@ enum Commands {
Docs(CLIArgs<setup::DocsArgs>),
/// Run SQL queries against Braintrust
Sql(CLIArgs<sql::SqlArgs>),
/// Manage project functions
Functions(CLIArgs<functions::FunctionsArgs>),
/// Manage login profiles and persistent auth
Login(CLIArgs<login::LoginArgs>),
/// View logs, traces, and spans
Expand Down Expand Up @@ -69,6 +74,7 @@ async fn main() -> Result<()> {
Commands::Setup(cmd) => setup::run_setup_top(cmd.base, cmd.args).await?,
Commands::Docs(cmd) => setup::run_docs_top(cmd.base, cmd.args).await?,
Commands::Sql(cmd) => sql::run(cmd.base, cmd.args).await?,
Commands::Functions(cmd) => functions::run(cmd.base, cmd.args).await?,
Commands::Login(cmd) => login::run(cmd.base, cmd.args).await?,
Commands::View(cmd) => traces::run(cmd.base, cmd.args).await?,
#[cfg(unix)]
Expand Down
Loading
Loading