From 2263927faa8e3e37b3542a7c2b61c9d20b3402d0 Mon Sep 17 00:00:00 2001 From: Imen Kedir Date: Fri, 24 Apr 2026 17:18:23 -0700 Subject: [PATCH] feat(functions push): add --manifest path for non-SDK function types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner-based push path can only ship function types the Braintrust SDK exposes builders for: tools, scorers, prompts, parameters. Topics- pipeline types (facet, classifier) and inline-quickjs preprocessors have no SDK builder, so AI-agent workflows that author lenses cannot push them through `bt`. `--manifest ` accepts a JSON file with three shapes — the `/insert-functions` wire body `{"functions": [...]}`, a bare entry array, or a single entry object. The body bypasses the SDK runner and is posted directly to `/insert-functions` (the same endpoint `bt topics config enable` uses to create classifier functions). Reuses the runner path's preflight machinery: `parse_project_selector` + `add_selector_requirement` for project classification, `validate_direct_project_ids` for direct-id verification, `resolve_named_projects` (honors `--create-missing-projects`) + `resolve_default_project_id` for project resolution, and `validate_duplicate_slugs` for cross-specifier collision detection. A pre-resolution dedup pass canonicalizes Fallback to its default project name so duplicate slugs never trigger fresh-project creation on the server. Failure paths emit a manifest-aware `PushSummary` with `total_files: 1` and `source_file: ` so JSON consumers see the right context. Two-step parse distinguishes `ManifestInvalidJson` from `ManifestSchemaInvalid`. Spinner is suppressed in `--json` mode to keep stderr clean. Mutually exclusive only with `--file` and the positional file paths (the genuinely ambiguous overlap). Runner-specific options (`--runner`, `--language`, etc.) are silently ignored on this path so env-backed defaults don't block the command. Closes #149. --- src/functions/mod.rs | 38 ++ src/functions/push.rs | 405 ++++++++++++- .../push-help-env-vars/fixture.json | 3 +- .../push-help-flags/fixture.json | 3 +- .../fixture.json | 5 + tests/functions.rs | 533 ++++++++++++++++++ 6 files changed, 974 insertions(+), 13 deletions(-) create mode 100644 tests/functions-fixtures/push-manifest-conflicts-with-files/fixture.json diff --git a/src/functions/mod.rs b/src/functions/mod.rs index e7ce6c11..bb6c8e10 100644 --- a/src/functions/mod.rs +++ b/src/functions/mod.rs @@ -270,6 +270,15 @@ pub(crate) struct PushArgs { )] pub file_flag: Vec, + /// Push raw JSON function definitions, skipping the SDK runner. + #[arg( + long = "manifest", + env = "BT_FUNCTIONS_PUSH_MANIFEST", + value_name = "PATH", + conflicts_with_all = ["files", "file_flag"] + )] + pub manifest: Option, + /// Behavior when a function with the same slug already exists. #[arg( long = "if-exists", @@ -687,6 +696,35 @@ mod tests { assert!(msg.contains("--type")); } + #[test] + fn push_manifest_flag_parses() { + let _guard = test_lock(); + let parsed = + parse(&["functions", "push", "--manifest", "fn.json"]).expect("parse push manifest"); + let FunctionsCommands::Push(push) = parsed.command.expect("subcommand") else { + panic!("expected push command"); + }; + assert_eq!( + push.manifest, + Some(std::path::PathBuf::from("fn.json")), + "manifest path should be parsed" + ); + assert!(push.files.is_empty()); + assert!(push.file_flag.is_empty()); + } + + #[test] + fn push_manifest_conflicts_with_file_path() { + let _guard = test_lock(); + let err = parse(&["functions", "push", "--manifest", "fn.json", "extra.ts"]) + .expect_err("manifest+file path should conflict"); + let msg = err.to_string(); + assert!( + msg.contains("manifest") || msg.contains("--manifest") || msg.contains("conflict"), + "expected conflict-related error, got: {msg}" + ); + } + #[test] fn top_level_type_flag_still_parses_for_functions_namespace() { let _guard = test_lock(); diff --git a/src/functions/push.rs b/src/functions/push.rs index bc5c9820..22d00524 100644 --- a/src/functions/push.rs +++ b/src/functions/push.rs @@ -11,7 +11,7 @@ use anyhow::{anyhow, bail, Context, Result}; use dialoguer::console::style; use dialoguer::Confirm; use indicatif::{ProgressBar, ProgressStyle}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use crate::args::BaseArgs; @@ -27,7 +27,7 @@ use crate::js_runner; use crate::projects::api::{create_project, get_project_by_name, list_projects}; use crate::python_runner; use crate::source_language::{classify_runtime_extension, SourceLanguage}; -use crate::ui::{animations_enabled, is_interactive, is_quiet}; +use crate::ui::{animations_enabled, is_interactive, is_quiet, with_spinner}; use super::api; use super::{ @@ -278,6 +278,10 @@ pub async fn run(base: BaseArgs, args: PushArgs) -> Result<()> { } }; + if let Some(manifest_path) = args.manifest.clone() { + return run_manifest_push(base, args, auth_ctx, manifest_path).await; + } + let files = args.resolved_files(); let classified = match collect_classified_files(&files) { Ok(files) => files, @@ -688,6 +692,332 @@ struct FileSuccess { bundle_id: Option, } +#[derive(Debug, Deserialize, Serialize)] +struct RawFunctionEntry { + name: String, + slug: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + function_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + function_data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + project_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + project_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + if_exists: Option, + #[serde(flatten)] + extra: Map, +} + +// Push function definitions from a raw JSON manifest, skipping the SDK runner. +async fn run_manifest_push( + base: BaseArgs, + args: PushArgs, + auth_ctx: super::AuthContext, + manifest_path: PathBuf, +) -> Result<()> { + let raw = match std::fs::read_to_string(&manifest_path) { + Ok(raw) => raw, + Err(err) => { + return fail_manifest_push( + &base, + &manifest_path, + HardFailureReason::ManifestPathMissing, + format!("failed to read manifest {}: {err}", manifest_path.display()), + "failed to read manifest file", + ); + } + }; + + let parsed: Value = match serde_json::from_str(&raw) { + Ok(value) => value, + Err(err) => { + return fail_manifest_push( + &base, + &manifest_path, + HardFailureReason::ManifestInvalidJson, + format!( + "manifest {} is not valid JSON: {err}", + manifest_path.display() + ), + "manifest JSON parse failed", + ); + } + }; + + let parse_result = match parsed { + Value::Array(_) => serde_json::from_value::>(parsed), + Value::Object(mut obj) + if obj.len() == 1 && matches!(obj.get("functions"), Some(Value::Array(_))) => + { + serde_json::from_value(obj.remove("functions").unwrap()) + } + other => serde_json::from_value::(other).map(|e| vec![e]), + }; + let mut entries = match parse_result { + Ok(es) => es, + Err(err) => { + return fail_manifest_push( + &base, + &manifest_path, + HardFailureReason::ManifestSchemaInvalid, + format!("manifest {} schema invalid: {err}", manifest_path.display()), + "manifest schema invalid", + ); + } + }; + + if entries.is_empty() { + return fail_manifest_push( + &base, + &manifest_path, + HardFailureReason::ManifestSchemaInvalid, + "manifest contains no function definitions".to_string(), + "empty manifest", + ); + } + + let default_project_name = match resolve_default_project_name(&base) { + Ok(name) => name, + Err(err) => { + return fail_manifest_push( + &base, + &manifest_path, + HardFailureReason::ManifestSchemaInvalid, + format!("{err:#}"), + "invalid default project name", + ); + } + }; + let source_label = manifest_path.display().to_string(); + let mut preflight = ProjectPreflight { + default_project_name, + requires_default_project: false, + named_projects: BTreeSet::new(), + direct_project_ids: BTreeSet::new(), + }; + + let mut seen_local: BTreeSet<(String, String)> = BTreeSet::new(); + for entry in &entries { + let selector = match parse_project_selector( + entry.project_id.as_deref(), + entry.project_name.as_deref(), + ) { + Ok(selector) => selector, + Err(err) => { + return fail_manifest_push( + &base, + &manifest_path, + HardFailureReason::ManifestSchemaInvalid, + format!("manifest entry '{}': {err:#}", entry.slug), + "invalid project selector in manifest entry", + ); + } + }; + let project_key = match &selector { + ProjectSelector::Id(id) => format!("id:{id}"), + ProjectSelector::Name(name) => format!("name:{name}"), + ProjectSelector::Fallback => match preflight.default_project_name.as_deref() { + Some(default) => format!("name:{default}"), + None => "fallback".to_string(), + }, + }; + if !seen_local.insert((entry.slug.clone(), project_key)) { + return fail_manifest_push( + &base, + &manifest_path, + HardFailureReason::ManifestSchemaInvalid, + format!( + "duplicate slug '{}' for the same project in '{}'", + entry.slug, source_label + ), + "manifest contains duplicate slugs", + ); + } + if let Err(err) = add_selector_requirement( + &source_label, + &entry.slug, + &selector, + preflight.default_project_name.as_deref(), + &mut preflight.named_projects, + &mut preflight.direct_project_ids, + &mut preflight.requires_default_project, + ) { + return fail_manifest_push( + &base, + &manifest_path, + HardFailureReason::ManifestSchemaInvalid, + format!("{err:#}"), + "manifest entry missing project", + ); + } + } + + if !args.yes && is_interactive() { + let project_names: Vec = preflight.named_projects.iter().cloned().collect(); + let prompt = build_push_confirm_prompt(&auth_ctx, &[&source_label], &project_names); + let confirmed = Confirm::new() + .with_prompt(prompt) + .default(false) + .interact()?; + if !confirmed { + return cancel_push(&base, &[manifest_path]); + } + } + + if let Err(err) = validate_direct_project_ids(&auth_ctx, &preflight.direct_project_ids).await { + return fail_manifest_push( + &base, + &manifest_path, + HardFailureReason::ResponseInvalid, + format!("{err:#}"), + "manifest direct project_id validation failed", + ); + } + + let mut project_name_cache = match resolve_named_projects( + &auth_ctx, + &preflight.named_projects, + args.create_missing_projects, + ) + .await + { + Ok(cache) => cache, + Err(err) => { + return fail_manifest_push( + &base, + &manifest_path, + HardFailureReason::ResponseInvalid, + format!("{err:#}"), + "failed to resolve named projects in manifest", + ); + } + }; + + let cli_project_default_id = match resolve_default_project_id(&preflight, &project_name_cache) { + Ok(id) => id, + Err(err) => { + return fail_manifest_push( + &base, + &manifest_path, + HardFailureReason::ManifestSchemaInvalid, + format!("{err:#}"), + "failed to resolve default project id", + ); + } + }; + + let mut resolved_targets = Vec::with_capacity(entries.len()); + for entry in &mut entries { + let project_id = match resolve_project_id( + &auth_ctx.client, + cli_project_default_id.as_deref(), + entry.project_id.as_deref(), + entry.project_name.as_deref(), + &mut project_name_cache, + args.create_missing_projects, + ) + .await + { + Ok(id) => id, + Err(err) => { + return fail_manifest_push( + &base, + &manifest_path, + HardFailureReason::ManifestSchemaInvalid, + format!("manifest entry '{}': {err:#}", entry.slug), + "failed to resolve project for manifest entry", + ); + } + }; + + resolved_targets.push(ResolvedEntryTarget { + source_file: source_label.clone(), + slug: entry.slug.clone(), + project_id: project_id.clone(), + }); + entry.project_id = Some(project_id); + entry.project_name = None; + entry + .if_exists + .get_or_insert_with(|| args.if_exists.as_str().to_string()); + } + + if let Err(err) = validate_duplicate_slugs(&resolved_targets) { + return fail_manifest_push( + &base, + &manifest_path, + HardFailureReason::ManifestSchemaInvalid, + format!("{err:#}"), + "manifest contains duplicate slugs", + ); + } + + let payload: Vec = entries + .iter() + .map(|e| serde_json::to_value(e).expect("RawFunctionEntry serializes to Value")) + .collect(); + + // Spinner stderr noise breaks --json consumers that read stderr. + let insert_future = api::insert_functions(&auth_ctx.client, &payload); + let insert = if base.json { + insert_future.await + } else { + with_spinner("Pushing manifest...", insert_future).await + }; + let insert_result = match insert { + Ok(result) => result, + Err(err) => { + return fail_manifest_push( + &base, + &manifest_path, + HardFailureReason::InsertFunctionsFailed, + format!( + "failed to insert manifest functions from {}: {err:#}", + manifest_path.display() + ), + "failed to insert manifest functions", + ); + } + }; + + let (uploaded_entries, ignored_entries) = + calculate_upload_counts(payload.len(), insert_result.ignored_entries); + + let summary = PushSummary { + status: CommandStatus::Success, + total_files: 1, + uploaded_files: 1, + failed_files: 0, + skipped_files: 0, + ignored_entries, + files: vec![PushFileReport { + source_file: manifest_path.display().to_string(), + status: FileStatus::Success, + uploaded_entries, + skipped_reason: None, + error_reason: None, + bundle_id: None, + message: None, + }], + warnings: vec![], + errors: vec![], + }; + + if !base.json { + eprintln!( + "{} Pushed {} entries from {}", + style("✓").green(), + uploaded_entries, + manifest_path.display(), + ); + } + + emit_summary(&base, &summary)?; + Ok(()) +} + fn default_code_location(index: usize) -> Value { json!({ "type": "function", @@ -2298,7 +2628,7 @@ fn collect_project_preflight( }; add_selector_requirement( - file, + &file.source_file, entry_slug(entry)?, &selector, default_project_name.as_deref(), @@ -2331,7 +2661,7 @@ fn entry_slug(entry: &ManifestEntry) -> Result<&str> { } fn add_selector_requirement( - file: &ManifestFile, + source_label: &str, slug: &str, selector: &ProjectSelector, default_project_name: Option<&str>, @@ -2351,7 +2681,7 @@ fn add_selector_requirement( bail!( "missing project for slug '{}' in '{}'; set project in the definition or pass --project", slug, - file.source_file + source_label ); }; *requires_default_project = true; @@ -2868,6 +3198,41 @@ fn emit_failed_push_summary( emit_summary(base, &summary) } +fn fail_manifest_push( + base: &BaseArgs, + manifest_path: &Path, + reason: HardFailureReason, + message: String, + file_message: &str, +) -> Result<()> { + if base.json { + let summary = PushSummary { + status: CommandStatus::Failed, + total_files: 1, + uploaded_files: 0, + failed_files: 1, + skipped_files: 0, + ignored_entries: 0, + files: vec![PushFileReport { + source_file: manifest_path.display().to_string(), + status: FileStatus::Failed, + uploaded_entries: 0, + skipped_reason: None, + error_reason: Some(reason), + bundle_id: None, + message: Some(file_message.to_string()), + }], + warnings: vec![], + errors: vec![ReportError { + reason, + message: message.clone(), + }], + }; + emit_summary(base, &summary)?; + } + bail!(message); +} + fn fail_push( base: &BaseArgs, total_files: usize, @@ -2915,6 +3280,27 @@ mod tests { use super::*; + #[test] + fn manifest_entry_requires_name_and_slug() { + for missing in ["name", "slug"] { + let mut obj = serde_json::Map::new(); + obj.insert("name".into(), Value::String("x".into())); + obj.insert("slug".into(), Value::String("x-v1".into())); + obj.remove(missing); + let err = serde_json::from_value::(Value::Object(obj)) + .expect_err("missing field should fail"); + assert!(err.to_string().contains(missing), "error: {err}"); + } + } + + #[test] + fn manifest_entry_accepts_minimal_shape() { + let entry: RawFunctionEntry = serde_json::from_value(json!({"name": "x", "slug": "x-v1"})) + .expect("name+slug only should parse"); + assert!(entry.function_type.is_none()); + assert!(entry.function_data.is_none()); + } + #[test] fn supported_extension_filtering() { assert_eq!( @@ -2948,17 +3334,12 @@ mod tests { #[test] fn fallback_selector_requires_default_project_name() { - let file = ManifestFile { - source_file: "a.ts".to_string(), - entries: vec![], - python_bundle: None, - }; let mut named_projects = BTreeSet::new(); let mut direct_project_ids = BTreeSet::new(); let mut requires_default_project = false; let err = add_selector_requirement( - &file, + "a.ts", "same", &ProjectSelector::Fallback, None, @@ -3027,6 +3408,7 @@ mod tests { let args = PushArgs { files: vec![PathBuf::from(".")], file_flag: vec![], + manifest: None, if_exists: IfExistsMode::Error, terminate_on_failure: false, create_missing_projects: true, @@ -3056,6 +3438,7 @@ mod tests { let args = PushArgs { files: vec![PathBuf::from("a.ts"), PathBuf::from("b.py")], file_flag: vec![], + manifest: None, if_exists: IfExistsMode::Error, terminate_on_failure: false, create_missing_projects: true, diff --git a/tests/functions-fixtures/push-help-env-vars/fixture.json b/tests/functions-fixtures/push-help-env-vars/fixture.json index abe76c5c..95a90127 100644 --- a/tests/functions-fixtures/push-help-env-vars/fixture.json +++ b/tests/functions-fixtures/push-help-env-vars/fixture.json @@ -9,6 +9,7 @@ "BT_FUNCTIONS_PUSH_LANGUAGE", "BT_FUNCTIONS_PUSH_REQUIREMENTS", "BT_FUNCTIONS_PUSH_TSCONFIG", - "BT_FUNCTIONS_PUSH_EXTERNAL_PACKAGES" + "BT_FUNCTIONS_PUSH_EXTERNAL_PACKAGES", + "BT_FUNCTIONS_PUSH_MANIFEST" ] } diff --git a/tests/functions-fixtures/push-help-flags/fixture.json b/tests/functions-fixtures/push-help-flags/fixture.json index 4ffc0390..872c2783 100644 --- a/tests/functions-fixtures/push-help-flags/fixture.json +++ b/tests/functions-fixtures/push-help-flags/fixture.json @@ -10,6 +10,7 @@ "--requirements", "--tsconfig", "--external-packages", - "--runner" + "--runner", + "--manifest" ] } diff --git a/tests/functions-fixtures/push-manifest-conflicts-with-files/fixture.json b/tests/functions-fixtures/push-manifest-conflicts-with-files/fixture.json new file mode 100644 index 00000000..d668c247 --- /dev/null +++ b/tests/functions-fixtures/push-manifest-conflicts-with-files/fixture.json @@ -0,0 +1,5 @@ +{ + "command": ["functions", "push", "--manifest", "fn.json", "extra.ts"], + "expect_success": false, + "stderr_contains": ["--manifest"] +} diff --git a/tests/functions.rs b/tests/functions.rs index 1b78fd8f..64a40964 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_PUSH_MANIFEST", "BT_FUNCTIONS_PULL_OUTPUT_DIR", "BT_FUNCTIONS_PULL_PROJECT_ID", "BT_FUNCTIONS_PULL_PROJECT_NAME", @@ -1775,6 +1776,538 @@ exit 24 ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn functions_push_manifest_works_against_mock_api() { + 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(), + }); + let server = MockServer::start(state.clone()).await; + + let tmp = tempdir().expect("tempdir"); + let manifest_path = tmp.path().join("facet.json"); + let manifest_body = serde_json::json!({ + "name": "mock-facet", + "slug": "mock-facet-v1", + "function_type": "facet", + "function_data": { + "type": "facet", + "prompt": "score the trace", + "no_match_pattern": "^NONE", + "preprocessor": { "id": "preproc-id", "type": "function" } + } + }); + std::fs::write( + &manifest_path, + serde_json::to_string(&manifest_body).expect("serialize manifest"), + ) + .expect("write manifest file"); + + let output = Command::new(bt_binary_path()) + .current_dir(tmp.path()) + .args([ + "functions", + "--json", + "push", + "--manifest", + manifest_path + .to_str() + .expect("manifest path should be valid UTF-8 for test"), + "-p", + "mock-project", + "--if-exists", + "replace", + "--yes", + ]) + .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_remove("BRAINTRUST_PROFILE") + .output() + .expect("run bt functions push --manifest"); + + server.stop().await; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + panic!("manifest push failed:\n{stderr}"); + } + + let summary: Value = serde_json::from_slice(&output.stdout).expect("parse push summary"); + assert_eq!(summary["status"].as_str(), Some("success")); + assert_eq!(summary["uploaded_files"].as_u64(), Some(1)); + + let inserted = state + .inserted_functions + .lock() + .expect("inserted functions lock") + .clone(); + assert_eq!(inserted.len(), 1); + let first = inserted[0].as_object().expect("inserted function object"); + assert_eq!( + first.get("project_id").and_then(Value::as_str), + Some("proj_mock") + ); + assert_eq!( + first.get("function_type").and_then(Value::as_str), + Some("facet") + ); + assert_eq!( + first.get("if_exists").and_then(Value::as_str), + Some("replace") + ); + // The point of the patch: facet-shaped function_data passes through verbatim, + // including fields the SDK has no builder for (preprocessor reference). + assert_eq!( + first.get("function_data").and_then(|v| v.get("type")), + manifest_body + .get("function_data") + .and_then(|v| v.get("type")) + ); + assert_eq!( + first + .get("function_data") + .and_then(|v| v.get("preprocessor")), + manifest_body + .get("function_data") + .and_then(|v| v.get("preprocessor")) + ); + + let uploaded = state + .uploaded_bundles + .lock() + .expect("uploaded bundles lock") + .clone(); + assert!( + uploaded.is_empty(), + "manifest push must not upload a bundle" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn functions_push_manifest_inline_quickjs_preprocessor_array() { + 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(), + }); + let server = MockServer::start(state.clone()).await; + + let tmp = tempdir().expect("tempdir"); + let manifest_path = tmp.path().join("lens.json"); + let manifest_body = serde_json::json!([ + { + "name": "lens-preprocessor", + "slug": "lens-preprocessor-v1", + "function_type": "preprocessor", + "function_data": { + "type": "code", + "data": { + "type": "inline", + "code": "function handler({ span_attributes }) { return null; }", + "runtime_context": { "runtime": "quickjs", "version": "ES2023" } + } + } + }, + { + "name": "lens-facet", + "slug": "lens-facet-v1", + "function_type": "facet", + "function_data": { + "type": "facet", + "prompt": "score the trace", + "no_match_pattern": "^NONE", + "preprocessor": { "id": "preproc-id", "type": "function" } + } + } + ]); + std::fs::write( + &manifest_path, + serde_json::to_string(&manifest_body).expect("serialize"), + ) + .expect("write manifest"); + + let output = Command::new(bt_binary_path()) + .current_dir(tmp.path()) + .args([ + "functions", + "--json", + "push", + "--manifest", + manifest_path.to_str().expect("path utf8"), + "-p", + "mock-project", + "--yes", + ]) + .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_remove("BRAINTRUST_PROFILE") + .output() + .expect("run bt"); + + server.stop().await; + + if !output.status.success() { + panic!( + "manifest push failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let inserted = state.inserted_functions.lock().expect("lock").clone(); + assert_eq!(inserted.len(), 2); + + // Inline-quickjs preprocessor data shape passes through verbatim. + let preproc = &inserted[0]; + assert_eq!( + preproc.get("function_type").and_then(Value::as_str), + Some("preprocessor") + ); + let pdata = preproc + .get("function_data") + .and_then(|v| v.get("data")) + .expect("data"); + assert_eq!(pdata.get("type").and_then(Value::as_str), Some("inline")); + assert_eq!( + pdata + .get("runtime_context") + .and_then(|v| v.get("runtime")) + .and_then(Value::as_str), + Some("quickjs") + ); + + // Facet preserves preprocessor reference. + let facet = &inserted[1]; + assert_eq!( + facet.get("function_type").and_then(Value::as_str), + Some("facet") + ); + assert_eq!( + facet + .get("function_data") + .and_then(|v| v.get("preprocessor")), + manifest_body[1] + .get("function_data") + .and_then(|v| v.get("preprocessor")) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn functions_push_manifest_accepts_wrapped_shape() { + // The exact /insert-functions wire body: {"functions": [...]}. + 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(), + }); + let server = MockServer::start(state.clone()).await; + + let tmp = tempdir().expect("tempdir"); + let manifest_path = tmp.path().join("wrapped.json"); + let body = serde_json::json!({ + "functions": [ + {"name":"a","slug":"a-v1","function_type":"facet","function_data":{"type":"facet"}}, + {"name":"b","slug":"b-v1","function_type":"facet","function_data":{"type":"facet"}} + ] + }); + std::fs::write(&manifest_path, serde_json::to_string(&body).expect("ser")).expect("write"); + + let output = Command::new(bt_binary_path()) + .current_dir(tmp.path()) + .args([ + "functions", + "--json", + "push", + "--manifest", + manifest_path.to_str().expect("path utf8"), + "-p", + "mock-project", + "--yes", + ]) + .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_remove("BRAINTRUST_PROFILE") + .output() + .expect("run bt"); + + server.stop().await; + + if !output.status.success() { + panic!( + "wrapped manifest push failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } + assert_eq!(state.inserted_functions.lock().expect("lock").len(), 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn functions_push_manifest_ignores_runner_env_vars() { + // Regression: --manifest used to conflict with env-backed runner options + // (BT_FUNCTIONS_PUSH_RUNNER, BT_FUNCTIONS_PUSH_LANGUAGE, etc.), so a user + // with those exported in their shell could not push a manifest at all. + 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(), + }); + let server = MockServer::start(state.clone()).await; + + let tmp = tempdir().expect("tempdir"); + let manifest_path = tmp.path().join("simple.json"); + let body = serde_json::json!({ + "name": "x", + "slug": "x-v1", + "function_type": "facet", + "function_data": {"type": "facet"} + }); + std::fs::write(&manifest_path, serde_json::to_string(&body).expect("ser")).expect("write"); + + let output = Command::new(bt_binary_path()) + .current_dir(tmp.path()) + .args([ + "functions", + "--json", + "push", + "--manifest", + manifest_path.to_str().expect("path utf8"), + "-p", + "mock-project", + "--yes", + ]) + .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("BT_FUNCTIONS_PUSH_RUNNER", "tsx") + .env("BT_FUNCTIONS_PUSH_LANGUAGE", "javascript") + .env("BT_FUNCTIONS_PUSH_TSCONFIG", "/tmp/tsconfig.json") + .env_remove("BRAINTRUST_PROFILE") + .output() + .expect("run bt"); + + server.stop().await; + + if !output.status.success() { + panic!( + "manifest push must ignore runner env vars:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } + assert_eq!(state.inserted_functions.lock().expect("lock").len(), 1); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn functions_push_manifest_duplicate_slugs_dont_create_fresh_project() { + // Regression: duplicate-slug detection used to run after resolve_named_projects, + // so a manifest with duplicate slugs and -p new-project would create the project + // on the server even though no functions were inserted. Covers two alias shapes: + // both entries fall back to -p, and one falls back while the other names -p. + for entries_body in [ + serde_json::json!([ + {"name":"a","slug":"same-slug","function_type":"facet","function_data":{"type":"facet"}}, + {"name":"b","slug":"same-slug","function_type":"facet","function_data":{"type":"facet"}} + ]), + serde_json::json!([ + {"name":"a","slug":"same-slug","function_type":"facet","function_data":{"type":"facet"}}, + {"name":"b","slug":"same-slug","project_name":"fresh-project","function_type":"facet","function_data":{"type":"facet"}} + ]), + ] { + let state = Arc::new(MockServerState::default()); + let server = MockServer::start(state.clone()).await; + + let tmp = tempdir().expect("tempdir"); + let manifest_path = tmp.path().join("dup.json"); + std::fs::write( + &manifest_path, + serde_json::to_string(&entries_body).expect("serialize"), + ) + .expect("write manifest"); + + let output = Command::new(bt_binary_path()) + .current_dir(tmp.path()) + .args([ + "functions", + "--json", + "push", + "--manifest", + manifest_path.to_str().expect("path utf8"), + "-p", + "fresh-project", + "--yes", + ]) + .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_remove("BRAINTRUST_PROFILE") + .output() + .expect("run bt"); + + server.stop().await; + + assert!(!output.status.success(), "duplicate slugs must fail"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("duplicate slug"), + "expected 'duplicate slug' in stderr" + ); + assert!( + state.projects.lock().expect("lock").is_empty(), + "duplicate detection must run before project creation" + ); + assert!( + state.inserted_functions.lock().expect("lock").is_empty(), + "no functions should be inserted" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn functions_push_manifest_json_failure_reports_manifest_path() { + let state = Arc::new(MockServerState::default()); + let server = MockServer::start(state.clone()).await; + + let tmp = tempdir().expect("tempdir"); + let manifest_path = tmp.path().join("bad.json"); + std::fs::write(&manifest_path, "{ this is not json").expect("write manifest"); + + let output = Command::new(bt_binary_path()) + .current_dir(tmp.path()) + .args([ + "functions", + "--json", + "push", + "--manifest", + manifest_path.to_str().expect("path utf8"), + "-p", + "any-project", + "--yes", + ]) + .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_remove("BRAINTRUST_PROFILE") + .output() + .expect("run bt"); + + server.stop().await; + + assert!(!output.status.success(), "invalid JSON must fail"); + let summary: Value = serde_json::from_slice(&output.stdout).expect("parse summary"); + assert_eq!(summary["status"].as_str(), Some("failed")); + assert_eq!(summary["total_files"].as_u64(), Some(1)); + assert_eq!( + summary["files"][0]["source_file"].as_str(), + Some(manifest_path.display().to_string().as_str()), + "failure JSON should report the manifest path as source_file" + ); + assert_eq!( + summary["files"][0]["error_reason"].as_str(), + Some("manifest_invalid_json"), + "JSON parse failure should map to manifest_invalid_json, not manifest_schema_invalid" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn functions_push_manifest_creates_missing_default_project() { + let state = Arc::new(MockServerState::default()); + let server = MockServer::start(state.clone()).await; + + let tmp = tempdir().expect("tempdir"); + let manifest_path = tmp.path().join("new.json"); + let manifest_body = serde_json::json!({ + "name": "x", + "slug": "x-v1", + "function_type": "facet", + "function_data": {"type": "facet"} + }); + std::fs::write( + &manifest_path, + serde_json::to_string(&manifest_body).expect("serialize"), + ) + .expect("write manifest"); + + let output = Command::new(bt_binary_path()) + .current_dir(tmp.path()) + .args([ + "functions", + "--json", + "push", + "--manifest", + manifest_path.to_str().expect("path utf8"), + "-p", + "fresh-project", + "--yes", + ]) + .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_remove("BRAINTRUST_PROFILE") + .output() + .expect("run bt"); + + server.stop().await; + + if !output.status.success() { + panic!( + "manifest push failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let projects = state.projects.lock().expect("lock").clone(); + assert!( + projects.iter().any(|p| p.name == "fresh-project"), + "default project should have been created via --create-missing-projects" + ); + + let inserted = state.inserted_functions.lock().expect("lock").clone(); + assert_eq!(inserted.len(), 1); + let project_id = inserted[0] + .get("project_id") + .and_then(Value::as_str) + .expect("project_id"); + assert!( + project_id.starts_with("proj_created_"), + "entry should be associated with the freshly-created project, got '{project_id}'" + ); +} + #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn functions_push_external_packages_bundles_with_runner() {