//! `koh log` and `koh view` — the same line, printed or browsed. //! //! On a terminal these open the history view on their tab; piped, or with //! `--json`, they print plain text, so an agent always gets something it can //! parse. module cli import json2 import os import time import src.events import src.fmt import src.history import src.storage import src.view pub fn cmd_log(raw []string) int { return history_command(raw, .all, ['json']) } // cmd_view opens the history view, or prints the list it shows. Saves are the // default: checkpoints are the other tab, which `--checkpoints` starts on. pub fn cmd_view(raw []string) int { args := parse('view', raw, Options{ values: ['since', 'session'] flags: ['checkpoints', 'json'] }) or { return usage_error(err.msg()) } if args.positional.len > 0 { return usage_error('view takes no arguments, not "${args.positional_at(0)}"') } tab := if args.flag('checkpoints') { view.Tab.checkpoints } else { view.Tab.saves } return history_command(raw, tab, ['checkpoints', 'json']) } fn history_command(raw []string, tab view.Tab, option_flags []string) int { verb := match tab { .all { 'log' } else { 'view' } } args := parse(verb, raw, Options{ values: ['since', 'session'] flags: option_flags }) or { return usage_error(err.msg()) } if args.positional.len > 0 { return usage_error('${verb} takes no arguments, not "${args.positional_at(0)}"') } store := open_store() or { return refuse(args.json(), err.msg()) } defer { store.close() } // The view has its own search but no date filter, so a filtered log prints // plain text even on a terminal rather than quietly ignoring the filter. filtered := args.value('since') != '' || args.value('session') != '' if !args.json() && !filtered && on_a_terminal() { return history_view(store, tab) } mut cutoff := i64(0) if expr := args.value_or_none('since') { cutoff = parse_since(expr) or { return refuse(args.json(), err.msg()) } } session := args.value('session') head_id := history.head_id(store) flags := events.latest_flags(store) or { map[string]events.Flag{} } mut shown := []history.Save{} for save in line_for_tab(store, tab) or { return refuse(args.json(), err.msg()) } { if cutoff > 0 && save.created_at < cutoff { continue } if session != '' && save.session != session { continue } shown << save } if args.json() { emit_json(json2.encode(LogJson{ head_id: head_id saves: shown.map(to_json(it)) })) return 0 } if shown.len == 0 { ok('no saves in that range') return 0 } for save in shown { println(log_line(save, save.id == head_id, flags)) } return 0 } // line_for_tab picks the saves a tab shows: `log` is the whole line, `saves` // leaves checkpoints out, and `checkpoints` keeps only them, so the two lists // never overlap. fn line_for_tab(store &storage.Store, tab view.Tab) ![]history.Save { line := history.line(store)! if tab == .all { return line } want_checkpoints := tab == .checkpoints return line.filter(it.is_checkpoint == want_checkpoints) } fn history_view(store &storage.Store, tab view.Tab) int { view.run(store, tab, history_view_action) or { return fail(err.msg()) } return 0 } // history_view_action is what the view calls when the user picks an action. // The view presents; this changes history. fn history_view_action(store &storage.Store, action view.Action) string { match action.kind { .load { target := history.by_id(store, action.save_id) or { return 'no such save: ${action.save_id}' } restore_now(store, target, 'load') or { return 'could not load: ${err.msg()}' } return 'loaded [${action.save_id}]' } .flag { events.flag(store, action.save_id, action.reason) or { return 'could not flag: ${err.msg()}' } return 'flagged [${action.save_id}]' } .clear_flag { events.clear_flag(store, action.save_id) or { return 'could not clear: ${err.msg()}' } return 'cleared the flag on [${action.save_id}]' } .none { return '' } } } fn on_a_terminal() bool { return os.is_atty(0) != 0 && os.is_atty(1) != 0 } struct SaveJson { // Every field is always present, so a consumer never has to tell "absent" // from "missing key". An empty string means the save recorded none. id string parent string message string timestamp i64 machine string agent string session string is_checkpoint bool restores string replayed_from string } struct LogJson { head_id string saves []SaveJson } fn to_json(save history.Save) SaveJson { return SaveJson{ id: save.id parent: save.parent message: save.message timestamp: save.created_at machine: save.machine agent: save.agent session: save.session is_checkpoint: save.is_checkpoint restores: save.restores replayed_from: save.replayed_from } } fn log_line(save history.Save, is_head bool, flags map[string]events.Flag) string { marker := if is_head { fmt.amber('▸') } else { ' ' } mut line := ' ${marker} ${fmt.dim('[' + save.id + ']')} ${fmt.stamp(save.created_at)} ${save.message}' mut tags := []string{} if save.agent != '' || save.session != '' { mut inner := []string{} if save.agent != '' { inner << save.agent } if save.session != '' { inner << save.session } tags << '[${inner.join(' · ')}]' } if save.is_checkpoint { tags << '◐' } if (flags[save.id] or { events.Flag{ save_id: save.id } }).flagged { tags << '⚑' } if save.restores != '' { tags << '↩ ${save.restores}' } if save.replayed_from != '' { tags << 'replayed' } if tags.len > 0 { line += ' ${fmt.dim(tags.join(' '))}' } return line } // parse_since turns the natural-language expressions Koh has always accepted // into a unix timestamp. pub fn parse_since(expr string) !i64 { now := time.now().unix() text := expr.trim_space().to_lower() match text { 'today' { return start_of_day(now) } 'yesterday' { return start_of_day(now) - 86400 } 'last week' { return now - 7 * 86400 } 'last month' { return now - 30 * 86400 } 'last year' { return now - 365 * 86400 } else {} } if date := parse_date(text) { return date } parts := text.split(' ') if parts.len >= 2 { amount := parts[0].int() if amount > 0 { if unit := unit_seconds(parts[1]) { return now - i64(amount) * unit } } return error('cannot understand "--since ${expr}"') } days := text.int() if days > 0 { return now - i64(days) * 86400 } return error('cannot understand "--since ${expr}"') } fn unit_seconds(unit string) ?i64 { return match unit { 'm', 'min', 'mins', 'minute', 'minutes' { i64(60) } 'h', 'hr', 'hrs', 'hour', 'hours' { i64(3600) } 'd', 'day', 'days' { i64(86400) } 'w', 'week', 'weeks' { i64(7 * 86400) } 'month', 'months' { i64(30 * 86400) } 'y', 'year', 'years' { i64(365 * 86400) } else { none } } } fn parse_date(text string) ?i64 { parts := text.split('-') if parts.len != 3 || parts[0].len != 4 { return none } if parts[0].int() == 0 || parts[1].int() == 0 || parts[2].int() == 0 { return none } return midnight(parts[0].int(), parts[1].int(), parts[2].int()) } fn start_of_day(unix_seconds i64) i64 { moment := time.unix(unix_seconds).local() return midnight(moment.year, moment.month, moment.day) } // midnight builds a local-time timestamp for a calendar day. fn midnight(year int, month int, day int) i64 { return time.Time{ year: year month: month day: day is_local: true }.unix() }