Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: cron parser #19

Merged
merged 20 commits into from
Jun 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@
"-c",
"config.local.yaml"
]
}
},
{
"name": "Parse",
"type": "go",
"request": "launch",
"mode": "auto",
"program": ".",
"env": {},
"args": [
"parse",
"crontab.example",
"--verbose",
]
},
]
}
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"staticcheck",
"stylecheck",
"Tracef",
"Traceln",
"typecheck",
"unconvert",
"unparam",
Expand Down
8 changes: 8 additions & 0 deletions cmd/parser/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package parser

type parserConfig struct {
cronFile string
output string
cronMatcher string
hasUser bool
}
41 changes: 41 additions & 0 deletions cmd/parser/cron_line.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package parser

import (
"fmt"
"regexp"
"strings"
)

type cronLine struct {
string
}

var envRegex = regexp.MustCompile(`^(?<key>[\w\d_]+)=(?<value>.*)$`)

func (l cronLine) exportEnv() (map[string]string, error) {
match := envRegex.FindStringSubmatch(l.string)
answer := make(map[string]string)
switch len(match) {
case 0:
case 3:
answer[match[1]] = match[2]
default:
return nil, fmt.Errorf("unexpected response from environment parser for line:\n%s", l.string)

Check warning on line 23 in cmd/parser/cron_line.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_line.go#L22-L23

Added lines #L22 - L23 were not covered by tests
}
if len(answer) != 1 && len(strings.Trim(l.string, " \n\t")) != 0 {
return nil, fmt.Errorf("line cannot be parsed as environment:\n%s", l.string)
}
return answer, nil
}

func (l cronLine) exportSpec(regex *regexp.Regexp, env map[string]string, parser cronSpecParser) (*cronSpec, error) {
match := regex.FindStringSubmatch(l.string)
if len(match) < 1 {
if len(strings.Trim(l.string, " \n\t")) == 0 {
return nil, nil
} else {
return nil, fmt.Errorf("cannot parse this non-empty line as cron specification: %s", l.string)
}

Check warning on line 38 in cmd/parser/cron_line.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_line.go#L31-L38

Added lines #L31 - L38 were not covered by tests
}
return parser(match, env), nil

Check warning on line 40 in cmd/parser/cron_line.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_line.go#L40

Added line #L40 was not covered by tests
}
53 changes: 53 additions & 0 deletions cmd/parser/cron_line_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package parser

import (
"testing"

"github.com/alecthomas/assert/v2"
)

func TestExportEnv_SingleMatch(t *testing.T) {
tests := []struct {
name string
input string
expected map[string]string
wantErr bool
}{
{
name: "Single match",
input: "MY_VAR=value",
expected: map[string]string{"MY_VAR": "value"},
wantErr: false,
},
{
name: "No match",
input: "no_match",
expected: nil,
wantErr: true,
},
{
name: "Empty input",
input: "",
expected: map[string]string{},
wantErr: false,
},
{
name: "Special characters",
input: "VAR_WITH_UNDERSCORE=value_with_underscore",
expected: map[string]string{"VAR_WITH_UNDERSCORE": "value_with_underscore"},
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cl := cronLine{string: tt.input}
got, err := cl.exportEnv()
if tt.wantErr {
assert.Error(t, err)
} else {
assert.Equal(t, tt.expected, got)
}
})
}
}
50 changes: 50 additions & 0 deletions cmd/parser/cron_spec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package parser

import (
"fmt"
"regexp"
)

type (
cronSpecParser = func([]string, map[string]string) *cronSpec
cronSpec struct {
timing string
user string
command string
environ map[string]string
}
)

