diff --git a/src/Frog.test.ts b/src/Frog.test.ts index 8f7f3fa..14c4988 100644 --- a/src/Frog.test.ts +++ b/src/Frog.test.ts @@ -34,6 +34,16 @@ describe('create', () => { expect(await frog.logs()).toHaveLength(1) }) + test('behavior: does not deduplicate empty or fence-only titles', async () => { + const frog = Frog.create({ store: Store.file({ root: await tmpdir() }) }) + const first = await frog.log({ ...entry, title: '---' }) + const second = await frog.log({ ...entry, title: '---' }) + + expect(second.created).toBe(true) + expect(second.entry.id).not.toBe(first.entry.id) + expect(await frog.logs()).toHaveLength(2) + }) + test('behavior: delegates atomic logging to a store that provides it', async () => { const log = vi.fn(async () => ({ created: false, diff --git a/src/Frog.ts b/src/Frog.ts index 456a011..ce96fc4 100644 --- a/src/Frog.ts +++ b/src/Frog.ts @@ -55,7 +55,10 @@ async function log( ): Promise { if (store.log) return store.log(entry, options) - if (!options.force) { + // An empty normalized title (empty or punctuation-only, such as `---`) never deduplicates: every + // such entry would otherwise match every other, including the mangled piped entries this guard + // exists to contain. + if (!options.force && Entry.normalizeTitle(entry.title) !== '') { const duplicate = (await store.read()).find( (candidate) => Entry.normalizeTitle(candidate.title) === Entry.normalizeTitle(entry.title), ) diff --git a/src/Store.ts b/src/Store.ts index fa566f3..a335870 100644 --- a/src/Store.ts +++ b/src/Store.ts @@ -470,7 +470,10 @@ export function postgres(options: postgres.Options): Store { async write(entry, writeOptions = {}) { const id = writeOptions.id ?? newPostgresId(entry.title) const dedupeKey = `entry:${id}` - const titleKey = `title:${Entry.normalizeTitle(entry.title)}` + // An empty normalized title never takes the shared title key: every punctuation-only + // title would otherwise share `title:` and collide. + const normalized = Entry.normalizeTitle(entry.title) + const titleKey = normalized === '' ? dedupeKey : `title:${normalized}` await sql` INSERT INTO ${table} AS existing(namespace, id, dedupe_key, contents) VALUES (${namespace}, ${id}, ${dedupeKey}, ${Entry.serialize(entry)}) @@ -499,9 +502,14 @@ export function postgres(options: postgres.Options): Store { }, async log(entry, logOptions = {}) { const id = newPostgresId(entry.title) + const normalized = Entry.normalizeTitle(entry.title) + // An empty normalized title never deduplicates: every punctuation-only title would + // otherwise share one key and count as the same friction. const dedupeKey = logOptions.force ? `forced:${id}` - : `title:${Entry.normalizeTitle(entry.title)}` + : normalized === '' + ? `entry:${id}` + : `title:${normalized}` const result = await sql` INSERT INTO ${table}(namespace, id, dedupe_key, contents) VALUES (${namespace}, ${id}, ${dedupeKey}, ${Entry.serialize(entry)}) diff --git a/src/cli/commands/log.ts b/src/cli/commands/log.ts index 46b0050..c69fe7d 100644 --- a/src/cli/commands/log.ts +++ b/src/cli/commands/log.ts @@ -244,11 +244,14 @@ export const log = Cli.create('log', { if (entries && !entries.ok) return c.error({ code: entries.code, message: entries.message }) // Stores without atomic logging still catch the repeat at authoring time, where it is cheap. - const duplicate = entries?.ok - ? entries.value.find( - (entry) => Entry.normalizeTitle(entry.title) === Entry.normalizeTitle(title), - ) - : undefined + // An empty normalized title never matches: punctuation-only titles such as `---` all + // normalize to the empty string and must not collide with each other. + const duplicate = + entries?.ok && Entry.normalizeTitle(title) !== '' + ? entries.value.find( + (entry) => Entry.normalizeTitle(entry.title) === Entry.normalizeTitle(title), + ) + : undefined if (duplicate && !c.options.force) return c.error({ code: 'DUPLICATE_FRICTION', @@ -264,7 +267,10 @@ export const log = Cli.create('log', { if (promptedSeverity && !promptedSeverity.ok) return c.error({ code: promptedSeverity.code, message: promptedSeverity.message }) - const severity = c.options.severity ?? (promptedSeverity?.ok ? promptedSeverity.value : 'minor') + const severity = + c.options.severity ?? + input?.severity ?? + (promptedSeverity?.ok ? promptedSeverity.value : 'minor') const logged = await attempt( Frog.create({ store }).log( diff --git a/src/cli/internal/stdin.test.ts b/src/cli/internal/stdin.test.ts index 7c2c157..69b8518 100644 --- a/src/cli/internal/stdin.test.ts +++ b/src/cli/internal/stdin.test.ts @@ -54,6 +54,42 @@ describe('parse', () => { test.for(['', ' ', '\n\n\n'])('behavior: %j has no title', (contents) => { expect(stdin.parse(contents)).toBeUndefined() }) + + test('behavior: parses a frontmatter block for title, severity, and body', () => { + const contents = [ + '---', + "title: 'Filters ignored'", + 'severity: major', + '---', + '', + '## Description', + '', + 'The filter was swallowed.', + '', + ].join('\n') + expect(stdin.parse(contents)).toEqual({ + body: '## Description\n\nThe filter was swallowed.', + severity: 'major', + title: 'Filters ignored', + }) + }) + + test('behavior: a frontmatter block without severity defaults to minor', () => { + const contents = ["---", "title: 'Filters ignored'", '---', '', 'Body.'].join('\n') + expect(stdin.parse(contents)).toEqual({ + body: 'Body.', + severity: 'minor', + title: 'Filters ignored', + }) + }) + + test.for([ + '---\ntitle:\n---\n\nBody.', + '---\nseverity: minor\n---\n\nBody.', + '---\ntitle: one\n\nBody without a closing fence.', + ])('behavior: malformed frontmatter %j has no title', (contents) => { + expect(stdin.parse(contents)).toBeUndefined() + }) }) describe('read', () => { diff --git a/src/cli/internal/stdin.ts b/src/cli/internal/stdin.ts index 5925018..c2da06d 100644 --- a/src/cli/internal/stdin.ts +++ b/src/cli/internal/stdin.ts @@ -1,5 +1,6 @@ import fs from 'node:fs' import type { Readable } from 'node:stream' +import * as Entry from '../../Entry.js' /** How long to wait for the first byte when it is unclear whether anything is coming. */ export const grace = 100 @@ -103,20 +104,28 @@ function arriving(stream: Readable, deadline: number): Promise { } /** - * Splits piped input into a title and a body. + * Splits piped input into a title, a body, and an optional severity. * * Shaped like a commit message: the first line is the title, the rest is the body. A caller never has to * quote a multi-line string on a command line, where an apostrophe in the body would end the argument. * + * A complete entry write-up is recognized by its opening fence: when the first non-empty line is `---`, + * the title and severity come from the YAML frontmatter block and the body is the Markdown after the + * closing fence. Malformed frontmatter yields nothing rather than a `---` title that would poison + * later duplicate detection. + * * @param contents - Piped input. - * @returns The title and body, or `undefined` when there is no title to take. + * @returns The title, body, and frontmatter severity when present, or `undefined` when there is no + * title to take. */ -export function parse(contents: string): { body: string; title: string } | undefined { +export function parse(contents: string): parse.Result | undefined { const lines = contents.replace(/\r\n/g, '\n').split('\n') const start = lines.findIndex((line) => line.trim()) if (start === -1) return undefined + if (lines[start]?.trim() === '---') return parseFrontmatter(lines.slice(start)) + const title = lines[start]?.trim() if (!title) return undefined @@ -128,3 +137,31 @@ export function parse(contents: string): { body: string; title: string } | undef title, } } + +export declare namespace parse { + /** Piped entry: commit-message title and body, plus frontmatter severity when piped as a file. */ + type Result = { + /** Markdown body: everything after the title, or after the frontmatter block. */ + body: string + /** Frontmatter severity. Absent for the commit-message shape; the caller defaults it. */ + severity?: Entry.Severity | undefined + /** One line, specific enough to search for. */ + title: string + } +} + +/** + * Parses piped input that starts with a YAML frontmatter fence. + * + * @param lines - Piped lines starting at the opening `---`. + * @returns The frontmatter title, severity, and Markdown body, or `undefined` when the block is + * malformed or names no title. + */ +function parseFrontmatter(lines: readonly string[]): parse.Result | undefined { + try { + const parsed = Entry.parse(lines.join('\n'), { id: 'piped' }) + return { body: parsed.body, severity: parsed.severity, title: parsed.title } + } catch { + return undefined + } +}