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
-
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)
-
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.
-
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).
-
Safe-write sequence (exact order):
- 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).
- Existing regular file + !Force →
ErrExists (message names the path
and --force).
- 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).
os.CreateTemp(parentDir, ".worklog-*"), chmod 0600, write content,
f.Sync(), close, os.Rename onto the target.
- Any failure after temp creation removes the temp file (best-effort,
deferred).
-
With Force + existing target: same temp+rename path (atomic replace);
never truncate-in-place. Force does NOT override the symlink refusal.
-
Content written verbatim (renderer owns trailing newline).
-
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
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).
Title
internal/output: stdout/file writers with safe-write rules
Summary
Implement report output: stdout passthrough and the safe file-writing path
(
--out/--savesemantics, 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) whereworklog 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
API:
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.Target resolution:
<SaveDir>/<Date>-<Kind>.md(SaveDir=="" → error text pointingat
[output].directory— 07 already exits 2, defensive here).with
/→ treat as directory (<dir>/<Date>-<Kind>.md); else filepath (missing extension is left alone — user's choice).
Safe-write sequence (exact order):
filepath.EvalSymlinkson the deepestEXISTING ancestor to derive the real parent; the final component must
not exist as a symlink (
os.Lstat;ErrSymlinkTarget).ErrExists(message names the pathand
--force).os.MkdirAll), thenre-verify via
os.Lstatthat 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).
os.CreateTemp(parentDir, ".worklog-*"), chmod 0600, write content,f.Sync(), close,os.Renameonto the target.deferred).
With Force + existing target: same temp+rename path (atomic replace);
never truncate-in-place. Force does NOT override the symlink refusal.
Content written verbatim (renderer owns trailing newline).
Errors are typed (
errors.Is-able):ErrExists,ErrSymlinkTarget,ErrBothModes,ErrNoSaveDir. CLI mapping (25/26):ErrNoSaveDirandErrBothModesare usage/config errors → exit 2 (DESIGN §13; the CLIpre-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
--out(existing dir AND trailing-slash form) →conventional filename inside; file
--out→ exact path.ErrExists, file untouched(mtime+hash asserted).
(test: concurrent reader loop sees only old or new full content
during 100 forced rewrites).
/etc/nonexistent) →ErrSymlinkTarget, link target untouched, withand without Force.
os.Staton Linux+mac;skip permission-bit assert on platforms where umask interferes —
set umask in test to be exact).
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 errorcase exercised).
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, configpath only).
Design References
docs/DESIGN.md§10, §12.1 (B4), §1.2 (I3)Source of truth:
docs/issues/22-output-writer.md(PR #1, branchdocs/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).