func normalParser(regex *regexp.Regexp) (cronSpecParser, error) {
cronIndex := regex.SubexpIndex("cron")
// userIndex := regex.SubexpIndex("user")
cmdIndex := regex.SubexpIndex("cmd")
if cronIndex < 0 || cmdIndex < 0 {
return nil, fmt.Errorf("cannot find groups (cron,cmd) in regexp: `%s", regex)
}
return func(match []string, env map[string]string) *cronSpec {
return &cronSpec{
timing: match[cronIndex],
user: "",
command: match[cmdIndex],
environ: env,
}
}, nil

Check warning on line 32 in cmd/parser/cron_spec.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_spec.go#L18-L32

Added lines #L18 - L32 were not covered by tests
}

func withUserParser(regex *regexp.Regexp) (cronSpecParser, error) {
cronIndex := regex.SubexpIndex("cron")
userIndex := regex.SubexpIndex("user")
cmdIndex := regex.SubexpIndex("cmd")
if cronIndex < 0 || cmdIndex < 0 || userIndex < 0 {
return nil, fmt.Errorf("cannot find groups (cron,user,cmd) in regexp: `%s", regex)
}
return func(match []string, env map[string]string) *cronSpec {
return &cronSpec{
timing: match[cronIndex],
user: match[userIndex],
command: match[cmdIndex],
environ: env,
}
}, nil

Check warning on line 49 in cmd/parser/cron_spec.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_spec.go#L35-L49

Added lines #L35 - L49 were not covered by tests
}
188 changes: 188 additions & 0 deletions cmd/parser/cron_string.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package parser

import (
"fmt"
"regexp"
"strings"

log "github.com/sirupsen/logrus"

"github.com/FMotalleb/crontab-go/config"
)

type CronString struct {
string
}

func NewCronString(cron string) CronString {
return CronString{cron}

Check warning on line 18 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L17-L18

Added lines #L17 - L18 were not covered by tests
}
FMotalleb marked this conversation as resolved.
Show resolved Hide resolved

func (s CronString) replaceAll(regex string, repl string) CronString {
reg := regexp.MustCompile(regex)
out := reg.ReplaceAllString(s.string, repl)
return CronString{out}

Check warning on line 24 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L21-L24

Added lines #L21 - L24 were not covered by tests
Comment on lines +21 to +24
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add unit tests for replaceAll.

The function replaceAll is not covered by unit tests. It's crucial to add comprehensive unit tests for this function to ensure its reliability and correctness.

func TestReplaceAll(t *testing.T) {
	cron := NewCronString("0 0 * * *")
	result := cron.replaceAll(`\d`, "1")
	expected := "1 1 * * *"
	if result.string != expected {
		t.Errorf("Expected %s, got %s", expected, result.string)
	}
}
Tools
GitHub Check: codecov/patch

[warning] 21-24: cmd/parser/cron_string.go#L21-L24
Added lines #L21 - L24 were not covered by tests

}

func (s CronString) sanitizeLineBreaker() CronString {
return s.replaceAll(
`\s*\\\s*\n\s*([\n|\n\s])*`,
" ",
)

Check warning on line 31 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L27-L31

Added lines #L27 - L31 were not covered by tests
Comment on lines +27 to +31
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add unit tests for sanitizeLineBreaker.

The function sanitizeLineBreaker is not covered by unit tests. It's crucial to add comprehensive unit tests for this function to ensure its reliability and correctness.

func TestSanitizeLineBreaker(t *testing.T) {
	cron := NewCronString("0 0 * * * \\\n0 1 * * *")
	result := cron.sanitizeLineBreaker()
	expected := "0 0 * * * 0 1 * * *"
	if result.string != expected {
		t.Errorf("Expected %s, got %s", expected, result.string)
	}
}
Tools
GitHub Check: codecov/patch

[warning] 27-31: cmd/parser/cron_string.go#L27-L31
Added lines #L27 - L31 were not covered by tests

}

