# Link Shortener CLI — Implementation Plan

## Overview

We are building a Node.js CLI named `shorten` that manages a JSON database of shortened links at `~/.shorten/links.json`. It has three commands: `add`, `resolve`, and `list`. All three share a single storage module.

**Tech constraints:**
- Node 20+ (uses built-in `node:test`, `node:assert`, `node:fs`, `node:path`, `node:os`, `node:crypto`).
- No third-party dependencies.
- TDD: every behavior gets a failing test first, then implementation.

**Project layout we will create:**

```
shorten-cli/
├── package.json
├── bin/
│   └── shorten.js          # CLI entry point (arg parsing + dispatch)
├── src/
│   ├── storage.js          # shared JSON read/write + record model
│   ├── add.js              # add command logic
│   ├── resolve.js          # resolve command logic
│   └── list.js             # list command logic
└── test/
    ├── storage.test.js
    ├── add.test.js
    ├── resolve.test.js
    └── list.test.js
```

**Storage data model.** The JSON file holds an object with a `links` array, where each entry is `{ "code": string, "url": string }`. We use an array (not a map) so insertion order is preserved for `list`. Example file contents:

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

**Design decision — testability of storage path.** The storage functions must not hard-code `~/.shorten/links.json`, or tests would clobber the real user file. Every storage function takes the file path as its **first argument**. The CLI entry point (`bin/shorten.js`) computes the real path from `os.homedir()` and passes it in. Tests pass a temp-directory path.

---

## Task 0: Project scaffolding

**Goal:** create the directory structure and `package.json` so tests can run.

### Step 0.1: Create directories and package.json

Run these commands from your working directory:

```bash
mkdir -p shorten-cli/bin shorten-cli/src shorten-cli/test
cd shorten-cli
```

Create `package.json` with exactly this content:

```json
{
  "name": "shorten-cli",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "shorten": "./bin/shorten.js"
  },
  "scripts": {
    "test": "node --test"
  }
}
```

Note `"type": "module"` — all files use ESM (`import`/`export`).

### Step 0.2: Verify the test runner works

Create a temporary throwaway test `test/smoke.test.js`:

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

test('smoke', () => {
  assert.equal(1 + 1, 2);
});
```

Run:

```bash
npm test
```

Expected output (the key line):

```
# pass 1
# fail 0
```

Now delete the smoke test — it has served its purpose:

```bash
rm test/smoke.test.js
```

### Step 0.3: Commit

```bash
git init
git add .
git commit -m "Scaffold shorten-cli project structure"
```

Expected: a commit is created. (`git init` is harmless if a repo already exists.)

---

## Task 1: Storage module (shared)

**Goal:** a module that reads and writes the JSON database, creating the file/dir on first use, and dealing with corrupt files. This is the shared foundation for all three commands.

### Public API of `src/storage.js`

```
readLinks(filePath) -> Array<{code, url}>
    Reads the file. If the file does not exist, returns [].
    If the file is corrupt (invalid JSON or wrong shape), throws StorageError.

writeLinks(filePath, links) -> void
    Creates the parent directory if needed, then writes the file
    as { "links": links } with 2-space indentation.

findByCode(links, code) -> {code, url} | undefined
    Pure helper: linear search of the array.

class StorageError extends Error  // thrown on corrupt file
```

We will build these test-first.

---

### Step 1.1: Write failing tests for `readLinks`

Create `test/storage.test.js`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { readLinks, writeLinks, findByCode, StorageError } from '../src/storage.js';

// Helper: make a fresh temp dir and return the path to a links.json inside it.
async function tempFilePath() {
  const dir = await mkdtemp(join(tmpdir(), 'shorten-test-'));
  return { dir, file: join(dir, 'links.json') };
}

test('readLinks returns [] when file does not exist', async () => {
  const { dir, file } = await tempFilePath();
  try {
    const links = await readLinks(file);
    assert.deepEqual(links, []);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test('readLinks parses a valid file', async () => {
  const { dir, file } = await tempFilePath();
  try {
    await writeFile(
      file,
      JSON.stringify({ links: [{ code: 'abc123', url: 'https://example.com' }] }),
    );
    const links = await readLinks(file);
    assert.deepEqual(links, [{ code: 'abc123', url: 'https://example.com' }]);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test('readLinks throws StorageError on invalid JSON', async () => {
  const { dir, file } = await tempFilePath();
  try {
    await writeFile(file, '{ this is not json');
    await assert.rejects(() => readLinks(file), StorageError);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test('readLinks throws StorageError on wrong shape (missing links array)', async () => {
  const { dir, file } = await tempFilePath();
  try {
    await writeFile(file, JSON.stringify({ notLinks: 5 }));
    await assert.rejects(() => readLinks(file), StorageError);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});
```

