# Link Shortener CLI — Implementation Plan

## Overview

We are building a Node.js CLI named `shorten` that manages a JSON-backed link-shortener database stored at `~/.shorten/links.json`. The CLI supports three commands: `add`, `resolve`, and `list`. All three share a single storage module.

**Tech constraints:**
- Node 20+ only, no third-party dependencies (standard library only).
- Tests use the built-in `node:test` runner and `node:assert`.
- TDD: every code change is preceded by a failing test.

**Project layout we will create:**

```
link-shortener/
  package.json
  bin/
    shorten.js          # CLI entry point (arg parsing + dispatch)
  src/
    storage.js          # shared load/save + data operations
  test/
    storage.test.js
    cli.test.js
```

**Data model.** The JSON file is an object with a single key `links`, whose value is an **array** of `{ code, url }` objects in insertion order (array preserves "order added" trivially):

```json
{
  "links": [
    { "code": "abc123", "url": "https://example.com" }
  ]
}
```

---

## Task 0: Project scaffolding

Create the project skeleton and verify the test runner works before writing real code.

### Step 0.1 — Create directories and `package.json`

Create the directory structure:

```bash
mkdir -p link-shortener/bin link-shortener/src link-shortener/test
cd link-shortener
```

Create `package.json` with this exact content:

```json
{
  "name": "link-shortener",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "shorten": "./bin/shorten.js"
  },
  "scripts": {
    "test": "node --test"
  },
  "engines": {
    "node": ">=20"
  }
}
```

Notes:
- `"type": "module"` means all `.js` files use ES module syntax (`import`/`export`).
- `node --test` auto-discovers files matching `test/**/*.test.js`.

### Step 0.2 — Verify the test runner runs (and reports zero tests)

Run:

```bash
node --test
```

Expected output (numbers may vary slightly by Node version, but `tests 0` and exit code 0):

```
ℹ tests 0
ℹ pass 0
ℹ fail 0
```

If you see a non-zero exit code or an error, confirm you are on Node 20+ with `node --version`.

### Step 0.3 — Commit

```bash
git init
git add .
git commit -m "Scaffold link-shortener project"
```

---

## Task 1: Storage module (shared)

The storage module is consumed by all three commands. It owns the file path resolution, JSON load/save (with corrupt-file handling), and the data operations: `add`, `resolve`, `list`.

### Design of `src/storage.js`

We will export the following functions. **These exact names and signatures are used by every later task — do not rename them.**

| Function | Signature | Behavior |
|---|---|---|
| `getStoragePath()` | `() => string` | Returns the resolved path to `links.json`. Honors `SHORTEN_HOME` env var (for tests); otherwise `~/.shorten/links.json`. |
| `load(path)` | `(string) => { links: Array<{code,url}> }` | Reads + parses the file. Returns `{ links: [] }` if the file does not exist. Throws `CorruptStoreError` if the file exists but is not valid/expected JSON. |
| `save(path, data)` | `(string, object) => void` | Ensures the parent dir exists, then writes `data` as pretty JSON. |
| `addLink(data, url, code?)` | `(object, string, string?) => string` | Validates the URL, generates a code if omitted, throws on duplicate code, mutates `data.links` by pushing, returns the final code. |
| `resolveLink(data, code)` | `(object, string) => string` | Returns the URL for `code`, or throws `NotFoundError`. |
| `listLinks(data)` | `(object) => Array<{code,url}>` | Returns `data.links` (the array, in insertion order). |
| `generateCode()` | `() => string` | Returns a random 6-char `[a-z0-9]` string. |

We will also export three error classes so callers can distinguish failure modes:
- `CorruptStoreError`
- `DuplicateCodeError`
- `InvalidUrlError`
- `NotFoundError`

**URL validation rule (concrete):** A URL is valid if `new URL(url)` does not throw **and** its protocol is `http:` or `https:`. Anything else throws `InvalidUrlError`.

We implement and test these in bite-sized pieces.

---

### Step 1.1 — Failing test: error classes exist and are distinct

Create `test/storage.test.js` with this content:

```js
import { test } from 'node:test';
import assert from 'node:assert/strict';

import {
  CorruptStoreError,
  DuplicateCodeError,
  InvalidUrlError,
  NotFoundError,
} from '../src/storage.js';

test('error classes are distinct Error subclasses with names', () => {
  for (const Cls of [CorruptStoreError, DuplicateCodeError, InvalidUrlError, NotFoundError]) {
    const err = new Cls('msg');
    assert.ok(err instanceof Error, `${Cls.name} should extend Error`);
    assert.equal(err.message, 'msg');
    assert.equal(err.name, Cls.name);
  }
});
```

Run it:

```bash
node --test
```

Expected: failure because `src/storage.js` does not exist yet. You will see something like:

