/** * The idempotency core. * * `sl0ppi up` must be a no-op the second time you run it. Everything else in * the CLI is plumbing; if this diff is wrong the tool is worse than useless. * So it is a pure function over data — no filesystem, no subprocesses — and the * tests hammer it directly. */ import { isProjectPurpose, projectPurpose, projectRelPath } from "./purpose.ts"; export interface TabInfo { /** zellij tab position, which is what `go-to-tab` wants. */ position: number; name: string; purpose?: string; } export interface PlanInput { /** Absolute path of the root whose session this is. */ root: string; /** Whether the root itself is present (see the mount guard below). */ rootExists: boolean; /** Discovered projects, as paths relative to the root. */ projects: string[]; /** Tabs currently in the session. */ tabs: TabInfo[]; /** Injected so the planner stays pure and testable. */ pathExists: (absPath: string) => boolean; } export interface CreateAction { relPath: string; purpose: string; } export interface Plan { create: CreateAction[]; close: TabInfo[]; /** Project tabs that already match a discovered project. */ keep: TabInfo[]; /** Tabs sl0ppi does not manage (no `project:` purpose). Never touched. */ foreign: TabInfo[]; } export function planTabs(input: PlanInput): Plan { const { root, rootExists, projects, tabs, pathExists } = input; // Expected state: one tab per discovered project, keyed by purpose. const expected = new Map(); // purpose -> relPath for (const relPath of projects) expected.set(projectPurpose(relPath), relPath); const create: CreateAction[] = []; const close: TabInfo[] = []; const keep: TabInfo[] = []; const foreign: TabInfo[] = []; const seen = new Set(); for (const tab of tabs) { if (!isProjectPurpose(tab.purpose)) { // Someone else's tab (a scratch tab the user opened by hand). Not ours. foreign.push(tab); continue; } const purpose = tab.purpose as string; if (expected.has(purpose)) { keep.push(tab); seen.add(purpose); continue; } // Not a project we discovered. Either its directory is gone, or it is a // project outside the depth-1 scan that was opened explicitly. Only the // first justifies closing a tab, and closing is destructive, so every // uncertain case keeps the tab. if (!rootExists) { // The ROOT is missing, not the project. That is an unmounted filesystem, // not twelve deleted projects: closing here would eat the whole session. keep.push(tab); continue; } const relPath = projectRelPath(purpose); if (relPath === null) { // Purpose does not reconstruct (truncated/hashed), so we cannot check // the directory. Refuse to guess. keep.push(tab); continue; } if (pathExists(joinPath(root, relPath))) { // Directory is there; it simply was not discovered (nested, or its // .sl0ppi/ marker was removed). Leave it alone. keep.push(tab); continue; } close.push(tab); } // Alphabetical, per the agreed MVP ordering. for (const relPath of [...projects].sort()) { const purpose = projectPurpose(relPath); if (!seen.has(purpose)) create.push({ relPath, purpose }); } return { create, close, keep, foreign }; } /** Trivial join; avoids pulling node:path into a pure module. */ function joinPath(root: string, rel: string): string { return root.endsWith("/") ? `${root}${rel}` : `${root}/${rel}`; } /** True when applying this plan would change nothing. */ export function isNoOp(plan: Plan): boolean { return plan.create.length === 0 && plan.close.length === 0; }