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', 'offline', '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 skip := if args.flag('offline') {72 'offline'73 } else if args.flag('fast') { 'fast' } else { '' }74 if skip != '' {75 report.checks << DoctorCheck{'remote audit', 'skipped', '--${skip} requested; remote content not checked'}76 } else {77 working('auditing remote history and releases (read-only)')78 doctor_remote(store, mut report) or { report.check('remote audit', [err.msg()]) }79 }80 report.scope = 'repository data and configuration only; not application tests, backup recovery or remote write access'81 report.health = if report.problems.len > 0 {82 'unhealthy'83 } else if report.warnings.len > 0 { 'warning' } else { 'healthy' }84 if args.json() { emit_json(json2.encode(report)) } else { print_doctor(report) }85 return if report.problems.len > 0 { 1 } else { 0 }86}8788fn (mut report DoctorJson) check(name string, problems []string) {89 report.checks << DoctorCheck{name, if problems.len == 0 { 'pass' } else { 'fail' }, if problems.len == 0 {90 'verified'91 } else {92 problems.join('; ')93 }}94 for problem in problems {95 if problem !in report.problems { report.problems << problem }96 }97}9899fn print_doctor(report DoctorJson) {100 root := report.root.replace_once(os.home_dir(), '~')101 if report.problems.len > 0 {102 warn('${root} has ${fmt.plural(report.problems.len, 'problem')}')103 } else if report.warnings.len > 0 {104 ok('${root} is sound · ${fmt.plural(report.warnings.len, 'warning')}')105 } else {106 ok('${root} is healthy')107 }108 println('')109 if report.metadata_available {110 doctor_metadata(report)111 } else {112 println(' ${fmt.dim('repository metadata unavailable; see failed checks')}')113 }114 println('')115 passed := report.checks.filter(it.status == 'pass').map(it.name)116 if passed.len > 0 {117 ok('${fmt.plural(passed.len, 'check')} passed')118 for line in wrap_items(passed, 76) {119 println(' ${fmt.dim(line)}')120 }121 }122 for check in report.checks.filter(it.status != 'pass') {123 mark := match check.status {124 'fail' { fmt.red(fmt.glyph_fail) }125 'skipped' { fmt.dim('–') }126 else { fmt.amber('!') }127 }128 println(' ${mark} ${check.name} ${fmt.dim('· ${check.detail}')}')129 }130 for warning in report.warnings {131 warn(warning)132 }133 if report.peek_folders_removed > 0 {134 ok('removed ${report.peek_folders_removed} old peek folders')135 }136 if report.backup != '' { ok('backed up to ${report.backup}') }137 if report.problems.len > 0 {138 note('keep .koh intact before recovering history; use a known-good backup or another verified copy')139 }140 println('')141 note(report.scope)142}143144fn doctor_metadata(report DoctorJson) {145 if report.head.id != '' {146 doctor_row('head', '[${report.head.id}] ${report.head.message.all_before('\n')}')147 doctor_row('', fmt.dim([report.head.agent, report.head.session,148 fmt.stamp(report.head.created_at), fmt.plural(report.head_files, 'file')].filter(it != '').join(' · ')))149 } else {150 doctor_row('head', if report.saves == 0 { 'no saves yet' } else { report.head_id })151 }152 mut saves := ['${report.saves} saves', '${report.checkpoints} checkpoints']153 if report.off_line_saves > 0 { saves << '${report.off_line_saves} off line' }154 if report.events > 0 { saves << fmt.plural(report.events, 'event') }155 if report.flagged_saves > 0 { saves << '${report.flagged_saves} flagged' }156 doctor_row('history', saves.join(' · '))157 doctor_row('storage', '${fmt.human_bytes(i64(report.size_bytes))} + ${fmt.human_bytes(i64(report.wal_bytes))} WAL · schema ${report.schema_version}')158 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'))159 changes := report.changes160 counts := ['${changes.added.len} added', '${changes.modified.len} modified',161 '${changes.removed.len} removed'].filter(!it.starts_with('0 '))162 mut tree := if counts.len == 0 { 'no changes' } else { counts.join(', ') }163 if report.cache_cleared {164 tree += fmt.dim(' · ${report.cached_files} cached hashes re-read')165 }166 doctor_row('tree', tree)167 if report.remote_target != '' {168 offer := if report.last_offered_head != '' {169 fmt.dim(' · last offer [${report.last_offered_head}]')170 } else {171 ''172 }173 doctor_row('remote', report.remote_target + offer)174 }175 if report.source != '' { doctor_row('source', '${report.source} (read-only)') }176 if report.project_version != '' {177 doctor_row('build', '${report.project_version} · ${report.build_platforms.join(', ')}')178 }179}180181fn doctor_row(label string, value string) {182 println(' ${fmt.dim('${label:-9}')}${value}')183}184185// wrap_items joins items with a dot, breaking lines between items so none passes width.186fn wrap_items(items []string, width int) []string {187 mut lines := []string{}188 mut line := ''189 for item in items {190 if line != '' && fmt.visible_width(line) + 3 + item.len > width {191 lines << line192 line = item193 } else {194 line = if line == '' { item } else { '${line} · ${item}' }195 }196 }197 if line != '' {198 lines << line199 }200 return lines201}202203fn shell_quote(text string) string {204 return "'${text.replace("'", "'\\''")}'"205}206207// peek_age is how old a peek folder must be before doctor clears it.208const peek_age = 86400209210// sweep_peek_folders clears the scratch trees `koh peek` leaves in the system211// temp directory, once they are a day old.212fn sweep_peek_folders() int {213 mut removed := 0214 for name in os.ls(os.temp_dir()) or { return 0 } {215 path := os.join_path(os.temp_dir(), name)216 info := os.lstat(path) or { continue }217 if !name.starts_with(peek_prefix) || os.is_link(path) || !os.is_dir(path)218 || info.uid != u32(os.getuid()) || !peek_is_old(os.file_last_mod_unix(path), time.now().unix()) {219 continue220 }221 os.rmdir_all(path) or { continue }222 removed++223 }224 return removed225}226227// peek_is_old is true once a peek is more than a day old. A younger one may228// still be open in somebody's editor.229fn peek_is_old(modified i64, now i64) bool {230 return modified < now - peek_age231}232233struct DoctorCheck {234 name string235 status string236 detail string237}238239struct DoctorJson {240mut:241 health string242 scope string243 metadata_available bool244 ignore_files map[string]int245 checks []DoctorCheck246 warnings []string247 problems []string248 root string249 database string250 schema_version int251 checked_at i64252 koh_version string253 head_id string254 head history.Save255 head_files int256 objects int257 saves int258 checkpoints int259 line_saves int260 off_line_saves int261 events int262 flagged_saves int263 head_flagged bool264 file_entries i64265 size_bytes u64266 wal_bytes u64267 object_bytes i64268 packed_bytes i64269 unreferenced_objects i64270 restore_unfinished bool271 pending_strips []string272 changes Changes273 ignored_saved_files []string274 build_platforms []string275 project_version string276 source string277 remote_repo string278 remote_target string279 last_offered_head string280 remote_head string281 remote_objects int282 remote_saves int283 remote_releases int284 cached_files int285 cache_cleared bool286 peek_folders_removed int287 backup string288}