```
Error: Cannot find module '.../src/storage.js'
ℹ fail 1
```

### Step 1.2 — Implement error classes

Create `src/storage.js` with this content:

```js
export class CorruptStoreError extends Error {
  constructor(message) {
    super(message);
    this.name = 'CorruptStoreError';
  }
}

export class DuplicateCodeError extends Error {
  constructor(message) {
    super(message);
    this.name = 'DuplicateCodeError';
  }
}

export class InvalidUrlError extends Error {
  constructor(message) {
    super(message);
    this.name = 'InvalidUrlError';
  }
}

export class NotFoundError extends Error {
  constructor(message) {
    super(message);
    this.name = 'NotFoundError';
  }
}
```

Run:

```bash
node --test
```

Expected: the error-class test passes.

```
ℹ tests 1
ℹ pass 1
ℹ fail 0
```

### Step 1.3 — Commit

```bash
git add .
git commit -m "storage: add error classes"
```

---

### Step 1.4 — Failing test: `getStoragePath` honors `SHORTEN_HOME` and defaults to home dir

Append to `test/storage.test.js`:

```js
import os from 'node:os';
import path from 'node:path';

import { getStoragePath } from '../src/storage.js';

test('getStoragePath uses SHORTEN_HOME when set', () => {
  const prev = process.env.SHORTEN_HOME;
  process.env.SHORTEN_HOME = '/tmp/custom-shorten-home';
  try {
    assert.equal(
      getStoragePath(),
      path.join('/tmp/custom-shorten-home', 'links.json'),
    );
  } finally {
    if (prev === undefined) delete process.env.SHORTEN_HOME;
    else process.env.SHORTEN_HOME = prev;
  }
});

test('getStoragePath defaults to ~/.shorten/links.json', () => {
  const prev = process.env.SHORTEN_HOME;
  delete process.env.SHORTEN_HOME;
  try {
    assert.equal(
      getStoragePath(),
      path.join(os.homedir(), '.shorten', 'links.json'),
    );
  } finally {
    if (prev !== undefined) process.env.SHORTEN_HOME = prev;
  }
});
```

Run:

```bash
node --test
```

Expected: failure — `getStoragePath` is not exported yet.

```
SyntaxError: ... does not provide an export named 'getStoragePath'
ℹ fail ...
```

### Step 1.5 — Implement `getStoragePath`

Add these imports at the **top** of `src/storage.js` (above the error classes):

```js
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
```

Then append this function to `src/storage.js`:

```js
export function getStoragePath() {
  if (process.env.SHORTEN_HOME) {
    return path.join(process.env.SHORTEN_HOME, 'links.json');
  }
  return path.join(os.homedir(), '.shorten', 'links.json');
}
```

Run:

```bash
node --test
```

Expected: all tests so far pass (`pass 3`).

### Step 1.6 — Commit

```bash
git add .
git commit -m "storage: add getStoragePath"
```

---

### Step 1.7 — Failing test: `load` and `save` round-trip, missing file, corrupt file

To avoid touching the real home directory, tests use a temp dir via `SHORTEN_HOME`. Append to `test/storage.test.js`:

```js
import fs from 'node:fs';

import { load, save } from '../src/storage.js';

function makeTempStore() {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shorten-test-'));
  return { dir, file: path.join(dir, 'links.json') };
}

test('load returns empty store when file does not exist', () => {
  const { file } = makeTempStore();
  // file not created yet
  assert.deepEqual(load(file), { links: [] });
});

test('save then load round-trips data', () => {
  const { file } = makeTempStore();
  const data = { links: [{ code: 'abc123', url: 'https://example.com' }] };
  save(file, data);
  assert.ok(fs.existsSync(file), 'file should be created by save');
  assert.deepEqual(load(file), data);
});

test('save creates the parent directory if missing', () => {
  const { dir } = makeTempStore();
  const nested = path.join(dir, 'deeper', 'links.json');
  save(nested, { links: [] });
  assert.ok(fs.existsSync(nested));
});

test('load throws CorruptStoreError on invalid JSON', () => {
  const { file } = makeTempStore();
  fs.writeFileSync(file, '{ this is not json', 'utf8');
  assert.throws(() => load(file), CorruptStoreError);
});

test('load throws CorruptStoreError when JSON lacks a links array', () => {
  const { file } = makeTempStore();
  fs.writeFileSync(file, JSON.stringify({ wrong: true }), 'utf8');
  assert.throws(() => load(file), CorruptStoreError);
});
```

Run:

```bash
node --test
```

Expected: failure — `load`/`save` not exported yet.

### Step 1.8 — Implement `load` and `save`

Append to `src/storage.js`:

