diff --git a/crates/forge-core/src/import_bruno.rs b/crates/forge-core/src/import_bruno.rs index d9ade78..24bf200 100644 --- a/crates/forge-core/src/import_bruno.rs +++ b/crates/forge-core/src/import_bruno.rs @@ -1,12 +1,15 @@ -//! Import a Bruno collection (native `.bruno` format) into Forge. +//! Import a Bruno collection (native `.bru` format) into Forge `.http` files. //! -//! Bruno structure: -//! - `.bruno/bruno.json` - collection metadata -//! - `.bruno/requests/request-name/request.bru` - request file -//! - `.bruno/environments/env-name/environment.env` - environment +//! Bruno stores each request as a `.bru` file written in "Bru Lang" — a small +//! block-structured format (`meta { … }`, `get { … }`, `headers { … }`, +//! `body:json { … }`, `auth:bearer { … }`, `script:pre-request { … }`, +//! `assert { … }`, `docs { … }`, …). Rather than copy that text verbatim (which +//! would not run), [`bru_to_http`] parses the blocks and re-emits an equivalent +//! `.http` request using Forge's directives, so an imported collection actually +//! executes. -use std::fs; use serde::Deserialize; +use std::fs; #[derive(Debug, Clone, Deserialize)] pub struct BrunoCollection { @@ -23,9 +26,15 @@ pub fn from_bruno(dir: &str) -> Result, String> { } let mut files = Vec::new(); - - // Parse bruno.json - let bruno_json = root.join(".bruno").join("bruno.json"); + + // Parse bruno.json (collection metadata) if present, to validate it is a + // Bruno collection. + let bruno_json = root.join("bruno.json"); + let bruno_json = if bruno_json.exists() { + bruno_json + } else { + root.join(".bruno").join("bruno.json") + }; if bruno_json.exists() { let contents = fs::read_to_string(&bruno_json) .map_err(|e| format!("Failed to read bruno.json: {e}"))?; @@ -33,83 +42,547 @@ pub fn from_bruno(dir: &str) -> Result, String> { .map_err(|e| format!("Failed to parse bruno.json: {e}"))?; } - // Process requests - let requests_dir = root.join(".bruno").join("requests"); - if requests_dir.exists() { - process_requests_dir(&requests_dir, "", &mut files)?; + // Requests can live either directly under the collection root (modern Bruno) + // or under a `.bruno/requests` directory (older layout). Support both. + for requests_root in [root.to_path_buf(), root.join(".bruno").join("requests")] { + if requests_root.exists() { + process_requests_dir(&requests_root, "", &mut files)?; + } } - // Process environments - let envs_dir = root.join(".bruno").join("environments"); - if envs_dir.exists() { - process_environments(&envs_dir, &mut files)?; + // Environments: `environments/.bru` (or `.env`). + for envs_dir in [ + root.join("environments"), + root.join(".bruno").join("environments"), + ] { + if envs_dir.exists() { + process_environments(&envs_dir, &mut files)?; + } } Ok(files) } -fn process_requests_dir(dir: &std::path::Path, prefix: &str, files: &mut Vec) -> Result<(), String> { +fn process_requests_dir( + dir: &std::path::Path, + prefix: &str, + files: &mut Vec, +) -> Result<(), String> { for entry in fs::read_dir(dir).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); - + if path.is_dir() { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("request"); - let new_prefix = if prefix.is_empty() { name.to_string() } else { format!("{}/{}/", prefix, name) }; - process_requests_dir(&path, &new_prefix, files)?; - } else if path.extension().map(|e| e == "bru").unwrap_or(false) - && path.file_name().and_then(|n| n.to_str()) == Some("request.bru") - { - let request_content = fs::read_to_string(&path) - .map_err(|e| format!("Failed to read {:?}: {e}", path))?; - - // Get parent folder name as request name - let request_name = path - .parent() - .and_then(|p| p.file_name().and_then(|n| n.to_str())) - .unwrap_or("request"); - - let path_str = if prefix.is_empty() { - format!("{}.http", slug(request_name)) + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("folder"); + // Skip Bruno's own metadata/environment directories. + if matches!(name, ".bruno" | "environments") { + continue; + } + let new_prefix = if prefix.is_empty() { + format!("{name}/") } else { - format!("{}{}.http", prefix, slug(request_name)) + format!("{prefix}{name}/") }; + process_requests_dir(&path, &new_prefix, files)?; + } else if path.extension().map(|e| e == "bru").unwrap_or(false) { + // Skip folder-settings and collection-settings .bru files. + let stem = path.file_stem().and_then(|n| n.to_str()).unwrap_or(""); + if matches!(stem, "folder" | "collection") { + continue; + } + let content = + fs::read_to_string(&path).map_err(|e| format!("Failed to read {path:?}: {e}"))?; + // Prefer the `meta { name }` for the request name, falling back to + // the file stem. + let blocks = parse_blocks(&content); + let name = block_dict(&blocks, "meta") + .and_then(|d| dict_get(&d, "name")) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| stem.to_string()); files.push(super::GeneratedFile { - path: path_str, - content: request_content, + path: format!("{prefix}{}.http", slug(&name)), + content: bru_to_http(&content), }); } } Ok(()) } -fn process_environments(dir: &std::path::Path, files: &mut Vec) -> Result<(), String> { +fn process_environments( + dir: &std::path::Path, + files: &mut Vec, +) -> Result<(), String> { + let mut emit = |env_path: &std::path::Path| -> Result<(), String> { + let ext = env_path.extension().and_then(|e| e.to_str()).unwrap_or(""); + let name = env_path + .file_stem() + .and_then(|n| n.to_str()) + .unwrap_or("environment"); + let raw = fs::read_to_string(env_path) + .map_err(|e| format!("Failed to read {env_path:?}: {e}"))?; + let content = if ext == "bru" { + // Translate a Bruno environment's `vars { K: V }` block into a + // KEY=VALUE env file Forge can load with `--env-file`. + let blocks = parse_blocks(&raw); + let mut out = String::new(); + if let Some(vars) = block_dict(&blocks, "vars") { + for (k, v, disabled) in vars { + if !disabled { + out.push_str(&format!("{k}={v}\n")); + } + } + } + out + } else { + raw + }; + files.push(super::GeneratedFile { + path: format!("environments/{name}.env"), + content, + }); + Ok(()) + }; + for entry in fs::read_dir(dir).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); - if path.is_dir() { + // Older layout: environments//.env for env_entry in fs::read_dir(&path).map_err(|e| e.to_string())? { let env_entry = env_entry.map_err(|e| e.to_string())?; let env_path = env_entry.path(); - if env_path.extension().map(|e| e == "env").unwrap_or(false) { - let env_content = fs::read_to_string(&env_path) - .map_err(|e| format!("Failed to read {:?}: {e}", env_path))?; - let env_name = env_path.file_stem() - .and_then(|n| n.to_str()) - .unwrap_or("environment"); - files.push(super::GeneratedFile { - path: format!("environments/{}.env", env_name), - content: env_content, - }); + if matches!( + env_path.extension().and_then(|e| e.to_str()), + Some("env") | Some("bru") + ) { + emit(&env_path)?; } } + } else if matches!( + path.extension().and_then(|e| e.to_str()), + Some("env") | Some("bru") + ) { + emit(&path)?; } } Ok(()) } +// ─── Bru Lang → .http translation ─────────────────────────────────────────── + +const METHODS: [&str; 7] = ["get", "post", "put", "patch", "delete", "head", "options"]; + +/// Translate a single `.bru` request into an equivalent `.http` request. +pub fn bru_to_http(bru: &str) -> String { + let blocks = parse_blocks(bru); + let find = |header: &str| blocks.iter().find(|(h, _)| h == header).map(|(_, b)| b.as_str()); + + let name = block_dict(&blocks, "meta") + .and_then(|d| dict_get(&d, "name")) + .unwrap_or_default(); + + // Method + URL come from the `get { … }` / `post { … }` / … block. + let mut method = String::from("GET"); + let mut url = String::new(); + for m in METHODS { + if let Some(inner) = find(m) { + method = m.to_uppercase(); + url = dict_get(&parse_dict(inner), "url").unwrap_or_default(); + break; + } + } + + // Query parameters: append to the URL only if it doesn't already carry them. + if !url.contains('?') { + let q = block_dict(&blocks, "params:query") + .or_else(|| block_dict(&blocks, "query")) + .map(|d| { + d.iter() + .filter(|(_, _, disabled)| !disabled) + .map(|(k, v, _)| format!("{k}={v}")) + .collect::>() + }) + .unwrap_or_default(); + if !q.is_empty() { + url.push('?'); + url.push_str(&q.join("&")); + } + } + + // Accumulators for the directive/header/body sections. + let mut file_vars: Vec = Vec::new(); + let mut directives: Vec = Vec::new(); + let mut headers: Vec = Vec::new(); + let mut content_type: Option<&str> = None; + let mut body: Option = None; + + // Headers. + if let Some(hs) = block_dict(&blocks, "headers") { + for (k, v, disabled) in hs { + if !disabled { + headers.push(format!("{k}: {v}")); + } + } + } + + // Auth. + if let Some(line) = auth_directive(&blocks) { + directives.push(line); + } + + // Body (priority mirrors Forge's own: form/multipart → graphql → raw). + if let Some(inner) = find("body:form-urlencoded") { + for (k, v, disabled) in parse_dict(inner) { + if !disabled { + directives.push(format!("@form {k} = {v}")); + } + } + } else if let Some(inner) = find("body:multipart-form") { + for (k, v, disabled) in parse_dict(inner) { + if disabled { + continue; + } + // A file part is written `key: @file(/path)`. + if let Some(path) = v.strip_prefix("@file(").and_then(|s| s.strip_suffix(')')) { + directives.push(format!("@file {k} = {path}")); + } else { + directives.push(format!("@form {k} = {v}")); + } + } + } else if let Some(q) = find("body:graphql") { + directives.push("@graphql".into()); + let mut b = q.trim().to_string(); + if let Some(vars) = find("body:graphql:vars") { + let vars = vars.trim(); + if !vars.is_empty() { + b.push_str("\n\n"); + b.push_str(vars); + } + } + body = Some(b); + } else if let Some(b) = find("body:json") { + content_type = Some("application/json"); + body = Some(b.trim().to_string()); + } else if let Some(b) = find("body:xml") { + content_type = Some("application/xml"); + body = Some(b.trim().to_string()); + } else if let Some(b) = find("body:text") { + content_type = Some("text/plain"); + body = Some(b.trim().to_string()); + } else if let Some(b) = find("body:sparql") { + content_type = Some("application/sparql-query"); + body = Some(b.trim().to_string()); + } + + // Pre-request variables → file-level `@name = value` declarations. + if let Some(vars) = block_dict(&blocks, "vars:pre-request") { + for (k, v, disabled) in vars { + if !disabled { + file_vars.push(format!("@{k} = {v}")); + } + } + } + + // Post-response variables → `@capture` directives (transforming the Bruno + // `res.body.x` / `res.headers.x` / `res.status` expressions into Forge's + // capture subjects). + if let Some(vars) = block_dict(&blocks, "vars:post-response") { + for (k, expr, disabled) in vars { + if disabled { + continue; + } + if let Some(subject) = capture_subject(&expr) { + directives.push(format!("@capture {k} = {subject}")); + } + } + } + + // Assertions. + if let Some(asserts) = block_dict(&blocks, "assert") { + for (subject, rhs, disabled) in asserts { + if disabled { + continue; + } + if let Some(line) = assert_directive(&subject, &rhs) { + directives.push(line); + } + } + } + + // Scripts. The `tests { … }` block is folded into the post-response script, + // since Forge runs `test()` / `expect()` there. + let pre = find("script:pre-request").map(str::to_string); + let mut post = find("script:post-response").map(str::to_string).unwrap_or_default(); + if let Some(tests) = find("tests") { + if !post.trim().is_empty() { + post.push('\n'); + } + post.push_str(tests); + } + + // ── Assemble the .http text ──────────────────────────────────────────── + let mut out = String::new(); + for v in &file_vars { + out.push_str(v); + out.push('\n'); + } + if !file_vars.is_empty() { + out.push('\n'); + } + + out.push_str(&format!("### {}\n", if name.is_empty() { "Request" } else { &name })); + + // Docs as plain comment lines (prefixed so a markdown `###` can't be read as + // a request separator). + if let Some(docs) = find("docs") { + for line in docs.trim_matches('\n').lines() { + out.push_str(&format!("# {line}\n")); + } + } + + for d in &directives { + out.push_str(&format!("# {d}\n")); + } + push_script(&mut out, "pre", pre.as_deref()); + push_script(&mut out, "post", Some(&post)); + + out.push_str(&format!("{method} {url}\n")); + + if let Some(ct) = content_type { + if !headers.iter().any(|h| h.to_lowercase().starts_with("content-type:")) { + headers.push(format!("Content-Type: {ct}")); + } + } + for h in &headers { + out.push_str(h); + out.push('\n'); + } + + if let Some(b) = &body { + if !b.is_empty() { + out.push('\n'); + out.push_str(b); + out.push('\n'); + } + } + out +} + +fn push_script(out: &mut String, kind: &str, src: Option<&str>) { + let Some(src) = src else { return }; + let src = src.trim_matches('\n'); + if src.trim().is_empty() { + return; + } + out.push_str(&format!("# @script:{kind}\n")); + for line in src.lines() { + if line.is_empty() { + out.push_str("#\n"); + } else { + out.push_str(&format!("# {line}\n")); + } + } + out.push_str("# @end\n"); +} + +/// Map a Bruno post-response variable expression to a Forge capture subject. +/// Returns `None` for expressions too complex to translate losslessly. +fn capture_subject(expr: &str) -> Option { + let e = expr.trim(); + if let Some(path) = e.strip_prefix("res.body.") { + Some(format!("body.{path}")) + } else if e == "res.body" { + Some("body".into()) + } else if let Some(name) = e.strip_prefix("res.headers.") { + Some(format!("headers.{name}")) + } else if e == "res.status" { + Some("status".into()) + } else if e == "res.responseTime" { + Some("time".into()) + } else { + None + } +} + +/// Map a Bruno `assert` entry (`subject: `) to an `@assert` line. +fn assert_directive(subject: &str, rhs: &str) -> Option { + // Subjects that aren't `res.*` are passed through (e.g. already + // `status` / `body.x`). + let subject = + capture_subject(subject).unwrap_or_else(|| subject.trim().trim_start_matches("res.").to_string()); + let mut parts = rhs.trim().splitn(2, char::is_whitespace); + let op = parts.next().unwrap_or(""); + let value = parts.next().unwrap_or("").trim(); + let mapped = match op { + "eq" => "==", + "neq" => "!=", + "gt" => ">", + "gte" => ">=", + "lt" => "<", + "lte" => "<=", + "contains" => "contains", + "matches" => "matches", + "isDefined" => "exists", + "isEmpty" => "empty", + _ => return None, // unsupported operator → drop rather than mistranslate + }; + if matches!(mapped, "exists" | "empty") { + Some(format!("@assert {subject} {mapped}")) + } else { + Some(format!("@assert {subject} {mapped} {value}")) + } +} + +/// Build a Forge `@auth` directive from whichever `auth:*` block is present. +fn auth_directive(blocks: &[(String, String)]) -> Option { + let get = |header: &str, key: &str| { + block_dict(blocks, header).and_then(|d| dict_get(&d, key)) + }; + let has = |header: &str| blocks.iter().any(|(h, _)| h == header); + + if has("auth:bearer") { + Some(format!("@auth bearer {}", get("auth:bearer", "token").unwrap_or_default())) + } else if has("auth:basic") { + Some(format!( + "@auth basic {} {}", + get("auth:basic", "username").unwrap_or_default(), + get("auth:basic", "password").unwrap_or_default() + )) + } else if has("auth:apikey") { + let key = get("auth:apikey", "key").unwrap_or_default(); + let value = get("auth:apikey", "value").unwrap_or_default(); + if key.is_empty() { + None + } else { + Some(format!("@auth apikey {key} {value}")) + } + } else if has("auth:digest") { + Some(format!( + "@auth digest {} {}", + get("auth:digest", "username").unwrap_or_default(), + get("auth:digest", "password").unwrap_or_default() + )) + } else if has("auth:ntlm") { + Some(format!( + "@auth ntlm {} {} {}", + get("auth:ntlm", "username").unwrap_or_default(), + get("auth:ntlm", "password").unwrap_or_default(), + get("auth:ntlm", "domain").unwrap_or_default() + ).trim().to_string()) + } else if has("auth:wsse") { + Some(format!( + "@auth wsse {} {}", + get("auth:wsse", "username").unwrap_or_default(), + get("auth:wsse", "password").unwrap_or_default() + )) + } else if has("auth:awsv4") { + Some(format!( + "@auth aws4 {} {} {} {} {}", + get("auth:awsv4", "accessKeyId").unwrap_or_default(), + get("auth:awsv4", "secretAccessKey").unwrap_or_default(), + get("auth:awsv4", "region").unwrap_or_default(), + get("auth:awsv4", "service").unwrap_or_default(), + get("auth:awsv4", "sessionToken").unwrap_or_default() + ).trim_end().to_string()) + } else if has("auth:oauth2") { + let grant = get("auth:oauth2", "grant_type").unwrap_or_default(); + let token_url = get("auth:oauth2", "access_token_url").unwrap_or_default(); + let client_id = get("auth:oauth2", "client_id").unwrap_or_default(); + let client_secret = get("auth:oauth2", "client_secret").unwrap_or_default(); + let scope = get("auth:oauth2", "scope").unwrap_or_default(); + match grant.as_str() { + "password" => Some(format!( + "@auth oauth2-password {token_url} {client_id} {client_secret} {} {} {scope}", + get("auth:oauth2", "username").unwrap_or_default(), + get("auth:oauth2", "password").unwrap_or_default() + ).trim_end().to_string()), + "authorization_code" => Some(format!( + "@auth oauth2-authcode {} {token_url} {client_id} {client_secret} {scope}", + get("auth:oauth2", "authorization_url").unwrap_or_default() + ).trim_end().to_string()), + // Default to client-credentials. + _ => Some(format!("@auth oauth2 {token_url} {client_id} {client_secret} {scope}").trim_end().to_string()), + } + } else { + None + } +} + +// ─── Bru Lang block/dict parsing ──────────────────────────────────────────── + +/// Split a `.bru` document into `(header, inner)` blocks, honouring nested +/// braces (so JSON bodies survive intact). +fn parse_blocks(src: &str) -> Vec<(String, String)> { + let bytes = src.as_bytes(); + let n = bytes.len(); + let mut blocks = Vec::new(); + let mut i = 0; + while i < n { + while i < n && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i >= n { + break; + } + let hstart = i; + while i < n && bytes[i] != b'{' { + i += 1; + } + if i >= n { + break; + } + let header = src[hstart..i].trim().to_string(); + i += 1; // consume '{' + let cstart = i; + let mut depth = 1; + while i < n && depth > 0 { + match bytes[i] { + b'{' => depth += 1, + b'}' => depth -= 1, + _ => {} + } + i += 1; + } + let inner_end = if depth == 0 { i - 1 } else { i }; + blocks.push((header, src[cstart..inner_end].to_string())); + } + blocks +} + +/// Parse a dictionary block's inner text into `(key, value, disabled)` rows. +/// A leading `~` marks a disabled entry. +fn parse_dict(inner: &str) -> Vec<(String, String, bool)> { + let mut out = Vec::new(); + for line in inner.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let (disabled, line) = match line.strip_prefix('~') { + Some(rest) => (true, rest.trim()), + None => (false, line), + }; + if let Some((k, v)) = line.split_once(':') { + out.push((k.trim().to_string(), v.trim().to_string(), disabled)); + } + } + out +} + +fn block_dict(blocks: &[(String, String)], header: &str) -> Option> { + blocks + .iter() + .find(|(h, _)| h == header) + .map(|(_, inner)| parse_dict(inner)) +} + +fn dict_get(dict: &[(String, String, bool)], key: &str) -> Option { + dict.iter() + .find(|(k, _, disabled)| k == key && !disabled) + .map(|(_, v, _)| v.clone()) +} + fn slug(name: &str) -> String { let cleaned: String = name .chars() @@ -127,4 +600,4 @@ fn slug(name: &str) -> String { } else { s } -} \ No newline at end of file +} diff --git a/crates/forge-core/src/lib.rs b/crates/forge-core/src/lib.rs index 8d4b357..fe02c4d 100644 --- a/crates/forge-core/src/lib.rs +++ b/crates/forge-core/src/lib.rs @@ -18,7 +18,7 @@ pub use curl_import::from_curl; pub use docs::generate as generate_docs; pub use edit::{add_request, delete_request, duplicate_request, rename_request}; pub use export_openapi::to_openapi; -pub use import_bruno::from_bruno; +pub use import_bruno::{bru_to_http, from_bruno}; pub use import_collection::{from_collection, GeneratedFile}; pub use import_openapi::{from_openapi, is_openapi}; pub use model::*; diff --git a/crates/forge-core/tests/import_bruno.rs b/crates/forge-core/tests/import_bruno.rs index e06a9c2..5fb75ff 100644 --- a/crates/forge-core/tests/import_bruno.rs +++ b/crates/forge-core/tests/import_bruno.rs @@ -1,6 +1,8 @@ -//! Tests for the Bruno (`.bruno`) collection importer. +//! Tests for the Bruno (`.bru`) → `.http` translator and collection importer. +//! Translation is validated by re-parsing the emitted `.http` with the Forge +//! parser and asserting the resulting model — a true round trip. -use forge_core::from_bruno; +use forge_core::{bru_to_http, from_bruno, parse, Auth, BodyType}; use std::fs; use std::path::PathBuf; @@ -18,125 +20,529 @@ fn tmpdir(tag: &str) -> PathBuf { dir } +const FULL_REQUEST: &str = r#"meta { + name: Create User + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/users?source=test + body: json + auth: bearer +} + +headers { + Accept: application/json + ~X-Disabled: nope +} + +auth:bearer { + token: {{token}} +} + +body:json { + { + "name": "Ada" + } +} + +vars:pre-request { + region: us-east-1 +} + +vars:post-response { + userId: res.body.id + loc: res.headers.location +} + +assert { + res.status: eq 201 + res.body.id: isDefined +} + +script:pre-request { + bru.setVar('ts', '123'); +} + +script:post-response { + test("created", function() { expect(res.status).to.equal(201); }); +} + +docs { + # Create User + Creates a new user. +} +"#; + +#[test] +fn full_request_round_trips_through_the_parser() { + let http = bru_to_http(FULL_REQUEST); + let file = parse(&http, "create-user"); + assert_eq!(file.requests.len(), 1, "emitted .http:\n{http}"); + let r = &file.requests[0]; + + assert_eq!(r.name, "Create User"); + assert_eq!(r.method, "POST"); + assert_eq!(r.url, "{{baseUrl}}/users"); + + // Query parameter lifted from the URL. + assert!(r.query_params.iter().any(|q| q.name == "source" && q.value == "test")); + + // Header (disabled one dropped) + injected content type. + assert!(r.headers.iter().any(|h| h.name == "Accept" && h.value == "application/json")); + assert!(!r.headers.iter().any(|h| h.name == "X-Disabled")); + + // Auth. + assert!(matches!(&r.auth, Some(Auth::Bearer { token }) if token == "{{token}}")); + + // Body. + assert_eq!(r.body_type, BodyType::Json); + assert!(r.body.as_deref().unwrap().contains("\"name\": \"Ada\"")); + + // Pre-request var → file variable. + assert!(file.variables.iter().any(|v| v.name == "region" && v.value == "us-east-1")); + + // Post-response vars → captures. + assert!(r.captures.iter().any(|c| c.name == "userId" && c.expr == "body.id")); + assert!(r.captures.iter().any(|c| c.name == "loc" && c.expr == "headers.location")); + + // Assertions. + assert!(r + .assertions + .iter() + .any(|a| a.subject == "status" && a.op == "==" && a.value == "201")); + assert!(r.assertions.iter().any(|a| a.subject == "body.id" && a.op == "exists")); + + // Scripts. + assert!(r.pre_script.as_deref().unwrap().contains("bru.setVar('ts', '123')")); + assert!(r.post_script.as_deref().unwrap().contains("test(\"created\"")); + + // Docs survived without being mistaken for a request separator. + assert!(r.docs.as_deref().unwrap_or("").contains("Creates a new user")); +} + +#[test] +fn graphql_body_is_translated() { + let bru = r#"meta { name: Hero } +post { + url: https://api/graphql + body: graphql +} +body:graphql { + query { hero { name } } +} +body:graphql:vars { + { "episode": "JEDI" } +} +"#; + let r = &parse(&bru_to_http(bru), "g").requests[0]; + assert_eq!(r.body_type, BodyType::Graphql); + let body = r.body.as_deref().unwrap(); + assert!(body.contains("hero { name }")); + assert!(body.contains("\"episode\": \"JEDI\"")); +} + +#[test] +fn form_and_multipart_bodies() { + let urlenc = r#"meta { name: Form } +post { url: https://x/ } +body:form-urlencoded { + a: 1 + ~b: 2 +} +"#; + let r = &parse(&bru_to_http(urlenc), "f").requests[0]; + assert_eq!(r.form.len(), 1); + assert_eq!(r.form[0].name, "a"); + assert!(!r.form[0].file); + + let multipart = r#"meta { name: Upload } +post { url: https://x/ } +body:multipart-form { + caption: hello + doc: @file(/tmp/x.pdf) +} +"#; + let r = &parse(&bru_to_http(multipart), "u").requests[0]; + assert!(r.form.iter().any(|f| f.name == "caption" && !f.file)); + let doc = r.form.iter().find(|f| f.name == "doc").unwrap(); + assert!(doc.file); + assert_eq!(doc.value, "/tmp/x.pdf"); +} + +#[test] +fn auth_schemes_are_mapped() { + type Check = fn(&Auth) -> bool; + let cases: &[(&str, Check)] = &[ + ( + "auth:basic {\n username: u\n password: p\n}", + |a| matches!(a, Auth::Basic { username, .. } if username == "u"), + ), + ( + "auth:apikey {\n key: X-Api-Key\n value: secret\n}", + |a| matches!(a, Auth::ApiKey { key, .. } if key == "X-Api-Key"), + ), + ( + "auth:digest {\n username: u\n password: p\n}", + |a| matches!(a, Auth::Digest { .. }), + ), + ( + "auth:awsv4 {\n accessKeyId: AK\n secretAccessKey: SK\n region: us-east-1\n service: s3\n}", + |a| matches!(a, Auth::Aws4 { access_key, .. } if access_key == "AK"), + ), + ( + "auth:wsse {\n username: u\n password: p\n}", + |a| matches!(a, Auth::Wsse { .. }), + ), + ( + "auth:oauth2 {\n grant_type: client_credentials\n access_token_url: https://t\n client_id: id\n client_secret: sec\n}", + |a| matches!(a, Auth::OAuth2 { .. }), + ), + ( + "auth:oauth2 {\n grant_type: password\n access_token_url: https://t\n client_id: id\n client_secret: sec\n username: u\n password: p\n}", + |a| matches!(a, Auth::OAuth2Password { .. }), + ), + ]; + for (block, check) in cases { + let bru = format!("meta {{ name: A }}\nget {{ url: https://x/ }}\n{block}\n"); + let r = &parse(&bru_to_http(&bru), "a").requests[0]; + assert!(check(r.auth.as_ref().unwrap()), "auth block:\n{block}"); + } +} + +#[test] +fn json_body_with_braces_is_not_truncated() { + // The block parser must balance nested braces inside the JSON body. + let bru = r#"meta { name: Nested } +post { url: https://x/ } +body:json { + { "a": { "b": [1, 2, { "c": 3 }] }, "d": "}" } +} +"#; + let r = &parse(&bru_to_http(bru), "n").requests[0]; + let body = r.body.as_deref().unwrap(); + assert!(body.contains("\"c\": 3")); + assert!(body.contains("\"d\"")); +} + +#[test] +fn assert_operators_map_or_drop() { + let bru = r#"meta { name: A } +get { url: https://x/ } +assert { + res.status: eq 200 + res.body.count: gt 5 + res.body.name: contains foo + res.body.id: isDefined + res.body.list: isEmpty + res.body.x: weirdOp 1 +} +"#; + let r = &parse(&bru_to_http(bru), "a").requests[0]; + assert!(r.assertions.iter().any(|a| a.subject == "status" && a.op == "==")); + assert!(r.assertions.iter().any(|a| a.subject == "body.count" && a.op == ">")); + assert!(r.assertions.iter().any(|a| a.op == "contains")); + assert!(r.assertions.iter().any(|a| a.op == "exists")); + assert!(r.assertions.iter().any(|a| a.op == "empty")); + // Unsupported operator is dropped, not mistranslated. + assert!(!r.assertions.iter().any(|a| a.subject == "body.x")); +} + +// ─── Directory / collection importer ──────────────────────────────────────── + #[test] fn missing_path_is_an_error() { - let err = from_bruno("/no/such/bruno/collection/at/all").unwrap_err(); - assert!(err.contains("Path not found")); + assert!(from_bruno("/no/such/bruno/collection").unwrap_err().contains("Path not found")); } #[test] fn empty_directory_yields_no_files() { let root = tmpdir("empty"); - let files = from_bruno(root.to_str().unwrap()).unwrap(); - assert!(files.is_empty()); + assert!(from_bruno(root.to_str().unwrap()).unwrap().is_empty()); fs::remove_dir_all(&root).ok(); } #[test] -fn imports_requests_and_environments() { - let root = tmpdir("full"); - let bruno = root.join(".bruno"); - fs::create_dir_all(bruno.join("requests").join("Get User")).unwrap(); - fs::create_dir_all(bruno.join("environments").join("dev")).unwrap(); - +fn imports_modern_layout_with_folders_and_envs() { + let root = tmpdir("modern"); fs::write( - bruno.join("bruno.json"), + root.join("bruno.json"), r#"{"version":"1","name":"My API","type":"collection"}"#, ) .unwrap(); + // Folder-nested request. + let users = root.join("Users"); + fs::create_dir_all(&users).unwrap(); fs::write( - bruno.join("requests").join("Get User").join("request.bru"), - "GET https://api.example.com/users/1\n", + users.join("get-user.bru"), + "meta {\n name: Get User\n}\nget {\n url: {{host}}/users/1\n}\n", ) .unwrap(); + // Folder settings file that must be skipped. + fs::write(users.join("folder.bru"), "meta {\n name: Users\n}\n").unwrap(); + // Environment. + let envs = root.join("environments"); + fs::create_dir_all(&envs).unwrap(); fs::write( - bruno.join("environments").join("dev").join("dev.env"), - "HOST=https://dev.example.com\n", + envs.join("dev.bru"), + "vars {\n host: https://dev.example.com\n ~secret: x\n}\n", ) .unwrap(); - let mut files = from_bruno(root.to_str().unwrap()).unwrap(); - files.sort_by(|a, b| a.path.cmp(&b.path)); - assert_eq!(files.len(), 2); + let files = from_bruno(root.to_str().unwrap()).unwrap(); - let req = files.iter().find(|f| f.path.ends_with(".http")).unwrap(); - assert!(req.path.contains("Get User")); - assert!(req.content.contains("GET https://api.example.com/users/1")); + let req = files + .iter() + .find(|f| f.path.ends_with(".http") && f.path.starts_with("Users/")) + .expect("nested request file"); + assert!(req.content.contains("GET {{host}}/users/1")); + // The translated request parses cleanly. + assert_eq!(parse(&req.content, "x").requests.len(), 1); + // folder.bru was skipped (only one request). + assert_eq!(files.iter().filter(|f| f.path.ends_with(".http")).count(), 1); let env = files.iter().find(|f| f.path.contains("environments")).unwrap(); assert_eq!(env.path, "environments/dev.env"); - assert!(env.content.contains("HOST=https://dev.example.com")); + assert!(env.content.contains("host=https://dev.example.com")); + assert!(!env.content.contains("secret")); // disabled var dropped fs::remove_dir_all(&root).ok(); } #[test] -fn nested_request_folders_are_prefixed() { - let root = tmpdir("nested"); - let nested = root - .join(".bruno") - .join("requests") - .join("folder") - .join("Inner Request"); - fs::create_dir_all(&nested).unwrap(); - fs::write(nested.join("request.bru"), "POST https://x/\n").unwrap(); +fn imports_legacy_dot_bruno_layout() { + let root = tmpdir("legacy"); + let req_dir = root.join(".bruno").join("requests").join("Ping"); + fs::create_dir_all(&req_dir).unwrap(); + fs::write( + req_dir.join("request.bru"), + "meta {\n name: Ping\n}\nget {\n url: https://x/ping\n}\n", + ) + .unwrap(); let files = from_bruno(root.to_str().unwrap()).unwrap(); assert_eq!(files.len(), 1); - assert!(files[0].path.contains("folder/")); - assert!(files[0].path.ends_with(".http")); - + assert!(files[0].content.contains("GET https://x/ping")); fs::remove_dir_all(&root).ok(); } #[test] fn invalid_bruno_json_is_an_error() { let root = tmpdir("badjson"); - let bruno = root.join(".bruno"); - fs::create_dir_all(&bruno).unwrap(); - fs::write(bruno.join("bruno.json"), "{ not valid json").unwrap(); - let err = from_bruno(root.to_str().unwrap()).unwrap_err(); - assert!(err.contains("Failed to parse bruno.json")); + fs::write(root.join("bruno.json"), "{ not valid json").unwrap(); + assert!(from_bruno(root.to_str().unwrap()) + .unwrap_err() + .contains("Failed to parse bruno.json")); fs::remove_dir_all(&root).ok(); } #[test] -fn request_name_with_only_symbols_falls_back() { - let root = tmpdir("symbols"); - let dir = root.join(".bruno").join("requests").join("@@@"); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("request.bru"), "GET https://x/\n").unwrap(); +fn request_without_meta_name_uses_file_stem() { + let root = tmpdir("noname"); + fs::write( + root.join("solo.bru"), + "get {\n url: https://x/\n}\n", + ) + .unwrap(); let files = from_bruno(root.to_str().unwrap()).unwrap(); assert_eq!(files.len(), 1); - // slug() collapses the symbol-only name to "request". - assert!(files[0].path.contains("request")); + assert!(files[0].path.contains("solo")); + fs::remove_dir_all(&root).ok(); +} + +// ─── Additional branch coverage ───────────────────────────────────────────── + +fn first(bru: &str) -> forge_core::HttpRequest { + parse(&bru_to_http(bru), "x").requests.into_iter().next().unwrap() +} + +#[test] +fn text_and_sparql_bodies() { + let text = first("meta { name: T }\npost { url: https://x/ }\nbody:text {\n hello world\n}\n"); + assert_eq!(text.body_type, BodyType::Text); + assert_eq!(text.body.as_deref(), Some("hello world")); + + let sparql = first("meta { name: S }\npost { url: https://x/ }\nbody:sparql {\n SELECT * WHERE { ?s ?p ?o }\n}\n"); + // SPARQL content-type isn't JSON/XML/text → parser defaults to JSON bucket, + // but the body is preserved verbatim. + assert!(sparql.body.as_deref().unwrap().contains("SELECT")); +} + +#[test] +fn ntlm_and_aws4_minimal_auth() { + let ntlm = first("meta { name: N }\nget { url: https://x/ }\nauth:ntlm {\n username: u\n password: p\n domain: CORP\n}\n"); + assert!(matches!(ntlm.auth, Some(Auth::Ntlm { domain: Some(d), .. }) if d == "CORP")); + + let aws = first("meta { name: A }\nget { url: https://x/ }\nauth:awsv4 {\n accessKeyId: AK\n secretAccessKey: SK\n}\n"); + assert!(matches!(aws.auth, Some(Auth::Aws4 { region: None, .. }))); +} + +#[test] +fn oauth2_grants_map() { + let authcode = first("meta { name: O }\nget { url: https://x/ }\nauth:oauth2 {\n grant_type: authorization_code\n authorization_url: https://a\n access_token_url: https://t\n client_id: id\n client_secret: sec\n scope: read\n}\n"); + assert!(matches!(authcode.auth, Some(Auth::OAuth2AuthCode { .. }))); + + // Unknown/blank grant defaults to client-credentials. + let cc = first("meta { name: O }\nget { url: https://x/ }\nauth:oauth2 {\n access_token_url: https://t\n client_id: id\n client_secret: sec\n}\n"); + assert!(matches!(cc.auth, Some(Auth::OAuth2 { .. }))); +} + +#[test] +fn apikey_without_key_drops_auth() { + let r = first("meta { name: A }\nget { url: https://x/ }\nauth:apikey {\n value: v\n}\n"); + assert!(r.auth.is_none()); +} + +#[test] +fn capture_subject_variants() { + let r = first( + "meta { name: C }\nget { url: https://x/ }\nvars:post-response {\n whole: res.body\n st: res.status\n rt: res.responseTime\n weird: someFunc()\n}\n", + ); + assert!(r.captures.iter().any(|c| c.name == "whole" && c.expr == "body")); + assert!(r.captures.iter().any(|c| c.name == "st" && c.expr == "status")); + assert!(r.captures.iter().any(|c| c.name == "rt" && c.expr == "time")); + // Non-translatable expression is dropped. + assert!(!r.captures.iter().any(|c| c.name == "weird")); +} + +#[test] +fn docs_with_markdown_h3_do_not_break_parsing() { + let r = first("meta { name: D }\nget { url: https://x/ }\ndocs {\n ### Heading\n body text\n}\n"); + // Exactly one request despite the `###` inside docs. + assert!(r.docs.as_deref().unwrap_or("").contains("Heading")); +} + +#[test] +fn unclosed_block_is_handled_gracefully() { + // A truncated .bru (missing closing brace) must not panic. + let http = bru_to_http("meta { name: X }\nget { url: https://x/\n"); + assert!(http.contains("### X")); +} + +#[test] +fn collection_settings_bru_is_skipped() { + let root = tmpdir("collskip"); + fs::write(root.join("collection.bru"), "headers {\n X: y\n}\n").unwrap(); + fs::write( + root.join("real.bru"), + "meta { name: Real }\nget { url: https://x/ }\n", + ) + .unwrap(); + let files = from_bruno(root.to_str().unwrap()).unwrap(); + assert_eq!(files.iter().filter(|f| f.path.ends_with(".http")).count(), 1); fs::remove_dir_all(&root).ok(); } #[test] -fn top_level_request_bru_uses_empty_prefix() { - let root = tmpdir("toplevel"); - let requests = root.join(".bruno").join("requests"); - fs::create_dir_all(&requests).unwrap(); - // request.bru directly under requests/ (no intermediate folder). - fs::write(requests.join("request.bru"), "GET https://x/\n").unwrap(); +fn legacy_environment_dir_and_plain_env_file() { + let root = tmpdir("legacyenv"); + // Legacy nested layout: environments//.env + let nested = root.join("environments").join("prod"); + fs::create_dir_all(&nested).unwrap(); + fs::write(nested.join("prod.env"), "HOST=https://prod\n").unwrap(); + // A flat plain .env file alongside. + fs::write( + root.join("environments").join("staging.env"), + "HOST=https://staging\n", + ) + .unwrap(); + let files = from_bruno(root.to_str().unwrap()).unwrap(); - assert_eq!(files.len(), 1); - assert!(files[0].path.ends_with(".http")); - assert!(!files[0].path.contains('/')); + let envs: Vec<_> = files.iter().filter(|f| f.path.contains("environments")).collect(); + assert!(envs.iter().any(|f| f.content.contains("https://prod"))); + assert!(envs.iter().any(|f| f.content.contains("https://staging"))); fs::remove_dir_all(&root).ok(); } #[test] -fn environment_non_env_files_are_ignored() { - let root = tmpdir("envignore"); - let env_dir = root.join(".bruno").join("environments").join("dev"); - fs::create_dir_all(&env_dir).unwrap(); - fs::write(env_dir.join("dev.env"), "HOST=x\n").unwrap(); - // A stray non-.env file should be skipped. - fs::write(env_dir.join("notes.txt"), "ignore me\n").unwrap(); +fn bruno_json_under_dot_bruno_is_read() { + let root = tmpdir("dotbruno"); + let bruno = root.join(".bruno"); + fs::create_dir_all(&bruno).unwrap(); + fs::write( + bruno.join("bruno.json"), + r#"{"version":"1","name":"Legacy","type":"collection"}"#, + ) + .unwrap(); + fs::write( + root.join("r.bru"), + "meta { name: R }\nget { url: https://x/ }\n", + ) + .unwrap(); let files = from_bruno(root.to_str().unwrap()).unwrap(); - assert_eq!(files.len(), 1); - assert_eq!(files[0].path, "environments/dev.env"); + assert!(files.iter().any(|f| f.path.ends_with(".http"))); + fs::remove_dir_all(&root).ok(); +} + +#[test] +fn bruno_collection_fields_deserialize() { + let c: forge_core::import_bruno::BrunoCollection = + serde_json::from_str(r#"{"version":"1","name":"X","type":"collection"}"#).unwrap(); + assert_eq!(c.version.as_deref(), Some("1")); + assert_eq!(c.name.as_deref(), Some("X")); + assert_eq!(c.collection_type.as_deref(), Some("collection")); +} + +#[test] +fn symbol_only_name_falls_back_to_request() { + // meta name of only symbols → slug() yields "request". + let root = tmpdir("symname"); + fs::write(root.join("a.bru"), "meta { name: @@@ }\nget { url: https://x/ }\n").unwrap(); + let files = from_bruno(root.to_str().unwrap()).unwrap(); + assert!(files[0].path.contains("request")); fs::remove_dir_all(&root).ok(); } + +#[test] +fn standalone_query_block_is_appended() { + // No inline query in the URL → the params:query block is appended. + let r = first( + "meta { name: Q }\nget { url: https://x/search }\nparams:query {\n q: hi\n ~off: no\n}\n", + ); + assert!(r.query_params.iter().any(|p| p.name == "q" && p.value == "hi")); + assert!(!r.query_params.iter().any(|p| p.name == "off")); +} + +#[test] +fn legacy_query_block_keyword() { + let r = first("meta { name: Q }\nget { url: https://x/s }\nquery {\n page: 2\n}\n"); + assert!(r.query_params.iter().any(|p| p.name == "page" && p.value == "2")); +} + +#[test] +fn disabled_entries_are_skipped_everywhere() { + let r = first( + "meta { name: D }\npost { url: https://x/ }\nbody:multipart-form {\n keep: yes\n ~drop: no\n}\nvars:pre-request {\n ~skipPre: x\n}\nvars:post-response {\n ~skipPost: res.body.id\n}\n", + ); + assert!(r.form.iter().any(|f| f.name == "keep")); + assert!(!r.form.iter().any(|f| f.name == "drop")); + assert!(r.captures.is_empty()); +} + +#[test] +fn script_with_blank_lines_preserved() { + let r = first( + "meta { name: S }\nget { url: https://x/ }\nscript:pre-request {\n var a = 1;\n\n var b = 2;\n}\n", + ); + let pre = r.pre_script.as_deref().unwrap(); + assert!(pre.contains("var a = 1;")); + assert!(pre.contains("var b = 2;")); +} + +#[test] +fn graphql_with_empty_vars_block() { + let r = first( + "meta { name: G }\npost { url: https://x/ }\nbody:graphql {\n { ping }\n}\nbody:graphql:vars {\n}\n", + ); + assert_eq!(r.body_type, BodyType::Graphql); + assert!(r.body.as_deref().unwrap().contains("ping")); +} + +#[test] +fn tests_without_post_script_become_post_script() { + let r = first( + "meta { name: T }\nget { url: https://x/ }\ntests {\n test(\"ok\", () => {});\n}\n", + ); + assert!(r.post_script.as_deref().unwrap().contains("test(\"ok\"")); +}