From f3765303a072de95dcd969d6d6fa2cc2d80ba863 Mon Sep 17 00:00:00 2001 From: Jan Mewes Date: Sat, 3 Jan 2026 10:10:35 +0100 Subject: [PATCH 1/2] feat: add remove command --- .gremlins.yaml | 3 + README.md | 9 ++ cmd/add.go | 5 +- cmd/remove.go | 28 ++++++ cmd/root.go | 7 +- cmd/search.go | 5 +- config/config.go | 16 ++-- config/config_test.go | 23 ++++- config/t/invalid_syntax_config.yaml | 2 + core/adding.go | 8 +- core/archiving.go | 37 -------- core/archiving_test.go | 61 ------------- core/domain.go | 2 +- core/management.go | 54 +++++++++++ core/management_test.go | 137 ++++++++++++++++++++++++++++ core/searching.go | 15 ++- 16 files changed, 283 insertions(+), 129 deletions(-) create mode 100644 .gremlins.yaml create mode 100644 cmd/remove.go create mode 100644 config/t/invalid_syntax_config.yaml delete mode 100644 core/archiving.go delete mode 100644 core/archiving_test.go create mode 100644 core/management.go create mode 100644 core/management_test.go diff --git a/.gremlins.yaml b/.gremlins.yaml new file mode 100644 index 0000000..e1e5491 --- /dev/null +++ b/.gremlins.yaml @@ -0,0 +1,3 @@ +unleash: + output-statuses: "lc" + exclude-files: ["cmd/*"] diff --git a/README.md b/README.md index 1d02e11..279b8b8 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ The following snippet shows the configuration options with their default values: ```yaml # The directory where new log entries are added. logDirectory: ~/Logs + +# The directory where log entries are moved when they are archived. +archiveDirectory: ~/Archive ``` ## Usage @@ -43,6 +46,12 @@ logbook2 search $SEARCH_TERM go test ./... -coverprofile=./cov.out ``` +With the help of the [gremlins](https://gremlins.dev/) program, the tests can be executed with mutations: + +```sh +gremlins unleash +``` + ### Component integration test ```sh diff --git a/cmd/add.go b/cmd/add.go index 0908718..f8da624 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -10,8 +10,9 @@ import ( ) var addCmd = &cobra.Command{ - Use: "add [flags] title", - Args: cobra.ExactArgs(1), + Use: "add [flags] title", + Args: cobra.ExactArgs(1), + Short: "Add new logbook entries", Run: func(cmd *cobra.Command, args []string) { title := args[0] diff --git a/cmd/remove.go b/cmd/remove.go new file mode 100644 index 0000000..8487130 --- /dev/null +++ b/cmd/remove.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/experimental-software/logbook2/core" + "github.com/experimental-software/logbook2/logging" + "github.com/spf13/cobra" +) + +var removeCmd = &cobra.Command{ + Use: "remove [flags] path [path...]", + Short: "Deletes log entries", + Args: cobra.MinimumNArgs(1), + Aliases: []string{"rm"}, + + Run: func(cmd *cobra.Command, args []string) { + for _, path := range args { + err := core.Remove(path) + if err != nil { + logging.Error("Removing logbook entry filed failed for path: "+path, err) + os.Exit(1) + } + fmt.Println(path) + } + }, +} diff --git a/cmd/root.go b/cmd/root.go index d9ce62e..36a25b5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,7 +1,6 @@ package cmd import ( - "fmt" "os" "github.com/experimental-software/logbook2/config" @@ -9,10 +8,11 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "logbook2", + Use: "logbook2", + Short: "A markdown-based engineering logbook", Run: func(cmd *cobra.Command, args []string) { - fmt.Println("A markdown-based engineering logbook") + // noop }, } @@ -20,6 +20,7 @@ func Execute() { rootCmd.AddCommand(searchCmd) rootCmd.AddCommand(addCmd) rootCmd.AddCommand(archiveCmd) + rootCmd.AddCommand(removeCmd) rootCmd.CompletionOptions.DisableDefaultCmd = true diff --git a/cmd/search.go b/cmd/search.go index 7873e69..a047a39 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -12,6 +12,7 @@ import ( var searchCmd = &cobra.Command{ Use: "search [flags] search_term", Aliases: []string{"s"}, + Short: "Search for logbook entries", Run: func(cmd *cobra.Command, args []string) { searchTerm := "" @@ -24,8 +25,8 @@ var searchCmd = &cobra.Command{ t.SetHeaders("Date / Time", "Title", "Path") for _, entry := range logEntries { title := entry.Title - if len(title) > 45 { - title = title[:45] + if len(title) > 50 { + title = title[:50] title += " (...)" } t.AddRow(strings.Replace(entry.DateTime, "T", " ", 1), title, entry.Directory) diff --git a/config/config.go b/config/config.go index 75830c9..8239f78 100644 --- a/config/config.go +++ b/config/config.go @@ -17,29 +17,27 @@ type Configuration struct { } var defaultConfiguration = Configuration{ - LogDirectory: filepath.Join(userHomeDir(), "Logs"), + LogDirectory: filepath.Join(userHomeDir(), "Logs"), + ArchiveDirectory: filepath.Join(userHomeDir(), "Archive"), } func LoadConfiguration(configurationFilePath string) Configuration { - var result Configuration + result := defaultConfiguration configurationBytes, err := os.ReadFile(configurationFilePath) if err != nil { logging.Warn("Failed to read Configuration file: " + configurationFilePath) - result = defaultConfiguration + return result } err = yaml.Unmarshal(configurationBytes, &result) if err != nil { logging.Warn("Failed to unmarshal Configuration file: " + configurationFilePath) - result = defaultConfiguration + return result } - result.LogDirectory = strings.Replace(result.LogDirectory, "~", userHomeDir(), -1) - result.LogDirectory = strings.TrimSpace(result.LogDirectory) - - result.ArchiveDirectory = strings.Replace(result.ArchiveDirectory, "~", userHomeDir(), -1) - result.ArchiveDirectory = strings.TrimSpace(result.ArchiveDirectory) + result.LogDirectory = strings.ReplaceAll(result.LogDirectory, "~", userHomeDir()) + result.ArchiveDirectory = strings.ReplaceAll(result.ArchiveDirectory, "~", userHomeDir()) return result } diff --git a/config/config_test.go b/config/config_test.go index 5db0889..38c2470 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,8 +1,11 @@ package config -import "testing" +import ( + "strings" + "testing" +) -func TestLoadConfiguration(t *testing.T) { +func Test_LoadConfiguration_happy_path(t *testing.T) { result := LoadConfiguration("./t/config.yaml") if result.LogDirectory != "/path/for/tests" { @@ -12,3 +15,19 @@ func TestLoadConfiguration(t *testing.T) { t.Errorf("Unexpected archive directory: got %s, expected /archive/path/for/tests", result.ArchiveDirectory) } } + +func Test_LoadConfiguration_config_file_not_existent(t *testing.T) { + result := LoadConfiguration("./t/67b2070e-8b21-44d1-8a02-21909d8037f9.yaml") + + if !strings.HasSuffix(result.LogDirectory, "Logs") { + t.Errorf("Unexpected log directory: got '%s', expected default value", result.LogDirectory) + } +} + +func Test_LoadConfiguration_config_file_invalid(t *testing.T) { + result := LoadConfiguration("./t/invalid_syntax_config.yaml") + + if !strings.HasSuffix(result.LogDirectory, "Logs") { + t.Errorf("Unexpected log directory: got '%s', expected default value", result.LogDirectory) + } +} diff --git a/config/t/invalid_syntax_config.yaml b/config/t/invalid_syntax_config.yaml new file mode 100644 index 0000000..f104062 --- /dev/null +++ b/config/t/invalid_syntax_config.yaml @@ -0,0 +1,2 @@ +logDirectory:::::::: - ~, 2938873294 /path/for/tests + diff --git a/core/adding.go b/core/adding.go index dc8ba42..b5e544f 100644 --- a/core/adding.go +++ b/core/adding.go @@ -9,7 +9,7 @@ import ( "time" ) -func AddLogEntry(baseDirectory, title string) (LogEntry, error) { +func AddLogEntry(baseDirectory, title string) (LogbookEntry, error) { currentTime := time.Now() slug := slugify(title) dateTime := fmt.Sprintf("%d-0%d-0%dT0%d:0%d", @@ -28,16 +28,16 @@ func AddLogEntry(baseDirectory, title string) (LogEntry, error) { ) err := os.MkdirAll(logDirectoryPath, 0777) if err != nil { - return LogEntry{}, err + return LogbookEntry{}, err } logFilePath := filepath.Join(logDirectoryPath, slug+".md") err = os.WriteFile(logFilePath, []byte(fmt.Sprintf("# %s\n\n", title)), 0777) if err != nil { - return LogEntry{}, err + return LogbookEntry{}, err } - return LogEntry{DateTime: dateTime, Title: title, Directory: logDirectoryPath}, nil + return LogbookEntry{DateTime: dateTime, Title: title, Directory: logDirectoryPath}, nil } func slugify(s string) string { diff --git a/core/archiving.go b/core/archiving.go deleted file mode 100644 index 60ce854..0000000 --- a/core/archiving.go +++ /dev/null @@ -1,37 +0,0 @@ -package core - -import ( - "errors" - "os" - "regexp" - "strings" - - "github.com/experimental-software/logbook2/config" - "github.com/plus3it/gorecurcopy" -) - -var archivePathPattern = regexp.MustCompile(`(.*[/\\]\d{4}[/\\]\d{2}[/\\]\d{2}[/\\]\d{2}\.\d{2}_.*?[/\\]).*`) - -func Archive(configuration config.Configuration, sourcePath string) error { - if !strings.HasSuffix(sourcePath, "/") { - sourcePath += "/" - } - m := archivePathPattern.FindStringSubmatch(sourcePath) - if len(m) != 2 { - return errors.New("invalid source path: " + sourcePath) - } - sourceDirectoryPath := strings.TrimSpace(m[1]) - targetDirectoryPath := strings.Replace(sourceDirectoryPath, configuration.LogDirectory, configuration.ArchiveDirectory, 1) - - err := os.MkdirAll(targetDirectoryPath, 0777) - if err != nil { - return err - } - - err = gorecurcopy.CopyDirectory(sourceDirectoryPath, targetDirectoryPath) - if err != nil { - return err - } - err = os.RemoveAll(sourceDirectoryPath) - return err -} diff --git a/core/archiving_test.go b/core/archiving_test.go deleted file mode 100644 index e8173ae..0000000 --- a/core/archiving_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package core - -import ( - "os" - "path/filepath" - "testing" - - "github.com/experimental-software/logbook2/config" -) - -func TestArchiveLogEntry(t *testing.T) { - // Arrange - logBaseDir := createTempDir() - archiveBaseDir := createTempDir() - defer func(path string) { - _ = os.RemoveAll(logBaseDir) - _ = os.RemoveAll(archiveBaseDir) - }(logBaseDir) - - logEntry, err := AddLogEntry(logBaseDir, "Log entry for archive test") - if err != nil { - t.Fatal(err) - } - fileInLog := createFileInSubdirectory(logEntry) - - c := config.Configuration{ - LogDirectory: logBaseDir, - ArchiveDirectory: archiveBaseDir, - } - - // Act - err = Archive(c, fileInLog) - if err != nil { - t.Fatal(err) - } - - // Assert - searchResultForLogBaseDir := Search(logBaseDir, "") - if len(searchResultForLogBaseDir) != 0 { - t.Fatal("Expected empty search result") - } - searchResultForArchiveBaseDir := Search(archiveBaseDir, "") - if len(searchResultForArchiveBaseDir) != 1 { - t.Fatal("Expected 1 search result") - } -} - -func createFileInSubdirectory(entry LogEntry) string { - subdirectory := filepath.Join(entry.Directory, "foo") - err := os.MkdirAll(subdirectory, 0755) - if err != nil { - panic(err) - } - filePath := filepath.Join(subdirectory, "bar.txt") - data := []byte("Hello, World!") - err = os.WriteFile(filePath, data, 0644) - if err != nil { - panic(err) - } - return filePath -} diff --git a/core/domain.go b/core/domain.go index 8fbc57c..47b868a 100644 --- a/core/domain.go +++ b/core/domain.go @@ -1,6 +1,6 @@ package core -type LogEntry struct { +type LogbookEntry struct { DateTime string Title string Directory string diff --git a/core/management.go b/core/management.go new file mode 100644 index 0000000..a96be1a --- /dev/null +++ b/core/management.go @@ -0,0 +1,54 @@ +package core + +import ( + "errors" + "os" + "regexp" + "strings" + + "github.com/experimental-software/logbook2/config" + "github.com/plus3it/gorecurcopy" +) + +func Archive(configuration config.Configuration, sourcePath string) error { + sourceDirectoryPath, err := logbookEntryRootPath(sourcePath) + if err != nil { + return err + } + targetDirectoryPath := strings.Replace( + sourceDirectoryPath, configuration.LogDirectory, configuration.ArchiveDirectory, 1, + ) + + err = os.MkdirAll(targetDirectoryPath, 0777) + if err != nil { + return err + } + + err = gorecurcopy.CopyDirectory(sourceDirectoryPath, targetDirectoryPath) + if err != nil { + return err + } + err = os.RemoveAll(sourceDirectoryPath) + return err +} + +func Remove(sourcePath string) error { + sourceDirectoryPath, err := logbookEntryRootPath(sourcePath) + if err != nil { + return err + } + err = os.RemoveAll(sourceDirectoryPath) + return err +} + +func logbookEntryRootPath(path string) (string, error) { + if !strings.HasSuffix(path, "/") { + path += "/" + } + re := regexp.MustCompile(`(.*[/\\]\d{4}[/\\]\d{2}[/\\]\d{2}[/\\]\d{2}\.\d{2}_.*?[/\\]).*`) + m := re.FindStringSubmatch(path) + if len(m) != 2 { + return "", errors.New("invalid logbook entry path: " + path) + } + return m[1], nil +} diff --git a/core/management_test.go b/core/management_test.go new file mode 100644 index 0000000..37e8686 --- /dev/null +++ b/core/management_test.go @@ -0,0 +1,137 @@ +package core + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/experimental-software/logbook2/config" +) + +func Test_Delete_happy_path(t *testing.T) { + // Arrange + logBaseDir := createTempDir() + archiveBaseDir := createTempDir() + defer func(path string) { + _ = os.RemoveAll(logBaseDir) + _ = os.RemoveAll(archiveBaseDir) + }(logBaseDir) + + logEntry, err := AddLogEntry(logBaseDir, "Log entry for archive test") + if err != nil { + t.Fatal(err) + } + searchResultForArchiveBaseDir := Search(logBaseDir, "") + if len(searchResultForArchiveBaseDir) != 1 { + t.Fatal("Expected 1 search result") + } + + // Act + err = Remove(logEntry.Directory) + if err != nil { + t.Fatal(err) + } + + // Assert + searchResultForLogBaseDir := Search(logBaseDir, "") + if len(searchResultForLogBaseDir) != 0 { + t.Fatal("Expected empty search result") + } +} + +func Test_Archive_happy_path(t *testing.T) { + // Arrange + logBaseDir := createTempDir() + archiveBaseDir := createTempDir() + defer func(path string) { + _ = os.RemoveAll(logBaseDir) + _ = os.RemoveAll(archiveBaseDir) + }(logBaseDir) + + logEntry, err := AddLogEntry(logBaseDir, "Log entry for archive test") + if err != nil { + t.Fatal(err) + } + + c := config.Configuration{ + LogDirectory: logBaseDir, + ArchiveDirectory: archiveBaseDir, + } + + // Act + err = Archive(c, logEntry.Directory) + if err != nil { + t.Fatal(err) + } + + // Assert + searchResultForLogBaseDir := Search(logBaseDir, "") + if len(searchResultForLogBaseDir) != 0 { + t.Fatal("Expected empty search result") + } + searchResultForArchiveBaseDir := Search(archiveBaseDir, "") + if len(searchResultForArchiveBaseDir) != 1 { + t.Fatal("Expected 1 search result") + } +} + +func Test_Archive_path_in_subdirectory(t *testing.T) { + // Arrange + logBaseDir := createTempDir() + archiveBaseDir := createTempDir() + defer func(path string) { + _ = os.RemoveAll(logBaseDir) + _ = os.RemoveAll(archiveBaseDir) + }(logBaseDir) + + logEntry, err := AddLogEntry(logBaseDir, "Log entry for archive test") + if err != nil { + t.Fatal(err) + } + fileInLog := createFileInSubdirectory(logEntry) + + c := config.Configuration{ + LogDirectory: logBaseDir, + ArchiveDirectory: archiveBaseDir, + } + + // Act + err = Archive(c, fileInLog) + if err != nil { + t.Fatal(err) + } + + // Assert + searchResultForLogBaseDir := Search(logBaseDir, "") + if len(searchResultForLogBaseDir) != 0 { + t.Fatal("Expected empty search result") + } + searchResultForArchiveBaseDir := Search(archiveBaseDir, "") + if len(searchResultForArchiveBaseDir) != 1 { + t.Fatal("Expected 1 search result") + } +} + +func Test_logbookEntryRootPath_invalid_path(t *testing.T) { + _, err := logbookEntryRootPath("/Users/jdoe/Notes/2025/12/21/just-a-test") + if err == nil { + t.Fatal("Expected error") + } + fmt.Println(err.Error()) +} + +func createFileInSubdirectory(entry LogbookEntry) string { + subdirectory := filepath.Join(entry.Directory, "foo") + err := os.MkdirAll(subdirectory, 0755) + if err != nil { + panic(err) + } + filePath := filepath.Join(subdirectory, "bar.txt") + data := []byte("Hello, World!") + err = os.WriteFile(filePath, data, 0644) + if err != nil { + panic(err) + } + return filePath +} diff --git a/core/searching.go b/core/searching.go index 4608e06..1dd77f8 100644 --- a/core/searching.go +++ b/core/searching.go @@ -10,11 +10,10 @@ import ( "github.com/experimental-software/logbook2/logging" ) -var logFilePathPattern = regexp.MustCompile(`.*[/\\](\d{4})[/\\](\d{2})[/\\](\d{2})[/\\](\d{2})\.(\d{2})_.*`) -var logfileParentDirectoryPattern = regexp.MustCompile(`^\d{2}\.\d{2}_.*`) +var searchPathPattern = regexp.MustCompile(`.*[/\\](\d{4})[/\\](\d{2})[/\\](\d{2})[/\\](\d{2})\.(\d{2})_.*`) -func Search(baseDirectory, searchTerm string) []LogEntry { - var result = make([]LogEntry, 0) +func Search(baseDirectory, searchTerm string) []LogbookEntry { + var result = make([]LogbookEntry, 0) err := filepath.Walk(baseDirectory, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -23,7 +22,7 @@ func Search(baseDirectory, searchTerm string) []LogEntry { if !isLogEntryFile(path) { return nil } - pathDatetimeMatch := logFilePathPattern.FindStringSubmatch(path) + pathDatetimeMatch := searchPathPattern.FindStringSubmatch(path) logDatetime := fmt.Sprintf("%s-%s-%sT%s:%s", pathDatetimeMatch[1], pathDatetimeMatch[2], @@ -43,7 +42,7 @@ func Search(baseDirectory, searchTerm string) []LogEntry { } logDirectory, _ := filepath.Abs(filepath.Dir(path)) - result = append(result, LogEntry{ + result = append(result, LogbookEntry{ DateTime: logDatetime, Directory: logDirectory, Title: title, @@ -64,7 +63,7 @@ func isLogEntryFile(path string) bool { return false } parentDirectory := pathParts[len(pathParts)-2] - if !(logfileParentDirectoryPattern.MatchString(parentDirectory)) { + if !(regexp.MustCompile(`^\d{2}\.\d{2}_.*`).MatchString(parentDirectory)) { return false } parentDirectorySlug := parentDirectory[6:] @@ -72,7 +71,7 @@ func isLogEntryFile(path string) bool { if parentDirectorySlug != fileSlug { return false } - pathDatetimeMatch := logFilePathPattern.FindStringSubmatch(path) + pathDatetimeMatch := searchPathPattern.FindStringSubmatch(path) if len(pathDatetimeMatch) != 6 { return false } From f98f09bc6aa0a3bead11e1f369e948e86a20b617 Mon Sep 17 00:00:00 2001 From: Jan Mewes Date: Sat, 3 Jan 2026 10:12:59 +0100 Subject: [PATCH 2/2] Fix typo [skip ci] --- cmd/remove.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/remove.go b/cmd/remove.go index 8487130..ca2c75a 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -19,7 +19,7 @@ var removeCmd = &cobra.Command{ for _, path := range args { err := core.Remove(path) if err != nil { - logging.Error("Removing logbook entry filed failed for path: "+path, err) + logging.Error("Removing logbook entry failed for path: "+path, err) os.Exit(1) } fmt.Println(path)