diff --git a/README.md b/README.md index 0e754ef..883db8b 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,26 @@ A Markdown previewer that shows markdown files in your browser. ## Description -`mdp` converts a markdown file to HTML and opens it in your browser. It supports GitHub Flavored Markdown and custom themes. +`mdp` converts markdown files to HTML and opens them in your browser. It supports GitHub Flavored Markdown and custom themes. ```console $ mdp README.md Generated: /Users/you/.mdp/README.html ``` +You can pass more than one file. Each file is converted and opened in its own tab, and `--watch` regenerates whichever file changes. + +```console +$ mdp --watch README.md CHANGELOG.md +Generated: /Users/you/.mdp/README.html +Generated: /Users/you/.mdp/CHANGELOG.html +Watching for changes... (Ctrl+C to stop) +``` + ## Synopsis ``` -mdp [options] +mdp [options] ... ``` ## Options diff --git a/e2e/run.sh b/e2e/run.sh index bf1c935..9762733 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -17,6 +17,41 @@ FAILED=0 tmpdir=$(mktemp -d) trap "rm -rf $tmpdir" EXIT +# generated_path prints where mdp writes the HTML for an absolute markdown path +generated_path() { + local output_dir="$1" abs_md_path="$2" + local path_without_ext="${abs_md_path%.md}" + echo "$output_dir/${path_without_ext#/}/index.html" +} + +# check_output compares a generated HTML file with its expected HTML. +# Prints PASS/FAIL with the given label and returns non-zero on failure. +check_output() { + local label="$1" expected_file="$2" generated_file="$3" work_dir="$4" + + if [[ ! -f "$generated_file" ]]; then + echo "FAIL: $label (output not found)" + return 1 + fi + + # Expected html uses placeholders for the directories in file:// image URLs + local resolved_expected_file="$work_dir/expected.html" + sed -e "s|__TESTDATA_DIR__|$TESTDATA_DIR|g" -e "s|__E2E_DIR__|$SCRIPT_DIR|g" "$expected_file" > "$resolved_expected_file" + + if diff -q "$resolved_expected_file" "$generated_file" > /dev/null 2>&1; then + echo "PASS: $label" + return 0 + fi + + echo "FAIL: $label (content mismatch)" + echo "--- Expected ---" + cat "$resolved_expected_file" + echo "--- Actual ---" + cat "$generated_file" + echo "----------------" + return 1 +} + for md_file in "$TESTDATA_DIR"/*.md; do name=$(basename "$md_file" .md) expected_file="$TESTDATA_DIR/$name.html" @@ -65,31 +100,59 @@ EOF continue fi - path_without_ext="${abs_md_path%.md}" - relative_path="${path_without_ext#/}" - generated_file="$output_dir/$relative_path/index.html" - - if [[ ! -f "$generated_file" ]]; then - echo "FAIL: $name (output not found)" + generated_file=$(generated_path "$output_dir" "$abs_md_path") + if ! check_output "$name" "$expected_file" "$generated_file" "$test_dir"; then FAILED=1 - continue fi +done - # Expected html uses placeholders for the directories in file:// image URLs - resolved_expected_file="$test_dir/expected.html" - sed -e "s|__TESTDATA_DIR__|$TESTDATA_DIR|g" -e "s|__E2E_DIR__|$SCRIPT_DIR|g" "$expected_file" > "$resolved_expected_file" +# Multiple files: every file is converted and the browser is opened once per file +multi_name="multiple-files" +multi_dir="$tmpdir/$multi_name" +mkdir -p "$multi_dir" +multi_config="$multi_dir/config.yaml" +multi_output="$multi_dir/output" + +# The browser command records every path it is asked to open +multi_opened_log="$multi_dir/opened.log" +multi_browser="$multi_dir/browser.sh" +cat > "$multi_browser" <> "$multi_opened_log" +EOF +chmod +x "$multi_browser" - if diff -q "$resolved_expected_file" "$generated_file" > /dev/null 2>&1; then - echo "PASS: $name" - else - echo "FAIL: $name (content mismatch)" - echo "--- Expected ---" - cat "$resolved_expected_file" - echo "--- Actual ---" - cat "$generated_file" - echo "----------------" +cat > "$multi_config" < /dev/null 2>&1; then + echo "FAIL: $multi_name (command failed)" + FAILED=1 +else + touch "$multi_opened_log" + opened_total=$(wc -l < "$multi_opened_log" | tr -d ' ') + if [[ "$opened_total" != "${#multi_files[@]}" ]]; then + echo "FAIL: $multi_name (browser opened $opened_total times, want ${#multi_files[@]})" FAILED=1 fi -done + for md_file in "${multi_files[@]}"; do + name=$(basename "$md_file" .md) + generated_file=$(generated_path "$multi_output" "$md_file") + if ! check_output "$multi_name ($name)" "$TESTDATA_DIR/$name.html" "$generated_file" "$multi_dir"; then + FAILED=1 + continue + fi + + opened=$(grep -Fxc "$generated_file" "$multi_opened_log" || true) + if [[ "$opened" != "1" ]]; then + echo "FAIL: $multi_name ($name) (browser opened $opened times, want 1)" + FAILED=1 + fi + done +fi exit $FAILED diff --git a/internal/cli/args.go b/internal/cli/args.go index 15f0d99..239e574 100644 --- a/internal/cli/args.go +++ b/internal/cli/args.go @@ -14,7 +14,7 @@ var errHelp = errors.New("help requested") type parsedArgs struct { configPath string - filePath string + filePaths []string showList bool showVersion bool watchMode bool @@ -60,7 +60,7 @@ func parseArgs(args []string) (*parsedArgs, error) { return &parsedArgs{ configPath: *configPath, - filePath: fs.Arg(0), + filePaths: fs.Args(), watchMode: *watchMode, }, nil } diff --git a/internal/cli/args_test.go b/internal/cli/args_test.go index 2d5b4b6..396574c 100644 --- a/internal/cli/args_test.go +++ b/internal/cli/args_test.go @@ -1,6 +1,7 @@ package cli import ( + "slices" "strings" "testing" ) @@ -17,7 +18,7 @@ func TestParseArgs(t *testing.T) { args: []string{"test.md"}, wantArgs: &parsedArgs{ configPath: "", - filePath: "test.md", + filePaths: []string{"test.md"}, }, }, { @@ -25,7 +26,23 @@ func TestParseArgs(t *testing.T) { args: []string{"--config", "/path/to/config.yaml", "test.md"}, wantArgs: &parsedArgs{ configPath: "/path/to/config.yaml", - filePath: "test.md", + filePaths: []string{"test.md"}, + }, + }, + { + name: "multiple files", + args: []string{"a.md", "b.md", "c.md"}, + wantArgs: &parsedArgs{ + filePaths: []string{"a.md", "b.md", "c.md"}, + }, + }, + { + name: "multiple files with flags", + args: []string{"--watch", "--config", "config.yaml", "a.md", "b.md"}, + wantArgs: &parsedArgs{ + filePaths: []string{"a.md", "b.md"}, + configPath: "config.yaml", + watchMode: true, }, }, { @@ -82,7 +99,7 @@ func TestParseArgs(t *testing.T) { name: "watch flag", args: []string{"--watch", "test.md"}, wantArgs: &parsedArgs{ - filePath: "test.md", + filePaths: []string{"test.md"}, watchMode: true, }, }, @@ -90,7 +107,7 @@ func TestParseArgs(t *testing.T) { name: "watch flag with config", args: []string{"--watch", "--config", "config.yaml", "test.md"}, wantArgs: &parsedArgs{ - filePath: "test.md", + filePaths: []string{"test.md"}, configPath: "config.yaml", watchMode: true, }, @@ -117,8 +134,8 @@ func TestParseArgs(t *testing.T) { if got.configPath != tt.wantArgs.configPath { t.Errorf("parseArgs() configPath = %v, want %v", got.configPath, tt.wantArgs.configPath) } - if got.filePath != tt.wantArgs.filePath { - t.Errorf("parseArgs() filePath = %v, want %v", got.filePath, tt.wantArgs.filePath) + if !slices.Equal(got.filePaths, tt.wantArgs.filePaths) { + t.Errorf("parseArgs() filePaths = %v, want %v", got.filePaths, tt.wantArgs.filePaths) } if got.showList != tt.wantArgs.showList { t.Errorf("parseArgs() showList = %v, want %v", got.showList, tt.wantArgs.showList) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index de411af..2455562 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -23,13 +23,8 @@ func (c *cli) errorf(format string, args ...any) { _, _ = fmt.Fprintf(c.errWriter, format, args...) } -func (c *cli) run(filePath string, watchMode bool, cfg *config.Config) int { - if _, err := os.Stat(filePath); os.IsNotExist(err) { - c.errorf("error: file not found: %s\n", filePath) - return 1 - } - - absPath, err := filepath.Abs(filePath) +func (c *cli) run(filePaths []string, watchMode bool, cfg *config.Config) int { + absPaths, err := resolveFilePaths(filePaths) if err != nil { c.errorf("error: %v\n", err) return 1 @@ -43,34 +38,86 @@ func (c *cli) run(filePath string, watchMode bool, cfg *config.Config) int { } writer := output.NewWriter(cfg.OutputDir) - outputPath, err := c.convert(absPath, r, writer) - if err != nil { + if err := checkOutputPaths(absPaths, writer); err != nil { c.errorf("error: %v\n", err) return 1 } - _, _ = fmt.Fprintf(c.outWriter, "Generated: %s\n", outputPath) + // Convert every file before opening any of them so that a failure + // leaves no half-opened browser tabs + outputPaths := make([]string, 0, len(absPaths)) + for _, absPath := range absPaths { + outputPath, err := c.convert(absPath, r, writer) + if err != nil { + c.errorf("error: %v\n", err) + return 1 + } + _, _ = fmt.Fprintf(c.outWriter, "Generated: %s\n", outputPath) + outputPaths = append(outputPaths, outputPath) + } opener := browser.NewOpener(cfg.BrowserCommand) - if err := opener.Open(outputPath); err != nil { - c.errorf("error: failed to open browser: %v\n", err) - return 1 + for _, outputPath := range outputPaths { + if err := opener.Open(outputPath); err != nil { + c.errorf("error: failed to open browser: %v\n", err) + return 1 + } } // If watch mode is enabled, start the watch loop if watchMode { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - return c.runWatchLoop(absPath, r, writer, sigChan) + return c.runWatchLoop(absPaths, r, writer, sigChan) } return 0 } +// resolveFilePaths checks that every file exists and returns their absolute +// paths in the given order, dropping duplicates. +func resolveFilePaths(filePaths []string) ([]string, error) { + absPaths := make([]string, 0, len(filePaths)) + seen := make(map[string]bool, len(filePaths)) + for _, filePath := range filePaths { + if _, err := os.Stat(filePath); err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("file not found: %s", filePath) + } + return nil, err + } + + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, err + } + if seen[absPath] { + continue + } + seen[absPath] = true + absPaths = append(absPaths, absPath) + } + return absPaths, nil +} + +// checkOutputPaths rejects inputs that would be written to the same output +// path, such as notes.md and notes.markdown. +func checkOutputPaths(absPaths []string, w *output.Writer) error { + sources := make(map[string]string, len(absPaths)) + for _, absPath := range absPaths { + outputPath := w.BuildOutputPath(absPath) + if other, ok := sources[outputPath]; ok { + return fmt.Errorf("%s and %s would be written to the same output path: %s", other, absPath, outputPath) + } + sources[outputPath] = absPath + } + return nil +} + // runWatchLoop watches for file changes and regenerates HTML. -func (c *cli) runWatchLoop(filePath string, r *renderer.Renderer, w *output.Writer, sigChan <-chan os.Signal) int { +func (c *cli) runWatchLoop(filePaths []string, r *renderer.Renderer, w *output.Writer, sigChan <-chan os.Signal) int { // Create watcher - fileWatcher, err := watcher.New(filePath) + fileWatcher, err := watcher.New(filePaths...) if err != nil { c.errorf("error: failed to start watcher: %v\n", err) return 1 @@ -82,7 +129,7 @@ func (c *cli) runWatchLoop(filePath string, r *renderer.Renderer, w *output.Writ for { select { - case <-fileWatcher.Events(): + case filePath := <-fileWatcher.Events(): outputPath, err := c.convert(filePath, r, w) if err != nil { c.errorf("error: %v\n", err) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index ec4f4c2..5adc8a3 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -23,7 +23,7 @@ func TestRun_FileNotFound(t *testing.T) { } cfg := &config.Config{} - exitCode := c.run("/nonexistent/file.md", false, cfg) + exitCode := c.run([]string{"/nonexistent/file.md"}, false, cfg) if exitCode != 1 { t.Errorf("run() exit code = %d, want 1", exitCode) } @@ -58,7 +58,7 @@ func TestRun_Success(t *testing.T) { errWriter: &stderr, } - exitCode := c.run(mdFile, false, cfg) + exitCode := c.run([]string{mdFile}, false, cfg) if exitCode != 0 { t.Errorf("run() exit code = %d, want 0\nstderr: %s", exitCode, stderr.String()) } @@ -72,6 +72,194 @@ func TestRun_Success(t *testing.T) { } } +// expectedOutputPath returns the path where run() writes the HTML for mdFile. +func expectedOutputPath(t *testing.T, outputDir, mdFile string) string { + t.Helper() + absPath, err := filepath.Abs(mdFile) + if err != nil { + t.Fatal(err) + } + pathWithoutExt := strings.TrimSuffix(absPath, ".md") + relativePath := strings.TrimPrefix(pathWithoutExt, "/") + return filepath.Join(outputDir, relativePath, "index.html") +} + +// loadTestConfig writes a config pointing at outputDir with a no-op browser and loads it. +func loadTestConfig(t *testing.T, tmpDir, outputDir string) *config.Config { + t.Helper() + configFile := filepath.Join(tmpDir, "config.yaml") + configContent := fmt.Sprintf("output_dir: %s\nbrowser_command: echo\n", outputDir) + if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { //nolint:gosec // G306: test file + t.Fatal(err) + } + cfg, err := config.Load(configFile) + if err != nil { + t.Fatal(err) + } + return cfg +} + +func TestRun_MultipleFiles(t *testing.T) { + tmpDir := t.TempDir() + mdFiles := []string{filepath.Join(tmpDir, "a.md"), filepath.Join(tmpDir, "b.md")} + for _, f := range mdFiles { + if err := os.WriteFile(f, []byte("# Hello"), 0644); err != nil { //nolint:gosec // G306: test file + t.Fatal(err) + } + } + + outputDir := filepath.Join(tmpDir, "output") + cfg := loadTestConfig(t, tmpDir, outputDir) + + var stdout, stderr bytes.Buffer + c := &cli{ + outWriter: &stdout, + errWriter: &stderr, + } + + exitCode := c.run(mdFiles, false, cfg) + if exitCode != 0 { + t.Errorf("run() exit code = %d, want 0\nstderr: %s", exitCode, stderr.String()) + } + + for _, f := range mdFiles { + expectedHTML := expectedOutputPath(t, outputDir, f) + if _, err := os.Stat(expectedHTML); os.IsNotExist(err) { + t.Errorf("HTML file not created at %s", expectedHTML) + } + if !strings.Contains(stdout.String(), "Generated: "+expectedHTML+"\n") { + t.Errorf("stdout should report %s, got: %s", expectedHTML, stdout.String()) + } + } + if got := strings.Count(stdout.String(), "Generated: "); got != 2 { + t.Errorf("stdout should contain 2 Generated lines, got %d: %s", got, stdout.String()) + } +} + +func TestRun_DuplicateFiles(t *testing.T) { + tmpDir := t.TempDir() + mdFile := filepath.Join(tmpDir, "a.md") + if err := os.WriteFile(mdFile, []byte("# Hello"), 0644); err != nil { //nolint:gosec // G306: test file + t.Fatal(err) + } + + outputDir := filepath.Join(tmpDir, "output") + cfg := loadTestConfig(t, tmpDir, outputDir) + + var stdout, stderr bytes.Buffer + c := &cli{ + outWriter: &stdout, + errWriter: &stderr, + } + + exitCode := c.run([]string{mdFile, mdFile}, false, cfg) + if exitCode != 0 { + t.Errorf("run() exit code = %d, want 0\nstderr: %s", exitCode, stderr.String()) + } + if got := strings.Count(stdout.String(), "Generated: "); got != 1 { + t.Errorf("stdout should contain 1 Generated line, got %d: %s", got, stdout.String()) + } +} + +func TestRun_OneFileNotFound_GeneratesNothing(t *testing.T) { + tmpDir := t.TempDir() + mdFile := filepath.Join(tmpDir, "a.md") + if err := os.WriteFile(mdFile, []byte("# Hello"), 0644); err != nil { //nolint:gosec // G306: test file + t.Fatal(err) + } + missing := filepath.Join(tmpDir, "missing.md") + + outputDir := filepath.Join(tmpDir, "output") + cfg := loadTestConfig(t, tmpDir, outputDir) + + var stdout, stderr bytes.Buffer + c := &cli{ + outWriter: &stdout, + errWriter: &stderr, + } + + exitCode := c.run([]string{mdFile, missing}, false, cfg) + if exitCode != 1 { + t.Errorf("run() exit code = %d, want 1", exitCode) + } + if !strings.Contains(stderr.String(), "file not found: "+missing) { + t.Errorf("stderr should name the missing file, got: %s", stderr.String()) + } + if stdout.String() != "" { + t.Errorf("stdout should be empty, got: %s", stdout.String()) + } + if _, err := os.Stat(expectedOutputPath(t, outputDir, mdFile)); err == nil { + t.Errorf("HTML for existing file should not be generated when another file is missing") + } +} + +func TestRun_StatError_GeneratesNothing(t *testing.T) { + tmpDir := t.TempDir() + mdFile := filepath.Join(tmpDir, "a.md") + if err := os.WriteFile(mdFile, []byte("# Hello"), 0644); err != nil { //nolint:gosec // G306: test file + t.Fatal(err) + } + // A self-referential symlink makes os.Stat fail with an error other than "not exist" + loop := filepath.Join(tmpDir, "loop.md") + if err := os.Symlink(loop, loop); err != nil { + t.Fatal(err) + } + + outputDir := filepath.Join(tmpDir, "output") + cfg := loadTestConfig(t, tmpDir, outputDir) + + var stdout, stderr bytes.Buffer + c := &cli{ + outWriter: &stdout, + errWriter: &stderr, + } + + exitCode := c.run([]string{mdFile, loop}, false, cfg) + if exitCode != 1 { + t.Errorf("run() exit code = %d, want 1", exitCode) + } + if !strings.Contains(stderr.String(), loop) { + t.Errorf("stderr should name the failing file, got: %s", stderr.String()) + } + if _, err := os.Stat(expectedOutputPath(t, outputDir, mdFile)); err == nil { + t.Errorf("HTML for existing file should not be generated when another file cannot be stat'ed") + } +} + +func TestRun_OutputPathCollision_GeneratesNothing(t *testing.T) { + tmpDir := t.TempDir() + // Different extensions with the same stem map to the same output path + mdFiles := []string{filepath.Join(tmpDir, "notes.md"), filepath.Join(tmpDir, "notes.markdown")} + for _, f := range mdFiles { + if err := os.WriteFile(f, []byte("# Hello"), 0644); err != nil { //nolint:gosec // G306: test file + t.Fatal(err) + } + } + + outputDir := filepath.Join(tmpDir, "output") + cfg := loadTestConfig(t, tmpDir, outputDir) + + var stdout, stderr bytes.Buffer + c := &cli{ + outWriter: &stdout, + errWriter: &stderr, + } + + exitCode := c.run(mdFiles, false, cfg) + if exitCode != 1 { + t.Errorf("run() exit code = %d, want 1", exitCode) + } + if !strings.Contains(stderr.String(), "same output path") { + t.Errorf("stderr should explain the collision, got: %s", stderr.String()) + } + if stdout.String() != "" { + t.Errorf("stdout should be empty, got: %s", stdout.String()) + } + if _, err := os.Stat(expectedOutputPath(t, outputDir, mdFiles[0])); err == nil { + t.Errorf("HTML should not be generated when output paths collide") + } +} + func TestListFiles_WithFiles(t *testing.T) { tmpDir := t.TempDir() outputDir := filepath.Join(tmpDir, "output") @@ -235,8 +423,64 @@ func TestRunWatchLoop_SignalHandling(t *testing.T) { sigChan <- syscall.SIGINT }() - exitCode := c.runWatchLoop(mdFile, r, w, sigChan) + exitCode := c.runWatchLoop([]string{mdFile}, r, w, sigChan) if exitCode != 0 { t.Errorf("runWatchLoop() returned %d, want 0", exitCode) } } + +func TestRunWatchLoop_RegeneratesChangedFile(t *testing.T) { + tmpDir := t.TempDir() + fileA := filepath.Join(tmpDir, "a.md") + fileB := filepath.Join(tmpDir, "b.md") + for _, f := range []string{fileA, fileB} { + if err := os.WriteFile(f, []byte("# Hello"), 0644); err != nil { //nolint:gosec // G306: test file in temp dir + t.Fatal(err) + } + } + + outDir := filepath.Join(tmpDir, "output") + var outBuf, errBuf bytes.Buffer + + c := &cli{ + outWriter: &outBuf, + errWriter: &errBuf, + } + + r, _ := renderer.NewRenderer("", "", renderer.Options{}) + w := output.NewWriter(outDir) + + sigChan := make(chan os.Signal, 1) + outputB := expectedOutputPath(t, outDir, fileB) + + go func() { + time.Sleep(200 * time.Millisecond) + _ = os.WriteFile(fileB, []byte("# Updated"), 0600) + // Stop once the regenerated file appears; the loop prints before it + // returns to select, so the signal is handled after the report + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(outputB); err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + sigChan <- syscall.SIGINT + }() + + exitCode := c.runWatchLoop([]string{fileA, fileB}, r, w, sigChan) + if exitCode != 0 { + t.Errorf("runWatchLoop() returned %d, want 0", exitCode) + } + + if !strings.Contains(outBuf.String(), "Regenerated: "+outputB+"\n") { + t.Errorf("stdout should report regeneration of %s, got: %s", outputB, outBuf.String()) + } + if _, err := os.Stat(outputB); err != nil { + t.Errorf("regenerated HTML not found: %v", err) + } + outputA := expectedOutputPath(t, outDir, fileA) + if strings.Contains(outBuf.String(), outputA) { + t.Errorf("unchanged file should not be regenerated, got: %s", outBuf.String()) + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 89a932f..199a89e 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -8,7 +8,7 @@ import ( "github.com/masawada/mdp/internal/config" ) -const usageMessage = `usage: mdp [options] +const usageMessage = `usage: mdp [options] ... Options: --config path to config file @@ -26,7 +26,7 @@ func Run() int { return 0 } _, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err) - _, _ = fmt.Fprintln(os.Stderr, "usage: mdp [--config ] [--help] ") + _, _ = fmt.Fprintln(os.Stderr, "usage: mdp [--config ] [--help] ...") return 1 } @@ -50,5 +50,5 @@ func Run() int { return c.listFiles(cfg) } - return c.run(args.filePath, args.watchMode, cfg) + return c.run(args.filePaths, args.watchMode, cfg) } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 343c9dd..d593e6e 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -2,6 +2,7 @@ package watcher import ( + "errors" "fmt" "os" "path/filepath" @@ -13,44 +14,53 @@ import ( // Watcher watches for file changes. type Watcher struct { fsWatcher *fsnotify.Watcher - filePath string - fileName string - events chan struct{} - errors chan error - done chan struct{} + // watched holds the absolute paths of the files to watch. + watched map[string]bool + events chan string + errors chan error + done chan struct{} } -// New creates a new Watcher for the specified file. -func New(filePath string) (*Watcher, error) { - // Check if file exists - if _, err := os.Stat(filePath); err != nil { - return nil, fmt.Errorf("file not found: %w", err) +// New creates a new Watcher for the specified files. +func New(filePaths ...string) (*Watcher, error) { + if len(filePaths) == 0 { + return nil, errors.New("no files to watch") } - // Get absolute path and directory - absPath, err := filepath.Abs(filePath) - if err != nil { - return nil, fmt.Errorf("failed to get absolute path: %w", err) + watched := make(map[string]bool, len(filePaths)) + dirs := make(map[string]bool) + for _, filePath := range filePaths { + // Check if file exists + if _, err := os.Stat(filePath); err != nil { + return nil, fmt.Errorf("file not found: %w", err) + } + + // Get absolute path and directory + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + watched[absPath] = true + dirs[filepath.Dir(absPath)] = true } - dir := filepath.Dir(absPath) - fileName := filepath.Base(absPath) fsWatcher, err := fsnotify.NewWatcher() if err != nil { return nil, fmt.Errorf("failed to create watcher: %w", err) } - // Watch the directory instead of the file - if err := fsWatcher.Add(dir); err != nil { - _ = fsWatcher.Close() - return nil, fmt.Errorf("failed to watch directory: %w", err) + // Watch the directories instead of the files + for dir := range dirs { + if err := fsWatcher.Add(dir); err != nil { + _ = fsWatcher.Close() + return nil, fmt.Errorf("failed to watch directory: %w", err) + } } w := &Watcher{ fsWatcher: fsWatcher, - filePath: absPath, - fileName: fileName, - events: make(chan struct{}), + watched: watched, + events: make(chan string), errors: make(chan error), done: make(chan struct{}), } @@ -69,8 +79,8 @@ func (w *Watcher) Start() { go w.loop() } -// Events returns a channel that receives notifications when the file changes. -func (w *Watcher) Events() <-chan struct{} { +// Events returns a channel that receives the absolute path of a file when it changes. +func (w *Watcher) Events() <-chan string { return w.events } @@ -81,8 +91,8 @@ func (w *Watcher) Errors() <-chan error { //nolint:cyclop // complexity is acceptable for event loop with debouncing func (w *Watcher) loop() { - // Debounce timer to coalesce rapid events - var debounceTimer *time.Timer + // Debounce timers per file to coalesce rapid events + debounceTimers := make(map[string]*time.Timer) const debounceInterval = 100 * time.Millisecond for { @@ -93,19 +103,19 @@ func (w *Watcher) loop() { if !ok { return } - // Filter events by target file name - if filepath.Base(event.Name) != w.fileName { + // Filter events by watched files + if !w.watched[event.Name] { continue } // Handle Write and Create events (Create handles atomic saves) if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create { // Debounce: reset timer on each event - if debounceTimer != nil { - debounceTimer.Stop() + if timer := debounceTimers[event.Name]; timer != nil { + timer.Stop() } - debounceTimer = time.AfterFunc(debounceInterval, func() { + debounceTimers[event.Name] = time.AfterFunc(debounceInterval, func() { select { - case w.events <- struct{}{}: + case w.events <- event.Name: case <-w.done: } }) diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 45ebf2e..1e4f12c 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -35,6 +35,100 @@ func TestNew_FileNotFound(t *testing.T) { } } +func TestNew_NoFiles(t *testing.T) { + _, err := New() + if err == nil { + t.Fatal("New() should return error when no files are given") + } +} + +func TestNew_OneOfMultipleFilesNotFound(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.md") + if err := os.WriteFile(tmpFile, []byte("# Test"), 0644); err != nil { //nolint:gosec // G306: test file in temp dir + t.Fatal(err) + } + + _, err := New(tmpFile, "/nonexistent/file.md") + if err == nil { + t.Fatal("New() should return error when any file does not exist") + } +} + +func TestWatchMultipleFiles(t *testing.T) { + // Two files in the same directory and one in another directory + dirA := t.TempDir() + dirB := t.TempDir() + fileA1 := filepath.Join(dirA, "a1.md") + fileA2 := filepath.Join(dirA, "a2.md") + fileB := filepath.Join(dirB, "b.md") + for _, f := range []string{fileA1, fileA2, fileB} { + if err := os.WriteFile(f, []byte("# Test"), 0644); err != nil { //nolint:gosec // G306: test file in temp dir + t.Fatal(err) + } + } + + w, err := New(fileA1, fileA2, fileB) + if err != nil { + t.Fatalf("New() returned error: %v", err) + } + defer func() { _ = w.Close() }() + + w.Start() + + // Modify each file in turn and expect an event naming that file + for _, want := range []string{fileA2, fileB, fileA1} { + go func() { + time.Sleep(100 * time.Millisecond) + _ = os.WriteFile(want, []byte("# Updated"), 0600) + }() + + select { + case got := <-w.Events(): + if got != want { + t.Errorf("Events() = %q, want %q", got, want) + } + case err := <-w.Errors(): + t.Fatalf("Errors() returned: %v", err) + case <-time.After(2 * time.Second): + t.Fatalf("timeout waiting for event for %s", want) + } + } +} + +func TestWatchIgnoresUnwatchedFile(t *testing.T) { + tmpDir := t.TempDir() + watched := filepath.Join(tmpDir, "watched.md") + other := filepath.Join(tmpDir, "other.md") + for _, f := range []string{watched, other} { + if err := os.WriteFile(f, []byte("# Test"), 0644); err != nil { //nolint:gosec // G306: test file in temp dir + t.Fatal(err) + } + } + + w, err := New(watched) + if err != nil { + t.Fatalf("New() returned error: %v", err) + } + defer func() { _ = w.Close() }() + + w.Start() + + // Write synchronously so the negative wait below starts after the change + if err := os.WriteFile(other, []byte("# Updated"), 0600); err != nil { + t.Fatal(err) + } + + select { + case got := <-w.Events(): + t.Fatalf("Events() returned %q for unwatched file change", got) + case err := <-w.Errors(): + t.Fatalf("Errors() returned: %v", err) + case <-time.After(500 * time.Millisecond): + // Success: no event + } +} + func TestWatchFileChange(t *testing.T) { // Create a temporary file tmpDir := t.TempDir() @@ -60,8 +154,10 @@ func TestWatchFileChange(t *testing.T) { // Wait for event select { - case <-w.Events(): - // Success + case got := <-w.Events(): + if got != tmpFile { + t.Errorf("Events() = %q, want %q", got, tmpFile) + } case err := <-w.Errors(): t.Fatalf("Errors() returned: %v", err) case <-time.After(2 * time.Second):