Skip to content
Merged
86 changes: 76 additions & 10 deletions cmd/analyze.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package cmd

import (
"codacy/cli-v2/cmd/cmdutils"
"codacy/cli-v2/cmd/configsetup"
"codacy/cli-v2/config"
"codacy/cli-v2/constants"
"codacy/cli-v2/domain"
"codacy/cli-v2/plugins"
"codacy/cli-v2/tools"
Expand Down Expand Up @@ -44,7 +47,7 @@
// LoadLanguageConfig loads the language configuration from the file
func LoadLanguageConfig() (*LanguagesConfig, error) {
// First, try to load the YAML config
yamlPath := filepath.Join(config.Config.ToolsConfigDirectory(), "languages-config.yaml")
yamlPath := filepath.Join(config.Config.ToolsConfigDirectory(), constants.LanguagesConfigFileName)

// Check if the YAML file exists
if _, err := os.Stat(yamlPath); err == nil {
Expand Down Expand Up @@ -219,6 +222,7 @@
analyzeCmd.Flags().StringVarP(&toolsToAnalyzeParam, "tool", "t", "", "Which tool to run analysis with. If not specified, all configured tools will be run")
analyzeCmd.Flags().StringVar(&outputFormat, "format", "", "Output format (use 'sarif' for SARIF format)")
analyzeCmd.Flags().BoolVar(&autoFix, "fix", false, "Apply auto fix to your issues when available")
cmdutils.AddCloudFlags(analyzeCmd, &initFlags)
rootCmd.AddCommand(analyzeCmd)
}

Expand Down Expand Up @@ -279,7 +283,56 @@
return nil
}

func runToolByName(toolName string, workDirectory string, pathsToCheck []string, autoFix bool, outputFile string, outputFormat string, tool *plugins.ToolInfo, runtime *plugins.RuntimeInfo) error {
// checkIfConfigExistsAndIsNeeded validates if a tool has config file and creates one if needed
func checkIfConfigExistsAndIsNeeded(toolName string, cliLocalMode bool) error {

Check warning on line 287 in cmd/analyze.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/analyze.go#L287

Method checkIfConfigExistsAndIsNeeded has a cyclomatic complexity of 8 (limit is 7)
configFileName := constants.ToolConfigFileNames[toolName]
if configFileName == "" {
// Tool doesn't use config file
return nil
}

// Use the configuration system to get the tools config directory
toolsConfigDir := config.Config.ToolsConfigDirectory()
toolConfigPath := filepath.Join(toolsConfigDir, configFileName)

// Check if the config file exists
if _, err := os.Stat(toolConfigPath); os.IsNotExist(err) {
// Config file does not exist - create it if we have the means to do so
if (!cliLocalMode && initFlags.ApiToken != "") || cliLocalMode {
fmt.Printf("Creating new config file for tool %s\n", toolName)
if err := configsetup.CreateToolConfigurationFile(toolName, initFlags); err != nil {
return fmt.Errorf("failed to create config file for tool %s: %w", toolName, err)
}

// Ensure .gitignore exists FIRST to prevent config files from being analyzed
if err := configsetup.CreateGitIgnoreFile(); err != nil {
logger.Warn("Failed to create .gitignore file", logrus.Fields{
"error": err,
})
}
} else {
logger.Debug("Config file not found for tool, using tool defaults", logrus.Fields{
"tool": toolName,
"toolConfigPath": toolConfigPath,
"message": "No API token provided",
})
}
} else if err != nil {
return fmt.Errorf("error checking config file for tool %s: %w", toolName, err)
} else {
logger.Info("Config file found for tool", logrus.Fields{
"tool": toolName,
"toolConfigPath": toolConfigPath,
})
}
return nil
}

func runToolByName(toolName string, workDirectory string, pathsToCheck []string, autoFix bool, outputFile string, outputFormat string, tool *plugins.ToolInfo, runtime *plugins.RuntimeInfo, cliLocalMode bool) error {

Check warning on line 331 in cmd/analyze.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/analyze.go#L331

Method runToolByName has 9 parameters (limit is 5)

Check failure on line 331 in cmd/analyze.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/analyze.go#L331

Method runToolByName has a cyclomatic complexity of 11 (limit is 10)
err := checkIfConfigExistsAndIsNeeded(toolName, cliLocalMode)
if err != nil {
return err
}
switch toolName {
case "eslint":
binaryPath := runtime.Binaries[tool.Runtime]
Expand Down Expand Up @@ -310,13 +363,13 @@
return fmt.Errorf("unsupported tool: %s", toolName)
}

func runTool(workDirectory string, toolName string, pathsToCheck []string, outputFile string, autoFix bool, outputFormat string) error {
func runTool(workDirectory string, toolName string, pathsToCheck []string, outputFile string, autoFix bool, outputFormat string, cliLocalMode bool) error {

Check warning on line 366 in cmd/analyze.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/analyze.go#L366

Method runTool has 55 lines of code (limit is 50)

Check warning on line 366 in cmd/analyze.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/analyze.go#L366

Method runTool has 7 parameters (limit is 5)

Check failure on line 366 in cmd/analyze.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/analyze.go#L366

Method runTool has a cyclomatic complexity of 15 (limit is 10)
err := validateToolName(toolName)
if err != nil {
return err
}
log.Println("Running tools for the specified file(s)...")
log.Printf("Running %s...\n", toolName)
log.Printf("Running %s...", toolName)

tool := config.Config.Tools()[toolName]
var isToolInstalled bool
Expand Down Expand Up @@ -348,7 +401,7 @@
runtime = config.Config.Runtimes()[tool.Runtime]
isRuntimeInstalled = runtime == nil || config.Config.IsRuntimeInstalled(tool.Runtime, runtime)
if !isRuntimeInstalled {
fmt.Printf("%s runtime is not installed, installing...\n", tool.Runtime)
fmt.Printf("%s runtime is not installed, installing...", tool.Runtime)
err := config.InstallRuntime(tool.Runtime, runtime)
if err != nil {
return fmt.Errorf("failed to install %s runtime: %w", tool.Runtime, err)
Expand All @@ -360,15 +413,15 @@
runtime = config.Config.Runtimes()[tool.Runtime]
isRuntimeInstalled = runtime == nil || config.Config.IsRuntimeInstalled(tool.Runtime, runtime)
if !isRuntimeInstalled {
fmt.Printf("%s runtime is not installed, installing...\n", tool.Runtime)
fmt.Printf("%s runtime is not installed, installing...", tool.Runtime)
err := config.InstallRuntime(tool.Runtime, runtime)
if err != nil {
return fmt.Errorf("failed to install %s runtime: %w", tool.Runtime, err)
}
runtime = config.Config.Runtimes()[tool.Runtime]
}
}
return runToolByName(toolName, workDirectory, pathsToCheck, autoFix, outputFile, outputFormat, tool, runtime)
return runToolByName(toolName, workDirectory, pathsToCheck, autoFix, outputFile, outputFormat, tool, runtime, cliLocalMode)
}

// validatePaths checks if all provided paths exist and returns an error if any don't
Expand All @@ -384,10 +437,19 @@
return nil
}

func validateCloudMode(cliLocalMode bool) error {
if cliLocalMode {
fmt.Println("Warning: cannot run in cloud mode")
}
return nil
}

var analyzeCmd = &cobra.Command{
Use: "analyze",
Short: "Analyze code using configured tools",
Long: `Analyze code using configured tools and output results in the specified format.`,
Long: `Analyze code using configured tools and output results in the specified format.

Supports API token, provider, and repository flags to automatically fetch tool configurations from Codacy API if they don't exist locally.`,
Run: func(cmd *cobra.Command, args []string) {
// Validate paths before proceeding
if err := validatePaths(args); err != nil {
Expand All @@ -401,6 +463,10 @@
log.Fatalf("Failed to get current working directory: %v", err)
}

cliLocalMode := len(initFlags.ApiToken) == 0

validateCloudMode(cliLocalMode)

var toolsToRun map[string]*plugins.ToolInfo

if toolsToAnalyzeParam != "" {
Expand Down Expand Up @@ -437,7 +503,7 @@
var sarifOutputs []string
for toolName := range toolsToRun {
tmpFile := filepath.Join(tmpDir, fmt.Sprintf("%s.sarif", toolName))
if err := runTool(workDirectory, toolName, args, tmpFile, autoFix, outputFormat); err != nil {
if err := runTool(workDirectory, toolName, args, tmpFile, autoFix, outputFormat, cliLocalMode); err != nil {
log.Printf("Tool failed to run: %v\n", err)
}
sarifOutputs = append(sarifOutputs, tmpFile)
Expand Down Expand Up @@ -472,7 +538,7 @@
} else {
// Run tools without merging outputs
for toolName := range toolsToRun {
if err := runTool(workDirectory, toolName, args, outputFile, autoFix, outputFormat); err != nil {
if err := runTool(workDirectory, toolName, args, outputFile, autoFix, outputFormat, cliLocalMode); err != nil {
log.Printf("Tool failed to run: %v\n", err)
}
}
Expand Down
193 changes: 193 additions & 0 deletions cmd/analyze_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package cmd

import (
"os"
"path/filepath"
"strings"
"testing"

"codacy/cli-v2/config"
"codacy/cli-v2/constants"
"codacy/cli-v2/domain"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestCheckIfConfigExistsAndIsNeededBehavior tests the behavior of checkIfConfigExistsAndIsNeeded
// without creating actual config files in the project directory
func TestCheckIfConfigExistsAndIsNeededBehavior(t *testing.T) {
// Save original state
originalFlags := initFlags
originalConfig := config.Config
originalWorkingDir, _ := os.Getwd()
defer func() {
initFlags = originalFlags
config.Config = originalConfig
os.Chdir(originalWorkingDir)
}()

tests := []struct {
name string
toolName string
cliLocalMode bool
apiToken string
description string
expectNoError bool
}{
{
name: "tool_without_config_file",
toolName: "unsupported-tool",
cliLocalMode: false,
apiToken: "test-token",
description: "Tool that doesn't use config files should return without error",
expectNoError: true,
},
{
name: "eslint_local_mode",
toolName: "eslint",
cliLocalMode: true,
apiToken: "",
description: "ESLint in local mode should not error",
expectNoError: true,
},
{
name: "eslint_remote_mode_without_token",
toolName: "eslint",
cliLocalMode: false,
apiToken: "",
description: "ESLint in remote mode without token should not error",
expectNoError: true,
},
{
name: "trivy_local_mode",
toolName: "trivy",
cliLocalMode: true,
apiToken: "",
description: "Trivy in local mode should not error",
expectNoError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create temporary directory and change to it to avoid creating files in project dir
tmpDir, err := os.MkdirTemp("", "codacy-test-isolated-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

// Change to temp directory to avoid creating config files in project
err = os.Chdir(tmpDir)
require.NoError(t, err)

// Setup initFlags
initFlags = domain.InitFlags{
ApiToken: tt.apiToken,
}

// Mock config to use our temporary directory
config.Config = *config.NewConfigType(tmpDir, tmpDir, tmpDir)

// Create tools-configs directory since the function will try to create config files
toolsConfigDir := config.Config.ToolsConfigDirectory()
err = os.MkdirAll(toolsConfigDir, constants.DefaultDirPerms)
require.NoError(t, err)

// Execute the function - this tests it doesn't panic or return unexpected errors
err = checkIfConfigExistsAndIsNeeded(tt.toolName, tt.cliLocalMode)

// Verify results
if tt.expectNoError {
assert.NoError(t, err, "Function should not return error: %s", tt.description)
} else {
assert.Error(t, err, "Function should return error: %s", tt.description)
}
})
}
}

// TestToolConfigFileNameMapCompleteness ensures all expected tools have config mappings
func TestToolConfigFileNameMapCompleteness(t *testing.T) {
expectedTools := map[string]string{
"eslint": constants.ESLintConfigFileName,
"trivy": constants.TrivyConfigFileName,
"pmd": constants.PMDConfigFileName,
"pylint": constants.PylintConfigFileName,
"dartanalyzer": constants.DartAnalyzerConfigFileName,
"semgrep": constants.SemgrepConfigFileName,
"revive": constants.ReviveConfigFileName,
"lizard": constants.LizardConfigFileName,
}

t.Run("all_expected_tools_present", func(t *testing.T) {
for toolName, expectedFileName := range expectedTools {
actualFileName, exists := constants.ToolConfigFileNames[toolName]
assert.True(t, exists, "Tool %s should exist in constants.ToolConfigFileNames map", toolName)
assert.Equal(t, expectedFileName, actualFileName, "Config filename for %s should match expected", toolName)
}
})

t.Run("no_unexpected_tools", func(t *testing.T) {
for toolName := range constants.ToolConfigFileNames {
_, expected := expectedTools[toolName]
assert.True(t, expected, "Unexpected tool %s found in constants.ToolConfigFileNames map", toolName)
}
})

t.Run("config_files_have_proper_extensions", func(t *testing.T) {
validExtensions := map[string]bool{
".mjs": true,
".js": true,
".yaml": true,
".yml": true,
".xml": true,
".rc": true,
".toml": true,
}

for toolName, fileName := range constants.ToolConfigFileNames {
ext := filepath.Ext(fileName)
assert.True(t, validExtensions[ext], "Tool %s has config file %s with unexpected extension %s", toolName, fileName, ext)
}
})
}

// TestCheckIfConfigExistsAndIsNeededEdgeCases tests edge cases and error conditions
func TestCheckIfConfigExistsAndIsNeededEdgeCases(t *testing.T) {
originalFlags := initFlags
originalConfig := config.Config
originalWorkingDir, _ := os.Getwd()
defer func() {
initFlags = originalFlags
config.Config = originalConfig
os.Chdir(originalWorkingDir)
}()

// Create temporary directory for edge case tests
tmpDir, err := os.MkdirTemp("", "codacy-test-edge-*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

// Change to temp directory
err = os.Chdir(tmpDir)
require.NoError(t, err)

// Mock config
config.Config = *config.NewConfigType(tmpDir, tmpDir, tmpDir)

t.Run("empty_tool_name", func(t *testing.T) {
err := checkIfConfigExistsAndIsNeeded("", false)
assert.NoError(t, err, "Empty tool name should not cause error")
})

t.Run("tool_name_with_special_characters", func(t *testing.T) {
err := checkIfConfigExistsAndIsNeeded("tool-with-dashes_and_underscores", false)
assert.NoError(t, err, "Tool name with special characters should not cause error")
})

t.Run("very_long_tool_name", func(t *testing.T) {
longToolName := strings.Repeat("verylongtoolname", 10)
err := checkIfConfigExistsAndIsNeeded(longToolName, false)
assert.NoError(t, err, "Very long tool name should not cause error")
})
}
Loading