diff --git a/cmd/entry.go b/cmd/entry.go index 11be00d..91927c7 100644 --- a/cmd/entry.go +++ b/cmd/entry.go @@ -153,9 +153,17 @@ func directModeTmuxHint(opts WorkspaceEntryOptions) string { if !shell.HasSession(tmuxPath, sessionName) { return "" } + return formatDirectModeTmuxHint(opts.WsMeta.Slug) +} + +// formatDirectModeTmuxHint builds the hint string itself, with no tmux +// or filesystem checks. Split out so unit tests can verify that the +// command-form line uses invocationName() without needing a live tmux +// session set up against a real workspace. +func formatDirectModeTmuxHint(slug string) string { return fmt.Sprintf( "Tip: a tmux session exists for this workspace.\nTo reattach instead of starting a second direct-mode session, run:\n %s attach %s", - invocationName(), opts.WsMeta.Slug) + invocationName(), slug) } func invokePersistentRun(opts WorkspaceEntryOptions, tmuxPath, sessionName string) error { diff --git a/cmd/info_session_test.go b/cmd/info_session_test.go index ea487a7..a3c38b9 100644 --- a/cmd/info_session_test.go +++ b/cmd/info_session_test.go @@ -12,16 +12,7 @@ import ( "github.com/warrenronsiek/ctask/internal/workspace" ) -// withInvocationNameInfo pins invocationName() to "ctask" for the duration -// of the test so attach-hint substring assertions are stable across hosts. -// Mirrors the helper in persistent_test.go but kept local to avoid -// cross-test coupling. -func withInvocationNameInfo(t *testing.T, name string) { - t.Helper() - prev := invocationNameOverride - invocationNameOverride = name - t.Cleanup(func() { invocationNameOverride = prev }) -} +// withInvocationName is provided by persistent_test.go in this package. // makeInfoSessionWorkspace writes a workspace under root with the given // slug and returns the workspace directory path. The workspace metadata @@ -65,7 +56,7 @@ func TestInfoNoSession(t *testing.T) { } func TestInfoShowsActivePersistentSessionWithAttachHint(t *testing.T) { - withInvocationNameInfo(t, "ctask") + withInvocationName(t, "ctask") root := t.TempDir() wsDir := makeInfoSessionWorkspace(t, root, "active-persist") @@ -95,7 +86,7 @@ func TestInfoShowsActivePersistentSessionWithAttachHint(t *testing.T) { } func TestInfoShowsActiveDirectSessionWithoutAttachHint(t *testing.T) { - withInvocationNameInfo(t, "ctask") + withInvocationName(t, "ctask") root := t.TempDir() wsDir := makeInfoSessionWorkspace(t, root, "active-direct") @@ -124,7 +115,7 @@ func TestInfoShowsActiveDirectSessionWithoutAttachHint(t *testing.T) { } func TestInfoShowsRemoteHostnameInOwnerLine(t *testing.T) { - withInvocationNameInfo(t, "ctask") + withInvocationName(t, "ctask") root := t.TempDir() wsDir := makeInfoSessionWorkspace(t, root, "remote-active") diff --git a/cmd/invocation_audit_test.go b/cmd/invocation_audit_test.go new file mode 100644 index 0000000..dad7016 --- /dev/null +++ b/cmd/invocation_audit_test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "strings" + "testing" +) + +// v0.5.4 invocation-name audit: spec §2 codifies that command-form +// hints use invocationName() while product-identity references stay +// literal. The pre-existing tests already cover most paths individually +// (resume restore hint in resume_test.go, persistent bypass hints in +// persistent_test.go). These tests pin down the remaining piece — the +// Layer-1 active-session prompt's attach hint — and re-assert the +// resume restore-hint contract against an explicitly non-canonical +// invocation name so a regression that hard-codes "ctask" anywhere +// upstream of the hint format will fail loudly. + +func TestInvocationNameInActiveSessionPrompt(t *testing.T) { + // directModeTmuxHint composes the Layer-1 prompt's attach suggestion + // from formatDirectModeTmuxHint. The format-only helper is the right + // surface to test: it isolates the rendering decision from the tmux + // presence checks (which are environment-dependent) but exercises the + // exact string the user will see. + withInvocationName(t, "my-bin") + + got := formatDirectModeTmuxHint("demo-ws") + + if !strings.Contains(got, "my-bin attach demo-ws") { + t.Errorf("Layer-1 attach hint should use invocation name; got:\n%s", got) + } + if strings.Contains(got, "ctask attach demo-ws") { + t.Errorf("Layer-1 attach hint must NOT hard-code 'ctask attach' when invocation name differs; got:\n%s", got) + } +} + +func TestInvocationNameInRestoreHintNonCanonical(t *testing.T) { + // Complement to TestResumeArchivedShowsRestoreHint in resume_test.go, + // which pins invocationName to "ctask" — that protects against test + // binary noise but cannot detect a regression that hard-codes "ctask" + // in the format string. Pinning a non-default name flushes that out. + withInvocationName(t, "my-bin") + + got := formatResumeRestoreHint("my-archived-ws") + + if !strings.Contains(got, "my-bin restore my-archived-ws") { + t.Errorf("resume restore hint should use invocation name; got:\n%s", got) + } + if strings.Contains(got, "ctask restore") { + t.Errorf("resume restore hint must NOT hard-code 'ctask restore' when invocation name differs; got:\n%s", got) + } +} diff --git a/cmd/resume.go b/cmd/resume.go index b738dcd..53d9957 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -58,9 +58,7 @@ func doResume(query string, container, useShell, force bool, agentOverride strin ws := resolveOne(roots, query, true) if ws.Meta.Status == "archived" { - fmt.Fprintf(os.Stderr, - "[ctask] error: workspace %q is archived\n\nTo restore it:\n %s restore %s\n", - query, invocationName(), query) + fmt.Fprint(os.Stderr, formatResumeRestoreHint(query)) return fmt.Errorf("workspace archived") } @@ -88,3 +86,19 @@ func doResume(query string, container, useShell, force bool, agentOverride strin CommandName: "resume", }) } + +// formatResumeRestoreHint builds the multi-line stderr block printed +// when `ctask resume ` resolves to an archived workspace. +// Extracted so the v0.5.4 invocation-name audit can verify the +// command-form line uses invocationName() without depending on the +// surrounding fmt.Fprintf machinery. +// +// The "[ctask]" diagnostic prefix is intentionally a literal product +// reference (spec §2: product-identity references stay literal). The +// `restore ` line is the command-form portion and uses +// invocationName(). +func formatResumeRestoreHint(query string) string { + return fmt.Sprintf( + "[ctask] error: workspace %q is archived\n\nTo restore it:\n %s restore %s\n", + query, invocationName(), query) +}