func (s CronString) sanitizeEmptyLine() CronString {
return s.replaceAll(
`\n\s*\n`,
"\n",
)

Check warning on line 38 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L34-L38

Added lines #L34 - L38 were not covered by tests
Comment on lines +34 to +38
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add unit tests for sanitizeEmptyLine.

The function sanitizeEmptyLine is not covered by unit tests. It's crucial to add comprehensive unit tests for this function to ensure its reliability and correctness.

func TestSanitizeEmptyLine(t *testing.T) {
	cron := NewCronString("0 0 * * *\n\n0 1 * * *")
	result := cron.sanitizeEmptyLine()
	expected := "0 0 * * *\n0 1 * * *"
	if result.string != expected {
		t.Errorf("Expected %s, got %s", expected, result.string)
	}
}
Tools
GitHub Check: codecov/patch

[warning] 34-38: cmd/parser/cron_string.go#L34-L38
Added lines #L34 - L38 were not covered by tests

}

func (s CronString) sanitizeComments() CronString {
return s.replaceAll(
`\s*#.*`,
"",
)

Check warning on line 45 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L41-L45

Added lines #L41 - L45 were not covered by tests
Comment on lines +41 to +45
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add unit tests for sanitizeComments.

The function sanitizeComments is not covered by unit tests. It's crucial to add comprehensive unit tests for this function to ensure its reliability and correctness.

func TestSanitizeComments(t *testing.T) {
	cron := NewCronString("0 0 * * * # comment")
	result := cron.sanitizeComments()
	expected := "0 0 * * *"
	if result.string != expected {
		t.Errorf("Expected %s, got %s", expected, result.string)
	}
}
Tools
GitHub Check: codecov/patch

[warning] 41-45: cmd/parser/cron_string.go#L41-L45
Added lines #L41 - L45 were not covered by tests

}

func (s CronString) sanitize() CronString {
sane := s.
replaceAll("\r\n", "\n").
sanitizeComments().
sanitizeLineBreaker().
sanitizeEmptyLine()
log.TraceFn(func() []interface{} {
return []any{
"sanitizing input:\n",
s.string,
"\nOutput:\n",
sane.string,
}
})
return sane

Check warning on line 62 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L48-L62

Added lines #L48 - L62 were not covered by tests
Comment on lines +48 to +62
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add unit tests for sanitize.

The function sanitize is not covered by unit tests. It's crucial to add comprehensive unit tests for this function to ensure its reliability and correctness.

func TestSanitize(t *testing.T) {
	cron := NewCronString("0 0 * * * \\\n# comment\n\n0 1 * * *")
	result := cron.sanitize()
	expected := "0 0 * * * 0 1 * * *"
	if result.string != expected {
		t.Errorf("Expected %s, got %s", expected, result.string)
	}
}
Tools
GitHub Check: codecov/patch

[warning] 48-59: cmd/parser/cron_string.go#L48-L59
Added lines #L48 - L59 were not covered by tests


[warning] 61-62: cmd/parser/cron_string.go#L61-L62
Added lines #L61 - L62 were not covered by tests

}

func (s CronString) lines() []string {
return strings.Split(s.string, "\n")

Check warning on line 66 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L65-L66

Added lines #L65 - L66 were not covered by tests
Comment on lines +65 to +66
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add unit tests for lines.

The function lines is not covered by unit tests. It's crucial to add comprehensive unit tests for this function to ensure its reliability and correctness.

func TestLines(t *testing.T) {
	cron := NewCronString("0 0 * * *\n0 1 * * *")
	result := cron.lines()
	expected := []string{"0 0 * * *", "0 1 * * *"}
	for i, line := range result {
		if line != expected[i] {
			t.Errorf("Expected %s, got %s", expected[i], line)
		}
	}
}
Tools
GitHub Check: codecov/patch

[warning] 65-66: cmd/parser/cron_string.go#L65-L66
Added lines #L65 - L66 were not covered by tests

}

