diff --git a/README.md b/README.md index fded41d4..b0625931 100644 --- a/README.md +++ b/README.md @@ -653,7 +653,7 @@ codeburn month --project api --project web # include multiple projects codeburn export --project inventory # export only "inventory" project data ``` -Filter by provider, project, or exact date range. The `--project` and `--exclude` flags work on every reporting command and can be combined with `--provider`. A plain word matches a project's name or path as a case-insensitive substring, so `--project my-company` also covers `my-company-kit` and its worktrees. An absolute path selects that one project and anything inside it, so `--exclude /Users/me/work/my-company` leaves the sibling `/Users/me/work/my-company-kit` alone. A leading `~` is the shell's to expand, so quote it and it is read as a plain word, not a path. An absolute POSIX path is case-sensitive, the same rule that decides project identity everywhere else (`/Users/me/Vault` and `/Users/me/vault` are two projects); a Windows drive or UNC path folds case. An absolute path that matches no project in the period you asked for is reported on stderr, since it would otherwise leave a total that looks right. +Filter by provider, project, or exact date range. The `--project` and `--exclude` flags work on every reporting command and can be combined with `--provider`. A plain word matches a project's name or path as a case-insensitive substring, so `--project my-company` also covers `my-company-kit` and its worktrees. An absolute path selects that one project and anything inside it, so `--exclude /Users/me/work/my-company` leaves the sibling `/Users/me/work/my-company-kit` alone. A leading `~` is expanded against your home directory, so a quoted `'~/work/my-company'` selects the same project as the path the shell would have expanded. An absolute POSIX path is case-sensitive, the same rule that decides project identity everywhere else (`/Users/me/Vault` and `/Users/me/vault` are two projects); a Windows drive or UNC path folds case. An absolute path that matches no project in the period you asked for is reported on stderr, since it would otherwise leave a total that looks right. ```bash codeburn report --from 2026-04-01 --to 2026-04-10 # explicit window diff --git a/src/parser.ts b/src/parser.ts index b19b8ed1..1869765c 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,3 +1,4 @@ +import { homedir } from 'node:os' import { existsSync } from 'fs' import { lstat, readFile, readdir, stat } from 'fs/promises' import { createHash } from 'crypto' @@ -3170,6 +3171,10 @@ export function setInteractiveScanUI(active = true): void { interactiveScanUI = active } +export function isInteractiveScanUI(): boolean { + return interactiveScanUI +} + // Machine-readable scan progress for the desktop app's first-run splash. Plain // CLI/terminal usage is untouched: emission is gated on CODEBURN_PROGRESS=1, // which only the app's cold-start warmup spawn sets. Each event is one @@ -4415,7 +4420,16 @@ export function setCachePutMeta(meta: { startMs: number; endMs: number; sig: str export function isRootedProjectPattern(pattern: string): boolean { const raw = pattern.trim().replace(/\\/g, '/') - return raw.startsWith('/') || /^[a-zA-Z]:\//.test(raw) + return raw.startsWith('/') || raw === '~' || raw.startsWith('~/') || /^[a-zA-Z]:\//.test(raw) +} + +/// A quoted "~/proj" reaches us unexpanded, and so does one typed into a field +/// with no shell behind it. Left as a loose word it would match nothing and say +/// nothing, since no stored path contains a tilde. +function expandTilde(pattern: string): string { + const raw = pattern.trim().replace(/\\/g, '/') + if (raw !== '~' && !raw.startsWith('~/')) return raw + return homedir().replace(/\\/g, '/') + raw.slice(1) } /// A pattern is normalized once and matched many times: the day cache runs the @@ -4426,7 +4440,7 @@ export type ProjectFilterTarget = { project: string; projectPath?: string } function compile(patterns: readonly string[]): CompiledPattern[] { return patterns.map(pattern => isRootedProjectPattern(pattern) - ? { rooted: true as const, anchor: normalizeAbsProjectPathKey(pattern) } + ? { rooted: true as const, anchor: normalizeAbsProjectPathKey(expandTilde(pattern)) } : { rooted: false as const, needle: pattern.toLowerCase() }) } @@ -4452,8 +4466,12 @@ export function makeProjectFilter( ): (entry: ProjectFilterTarget) => boolean { const inc = compile(include ?? []) const exc = compile(exclude ?? []) + // The key costs a trim, a global replace and three regex passes. The day cache + // runs this per project, per day, per provider slice, so it is only paid when + // some pattern is rooted and can actually read it. + const needsKey = inc.some(p => p.rooted) || exc.some(p => p.rooted) return entry => { - const key = normalizeAbsProjectPathKey(entry.projectPath ?? '') + const key = needsKey ? normalizeAbsProjectPathKey(entry.projectPath ?? '') : null if (inc.length > 0 && !inc.some(pattern => hit(entry, pattern, key))) return false if (exc.length > 0 && exc.some(pattern => hit(entry, pattern, key))) return false return true @@ -4471,8 +4489,7 @@ export function matchesProjectPattern(project: ProjectFilterTarget, pattern: str export function unmatchedRootedPatterns(projects: readonly ProjectFilterTarget[], patterns: readonly string[]): string[] { return patterns.filter(pattern => { if (!isRootedProjectPattern(pattern)) return false - const matches = makeProjectFilter([pattern]) - return !projects.some(entry => matches(entry)) + return !projects.some(entry => matchesProjectPattern(entry, pattern)) }) } diff --git a/tests/parser-filter.test.ts b/tests/parser-filter.test.ts index 829522d4..49c5a742 100644 --- a/tests/parser-filter.test.ts +++ b/tests/parser-filter.test.ts @@ -1,3 +1,5 @@ +import { homedir } from 'node:os' + import { describe, it, expect } from 'vitest' import { filterProjectsByName } from '../src/parser.js' @@ -104,6 +106,14 @@ describe('filterProjectsByName', () => { expect(filterProjectsByName(projects, undefined, ['//'])).toEqual(projects) }) + it('expands a leading ~ the shell did not, and anchors it', () => { + const home = homedir().replace(/\\/g, '/') + const mine = [makeProject('app', `${home}/work/app`), makeProject('app-kit', `${home}/work/app-kit`)] + expect(filterProjectsByName(mine, ['~/work/app']).map(p => p.project)).toEqual(['app']) + expect(filterProjectsByName(mine, undefined, ['~/work/app']).map(p => p.project)).toEqual(['app-kit']) + expect(filterProjectsByName(mine, ['~/nowhere'])).toEqual([]) + }) + it('exclude matches path substring', () => { const result = filterProjectsByName(projects, undefined, ['/tmp']) expect(result.map(p => p.project)).not.toContain('sandbox')