package main import ( "os" "path/filepath" "strconv" "strings" "testing" "time" ) func TestSanitizeComment(t *testing.T) { cases := []struct{ in, want string }{ {"", ""}, {"simple comment", "simple_comment"}, {"it's a test", "it_s_a_test"}, {"a/b:c;d", "a_b_c_d"}, {"added -v flag", "added_v_flag"}, {" padded spaces ", "padded_spaces"}, {"weird<>|?chars", "weird_chars"}, } for _, c := range cases { if got := sanitizeComment(c.in); got != c.want { t.Errorf("sanitizeComment(%q) = %q, want %q", c.in, got, c.want) } } } func TestArchiveStampFormat(t *testing.T) { s := archiveStamp() // expect DDMMYY.HHMM => 6 digits, dot, 4 digits if len(s) != 11 || s[6] != '.' { t.Fatalf("archiveStamp() = %q, want DDMMYY.HHMM shape", s) } for i, r := range s { if i == 6 { continue } if r < '0' || r > '9' { t.Fatalf("archiveStamp() = %q has non-digit at %d", s, i) } } } func TestFormatLogNoColor(t *testing.T) { useColor = false // fixed reference time: 2020-06-15 12:00:00 local now := time.Date(2020, 6, 15, 12, 0, 0, 0, time.Local) old := time.Date(2019, 10, 17, 16, 9, 0, 0, time.Local).Unix() line := "f9e76ff " + strconv.FormatInt(old, 10) + " initial commit" out := formatLog([]string{line}, now) if !strings.Contains(out, "f9e76ff") || !strings.Contains(out, "initial commit") { t.Fatalf("formatLog missing hash/subject: %q", out) } // older than a week -> full date form contains year and weekday if !strings.Contains(out, "2019") || !strings.Contains(out, "Thu") { t.Fatalf("formatLog full date form expected, got %q", out) } } func TestFormatLogRecentCompact(t *testing.T) { useColor = false now := time.Date(2020, 6, 15, 12, 0, 0, 0, time.Local) recent := now.Add(-2 * time.Hour).Unix() out := formatLog([]string{"abc123 " + strconv.FormatInt(recent, 10) + " recent work"}, now) // within a day -> tiny form: only HH:MM, no year, no weekday if strings.Contains(out, "2020") { t.Fatalf("recent entry should use compact time form, got %q", out) } if !strings.Contains(out, "recent work") { t.Fatalf("missing subject, got %q", out) } } func TestColorRepoLine(t *testing.T) { useColor = false in := "Sep 28 2016 Betaflight3.0.0" if got := colorRepoLine(in); got != in { t.Errorf("colorRepoLine with color off changed input: %q", got) } useColor = true got := colorRepoLine(in) if !strings.Contains(got, "Betaflight3.0.0") || !strings.Contains(got, cGreen) || !strings.Contains(got, cYellow) { t.Errorf("colorRepoLine did not color parts: %q", got) } useColor = false } func TestParseConfig(t *testing.T) { rc := ` # comment line githost = 10.0.0.1 GitPort: 22 gituser = "deploy" editor = 'code' ignored line without separator base=/tmp/src ` m := parseConfig(rc) checks := map[string]string{ "githost": "10.0.0.1", "gitport": "22", "gituser": "deploy", "editor": "code", "base": "/tmp/src", } for k, want := range checks { if m[k] != want { t.Errorf("parseConfig[%q] = %q, want %q", k, m[k], want) } } if _, ok := m["ignored line without separator"]; ok { t.Errorf("line without separator should be ignored") } } func TestApplyConfig(t *testing.T) { c := Config{GitName: "Original Name"} applyConfig(&c, map[string]string{ "githost": "example.com", "gitport": "2200", "gitname": "", // empty must not override an existing value }) if c.GitHost != "example.com" { t.Errorf("GitHost = %q, want example.com", c.GitHost) } if c.GitPort != "2200" { t.Errorf("GitPort = %q, want 2200", c.GitPort) } if c.GitName != "Original Name" { t.Errorf("empty value must not override GitName, got %q", c.GitName) } } func TestMissingRequired(t *testing.T) { full := Config{Base: "/b", GitHost: "h", GitPort: "22", GitUser: "u", GitPath: "/p"} if m := full.missingRequired(); len(m) != 0 { t.Errorf("complete config reported missing: %v", m) } partial := Config{Base: "/b", GitPort: "22"} got := strings.Join(partial.missingRequired(), ",") if got != "githost,gituser,gitpath" { t.Errorf("missingRequired = %q, want githost,gituser,gitpath", got) } } func TestSplitLines(t *testing.T) { if got := splitLines(""); got != nil { t.Errorf("splitLines(\"\") = %v, want nil", got) } got := splitLines("a\nb\nc\n") if len(got) != 3 || got[0] != "a" || got[2] != "c" { t.Errorf("splitLines = %v", got) } } func TestWord(t *testing.T) { ws := []string{"a", "b"} if word(ws, 0) != "a" || word(ws, 1) != "b" || word(ws, 2) != "" { t.Errorf("word indexing wrong") } } func TestUnquote(t *testing.T) { cases := []struct{ in, want string }{ {"'echo $1'", "echo $1"}, {`"echo $1"`, "echo $1"}, {"echo $1", "echo $1"}, {"'unbalanced", "'unbalanced"}, {"''", ""}, {"'", "'"}, } for _, c := range cases { if got := unquote(c.in); got != c.want { t.Errorf("unquote(%q) = %q, want %q", c.in, got, c.want) } } } func TestConfigTemplateGenerated(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) for _, e := range []string{"MGSH_BASE", "MGSH_GITHOST", "MGSH_GITPORT", "MGSH_GITUSER", "MGSH_GITPATH"} { t.Setenv(e, "") // ensure env can't satisfy the requirements } rc := filepath.Join(home, ".mgshrc") // no ~/.mgshrc yet -> loadConfig writes a blank template (no real values) c := loadConfig() if !fileExists(rc) { t.Fatalf("loadConfig did not generate %s", rc) } if m := c.missingRequired(); len(m) != len(requiredKeys) { t.Fatalf("blank template should leave all required unset, missing=%v", m) } if s := readFile(t, rc); !strings.Contains(s, "base") || !strings.Contains(s, "githost") { t.Fatalf("template missing key hints:\n%s", s) } } func TestConfigLoadAndAliasRoundTrip(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) rc := filepath.Join(home, ".mgshrc") os.WriteFile(rc, []byte( "base = "+home+"\ngithost = h.example\ngitport = 22\ngituser = git\ngitpath = /home/git\n"), 0644) c := loadConfig() if m := c.missingRequired(); len(m) != 0 { t.Fatalf("configured file still reports missing: %v", m) } if c.GitHost != "h.example" { t.Fatalf("GitHost = %q, want h.example", c.GitHost) } // defining an alias persists it into ~/.mgshrc without losing config lines aliases = map[string]string{"co": "checkout $1"} saveAliases() s := readFile(t, rc) if !strings.Contains(s, "alias co 'checkout $1'") { t.Fatalf("alias not written to rc:\n%s", s) } if !strings.Contains(s, "githost = h.example") { t.Fatalf("saveAliases clobbered config lines:\n%s", s) } // and it reloads from the same file aliases = map[string]string{} loadAliases() if aliases["co"] != "checkout $1" { t.Fatalf("alias did not round-trip, got %q", aliases["co"]) } } func TestLegacyAliasMigration(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) legacy := filepath.Join(home, ".mgsh_aliases") if err := os.WriteFile(legacy, []byte("# header\nls\t!lsd -la\n"), 0644); err != nil { t.Fatal(err) } loadConfig() // generates ~/.mgshrc and migrates the legacy alias rc := filepath.Join(home, ".mgshrc") if s := readFile(t, rc); !strings.Contains(s, "alias ls '!lsd -la'") { t.Fatalf("legacy alias not migrated into rc:\n%s", s) } if fileExists(legacy) { t.Fatalf("legacy alias file should be removed after migration") } aliases = map[string]string{} loadAliases() if aliases["ls"] != "!lsd -la" { t.Fatalf("migrated alias not loaded, got %q", aliases["ls"]) } } func readFile(t *testing.T, p string) string { t.Helper() data, err := os.ReadFile(p) if err != nil { t.Fatal(err) } return string(data) } func TestTruthy(t *testing.T) { on := []string{"1", "true", "TRUE", "yes", "On", " true "} off := []string{"", "0", "false", "no", "off", "nope"} for _, s := range on { if !truthy(s) { t.Errorf("truthy(%q) = false, want true", s) } } for _, s := range off { if truthy(s) { t.Errorf("truthy(%q) = true, want false", s) } } } func TestFormatProjStatus(t *testing.T) { useColor = false defer func() { useColor = false }() cases := []struct { s projStatus contains []string absent []string }{ {projStatus{name: "a", branch: "master", dirty: true, hasUpstream: true, ahead: 2}, []string{"a", "*", "↑2"}, []string{"↓", "(master)"}}, {projStatus{name: "b", branch: "main", hasUpstream: true, behind: 3}, []string{"b", "↓3"}, []string{"*", "✓", "(main)"}}, {projStatus{name: "c", branch: "master", hasUpstream: true}, []string{"c", "✓"}, []string{"*", "↑", "↓"}}, {projStatus{name: "d", branch: "feature", dirty: true}, []string{"d", "*", "(feature)"}, nil}, {projStatus{name: "e", branch: "master"}, // clean, no upstream []string{"e", "no upstream"}, []string{"*"}}, } for _, c := range cases { got := formatProjStatus(c.s, 8) for _, sub := range c.contains { if !strings.Contains(got, sub) { t.Errorf("formatProjStatus(%+v) = %q, missing %q", c.s, got, sub) } } for _, sub := range c.absent { if strings.Contains(got, sub) { t.Errorf("formatProjStatus(%+v) = %q, should not contain %q", c.s, got, sub) } } } } func TestDetectRemoteKind(t *testing.T) { cases := []struct { url, override string want remoteKind }{ {"https://git.fhi.mpg.de", "", kindGitea}, {"https://github.com", "", kindGitHub}, {"https://api.github.com", "", kindGitHub}, {"https://gitlab.com", "", kindGitLab}, {"https://gitlab.example.org", "", kindGitLab}, {"https://git.fhi.mpg.de", "github", kindGitHub}, // override wins {"https://github.com", "gitlab", kindGitLab}, // override wins {"https://anything", "", kindGitea}, // default } for _, c := range cases { if got := detectRemoteKind(c.url, c.override); got != c.want { t.Errorf("detectRemoteKind(%q,%q) = %d, want %d", c.url, c.override, got, c.want) } } } func TestRemoteAPIEndpoints(t *testing.T) { cases := []struct { url, typ string root, web, hdrKey string }{ {"https://git.fhi.mpg.de/", "", "https://git.fhi.mpg.de/api/v1", "https://git.fhi.mpg.de/mike/mgsh.git", "Authorization"}, {"https://github.com", "", "https://api.github.com", "https://github.com/mike/mgsh.git", "Authorization"}, {"https://gitlab.com", "", "https://gitlab.com/api/v4", "https://gitlab.com/mike/mgsh.git", "PRIVATE-TOKEN"}, } for _, c := range cases { api := newRemoteAPI(c.url, "tok", c.typ) if got := api.apiRoot(); got != c.root { t.Errorf("apiRoot(%q) = %q, want %q", c.url, got, c.root) } if got := api.repoWebURL("mike", "mgsh"); got != c.web { t.Errorf("repoWebURL(%q) = %q, want %q", c.url, got, c.web) } if k, _ := api.authHeader(); k != c.hdrKey { t.Errorf("authHeader(%q) key = %q, want %q", c.url, k, c.hdrKey) } } } func TestFirstLine(t *testing.T) { if got := firstLine([]byte(" hello\nworld ")); got != "hello" { t.Errorf("firstLine multiline = %q, want hello", got) } long := strings.Repeat("x", 300) if got := firstLine([]byte(long)); len(got) != 200 { t.Errorf("firstLine did not truncate, len=%d", len(got)) } } func TestCountSourceLines(t *testing.T) { dir := t.TempDir() write := func(rel string, data []byte) { p := filepath.Join(dir, rel) if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, data, 0644); err != nil { t.Fatal(err) } } // counted: any text file, regardless of extension, in any visible subdir write("main.go", []byte("a\nb\nc\n")) // 3 write("Makefile", []byte("all:\n\techo hi\n")) // 2 (no extension) write("sub/util.py", []byte("x\ny\n")) // 2 write("bin/script", []byte("#!/bin/sh\nls\n")) // 2 // skipped: hidden file, file in hidden dir, and a binary file write(".gitignore", []byte("node_modules\n")) write(".git/config", []byte("[core]\n\trepo\n")) write("logo.png", []byte{0x89, 'P', 'N', 'G', 0x00, 0x0a, 0x00}) lines, files := countSourceLines(dir) if files != 4 { t.Errorf("countSourceLines files = %d, want 4", files) } if lines != 9 { t.Errorf("countSourceLines lines = %d, want 9", lines) } } func TestExpandAlias(t *testing.T) { cases := []struct { body string args []string want string }{ {"checkout $1", []string{"main"}, "checkout main"}, {"!echo $1", []string{"hello"}, "!echo hello"}, {"push $*", []string{"fixed", "bug"}, "push fixed bug"}, {"push $@", []string{"a", "b"}, "push a b"}, {"echo $1 $2", []string{"a"}, "echo a"}, // missing $2 -> empty {"status", []string{"x"}, "status x"}, // no placeholder -> append args {"status", nil, "status"}, // no placeholder, no args {"log", []string{}, "log"}, // empty args slice {"diff $1", nil, "diff"}, // placeholder with no arg -> empty } for _, c := range cases { if got := expandAlias(c.body, c.args); got != c.want { t.Errorf("expandAlias(%q, %v) = %q, want %q", c.body, c.args, got, c.want) } } }