All files / features / projects projectConfigLint.ts

53.22% Statements 33/62
50% Branches 25/50
50% Functions 3/6
78.04% Lines 32/41

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                    69x 69x   2x 2x     56x       30x       33x 33x   33x 6x     27x 27x 4x       27x 27x 23x 6x   17x 23x 3x     17x 51x 51x 20x 7x 4x   7x   13x 5x 8x 2x           27x    
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). 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, non-string
// (including null) or empty url / project_id values, and non-integer
// (including null) 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
 
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 }
}
 
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) {
    if (!isPlainObject(vikunja)) {
      errors.push(error('vikunja', 'vikunja: must be an object'))
    } else {
      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_CONFIG_KEYS) {
        const value = vikunja[key]
        if (value === undefined) continue
        if (key === 'view_id') {
          if (typeof value !== 'number' || !Number.isInteger(value)) {
            errors.push(error('vikunja.view_id', 'vikunja.view_id: must be an 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`))
        }
      }
    }
  }
 
  return { errors, warnings }
}