func (s *CronString) parseAsSpec(
pattern string,
hasUser bool,
) ([]cronSpec, error) {
envTable := make(map[string]string)
specs := make([]cronSpec, 0)
lines := s.sanitize().lines()
matcher, parser, err := buildMapper(hasUser, pattern)
log.Tracef("parsing lines using `%s` line matcher", matcher.String())
if err != nil {
return []cronSpec{}, err
}
for num, line := range lines {
l := cronLine{line}
if env, err := l.exportEnv(); len(env) > 0 {
log.Tracef("line %d(post sanitize) is identified as environment line", num)
if err != nil {
return nil, err
}
for key, val := range env {
if old, ok := envTable[key]; ok {
log.Warnf("env var of key `%s`, value `%s`, is going to be replaced by `%s`", key, old, val)
}
envTable[key] = val

Check warning on line 92 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L72-L92

Added lines #L72 - L92 were not covered by tests
}
} else {
spec, err := l.exportSpec(matcher, envTable, parser)
if err != nil {
return nil, err
}
if spec != nil {
specs = append(specs, *spec)
}

Check warning on line 101 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L94-L101

Added lines #L94 - L101 were not covered by tests
}
}
return specs, nil

Check warning on line 104 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L104

Added line #L104 was not covered by tests
Comment on lines +69 to +104
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improve error handling and logging in parseAsSpec.

The function parseAsSpec handles errors but does not log them, which could make debugging difficult. Consider adding logging before returning errors to provide more context about the failure.

if err != nil {
+	log.Errorf("Error building mapper: %v", err)
	return []cronSpec{}, err
}
...
if err != nil {
+	log.Errorf("Error exporting spec: %v", err)
	return nil, err
}

Additionally, add unit tests for parseAsSpec to ensure its reliability and correctness.

func TestParseAsSpec(t *testing.T) {
	cron := NewCronString("0 0 * * * /command")
	specs, err := cron.parseAsSpec("* * * * *", false)
	if err != nil {
		t.Errorf("Unexpected error: %v", err)
	}
	if len(specs) != 1 {
		t.Errorf("Expected 1 spec, got %d", len(specs))
	}
}
Tools
GitHub Check: codecov/patch

[warning] 72-79: cmd/parser/cron_string.go#L72-L79
Added lines #L72 - L79 were not covered by tests


[warning] 81-86: cmd/parser/cron_string.go#L81-L86
Added lines #L81 - L86 were not covered by tests


[warning] 88-90: cmd/parser/cron_string.go#L88-L90
Added lines #L88 - L90 were not covered by tests


[warning] 92-92: cmd/parser/cron_string.go#L92
Added line #L92 was not covered by tests


[warning] 94-97: cmd/parser/cron_string.go#L94-L97
Added lines #L94 - L97 were not covered by tests


[warning] 99-100: cmd/parser/cron_string.go#L99-L100
Added lines #L99 - L100 were not covered by tests


[warning] 104-104: cmd/parser/cron_string.go#L104
Added line #L104 was not covered by tests

}

func (s *CronString) ParseConfig(
pattern string,
hasUser bool,
) (*config.Config, error) {
specs, err := s.parseAsSpec(pattern, hasUser)
if err != nil {
return nil, err
}
cfg := &config.Config{}
for _, spec := range specs {
addSpec(cfg, spec)
}
return cfg, nil

Check warning on line 119 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L110-L119

Added lines #L110 - L119 were not covered by tests
}
Comment on lines +107 to +120
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validate configuration before returning in ParseConfig.

The method ParseConfig constructs a configuration but does not perform any validation on the resulting configuration. It's a good practice to validate the configuration to catch any potential issues before the configuration is used elsewhere.

cfg := &config.Config{}
...
return cfg, nil
+	if err := cfg.Validate(); err != nil {
+		return nil, err
+	}
+	return cfg, nil

Additionally, add unit tests for ParseConfig to ensure its reliability and correctness.