Run it:

```bash
npm test
```

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

```
Cannot find module '.../src/storage.js'
```

and `# fail` greater than 0.

### Step 1.2: Implement `readLinks` (and `StorageError`)

Create `src/storage.js`:

```javascript
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';

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

export async function readLinks(filePath) {
  let raw;
  try {
    raw = await readFile(filePath, 'utf8');
  } catch (err) {
    if (err.code === 'ENOENT') {
      return [];
    }
    throw err;
  }

  let parsed;
  try {
    parsed = JSON.parse(raw);
  } catch {
    throw new StorageError(`Database file is corrupt (invalid JSON): ${filePath}`);
  }

  if (
    parsed === null ||
    typeof parsed !== 'object' ||
    !Array.isArray(parsed.links)
  ) {
    throw new StorageError(`Database file is corrupt (unexpected shape): ${filePath}`);
  }

  return parsed.links;
}
```

Run:

```bash
npm test
```

Expected: the four `readLinks` tests pass. (`writeLinks` and `findByCode` are imported but not yet tested-against in a way that runs — actually they are imported at top, so the import will fail because they aren't exported yet.) To avoid a confusing import error, add temporary stub exports now so the file loads; we implement them properly in the next steps:

Add these to the bottom of `src/storage.js`:

```javascript
export async function writeLinks(filePath, links) {
  await mkdir(dirname(filePath), { recursive: true });
  const contents = JSON.stringify({ links }, null, 2);
  await writeFile(filePath, contents, 'utf8');
}

export function findByCode(links, code) {
  return links.find((entry) => entry.code === code);
}
```

Now run:

```bash
npm test
```

Expected:

```
# pass 4
# fail 0
```

(We implemented `writeLinks` and `findByCode` here so imports resolve; the next steps add tests that exercise them.)

### Step 1.3: Add failing tests for `writeLinks` and `findByCode`

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

```javascript
test('writeLinks creates parent directory and round-trips data', async () => {
  const { dir } = await tempFilePath();
  // Use a nested path whose directory does NOT exist yet.
  const nestedFile = join(dir, 'nested', 'sub', 'links.json');
  try {
    const data = [{ code: 'xY9zQ1', url: 'https://nodejs.org' }];
    await writeLinks(nestedFile, data);
    const readBack = await readLinks(nestedFile);
    assert.deepEqual(readBack, data);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test('writeLinks formats with 2-space indentation', async () => {
  const { dir, file } = await tempFilePath();
  try {
    await writeLinks(file, [{ code: 'a1', url: 'https://x.test' }]);
    const raw = await readFile(file, 'utf8');
    assert.equal(
      raw,
      '{\n  "links": [\n    {\n      "code": "a1",\n      "url": "https://x.test"\n    }\n  ]\n}',
    );
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test('findByCode returns the matching entry', () => {
  const links = [
    { code: 'aaa', url: 'https://a.test' },
    { code: 'bbb', url: 'https://b.test' },
  ];
  assert.deepEqual(findByCode(links, 'bbb'), { code: 'bbb', url: 'https://b.test' });
});

test('findByCode returns undefined when not found', () => {
  assert.equal(findByCode([], 'zzz'), undefined);
});
```

You also need `readFile` available in the test file. Update the import line near the top of `test/storage.test.js` from:

```javascript
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
```

to:

```javascript
import { mkdtemp, writeFile, readFile, rm, mkdir } from 'node:fs/promises';
```

Run:

```bash
npm test
```

Expected: all storage tests pass (because we already wrote the implementations in Step 1.2):

```
# pass 8
# fail 0
```

### Step 1.4: Commit

```bash
git add .
git commit -m "Add shared storage module with corrupt-file handling"
```

---

## Task 2: `add` command

**Goal:** implement `shorten add <url> [--code <code>]`. Generates a random code if none given, validates the URL, rejects duplicate codes, persists, and returns the code.

### Public API of `src/add.js`

```
generateCode() -> string
    Returns a random 6-character alphanumeric code (a-z, A-Z, 0-9).

isValidUrl(url) -> boolean
    Returns true if url parses via the WHATWG URL constructor AND
    has an http: or https: protocol.

class AddError extends Error
    Thrown for user-facing failures (invalid URL, duplicate code).

addLink(filePath, { url, code }) -> Promise<string>
    code is optional. Validates url; generates code if absent;
    rejects duplicate code; appends and writes; returns the final code.
```

### Step 2.1: Write failing tests for `generateCode` and `isValidUrl`

Create `test/add.test.js`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { generateCode, isValidUrl, addLink, AddError } from '../src/add.js';
import { readLinks } from '../src/storage.js';

async function tempFilePath() {
  const dir = await mkdtemp(join(tmpdir(), 'shorten-add-test-'));
  return { dir, file: join(dir, 'links.json') };
}

test('generateCode returns a 6-char alphanumeric string', () => {
  const code = generateCode();
  assert.equal(typeof code, 'string');
  assert.equal(code.length, 6);
  assert.match(code, /^[A-Za-z0-9]{6}$/);
});

test('generateCode is reasonably random (no immediate collision)', () => {
  const a = generateCode();
  const b = generateCode();
  assert.notEqual(a, b);
});

test('isValidUrl accepts http and https URLs', () => {
  assert.equal(isValidUrl('https://example.com'), true);
  assert.equal(isValidUrl('http://example.com/path?q=1'), true);
});

test('isValidUrl rejects non-http(s) and garbage', () => {
  assert.equal(isValidUrl('not a url'), false);
  assert.equal(isValidUrl('ftp://example.com'), false);
  assert.equal(isValidUrl(''), false);
});
```

Run:

```bash
npm test
```

Expected: failure — `src/add.js` does not exist. `# fail` > 0.

### Step 2.2: Implement `generateCode` and `isValidUrl`

Create `src/add.js`:

```javascript
import { randomInt } from 'node:crypto';
import { readLinks, writeLinks, findByCode } from './storage.js';

const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const CODE_LENGTH = 6;

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

export function generateCode() {
  let code = '';
  for (let i = 0; i < CODE_LENGTH; i += 1) {
    code += ALPHABET[randomInt(ALPHABET.length)];
  }
  return code;
}

export function isValidUrl(url) {
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    return false;
  }
  return parsed.protocol === 'http:' || parsed.protocol === 'https:';
}
```

Run:

```bash
npm test
```

Expected: the four new tests pass. Note `addLink` is imported in the test file but not yet exported — the import will fail. Add a stub now so the import resolves, to be fleshed out and tested in the next step. Append to `src/add.js`:

```javascript
export async function addLink(filePath, { url, code }) {
  if (!isValidUrl(url)) {
    throw new AddError(`Invalid URL: ${url}`);
  }

  const links = await readLinks(filePath);

  let finalCode = code;
  if (finalCode) {
    if (findByCode(links, finalCode)) {
      throw new AddError(`Code already exists: ${finalCode}`);
    }
  } else {
    // Generate a code that does not collide with existing ones.
    do {
      finalCode = generateCode();
    } while (findByCode(links, finalCode));
  }

  links.push({ code: finalCode, url });
  await writeLinks(filePath, links);
  return finalCode;
}
```

Run again:

```bash
npm test
```

Expected: all tests still pass (`# fail 0`). We implemented `addLink` here so imports resolve; next step adds tests that exercise it.

### Step 2.3: Add failing/confirming tests for `addLink`

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

```javascript
test('addLink stores a URL with a provided code and returns it', async () => {
  const { dir, file } = await tempFilePath();
  try {
    const code = await addLink(file, { url: 'https://example.com', code: 'mycode' });
    assert.equal(code, 'mycode');
    const links = await readLinks(file);
    assert.deepEqual(links, [{ code: 'mycode', url: 'https://example.com' }]);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test('addLink generates a code when none provided', async () => {
  const { dir, file } = await tempFilePath();
  try {
    const code = await addLink(file, { url: 'https://example.com' });
    assert.match(code, /^[A-Za-z0-9]{6}$/);
    const links = await readLinks(file);
    assert.equal(links.length, 1);
    assert.equal(links[0].code, code);
    assert.equal(links[0].url, 'https://example.com');
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test('addLink rejects an invalid URL with AddError', async () => {
  const { dir, file } = await tempFilePath();
  try {
    await assert.rejects(
      () => addLink(file, { url: 'nonsense' }),
      AddError,
    );
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test('addLink rejects a duplicate provided code with AddError', async () => {
  const { dir, file } = await tempFilePath();
  try {
    await addLink(file, { url: 'https://a.test', code: 'dup' });
    await assert.rejects(
      () => addLink(file, { url: 'https://b.test', code: 'dup' }),
      AddError,
    );
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test('addLink preserves insertion order across multiple adds', async () => {
  const { dir, file } = await tempFilePath();
  try {
    await addLink(file, { url: 'https://1.test', code: 'one' });
    await addLink(file, { url: 'https://2.test', code: 'two' });
    await addLink(file, { url: 'https://3.test', code: 'three' });
    const links = await readLinks(file);
    assert.deepEqual(
      links.map((l) => l.code),
      ['one', 'two', 'three'],
    );
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});
```

Run:

```bash
npm test
```

Expected: all add tests pass:

```
# fail 0
```

### Step 2.4: Commit

```bash
git add .
git commit -m "Add 'add' command: URL validation, code generation, dedupe"
```

---

## Task 3: `resolve` and `list` commands + CLI wiring

**Goal:** implement `resolve` and `list` (both read-only, sharing storage), then wire all three commands into the `bin/shorten.js` entry point with argument parsing and exit codes.

### Public APIs

`src/resolve.js`:

```
class ResolveError extends Error   // thrown when code is unknown
resolveLink(filePath, code) -> Promise<string>   // returns the URL
```

`src/list.js`:

```
listLinks(filePath) -> Promise<Array<{code, url}>>   // in insertion order
formatList(links) -> string
    One "code -> url" line per entry, joined by "\n".
    Returns "" for an empty list.
```

### Step 3.1: Write failing tests for `resolve`

Create `test/resolve.test.js`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { resolveLink, ResolveError } from '../src/resolve.js';
import { writeLinks } from '../src/storage.js';

async function tempFilePath() {
  const dir = await mkdtemp(join(tmpdir(), 'shorten-resolve-test-'));
  return { dir, file: join(dir, 'links.json') };
}

test('resolveLink returns the URL for a known code', async () => {
  const { dir, file } = await tempFilePath();
  try {
    await writeLinks(file, [{ code: 'abc', url: 'https://example.com' }]);
    const url = await resolveLink(file, 'abc');
    assert.equal(url, 'https://example.com');
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test('resolveLink throws ResolveError for an unknown code', async () => {
  const { dir, file } = await tempFilePath();
  try {
    await writeLinks(file, [{ code: 'abc', url: 'https://example.com' }]);
    await assert.rejects(() => resolveLink(file, 'nope'), ResolveError);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});

test('resolveLink throws ResolveError when database is empty/missing', async () => {
  const { dir, file } = await tempFilePath();
  try {
    await assert.rejects(() => resolveLink(file, 'anything'), ResolveError);
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});
```

Run:

```bash
npm test
```

Expected: failure — `src/resolve.js` does not exist. `# fail` > 0.

### Step 3.2: Implement `resolve`

Create `src/resolve.js`:

```javascript
import { readLinks, findByCode } from './storage.js';

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

export async function resolveLink(filePath, code) {
  const links = await readLinks(filePath);
  const entry = findByCode(links, code);
  if (!entry) {
    throw new ResolveError(`Unknown code: ${code}`);
  }
  return entry.url;
}
```

Run:

```bash
npm test
```

Expected: the three resolve tests pass, `# fail 0`.

### Step 3.3: Write failing tests for `list`

Create `test/list.test.js`:

```javascript
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { listLinks, formatList } from '../src/list.js';
import { writeLinks } from '../src/storage.js';

async function tempFilePath() {
  const dir = await mkdtemp(join(tmpdir(), 'shorten-list-test-'));
  return { dir, file: join(dir, 'links.json') };
}

test('listLinks returns [] for a