```js
export function load(filePath) {
  let raw;
  try {
    raw = fs.readFileSync(filePath, 'utf8');
  } catch (err) {
    if (err.code === 'ENOENT') {
      return { links: [] };
    }
    throw err;
  }

  let parsed;
  try {
    parsed = JSON.parse(raw);
  } catch {
    throw new CorruptStoreError(
      `Store file at ${filePath} is not valid JSON`,
    );
  }

  if (
    parsed === null ||
    typeof parsed !== 'object' ||
    !Array.isArray(parsed.links)
  ) {
    throw new CorruptStoreError(
      `Store file at ${filePath} does not contain a "links" array`,
    );
  }

  return parsed;
}

export function save(filePath, data) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
}
```

Run:

```bash
node --test
```

Expected: all tests pass (`pass 8`).

### Step 1.9 — Commit

```bash
git add .
git commit -m "storage: add load/save with corrupt-file handling"
```

---

### Step 1.10 — Failing test: `generateCode`

Append to `test/storage.test.js`:

```js
import { generateCode } from '../src/storage.js';

test('generateCode returns a 6-char lowercase alphanumeric string', () => {
  for (let i = 0; i < 50; i++) {
    const code = generateCode();
    assert.match(code, /^[a-z0-9]{6}$/, `bad code: ${code}`);
  }
});

test('generateCode produces varied output', () => {
  const seen = new Set();
  for (let i = 0; i < 100; i++) seen.add(generateCode());
  // Astronomically unlikely to collide into < 50 distinct values.
  assert.ok(seen.size > 50, `too few distinct codes: ${seen.size}`);
});
```

Run:

```bash
node --test
```

Expected: failure — `generateCode` not exported.

### Step 1.11 — Implement `generateCode`

Add this import near the top of `src/storage.js` (with the other imports):

```js
import crypto from 'node:crypto';
```

Append this function:

```js
const CODE_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
const CODE_LENGTH = 6;

export function generateCode() {
  const bytes = crypto.randomBytes(CODE_LENGTH);
  let out = '';
  for (let i = 0; i < CODE_LENGTH; i++) {
    out += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
  }
  return out;
}
```

Run:

```bash
node --test
```

Expected: all tests pass (`pass 10`).

### Step 1.12 — Commit

```bash
git add .
git commit -m "storage: add generateCode"
```

---

### Step 1.13 — Failing test: `addLink`, `resolveLink`, `listLinks`

Append to `test/storage.test.js`:

```js
import { addLink, resolveLink, listLinks } from '../src/storage.js';

test('addLink with explicit code stores and returns the code', () => {
  const data = { links: [] };
  const code = addLink(data, 'https://example.com', 'mycode');
  assert.equal(code, 'mycode');
  assert.deepEqual(data.links, [{ code: 'mycode', url: 'https://example.com' }]);
});

test('addLink without a code generates one', () => {
  const data = { links: [] };
  const code = addLink(data, 'https://example.com');
  assert.match(code, /^[a-z0-9]{6}$/);
  assert.equal(data.links.length, 1);
  assert.equal(data.links[0].url, 'https://example.com');
});

test('addLink rejects non-http(s) and malformed URLs', () => {
  const data = { links: [] };
  assert.throws(() => addLink(data, 'not a url'), InvalidUrlError);
  assert.throws(() => addLink(data, 'ftp://example.com'), InvalidUrlError);
  assert.equal(data.links.length, 0);
});

test('addLink rejects a duplicate code', () => {
  const data = { links: [{ code: 'dup', url: 'https://a.com' }] };
  assert.throws(
    () => addLink(data, 'https://b.com', 'dup'),
    DuplicateCodeError,
  );
  assert.equal(data.links.length, 1);
});

test('addLink preserves insertion order', () => {
  const data = { links: [] };
  addLink(data, 'https://1.com', 'one');
  addLink(data, 'https://2.com', 'two');
  addLink(data, 'https://3.com', 'three');
  assert.deepEqual(data.links.map((l) => l.code), ['one', 'two', 'three']);
});

test('resolveLink returns the stored url', () => {
  const data = { links: [{ code: 'x', url: 'https://x.com' }] };
  assert.equal(resolveLink(data, 'x'), 'https://x.com');
});

test('resolveLink throws NotFoundError for unknown code', () => {
  const data = { links: [] };
  assert.throws(() => resolveLink(data, 'nope'), NotFoundError);
});

test('listLinks returns all pairs in insertion order', () => {
  const data = {
    links: [
      { code: 'a', url: 'https://a.com' },
      { code: 'b', url: 'https://b.com' },
    ],
  };
  assert.deepEqual(listLinks(data), data.links);
});
```

Run:

```bash
node --test
```

Expected: failure — these three functions are not exported.

### Step 1.14 — Implement `addLink`, `resolveLink`, `listLinks`

Append to `src/storage.js`:

