bruno-cli.ts•37.8 kB
import { execa } from 'execa';
import * as fs from 'fs/promises';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { randomUUID } from 'crypto';
import { tmpdir } from 'os';
import { createRequire } from 'module';
import { getConfigLoader } from './config.js';
import { getPerformanceManager } from './performance.js';
export interface BrunoRunOptions {
  environment?: string;
  envVariables?: Record<string, string>;
  folderPath?: string;
  format?: 'json' | 'junit' | 'html';
  output?: string;
  recursive?: boolean;
  testsOnly?: boolean;
  bail?: boolean;
  // Report generation options
  reporterJson?: string;    // Path to write JSON report
  reporterJunit?: string;   // Path to write JUnit XML report
  reporterHtml?: string;    // Path to write HTML report
}
export interface BrunoRunResult {
  stdout: string;
  stderr: string;
  exitCode: number;
  summary?: {
    totalRequests: number;
    passedRequests: number;
    failedRequests: number;
    totalDuration: number;
  };
  results?: Array<{
    name: string;
    passed: boolean;
    status: number;
    duration: number;
    error?: string;
    request?: {
      method?: string;
      url?: string;
      headers?: Record<string, string>;
      body?: any;
    };
    response?: {
      status?: number;
      statusText?: string;
      headers?: Record<string, string>;
      body?: any;
      responseTime?: number;
    };
    assertions?: Array<{ 
      name: string;
      passed: boolean;
      error?: string;
    }>;
  }>;
}
export interface BrunoRequest {
  name: string;
  method?: string;
  url?: string;
  folder?: string;
  path?: string;
}
export class BrunoCLI {
  private brunoCommand: string = 'bru';
  constructor(brunoPath?: string) {
    if (brunoPath) {
      this.brunoCommand = brunoPath;
    } else {
      // Check configuration for custom Bruno CLI path
      const configLoader = getConfigLoader();
      const config = configLoader.getConfig();
      if (config.brunoCliPath) {
        this.brunoCommand = config.brunoCliPath;
      } else {
        // Try to find the local Bruno CLI installation
        this.brunoCommand = this.findBrunoCLI();
      }
    }
  }
  /**
   * Find the Bruno CLI executable
   */
  private findBrunoCLI(): string {
    const require = createRequire(import.meta.url);
    const fsSync = require('fs');
    // Get the directory where this module is located
    const moduleDir = path.dirname(fileURLToPath(import.meta.url));
    // First, try to find it relative to the current module location
    // This works whether we're in src/ or dist/
    const localBruPath = path.join(moduleDir, '..', 'node_modules', '.bin', 'bru');
    // On Windows, the executable might have a .cmd extension
    const isWindows = process.platform === 'win32';
    const executablePath = isWindows ? `${localBruPath}.cmd` : localBruPath;
    // Check if local installation exists
    try {
      if (fsSync.existsSync(executablePath)) {
        return executablePath;
      }
    } catch {
      // Fall through to try process.cwd()
    }
    // Try from process.cwd() as a fallback (for backward compatibility)
    const cwdBruPath = path.join(process.cwd(), 'node_modules', '.bin', 'bru');
    const cwdExecutablePath = isWindows ? `${cwdBruPath}.cmd` : cwdBruPath;
    try {
      if (fsSync.existsSync(cwdExecutablePath)) {
        return cwdExecutablePath;
      }
    } catch {
      // Fall through to use global command
    }
    // Fall back to global command (in case it's installed globally)
    return 'bru';
  }
  /**
   * Check if Bruno CLI is available
   */
  async isAvailable(): Promise<boolean> {
    try {
      const result = await execa(this.brunoCommand, ['--version']);
      console.error(`Bruno CLI found at: ${this.brunoCommand}`);
      console.error(`Bruno CLI version: ${result.stdout}`);
      return true;
    } catch (error) {
      console.error(`Bruno CLI not found at: ${this.brunoCommand}`);
      return false;
    }
  }
  /**
   * Run a specific request from a collection
   * Bruno CLI must be run from within the collection directory
   */
  async runRequest(
    collectionPath: string,
    requestName: string,
    options: BrunoRunOptions = {}
  ): Promise<BrunoRunResult> {
    // Find the .bru file for this request
    const requestFile = await this.findRequestFile(collectionPath, requestName);
    if (!requestFile) {
      throw new Error(`Request "${requestName}" not found in collection`);
    }
    // Get relative path from collection root
    const relativePath = path.relative(collectionPath, requestFile);
    
    // Create temporary file for JSON output
    const tempFile = path.join(tmpdir(), `bruno-result-${randomUUID()}.json`);
    
        // Build command arguments - run specific file
        const args = ['run', relativePath];
        
        // Always use JSON format to capture response data
        args.push('--format', 'json');
        args.push('--output', tempFile);
        
        // Add optional parameters
        if (options.environment) {
          let envPath = options.environment;
          // Check if it's an absolute path
          if (path.isAbsolute(envPath)) {
            // If absolute path, get just the filename without extension
            // Bruno expects just the environment name, not the full path
            const basename = path.basename(envPath, '.bru');
            envPath = basename;
          } else if (envPath.includes('/') || envPath.includes('\\')) {
            // If it's a relative path with separators, get just the filename
            const basename = path.basename(envPath, '.bru');
            envPath = basename;
          } else if (envPath.endsWith('.bru')) {
            // If it ends with .bru, remove the extension
            envPath = envPath.replace(/\.bru$/, '');
          }
          // If just a name (no path separators, no extension), use as-is
          args.push('--env', envPath);
        }
    
        // Add environment variable overrides
        if (options.envVariables) {
          for (const [key, value] of Object.entries(options.envVariables)) {
            args.push('--env-var', `${key}=${value}`);
          }
        }
    
        if (options.testsOnly) {
          args.push('--tests-only');
        }
        if (options.bail) {
          args.push('--bail');
        }
        // Add reporter options for report generation
        if (options.reporterJson) {
          args.push('--reporter-json', options.reporterJson);
        }
        if (options.reporterJunit) {
          args.push('--reporter-junit', options.reporterJunit);
        }
        if (options.reporterHtml) {
          args.push('--reporter-html', options.reporterHtml);
        }
        try {
          // Get timeout configuration
          const configLoader = getConfigLoader();
          const timeout = configLoader.getTimeout();
          // Run Bruno CLI from within the collection directory
          const result = await execa(this.brunoCommand, args, {
            cwd: collectionPath,
            env: { ...process.env },
            reject: false,
            timeout: timeout.request
          });
    
          // If Bruno CLI failed, handle the error
          if (result.exitCode !== 0 || result.failed) {
            throw result;
          }
          // Read the JSON output file
          // Note: If custom reporters are used, Bruno CLI may not write to the temp file
          let jsonResult: any = null;
          const hasCustomReporters = options.reporterJson || options.reporterJunit || options.reporterHtml;
          if (!hasCustomReporters) {
            // Only try to read temp file if no custom reporters are specified
            try {
              const jsonContent = await fs.readFile(tempFile, 'utf-8');
              jsonResult = JSON.parse(jsonContent);
            } catch (error) {
              console.error('Failed to read Bruno output file:', error);
            }
          }
      // Clean up temp file
      try {
        await fs.unlink(tempFile);
      } catch {
        // Ignore cleanup errors
      }
      return this.parseRunResult(result, jsonResult);
    } catch (error) {
      // Clean up temp file on error
      try {
        await fs.unlink(tempFile);
      } catch {
        // Ignore cleanup errors
      }
      throw this.handleError(error);
    }
  }
  /**
   * Run an entire collection or folder
   * Bruno CLI must be run from within the collection directory
   */
  async runCollection(
    collectionPath: string,
    options: BrunoRunOptions = {}
  ): Promise<BrunoRunResult> {
    // Determine what to run (folder or entire collection)
    let targetPath = '.';
    
    if (options.folderPath) {
      // Run specific folder
      targetPath = options.folderPath;
    }
    
    // Create temporary file for JSON output
    const tempFile = path.join(tmpdir(), `bruno-result-${randomUUID()}.json`);
    
    // Build command arguments
    const args = ['run', targetPath];
    
    // Add recursive flag (usually needed for folders)
    if (options.recursive !== false) {
      args.push('-r');
    }
    
    // Always use JSON format to capture response data
    args.push('--format', 'json');
    args.push('--output', tempFile);
    
    // Add optional parameters
    if (options.environment) {
      let envPath = options.environment;
      // Check if it's an absolute path
      if (path.isAbsolute(envPath)) {
        // If absolute path, get just the filename without extension
        // Bruno expects just the environment name, not the full path
        const basename = path.basename(envPath, '.bru');
        envPath = basename;
      } else if (envPath.includes('/') || envPath.includes('\\')) {
        // If it's a relative path with separators, get just the filename
        const basename = path.basename(envPath, '.bru');
        envPath = basename;
      } else if (envPath.endsWith('.bru')) {
        // If it ends with .bru, remove the extension
        envPath = envPath.replace(/\.bru$/, '');
      }
      // If just a name (no path separators, no extension), use as-is
      args.push('--env', envPath);
    }
    // Add environment variable overrides
    if (options.envVariables) {
      for (const [key, value] of Object.entries(options.envVariables)) {
        args.push('--env-var', `${key}=${value}`);
      }
    }
    if (options.testsOnly) {
      args.push('--tests-only');
    }
    if (options.bail) {
      args.push('--bail');
    }
    // Add reporter options for report generation
    if (options.reporterJson) {
      args.push('--reporter-json', options.reporterJson);
    }
    if (options.reporterJunit) {
      args.push('--reporter-junit', options.reporterJunit);
    }
    if (options.reporterHtml) {
      args.push('--reporter-html', options.reporterHtml);
    }
    try {
      // Get timeout configuration
      const configLoader = getConfigLoader();
      const timeout = configLoader.getTimeout();
      // Run Bruno CLI from within the collection directory
      const result = await execa(this.brunoCommand, args, {
        cwd: collectionPath,
        env: { ...process.env },
        reject: false,
        timeout: timeout.collection
      });
      // Read the JSON output file
      // Note: If custom reporters are used, Bruno CLI may not write to the temp file
      let jsonResult: any = null;
      const hasCustomReporters = options.reporterJson || options.reporterJunit || options.reporterHtml;
      if (!hasCustomReporters) {
        // Only try to read temp file if no custom reporters are specified
        try {
          const jsonContent = await fs.readFile(tempFile, 'utf-8');
          jsonResult = JSON.parse(jsonContent);
        } catch (error) {
          console.error('Failed to read Bruno output file:', error);
        }
      }
      // Clean up temp file
      try {
        await fs.unlink(tempFile);
      } catch {
        // Ignore cleanup errors
      }
      return this.parseRunResult(result, jsonResult);
    } catch (error) {
      // Clean up temp file on error
      try {
        await fs.unlink(tempFile);
      } catch {
        // Ignore cleanup errors
      }
      throw this.handleError(error);
    }
  }
  /**
   * List all requests in a collection
   */
  async listRequests(collectionPath: string): Promise<BrunoRequest[]> {
    try {
      // Check cache first
      const perfManager = getPerformanceManager();
      const cached = perfManager.getCachedRequestList(collectionPath);
      if (cached) {
        console.error(`Using cached request list for: ${collectionPath}`);
        return cached;
      }
      // Check if the collection path exists
      const stats = await fs.stat(collectionPath);
      if (!stats.isDirectory()) {
        throw new Error(`Collection path is not a directory: ${collectionPath}`);
      }
      // Check if it's a valid Bruno collection (should have bruno.json or collection.bru)
      const hasCollectionFile = await this.hasCollectionFile(collectionPath);
      if (!hasCollectionFile) {
        throw new Error(`Not a valid Bruno collection: ${collectionPath}`);
      }
      // Find all .bru files in the collection
      const requests = await this.findBrunoRequests(collectionPath);
      // Cache the results
      perfManager.cacheRequestList(collectionPath, requests);
      return requests;
    } catch (error) {
      if ((error as any).code === 'ENOENT') {
        throw new Error(`Collection not found: ${collectionPath}`);
      }
      throw error;
    }
  }
  /**
   * Check if directory has a Bruno collection file
   */
  private async hasCollectionFile(dirPath: string): Promise<boolean> {
    try {
      // Check for bruno.json or collection.bru
      const brunoJsonPath = path.join(dirPath, 'bruno.json');
      const collectionBruPath = path.join(dirPath, 'collection.bru');
      
      try {
        await fs.access(brunoJsonPath);
        return true;
      } catch {
        // Try collection.bru
        try {
          await fs.access(collectionBruPath);
          return true;
        } catch {
          return false;
        }
      }
    } catch {
      return false;
    }
  }
  /**
   * Find a specific request file by name
   */
  private async findRequestFile(collectionPath: string, requestName: string): Promise<string | null> {
    const requests = await this.findBrunoRequests(collectionPath);
    
    // Try exact match first
    let request = requests.find(r => r.name === requestName);
    
    // Try case-insensitive match
    if (!request) {
      request = requests.find(r => r.name.toLowerCase() === requestName.toLowerCase());
    }
    
    // Try partial match
    if (!request) {
      request = requests.find(r => r.name.includes(requestName));
    }
    
    return request?.path || null;
  }
  /**
   * Recursively find all Bruno request files
   */
  private async findBrunoRequests(
    dirPath: string,
    basePath: string = dirPath,
    requests: BrunoRequest[] = []
  ): Promise<BrunoRequest[]> {
    const entries = await fs.readdir(dirPath, { withFileTypes: true });
    for (const entry of entries) {
      const fullPath = path.join(dirPath, entry.name);
      
      if (entry.isDirectory() && !entry.name.startsWith('.')) {
        // Skip node_modules and other common non-request directories
        if (entry.name === 'node_modules' || entry.name === 'environments') {
          continue;
        }
        // Recursively search subdirectories
        await this.findBrunoRequests(fullPath, basePath, requests);
      } else if (entry.isFile() && entry.name.endsWith('.bru')) {
        // Skip collection.bru as it's not a request
        if (entry.name === 'collection.bru') {
          continue;
        }
        
        // Parse the .bru file for basic info
        const requestInfo = await this.parseBrunoFile(fullPath);
        const relativePath = path.relative(basePath, dirPath);
        
        requests.push({
          name: path.basename(entry.name, '.bru'),
          folder: relativePath || undefined,
          path: fullPath,
          ...requestInfo
        });
      }
    }
    return requests;
  }
  /**
   * Parse a Bruno request file for basic information
   */
  private async parseBrunoFile(filePath: string): Promise<Partial<BrunoRequest>> {
    try {
      const content = await fs.readFile(filePath, 'utf-8');
      const lines = content.split('\n');
      
      const info: Partial<BrunoRequest> = {};
      
      // Look for meta name first
      let inMeta = false;
      for (const line of lines) {
        if (line.trim() === 'meta {') {
          inMeta = true;
          continue;
        }
        if (inMeta && line.trim() === '}') {
          inMeta = false;
          continue;
        }
        if (inMeta) {
          const nameMatch = line.match(/^\s*name:\s*(.+)/);
          if (nameMatch) {
            info.name = nameMatch[1].trim();
          }
        }
        
        // Look for method and URL
        const methodMatch = line.match(/^(get|post|put|delete|patch|head|options)\s*\{/i);
        if (methodMatch) {
          info.method = methodMatch[1].toUpperCase();
          // Look for URL in the next few lines
          const urlIndex = lines.indexOf(line);
          for (let i = urlIndex + 1; i < Math.min(urlIndex + 5, lines.length); i++) {
            const urlMatch = lines[i].match(/^\s*url:\s*(.+)/);
            if (urlMatch) {
              info.url = urlMatch[1].trim();
              break;
            }
          }
        }
      }
      
      return info;
    } catch {
      // If we can't parse the file, just return empty info
      return {};
    }
  }
  /**
   * Parse the result from Bruno CLI execution
   */
  private parseRunResult(result: any, jsonOutput?: any): BrunoRunResult {
    const runResult: BrunoRunResult = {
      stdout: result.stdout || '',
      stderr: result.stderr || '',
      exitCode: result.exitCode || 0
    };
    // Use the JSON output if available (from file)
    let jsonResult = jsonOutput || this.tryParseJson(result.stdout);
    // Bruno CLI returns an array with a single object containing summary and results
    // Unwrap it if needed
    if (Array.isArray(jsonResult) && jsonResult.length > 0) {
      jsonResult = jsonResult[0];
    }
    if (jsonResult) {
      // Extract summary information
      if (jsonResult.summary) {
        runResult.summary = {
          totalRequests: jsonResult.summary.totalRequests || jsonResult.summary.total || 0,
          passedRequests: jsonResult.summary.passedRequests || jsonResult.summary.passed || 0,
          failedRequests: jsonResult.summary.failedRequests || jsonResult.summary.failed || 0,
          totalDuration: jsonResult.summary.totalDuration || jsonResult.summary.duration || 0
        };
      }
      // Extract individual results with response data
      if (jsonResult.results && Array.isArray(jsonResult.results)) {
        runResult.results = jsonResult.results.map((r: any) => {
          const result: any = {
            name: r.suitename || r.name || r.test?.filename || 'Unknown',
            passed: r.error === null || r.error === undefined,
            status: r.response?.status || 0,
            duration: r.response?.responseTime || r.runtime || 0,
            error: r.error
          };
          // Add request details if available
          if (r.request) {
            result.request = {
              method: r.request.method,
              url: r.request.url,
              headers: r.request.headers,
              body: r.request.body || r.request.data
            };
          }
          // Add response details if available
          if (r.response) {
            result.response = {
              status: r.response.status,
              statusText: r.response.statusText,
              headers: r.response.headers,
              body: r.response.data || r.response.body,
              responseTime: r.response.responseTime
            };
          }
          // Add test/assertion results (Bruno uses testResults and assertionResults)
          const testResults = r.testResults || r.tests || r.assertions;
          const assertionResults = r.assertionResults || [];
          const allTests = [];
          if (Array.isArray(testResults)) {
            allTests.push(...testResults.map((t: any) => ({
              name: t.description || t.name || t.test,
              passed: t.status === 'pass' || t.passed === true,
              error: t.status === 'fail' ? t.error || t.message : undefined
            })));
          }
          if (Array.isArray(assertionResults)) {
            allTests.push(...assertionResults.map((a: any) => ({
              name: a.description || a.name,
              passed: a.status === 'pass' || a.passed === true,
              error: a.status === 'fail' ? a.error || a.message : undefined
            })));
          }
          if (allTests.length > 0) {
            result.assertions = allTests;
          }
          return result;
        });
      }
      // If results are in a different structure (check for 'items' or direct array)
      if (!runResult.results && jsonResult.items && Array.isArray(jsonResult.items)) {
        runResult.results = this.parseItems(jsonResult.items);
      }
    }
    return runResult;
  }
  /**
   * Try to parse JSON from string
   */
  private tryParseJson(str: string): any {
    if (!str || !str.trim()) return null;
    
    try {
      // Try to parse as-is
      if (str.trim().startsWith('{') || str.trim().startsWith('[')) {
        return JSON.parse(str);
      }
      
      // Look for JSON in the output (sometimes there's extra text)
      const jsonMatch = str.match(/(\{[\s\S]*\}|[[\][\s\S]*\])/);
      if (jsonMatch) {
        return JSON.parse(jsonMatch[1]);
      }
    } catch {
      // Not JSON or parsing failed
    }
    
    return null;
  }
  /**
   * Parse items from alternative JSON structure
   */
  private parseItems(items: any[]): any[] {
    return items.map((item: any) => {
      const result: any = {
        name: item.name || 'Unknown',
        passed: item.status === 'passed' || item.passed !== false,
        status: item.response?.status || 0,
        duration: item.duration || 0
      };
      if (item.request) {
        result.request = {
          method: item.request.method,
          url: item.request.url,
          headers: item.request.headers,
          body: item.request.body
        };
      }
      if (item.response) {
        result.response = {
          status: item.response.status,
          statusText: item.response.statusText,
          headers: item.response.headers,
          body: item.response.body || item.response.data,
          responseTime: item.response.time || item.response.responseTime
        };
      }
      if (item.tests) {
        result.assertions = item.tests.map((t: any) => ({
          name: t.name || t.test,
          passed: t.passed || t.status === 'passed',
          error: t.error
        }));
      }
      return result;
    });
  }
  /**
   * Discover Bruno collections in a directory
   */
  async discoverCollections(searchPath: string, maxDepth: number = 5): Promise<string[]> {
    const perfManager = getPerformanceManager();
    // Check cache first
    const cached = perfManager.getCachedCollectionDiscovery(searchPath);
    if (cached) {
      return cached;
    }
    const collections: string[] = [];
    const maxSearchDepth = Math.min(maxDepth, 10); // Cap at 10 for safety
    async function searchDirectory(dirPath: string, currentDepth: number) {
      if (currentDepth > maxSearchDepth) {
        return;
      }
      try {
        const entries = await fs.readdir(dirPath, { withFileTypes: true });
        // Check if this directory contains bruno.json
        const hasBrunoJson = entries.some(entry =>
          entry.isFile() && entry.name === 'bruno.json'
        );
        if (hasBrunoJson) {
          collections.push(dirPath);
          // Don't search subdirectories of a collection
          return;
        }
        // Recursively search subdirectories
        for (const entry of entries) {
          if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
            const subPath = path.join(dirPath, entry.name);
            await searchDirectory(subPath, currentDepth + 1);
          }
        }
      } catch (error) {
        // Silently skip directories we can't read
        console.error(`Cannot read directory: ${dirPath}`, error);
      }
    }
    await searchDirectory(searchPath, 0);
    // Cache the results
    perfManager.cacheCollectionDiscovery(searchPath, collections);
    return collections;
  }
  /**
   * List environments in a collection
   */
  async listEnvironments(collectionPath: string): Promise<Array<{
    name: string;
    path: string;
    variables?: Record<string, string>;
  }>> {
    const perfManager = getPerformanceManager();
    // Check cache first
    const cached = perfManager.getCachedEnvironmentList(collectionPath);
    if (cached) {
      return cached;
    }
    const environmentsPath = path.join(collectionPath, 'environments');
    const environments: Array<{ name: string; path: string; variables?: Record<string, string> }> = [];
    try {
      const entries = await fs.readdir(environmentsPath, { withFileTypes: true });
      for (const entry of entries) {
        if (entry.isFile() && entry.name.endsWith('.bru')) {
          const envName = path.basename(entry.name, '.bru');
          const envPath = path.join(environmentsPath, entry.name);
          try {
            const content = await fs.readFile(envPath, 'utf-8');
            const variables = this.parseEnvironmentVariables(content);
            environments.push({
              name: envName,
              path: envPath,
              variables
            });
          } catch (error) {
            // Include environment even if we can't parse it
            environments.push({
              name: envName,
              path: envPath
            });
          }
        }
      }
    } catch (error) {
      // Return empty array if environments directory doesn't exist
      return [];
    }
    // Cache the results
    perfManager.cacheEnvironmentList(collectionPath, environments);
    return environments;
  }
  /**
   * Validate an environment file
   */
  async validateEnvironment(collectionPath: string, environmentName: string): Promise<{
    valid: boolean;
    exists: boolean;
    errors: string[];
    warnings: string[];
    variables?: Record<string, string>;
  }> {
    const result = {
      valid: true,
      exists: false,
      errors: [] as string[],
      warnings: [] as string[],
      variables: undefined as Record<string, string> | undefined
    };
    const envPath = path.join(collectionPath, 'environments', `${environmentName}.bru`);
    try {
      await fs.access(envPath);
      result.exists = true;
    } catch {
      result.valid = false;
      result.errors.push(`Environment file not found: ${envPath}`);
      return result;
    }
    try {
      const content = await fs.readFile(envPath, 'utf-8');
      // Check basic structure
      if (!content.includes('vars {')) {
        result.warnings.push('Environment file does not contain a "vars {}" block');
      }
      // Parse variables
      result.variables = this.parseEnvironmentVariables(content);
      // Check for common issues
      if (Object.keys(result.variables).length === 0) {
        result.warnings.push('No variables defined in environment');
      }
      // Check for potentially sensitive data that might be hardcoded
      for (const [key, value] of Object.entries(result.variables)) {
        if (key.toLowerCase().includes('password') ||
            key.toLowerCase().includes('secret') ||
            key.toLowerCase().includes('token')) {
          if (value && !value.startsWith('{{') && !value.startsWith('$')) {
            result.warnings.push(`Variable "${key}" may contain hardcoded sensitive data`);
          }
        }
      }
    } catch (error) {
      result.valid = false;
      result.errors.push(`Failed to read environment file: ${error}`);
    }
    return result;
  }
  /**
   * Parse environment variables from .bru file content
   */
  private parseEnvironmentVariables(content: string): Record<string, string> {
    const variables: Record<string, string> = {};
    // Match vars { ... } block
    const varsMatch = content.match(/vars\s*\{([^}]*)\}/s);
    if (!varsMatch) {
      return variables;
    }
    const varsContent = varsMatch[1];
    // Match variable assignments: name: value
    const varRegex = /^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.+?)\s*$/gm;
    let match;
    while ((match = varRegex.exec(varsContent)) !== null) {
      const [, key, value] = match;
      variables[key] = value;
    }
    return variables;
  }
  /**
   * Get detailed information about a request
   */
  async getRequestDetails(collectionPath: string, requestName: string): Promise<{
    name: string;
    method: string;
    url: string;
    headers: Record<string, string>;
    body?: {
      type: string;
      content: string;
    };
    auth: string;
    tests?: string[];
    metadata: {
      type: string;
      seq?: number;
    };
  }> {
    // Find the request file
    const requestFile = await this.findRequestFile(collectionPath, requestName);
    if (!requestFile) {
      throw new Error(`Request "${requestName}" not found in collection`);
    }
    // Read and parse the .bru file
    const content = await fs.readFile(requestFile, 'utf-8');
    const details = {
      name: requestName,
      method: 'GET',
      url: '',
      headers: {} as Record<string, string>,
      body: undefined as { type: string; content: string } | undefined,
      auth: 'none',
      tests: [] as string[],
      metadata: {
        type: 'http',
        seq: undefined as number | undefined
      }
    };
    // Parse metadata block
    const metaMatch = content.match(/meta\s*\{([\s\S]*?)\n\}/s);
    if (metaMatch) {
      const metaContent = metaMatch[1];
      const nameMatch = metaContent.match(/name:\s*(.+)/);
      if (nameMatch) details.name = nameMatch[1].trim();
      const typeMatch = metaContent.match(/type:\s*(.+)/);
      if (typeMatch) details.metadata.type = typeMatch[1].trim();
      const seqMatch = metaContent.match(/seq:\s*(\d+)/);
      if (seqMatch) details.metadata.seq = parseInt(seqMatch[1]);
    }
    // Parse method block (get, post, put, patch, delete, etc.)
    const methodBlocks = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
    for (const method of methodBlocks) {
      const methodRegex = new RegExp(`${method}\\s*\\{([\\s\\S]*?)\\n\\}`, 's');
      const methodMatch = content.match(methodRegex);
      if (methodMatch) {
        details.method = method.toUpperCase();
        const methodContent = methodMatch[1];
        const urlMatch = methodContent.match(/url:\s*(.+)/);
        if (urlMatch) details.url = urlMatch[1].trim();
        const authMatch = methodContent.match(/auth:\s*(.+)/);
        if (authMatch) details.auth = authMatch[1].trim();
        break;
      }
    }
    // Parse headers block
    const headersMatch = content.match(/headers\s*\{([\s\S]*?)\n\}/s);
    if (headersMatch) {
      const headersContent = headersMatch[1];
      const headerLines = headersContent.split('\n').filter(line => line.trim());
      for (const line of headerLines) {
        const colonIndex = line.indexOf(':');
        if (colonIndex > 0) {
          const key = line.substring(0, colonIndex).trim();
          const value = line.substring(colonIndex + 1).trim();
          details.headers[key] = value;
        }
      }
    }
    // Parse body block
    const bodyTypeMatch = content.match(/body:(json|text|xml|formUrlEncoded|multipartForm|graphql|sparql|none)\s*\{/);
    if (bodyTypeMatch) {
      const bodyType = bodyTypeMatch[1];
      const bodyRegex = new RegExp(`body:${bodyType}\\s*\\{([\\s\\S]*?)\\n\\}`, 's');
      const bodyMatch = content.match(bodyRegex);
      if (bodyMatch) {
        details.body = {
          type: bodyType,
          content: bodyMatch[1].trim()
        };
      }
    }
    // Parse tests block
    const testsMatch = content.match(/tests\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/s);
    if (testsMatch) {
      const testsContent = testsMatch[1];
      // Extract test function calls
      const testRegex = /test\s*\(\s*["']([^"']+)["']/g;
      let testMatch;
      while ((testMatch = testRegex.exec(testsContent)) !== null) {
        details.tests.push(testMatch[1]);
      }
    }
    return details;
  }
  /**
   * Validate a Bruno collection
   */
  async validateCollection(collectionPath: string): Promise<{
    valid: boolean;
    errors: string[];
    warnings: string[];
    summary: {
      hasBrunoJson: boolean;
      totalRequests: number;
      validRequests: number;
      invalidRequests: number;
      environments: number;
    };
  }> {
    const result = {
      valid: true,
      errors: [] as string[],
      warnings: [] as string[],
      summary: {
        hasBrunoJson: false,
        totalRequests: 0,
        validRequests: 0,
        invalidRequests: 0,
        environments: 0
      }
    };
    // Check if collection directory exists
    try {
      await fs.access(collectionPath);
    } catch {
      result.valid = false;
      result.errors.push(`Collection directory not found: ${collectionPath}`);
      return result;
    }
    // Check for bruno.json
    const brunoJsonPath = path.join(collectionPath, 'bruno.json');
    try {
      await fs.access(brunoJsonPath);
      result.summary.hasBrunoJson = true;
      // Validate bruno.json structure
      const brunoJsonContent = await fs.readFile(brunoJsonPath, 'utf-8');
      try {
        const brunoJson = JSON.parse(brunoJsonContent);
        // Check required fields
        if (!brunoJson.version) {
          result.warnings.push('bruno.json missing "version" field');
        }
        if (!brunoJson.name) {
          result.warnings.push('bruno.json missing "name" field');
        }
        if (!brunoJson.type) {
          result.warnings.push('bruno.json missing "type" field');
        } else if (brunoJson.type !== 'collection') {
          result.errors.push(`Invalid type in bruno.json: expected "collection", got "${brunoJson.type}"`);
          result.valid = false;
        }
      } catch (error) {
        result.errors.push(`Invalid JSON in bruno.json: ${error}`);
        result.valid = false;
      }
    } catch {
      result.errors.push('bruno.json not found in collection root');
      result.valid = false;
      return result;
    }
    // Validate requests
    try {
      const requests = await this.listRequests(collectionPath);
      result.summary.totalRequests = requests.length;
      if (requests.length === 0) {
        result.warnings.push('Collection contains no requests');
      }
      // Validate each request file
      for (const req of requests) {
        try {
          await this.getRequestDetails(collectionPath, req.name);
          result.summary.validRequests++;
        } catch (error) {
          result.summary.invalidRequests++;
          result.errors.push(`Invalid request "${req.name}": ${error}`);
          result.valid = false;
        }
      }
    } catch (error) {
      result.errors.push(`Failed to list requests: ${error}`);
      result.valid = false;
    }
    // Check for environments
    try {
      const environments = await this.listEnvironments(collectionPath);
      result.summary.environments = environments.length;
      if (environments.length === 0) {
        result.warnings.push('No environments found in collection');
      }
      // Validate each environment
      for (const env of environments) {
        const validation = await this.validateEnvironment(collectionPath, env.name);
        if (!validation.valid) {
          result.warnings.push(`Environment "${env.name}" has issues: ${validation.errors.join(', ')}`);
        }
        if (validation.warnings.length > 0) {
          validation.warnings.forEach(w => result.warnings.push(`Environment "${env.name}": ${w}`));
        }
      }
    } catch (error) {
      // Environments are optional, so this is just a warning
      result.warnings.push('Could not validate environments');
    }
    return result;
  }
  /**
   * Handle errors from Bruno CLI
   */
  private handleError(error: any): Error {
    // Check if it's an execa error by checking for specific properties
    if (error && typeof error === 'object' && 'stderr' in error) {
      if (error.stderr?.includes('command not found') || 
          error.stderr?.includes('not recognized') ||
          error.stderr?.includes('ENOENT')) {
        return new Error(
          `Bruno CLI not found at: ${this.brunoCommand}. ` +
          'Please ensure Bruno CLI is installed by running: npm install'
        );
      }
      
      if (error.stderr?.includes('You can run only at the root of a collection')) {
        return new Error(
          'Invalid collection directory. Bruno CLI must be run from the root of a Bruno collection. ' +
          'Ensure the directory contains a bruno.json or collection.bru file.'
        );
      }
      
      if (error.stderr) {
        return new Error(`Bruno CLI error: ${error.stderr}`);
      }
      
      return new Error(`Bruno CLI failed: ${error.message || 'Unknown error'}`);
    }
    
    return error instanceof Error ? error : new Error(String(error));
  }
}