import { test } from 'brittle'
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import {
loadConfig,
applyCliOverrides,
isExternalRef,
isPlaceholder,
getDefaults,
DEFAULT_CONFIG
} from '../lib/config.js'
function mkFixture (name) {
const dir = join(tmpdir(), 'codetree-test-' + name + '-' + Date.now())
mkdirSync(dir, { recursive: true })
return dir
}
// --- loadConfig ---
test('config: missing .codetree.json returns defaults', (t) => {
const dir = mkFixture('missing')
const config = loadConfig(dir)
t.is(config.autoCrawl, false, 'default autoCrawl is false')
t.ok(Array.isArray(config.externalSymbols), 'externalSymbols is an array')
t.is(config.externalSymbols.length, 0, 'no externals by default')
t.ok(Array.isArray(config.placeholderPatterns), 'placeholder patterns defaulted')
t.ok(config.placeholderPatterns.length > 0, 'has some default placeholders')
rmSync(dir, { recursive: true, force: true })
})
test('config: user values override defaults', (t) => {
const dir = mkFixture('override')
writeFileSync(join(dir, '.codetree.json'), JSON.stringify({
autoCrawl: true,
externalSymbols: ['an.sign', 'an.open'],
externalPackages: ['anproto']
}))
const config = loadConfig(dir)
t.is(config.autoCrawl, true, 'autoCrawl overridden')
t.ok(config.externalSymbols.includes('an.sign'), 'external symbol loaded')
t.ok(config.externalPackages.includes('anproto'), 'external package loaded')
// Defaults still present
t.ok(config.placeholderPatterns.length > 0, 'placeholders preserved')
rmSync(dir, { recursive: true, force: true })
})
test('config: bad JSON logs but does not throw', (t) => {
const dir = mkFixture('badjson')
writeFileSync(join(dir, '.codetree.json'), '{ this is not valid json')
// Silence the expected console.warn so the test log stays clean
const origWarn = console.warn
console.warn = () => {}
try {
const config = loadConfig(dir)
t.ok(config, 'returns some config on bad JSON')
t.is(config.autoCrawl, false, 'falls back to defaults')
} finally {
console.warn = origWarn
}
rmSync(dir, { recursive: true, force: true })
})
// --- applyCliOverrides ---
test('config: CLI overrides win over loaded config', (t) => {
const base = { ...getDefaults(), autoCrawl: false, siblingDirs: ['../a'] }
const merged = applyCliOverrides(base, { autoCrawl: true, siblingDirs: ['../b'] })
t.is(merged.autoCrawl, true, 'CLI autoCrawl wins')
t.ok(merged.siblingDirs.includes('../a'), 'original sibling preserved')
t.ok(merged.siblingDirs.includes('../b'), 'CLI sibling merged in')
})
test('config: applyCliOverrides without fields is a no-op copy', (t) => {
const base = { ...getDefaults(), autoCrawl: true }
const merged = applyCliOverrides(base, {})
t.is(merged.autoCrawl, true, 'no accidental reset')
t.not(merged, base, 'returns a new object')
})
// --- isExternalRef ---
test('config: isExternalRef matches external symbols', (t) => {
const config = { ...getDefaults(), externalSymbols: ['an.sign', 'IdentityKey'] }
t.ok(isExternalRef('an.sign', config), 'symbol match')
t.ok(isExternalRef('IdentityKey', config), 'class-like external')
t.not(isExternalRef('SomethingElse', config), 'non-external not matched')
})
test('config: isExternalRef matches external packages', (t) => {
const config = { ...getDefaults(), externalPackages: ['anproto', 'keet-identity-key'] }
t.ok(isExternalRef('anproto', config), 'package name match')
t.not(isExternalRef('something-else', config), 'non-package not matched')
})
test('config: isExternalRef matches external path prefixes', (t) => {
const config = { ...getDefaults(), externalPaths: ['hyper-fleeky/anproto'] }
t.ok(isExternalRef('hyper-fleeky/anproto', config), 'exact path match')
t.ok(isExternalRef('hyper-fleeky/anproto/lib/an.js', config), 'subpath match')
t.not(isExternalRef('hyper-fleeky/anproto-other', config), 'different prefix not matched')
})
// --- isPlaceholder (invariant I3) ---
test('config: isPlaceholder recognizes common placeholder forms', (t) => {
const config = getDefaults()
// Home paths
t.ok(isPlaceholder('~/.hyperident/', config), 'home dir shorthand')
t.ok(isPlaceholder('~/config/foo', config), 'home subdir')
// Canonical "/path/to/" placeholder
t.ok(isPlaceholder('/path/to/hyperident', config), '/path/to/ placeholder')
// Angle-bracket placeholders
t.ok(isPlaceholder('<placeholder>', config), 'angle brackets')
t.ok(isPlaceholder('<name>', config), 'angle brackets short form')
// Variable interpolation
t.ok(isPlaceholder('${variable}', config), 'shell interpolation')
t.ok(isPlaceholder('prefix${foo}suffix', config), 'embedded interpolation')
// Path with embedded template token
t.ok(isPlaceholder('/etc/<name>', config), 'path with embedded token')
// Real paths should NOT match
t.not(isPlaceholder('lib/auth/verify.js', config), 'real path not a placeholder')
t.not(isPlaceholder('SomeClass', config), 'class name not a placeholder')
t.not(isPlaceholder('foo()', config), 'function call not a placeholder')
})
test('config: custom placeholder patterns can be added', (t) => {
const config = {
...getDefaults(),
placeholderPatterns: [...DEFAULT_CONFIG.placeholderPatterns, '^@@internal/']
}
t.ok(isPlaceholder('@@internal/foo', config), 'custom pattern matched')
t.ok(isPlaceholder('~/.config', config), 'default pattern still applied')
})
test('config: malformed regex in placeholderPatterns is tolerated', (t) => {
const config = {
...getDefaults(),
placeholderPatterns: ['(unclosed group', '^~/']
}
// Should not throw; bad regex just doesn't match
const result = isPlaceholder('~/foo', config)
t.ok(result, 'good pattern still works despite sibling bad regex')
})