```js
function validateUrl(url) {
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    throw new InvalidUrlError(`Invalid URL: ${url}`);
  }
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
    throw new InvalidUrlError(
      `URL must use http or https: ${url}`,
    );
  }
}

export function addLink(data, url, code) {
  validateUrl(url);

  let finalCode = code;
  if (finalCode === undefined || finalCode === null || finalCode === '') {
    do {
      finalCode = generateCode();
    } while (data.links.some((l) => l.code === finalCode));
  } else if (data.links.some((l) => l.code === finalCode)) {
    throw new DuplicateCodeError(`Code already exists: ${finalCode}`);
  }

  data.links.push({ code: finalCode, url });
  return finalCode;
}

export function resolveLink(data, code) {
  const found = data.links.find((l) => l.code === code);
  if (!found) {
    throw new NotFoundError(`Unknown code: ${code}`);
  }
  return found.url;
}

export function listLinks(data) {
  return data.links;
}
```

Run:

```bash
node --test
```

Expected: all tests pass (`pass 18`).

### Step 1.15 — Commit

```bash
git add .
git commit -m "storage: add addLink/resolveLink/listLinks with URL validation"
```

---

## Task 2: `resolve` and `list` commands (shared data access) + CLI dispatch

We build the CLI entry point and wire up `resolve` and `list` first, because the spec notes they share identical data access (load store → read). `add` follows in Task 3.

### Design of `bin/shorten.js`

The entry point:
1. Reads `process.argv.slice(2)`.
2. Dispatches on the first argument (the command).
3. Catches known errors and exits with code `1` and a message on **stderr**; success prints to **stdout** and exits `0`.
4. Unknown command → usage message on stderr, exit `1`.

To make this testable without spawning processes for every case, we expose a `run(argv)` function and only call it when the file is executed directly.

**`run(argv)` contract used by tests:**
- `run` takes the argument array (everything after `node bin/shorten.js`).
- It returns an object `{ stdout, exitCode }` where `stdout` is the string to print and `exitCode` is the process exit code.
- On error it **throws** the underlying error (the thin top-level wrapper translates that into stderr + exit code). Tests assert on thrown error types and on `stdout`.

Wait — to keep stderr/exit handling in one place and keep `run` pure, we define:
- `run(argv)` → returns `{ stdout: string }` on success, **throws** on any failure.
- A top-level `main()` that calls `run`, prints `stdout`, and translates thrown errors into `console.error` + `process.exitCode = 1`.

This keeps `run` easy to unit-test (assert returned `stdout` or assert it throws), while `main` owns side effects.

### Step 2.1 — Failing test: `run` exists, unknown command throws, no-command throws

Create `test/cli.test.js` with this content:

```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { run } from '../bin/shorten.js';
import { NotFoundError } from '../src/storage.js';

// Each test gets an isolated store directory via SHORTEN_HOME.
function withTempHome(fn) {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'shorten-cli-'));
  const prev = process.env.SHORTEN_HOME;
  process.env.SHORTEN_HOME = dir;
  try {
    return fn(dir);
  } finally {
    if (prev === undefined) delete process.env.SHORTEN_HOME;
    else process.env.SHORTEN_HOME = prev;
  }
}

test('run throws on no command', () => {
  withTempHome(() => {
    assert.throws(() => run([]), /usage/i);
  });
});

test('run throws on unknown command', () => {
  withTempHome(() => {
    assert.throws(() => run(['frobnicate']), /unknown command/i);
  });
});
```

Run:

```bash
node --test
```

Expected: failure — `bin/shorten.js` does not exist / does not export `run`.

### Step 2.2 — Implement CLI skeleton with `run` and `main`

Create `bin/shorten.js` with this content:

```js
#!/usr/bin/env node
import process from 'node:process';

import {
  getStoragePath,
  load,
  save,
  addLink,
  resolveLink,
  listLinks,
} from '../src/storage.js';

const USAGE = `usage:
  shorten add <url> [--code <code>]
  shorten resolve <code>
  shorten list`;

export function run(argv) {
  const [command, ...rest] = argv;

  if (!command) {
    throw new Error(USAGE);
  }

  switch (command) {
    case 'add':
      return cmdAdd(rest);
    case 'resolve':
      return cmdResolve(rest);
    case 'list':
      return cmdList(rest);
    default:
      throw new Error(`unknown command: ${command}\n${USAGE}`);
  }
}

function cmdAdd() {
  throw new Error('not implemented'); // replaced in Task 3
}

function cmdResolve() {
  throw new Error('not implemented'); // replaced in Step 2.4
}

function cmdList() {
  throw new Error('not implemented'); // replaced in Step 2.6
}

function main() {
  try {
    const { stdout } = run(process.argv.slice(2));
    if (stdout) process.stdout.write(stdout.endsWith('\n') ? stdout : stdout + '\n');
  } catch (err) {
    process.stderr.write(`${err.