Skip to content

internal/output: stdout/file writers with safe-write rules #23

Description

@Saber5656

Title

internal/output: stdout/file writers with safe-write rules

Summary

Implement report output: stdout passthrough and the safe file-writing path
(--out/--save semantics, conventional naming, no-overwrite default,
symlink refusal, atomic write, restrictive permissions) per DESIGN.md §10.

Context

This is write-boundary B4 — the only place (besides worklog init) where
worklog writes to disk (invariant I3). Reports may contain personal data, so
0600/0700 permissions and refuse-by-default overwrite are product decisions,
not niceties.

Scope

  • internal/output/output.go + tests.

Detailed Requirements

  1. API:

    type Request struct {
        Content   string
        OutPath   string // --out value; "" if unset
        SaveDir   string // config output.directory (expanded); used when Save
        Save      bool   // --save
        Force     bool   // --force
        Date      string // YYYY-MM-DD
        Kind      string // "daily"|"standup"
    }
    // Write returns the final path ("" for stdout) for the CLI to report.
    func Write(w io.Writer, req Request) (string, error)
  2. Mode resolution (mutually exclusive by CLI validation in 07, re-checked
    here defensively): neither OutPath nor Save → write Content to w
    (stdout), return "". Both set → error.

  3. Target resolution:

    • Save: <SaveDir>/<Date>-<Kind>.md (SaveDir=="" → error text pointing
      at [output].directory — 07 already exits 2, defensive here).
    • OutPath: if the path exists AND is a directory, or the raw value ends
      with / → treat as directory (<dir>/<Date>-<Kind>.md); else file
      path (missing extension is left alone — user's choice).
  4. Safe-write sequence (exact order):

    1. Clean + absolutize the target; filepath.EvalSymlinks on the deepest
      EXISTING ancestor to derive the real parent; the final component must
      not exist as a symlink (os.Lstat; ErrSymlinkTarget).
    2. Existing regular file + !Force → ErrExists (message names the path
      and --force).
    3. Create missing parent directories with 0700 (os.MkdirAll), then
      re-verify via os.Lstat that the final parent is a real directory
      (not a symlink) — applies to components worklog just created;
      pre-existing symlinked ancestors resolved in step 1 are honored as
      user intent (DESIGN §10).
    4. os.CreateTemp(parentDir, ".worklog-*"), chmod 0600, write content,
      f.Sync(), close, os.Rename onto the target.
    5. Any failure after temp creation removes the temp file (best-effort,
      deferred).
  5. With Force + existing target: same temp+rename path (atomic replace);
    never truncate-in-place. Force does NOT override the symlink refusal.

  6. Content written verbatim (renderer owns trailing newline).

  7. Errors are typed (errors.Is-able): ErrExists, ErrSymlinkTarget,
    ErrBothModes, ErrNoSaveDir. CLI mapping (25/26): ErrNoSaveDir and
    ErrBothModes are usage/config errors → exit 2 (DESIGN §13; the CLI
    pre-checks normally catch them first — this is the defensive path);
    ErrExists, ErrSymlinkTarget, and I/O failures are write failures →
    exit 1. Tests assert types.

Acceptance Criteria

  • stdout mode: content on writer byte-exact, "" path, nothing on disk.
  • Directory --out (existing dir AND trailing-slash form) →
    conventional filename inside; file --out → exact path.
  • Existing target without Force → ErrExists, file untouched
    (mtime+hash asserted).
  • Force replaces atomically: old content never observable mid-write
    (test: concurrent reader loop sees only old or new full content
    during 100 forced rewrites).
  • Symlink final component (to an innocent file AND to
    /etc/nonexistent) → ErrSymlinkTarget, link target untouched, with
    and without Force.
  • Created parents 0700; file 0600 (asserted via os.Stat on Linux+mac;
    skip permission-bit assert on platforms where umask interferes —
    set umask in test to be exact).
  • Temp files never left behind after injected failures (write error via
    full-disk simulation: parent chmod 0500 after temp creation is
    awkward — instead inject an erroring rename via a test seam or
    accept: assert no .worklog-* remains in parent after each error
    case exercised).
  • ≥ 95% coverage.

Validation

go test -race -cover ./internal/output/ in PR.

Dependencies

01, 03 (only for shared error conventions; no model types needed — keep
imports minimal).

Non-goals

Filename customization (v2), writing anywhere implicitly, clipboard
integration, worklog init's config write (27 — different rules, config
path only).

Design References

  • docs/DESIGN.md §10, §12.1 (B4), §1.2 (I3)

Source of truth: docs/issues/22-output-writer.md (PR #1, branch docs/v1-design). If this issue and the repo docs disagree, the docs win. Execution order and dependencies: docs/ISSUE_PLAN.md (this is issue 22 of 33).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions