12345678module cli910import json211import os12import time13import src.fmt14import src.history15import src.storage1617pub fn cmd_doctor(raw []string) int {18 args := parse('doctor', raw, Options{19 values: ['backup']20 flags: ['json', 'yes', 'fast']21 }) or { return usage_error(err.msg()) }22 if args.positional.len > 0 {23 return usage_error('doctor takes no positional arguments')24 }25 store := open_store() or {26 report := DoctorJson{27 health: 'unhealthy'28 root: storage.find_root(os.getwd()) or { os.getwd() }29 problems: [err.msg()]30 checks: [DoctorCheck{'database open', 'fail', err.msg()}]31 scope: 'database could not be opened; further checks could not run'32 }33 if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report) }34 return 135 }36 defer { store.close() }37 mut report := DoctorJson{38 root: store.root39 database: os.join_path(store.root, storage.file_name)40 schema_version: storage.schema_version41 checked_at: time.now().unix()42 koh_version: version43 }44 working('checking database, objects and all stored history')45 store.db.exec('BEGIN') or { return refuse(args.json(), err.msg()) }46 report.check('SQLite integrity', store.integrity_check() or { [err.msg()] })47 report.check('object hashes and sizes', store.check_objects() or { [err.msg()] })48 report.check('save object references', store.check_save_files() or { [err.msg()] })49 doctor_local(store, mut report) or { report.check('local metadata', [err.msg()]) }50 store.end() or { report.check('read transaction', [err.msg()]) }51 working('reading working files without cached hashes')52 doctor_working_tree(store, mut report) or { report.check('working tree', [err.msg()]) }53 doctor_build(store.root, mut report)54 if args.flag('yes') {55 report.peek_folders_removed = sweep_peek_folders()56 }57 if path := args.value_or_none('backup') {58 mut backed_up := true59 store.backup(path) or {60 report.check('backup', [err.msg()])61 backed_up = false62 }63 if backed_up {64 report.backup = path65 }66 }67 report.size_bytes = os.file_size(report.database)68 report.wal_bytes = os.file_size('${report.database}-wal')69 doctor_remote_config(mut report)70 71 if args.flag('fast') {72 report.checks << DoctorCheck{'remote audit', 'skipped', '--fast requested; remote content not checked'}73 } else {74 working('auditing remote history and releases (read-only)')75 doctor_remote(store, mut report) or { report.check('remote audit', [err.msg()]) }76 }77 report.scope = 'repository data and configuration only; not application tests, backup recovery or remote write access'78 report.health = if report.problems.len > 0 {79 'unhealthy'80 } else if report.warnings.len > 0 { 'warning' } else { 'healthy' }81 if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report) }82 return if report.problems.len > 0 { 1 } else { 0 }83}8485fn (mut report DoctorJson) check(name string, problems []string) {86 report.checks << DoctorCheck{name, if problems.len == 0 { 'pass' } else { 'fail' }, if problems.len == 0 {87 'verified'88 } else {89 problems.join('; ')90 }}91 for problem in problems {92 if problem !in report.problems { report.problems << problem }93 }94}9596fn print_doctor(report DoctorJson) {97 root := report.root.replace_once(os.home_dir(), '~')98 if report.problems.len > 0 {99 warn('${root} has ${fmt.plural(report.problems.len, 'problem')}')100 } else if report.warnings.len > 0 {101 ok('${root} is sound · ${fmt.plural(report.warnings.len, 'warning')}')102 } else {103 ok('${root} is healthy')104 }105 println('')106 if report.metadata_available {107 doctor_metadata(report)108 } else {109 println(' ${fmt.dim('repository metadata unavailable; see failed checks')}')110 }111 println('')112 passed := report.checks.filter(it.status == 'pass').map(it.name)113 if passed.len > 0 {114 ok('${fmt.plural(passed.len, 'check')} passed')115 for line in wrap_items(passed, 76) {116 println(' ${fmt.dim(line)}')117 }118 }119 for check in report.checks.filter(it.status != 'pass') {120 mark := match check.status {121 'fail' { fmt.red(fmt.glyph_fail) }122 'skipped' { fmt.dim('–') }123 else { fmt.amber('!') }124 }125 println(' ${mark} ${check.name} ${fmt.dim('· ${check.detail}')}')126 }127 for warning in report.warnings {128 warn(warning)129 }130 if report.peek_folders_removed > 0 {131 ok('removed ${report.peek_folders_removed} old peek folders')132 }133 if report.backup != '' { ok('backed up to ${report.backup}') }134 if report.problems.len > 0 {135 note('keep .koh intact before recovering history; use a known-good backup or another verified copy')136 }137 println('')138 note(report.scope)139}140141fn doctor_metadata(report DoctorJson) {142 if report.head.id != '' {143 doctor_row('head', '[${report.head.id}] ${report.head.message.all_before('\n')}')144 doctor_row('', fmt.dim([report.head.agent, report.head.session,145 fmt.stamp(report.head.created_at), fmt.plural(report.head_files, 'file')].filter(it != '').join(' · ')))146 } else {147 doctor_row('head', if report.saves == 0 { 'no saves yet' } else { report.head_id })148 }149 mut saves := ['${report.saves} saves', '${report.checkpoints} checkpoints']150 if report.off_line_saves > 0 { saves << '${report.off_line_saves} off line' }151 if report.events > 0 { saves << fmt.plural(report.events, 'event') }152 if report.flagged_saves > 0 { saves << '${report.flagged_saves} flagged' }153 doctor_row('history', saves.join(' · '))154 doctor_row('storage', '${fmt.human_bytes(i64(report.size_bytes))} + ${fmt.human_bytes(i64(report.wal_bytes))} WAL · schema ${report.schema_version}')155 doctor_row('', fmt.dim('${report.objects} objects · ${fmt.human_bytes(report.object_bytes)} → ${fmt.human_bytes(report.packed_bytes)} compressed · ${report.file_entries} file entries'))156 changes := report.changes157 counts := ['${changes.added.len} added', '${changes.modified.len} modified',158 '${changes.removed.len} removed'].filter(!it.starts_with('0 '))159 mut tree := if counts.len == 0 { 'no changes' } else { counts.join(', ') }160 if report.cache_cleared {161 tree += fmt.dim(' · ${report.cached_files} cached hashes re-read')162 }163 doctor_row('tree', tree)164 if report.remote_target != '' {165 offer := if report.last_offered_head != '' {166 fmt.dim(' · last offer [${report.last_offered_head}]')167 } else {168 ''169 }170 doctor_row('remote', report.remote_target + offer)171 }172 if report.source != '' { doctor_row('source', '${report.source} (read-only)') }173 if report.project_version != '' {174 doctor_row('build', '${report.project_version} · ${report.build_platforms.join(', ')}')175 }176}177178fn doctor_row(label string, value string) {179 println(' ${fmt.dim('${label:-9}')}${value}')180}181182// wrap_items joins items with a dot, breaking lines between items so none passes width.183fn wrap_items(items []string, width int) []string {184 mut lines := []string{}185 mut line := ''186 for item in items {187 if line != '' && fmt.visible_width(line) + 3 + item.len > width {188 lines << line189 line = item190 } else {191 line = if line == '' { item } else { '${line} · ${item}' }192 }193 }194 if line != '' {195 lines << line196 }197 return lines198}199200fn shell_quote(text string) string {201 return "'${text.replace("'", "'\\''")}'"202}203204// peek_age is how old a peek folder must be before doctor clears it.205const peek_age = 86400206207// sweep_peek_folders clears the scratch trees `koh peek` leaves in the system208// temp directory, once they are a day old.209fn sweep_peek_folders() int {210 mut removed := 0211 for name in os.ls(os.temp_dir()) or { return 0 } {212 path := os.join_path(os.temp_dir(), name)213 info := os.lstat(path) or { continue }214 if !name.starts_with(peek_prefix) || os.is_link(path) || !os.is_dir(path)215 || info.uid != u32(os.getuid()) || !peek_is_old(os.file_last_mod_unix(path), time.now().unix()) {216 continue217 }218 os.rmdir_all(path) or { continue }219 removed++220 }221 return removed222}223224// peek_is_old is true once a peek is more than a day old. A younger one may225// still be open in somebody's editor.226fn peek_is_old(modified i64, now i64) bool {227 return modified < now - peek_age228}229230struct DoctorCheck {231 name string232 status string233 detail string234}235236struct DoctorJson {237mut:238 health string239 scope string240 metadata_available bool241 ignore_files map[string]int242 checks []DoctorCheck243 warnings []string244 problems []string245 root string246 database string247 schema_version int248 checked_at i64249 koh_version string250 head_id string251 head history.Save252 head_files int253 objects int254 saves int255 checkpoints int256 line_saves int257 off_line_saves int258 events int259 flagged_saves int260 head_flagged bool261 file_entries i64262 size_bytes u64263 wal_bytes u64264 object_bytes i64265 packed_bytes i64266 unreferenced_objects i64267 restore_unfinished bool268 pending_strips []string269 changes Changes270 ignored_saved_files []string271 build_platforms []string272 project_version string273 source string274 remote_repo string275 remote_target string276 last_offered_head string277 remote_head string278 remote_objects int279 remote_saves int280 remote_releases int281 cached_files int282 cache_cleared bool283 peek_folders_removed int284 backup string285}