All files / features / projects projectConfigLint.ts

53.7% Statements 29/54
50% Branches 19/38
50% Functions 3/6
68.29% Lines 28/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                  152x 152x   2x 2x     42x       26x       26x 26x   26x 6x     20x 20x 4x       20x 20x 16x 6x   10x 12x 3x     10x 20x 20x 9x 5x 4x 2x           20x    
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? } }). Undeclared root
// keys, a non-object vikunja value, undeclared vikunja keys, and non-string
// (including null) or empty url / project_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'] 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 (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 }
}