All files / features / projects projectConfigLint.ts

8% Statements 4/50
0% Branches 0/42
0% Functions 0/4
8.51% Lines 4/47

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101                          65x 65x         65x         65x                                                                                                                                                        
import type { LintIssue, LintResult } from '@/layers/interfaces'
 
// Mirrors api/internal/handlers/project_config.go LintProjectConfig exactly:
// same rule semantics, same message strings, same emission order (keys are
// visited in sorted order like the API's sortedObjectKeys; required-key
// issues are emitted before per-key checks like the API's loop order). The
// lint is wired to the project `config` field, so `parsed` is the full
// project config object ({ vikunja?: { url?, project_id?, view_id? } }).
// Undeclared root keys, a non-object vikunja value, undeclared vikunja
// keys, missing required keys, non-string (including null) or empty url /
// project_id values, url values that are not absolute http(s) URLs with a
// non-empty hostname, non-numeric project_id values, and non-positive-
// integer view_id values are errors. A config without vikunja yields no
// issues. The API's "config: must be valid JSON" issue cannot occur here —
// the YAML editor parses before linting.
 
const CONFIG_ROOT_KEYS = new Set(['vikunja'])
const VIKUNJA_CONFIG_KEYS = ['url', 'project_id', 'view_id'] as const
const VIKUNJA_REQUIRED_KEYS = ['project_id', 'url', 'view_id'] as const
const PROJECT_ID_PATTERN = /^[0-9]+$/
 
function isPlainObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value)
}
 
function error(field: string, message: string): LintIssue {
  return { field, message }
}

// Mirrors the API's url lint: a parsed absolute http(s) URL with a
// non-empty hostname. URL parsing normalizes the scheme, so the raw value
// must start with the parsed scheme + "://" — "HTTP://x" must not pass
// even though its protocol parses as http.
function isAbsoluteHttpUrl(value: string): boolean {
  let parsed: URL
  try {
    parsed = new URL(value)
  } catch {
    return false
  }
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
  if (parsed.hostname === '') return false
  return value.startsWith(parsed.protocol.slice(0, -1) + '://')
}
 
export function projectConfigLint(parsed: unknown, _raw: string): LintResult {
  const errors: LintIssue[] = []
  const warnings: LintIssue[] = []
 
  if (!isPlainObject(parsed)) {
    return { errors: [error('config', 'config: must be a JSON object')], warnings }
  }

  for (const key of Object.keys(parsed).sort()) {
    if (!CONFIG_ROOT_KEYS.has(key)) {
      errors.push(error('config', `config: unknown key "${key}"`))
    }
  }

  const vikunja = parsed['vikunja']
  if (vikunja === undefined) {
    return { errors, warnings }
  }
  if (!isPlainObject(vikunja)) {
    errors.push(error('vikunja', 'vikunja: must be an object'))
    return { errors, warnings }
  }
 
  for (const key of Object.keys(vikunja).sort()) {
    if (!VIKUNJA_CONFIG_KEYS.includes(key as (typeof VIKUNJA_CONFIG_KEYS)[number])) {
      errors.push(error('vikunja', `vikunja: unknown key "${key}"`))
    }
  }
  for (const key of VIKUNJA_REQUIRED_KEYS) {
    if (vikunja[key] === undefined) {
      errors.push(error(`vikunja.${key}`, `vikunja.${key}: required`))
    }
  }
  for (const key of VIKUNJA_CONFIG_KEYS) {
    const value = vikunja[key]
    if (value === undefined) continue
    if (key === 'view_id') {
      if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
        errors.push(error('vikunja.view_id', 'vikunja.view_id: must be a positive integer'))
      }
      continue
    }
    if (typeof value !== 'string') {
      errors.push(error(`vikunja.${key}`, `vikunja.${key}: must be a string`))
    } else if (value === '') {
      errors.push(error(`vikunja.${key}`, `vikunja.${key}: must be a non-empty string`))
    } else if (key === 'url' && !isAbsoluteHttpUrl(value)) {
      errors.push(error('vikunja.url', 'vikunja.url: must be an absolute http(s) URL'))
    } else if (key === 'project_id' && !PROJECT_ID_PATTERN.test(value)) {
      errors.push(error('vikunja.project_id', 'vikunja.project_id: must be a numeric string'))
    }
  }

  return { errors, warnings }
}