func TestParseConfig(t *testing.T) {
	cron := NewCronString("0 0 * * * /command")
	cfg, err := cron.ParseConfig("* * * * *", false)
	if err != nil {
		t.Errorf("Unexpected error: %v", err)
	}
	if len(cfg.Jobs) != 1 {
		t.Errorf("Expected 1 job, got %d", len(cfg.Jobs))
	}
}
Tools
GitHub Check: codecov/patch

[warning] 110-113: cmd/parser/cron_string.go#L110-L113
Added lines #L110 - L113 were not covered by tests


[warning] 115-117: cmd/parser/cron_string.go#L115-L117
Added lines #L115 - L117 were not covered by tests


[warning] 119-119: cmd/parser/cron_string.go#L119
Added line #L119 was not covered by tests


func buildMapper(hasUser bool, pattern string) (*regexp.Regexp, cronSpecParser, error) {
lineParser := "(?<cmd>.*)"
if hasUser {
lineParser = fmt.Sprintf(`(?<user>\w[\w\d]*)\s+%s`, lineParser)
}

Check warning on line 126 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L122-L126

Added lines #L122 - L126 were not covered by tests

cronLineMatcher := fmt.Sprintf(`^(?<cron>%s)\s+%s$`, pattern, lineParser)

matcher, err := regexp.Compile(cronLineMatcher)
if err != nil {
return nil, nil, fmt.Errorf("failed to compile cron line parser regexp: `%s`", matcher)
}
parser, err := getLineParser(hasUser, matcher)
if err != nil {
return nil, nil, err
}
return matcher, parser, nil

Check warning on line 138 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L128-L138

Added lines #L128 - L138 were not covered by tests
Comment on lines +122 to +138
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add unit tests for buildMapper.

The function buildMapper is not covered by unit tests. It's crucial to add comprehensive unit tests for this function to ensure its reliability and correctness.

func TestBuildMapper(t *testing.T) {
	matcher, parser, err := buildMapper(false, "* * * * *")
	if err != nil {
		t.Errorf("Unexpected error: %v", err)
	}
	if matcher == nil || parser == nil {
		t.Error("Expected matcher and parser to be non-nil")
	}
}
Tools
GitHub Check: codecov/patch

[warning] 122-125: cmd/parser/cron_string.go#L122-L125
Added lines #L122 - L125 were not covered by tests


[warning] 128-128: cmd/parser/cron_string.go#L128
Added line #L128 was not covered by tests

}

func getLineParser(hasUser bool, matcher *regexp.Regexp) (cronSpecParser, error) {
if hasUser {
return withUserParser(matcher)
} else {
return normalParser(matcher)
}

Check warning on line 146 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L141-L146

Added lines #L141 - L146 were not covered by tests
}

func addSpec(cfg *config.Config, spec cronSpec) {
jobName := fmt.Sprintf("FromCron: %s", spec.timing)
for _, job := range cfg.Jobs {
if job.Name == jobName {
task := config.Task{
Command: spec.command,
UserName: spec.user,
Env: spec.environ,
}
job.Tasks = append(
job.Tasks,
task,
)
return
}

Check warning on line 163 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L149-L163

Added lines #L149 - L163 were not covered by tests
}
initJob(jobName, spec.timing, cfg)
addSpec(cfg, spec)

Check warning on line 166 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L165-L166

Added lines #L165 - L166 were not covered by tests
}

func initJob(jobName string, timing string, cfg *config.Config) {
job := &config.JobConfig{}
job.Name = jobName
job.Description = "Imported from cron file"
job.Disabled = false
if strings.Contains(timing, "@reboot") {
job.Events = []config.JobEvent{
{
OnInit: true,
},
}
} else {
job.Events = []config.JobEvent{
{
Cron: timing,
},
}
}
cfg.Jobs = append(cfg.Jobs, job)

Check warning on line 187 in cmd/parser/cron_string.go

View check run for this annotation

Codecov / codecov/patch

cmd/parser/cron_string.go#L169-L187

Added lines #L169 - L187 were not covered by tests
}
Loading
Loading