cmd_log.v

7.4 KB · raw

1//! `koh log` and `koh view` — the same line, printed or browsed.2//!3//! On a terminal these open the history view on their tab; piped, or with4//! `--json`, they print plain text, so an agent always gets something it can5//! parse.67module cli89import json210import os11import time12import src.events13import src.fmt14import src.history15import src.storage16import src.view1718pub fn cmd_log(raw []string) int {19	return history_command(raw, .all, ['json'])20}2122// cmd_view opens the history view, or prints the list it shows. Saves are the23// default: checkpoints are the other tab, which `--checkpoints` starts on.24pub fn cmd_view(raw []string) int {25	args := parse('view', raw, Options{26		values: ['since', 'session']27		flags:  ['checkpoints', 'json']28	}) or { return usage_error(err.msg()) }29	if args.positional.len > 0 {30		return usage_error('view takes no arguments, not "${args.positional_at(0)}"')31	}32	tab := if args.flag('checkpoints') { view.Tab.checkpoints } else { view.Tab.saves }33	return history_command(raw, tab, ['checkpoints', 'json'])34}3536fn history_command(raw []string, tab view.Tab, option_flags []string) int {37	verb := match tab {38		.all { 'log' }39		else { 'view' }40	}4142	args := parse(verb, raw, Options{43		values: ['since', 'session']44		flags:  option_flags45	}) or { return usage_error(err.msg()) }46	if args.positional.len > 0 {47		return usage_error('${verb} takes no arguments, not "${args.positional_at(0)}"')48	}49	store := open_store() or { return refuse(args.json(), err.msg()) }50	defer {51		store.close()52	}53	// The view has its own search but no date filter, so a filtered log prints54	// plain text even on a terminal rather than quietly ignoring the filter.55	filtered := args.value('since') != '' || args.value('session') != ''56	if !args.json() && !filtered && on_a_terminal() {57		return history_view(store, tab)58	}59	mut cutoff := i64(0)60	if expr := args.value_or_none('since') {61		cutoff = parse_since(expr) or { return refuse(args.json(), err.msg()) }62	}63	session := args.value('session')64	head_id := history.head_id(store)65	flags := events.latest_flags(store) or {66		map[string]events.Flag{}67	}68	mut shown := []history.Save{}69	for save in line_for_tab(store, tab) or { return refuse(args.json(), err.msg()) } {70		if cutoff > 0 && save.created_at < cutoff {71			continue72		}73		if session != '' && save.session != session {74			continue75		}76		shown << save77	}78	if args.json() {79		emit_json(json2.encode(LogJson{80			head_id: head_id81			saves:   shown.map(to_json(it))82		}))83		return 084	}85	if shown.len == 0 {86		ok('no saves in that range')87		return 088	}89	for save in shown {90		println(log_line(save, save.id == head_id, flags))91	}92	return 093}9495// line_for_tab picks the saves a tab shows: `log` is the whole line, `saves`96// leaves checkpoints out, and `checkpoints` keeps only them, so the two lists97// never overlap.98fn line_for_tab(store &storage.Store, tab view.Tab) ![]history.Save {99	line := history.line(store)!100	if tab == .all {101		return line102	}103	want_checkpoints := tab == .checkpoints104	return line.filter(it.is_checkpoint == want_checkpoints)105}106107fn history_view(store &storage.Store, tab view.Tab) int {108	view.run(store, tab, history_view_action) or { return fail(err.msg()) }109	return 0110}111112// history_view_action is what the view calls when the user picks an action.113// The view presents; this changes history.114fn history_view_action(store &storage.Store, action view.Action) string {115	match action.kind {116		.load {117			target := history.by_id(store, action.save_id) or {118				return 'no such save: ${action.save_id}'119			}120			restore_now(store, target, 'load') or { return 'could not load: ${err.msg()}' }121			return 'loaded [${action.save_id}]'122		}123		.flag {124			events.flag(store, action.save_id, action.reason) or {125				return 'could not flag: ${err.msg()}'126			}127			return 'flagged [${action.save_id}]'128		}129		.clear_flag {130			events.clear_flag(store, action.save_id) or { return 'could not clear: ${err.msg()}' }131			return 'cleared the flag on [${action.save_id}]'132		}133		.none {134			return ''135		}136	}137}138139fn on_a_terminal() bool {140	return os.is_atty(0) != 0 && os.is_atty(1) != 0141}142143struct SaveJson {144	// Every field is always present, so a consumer never has to tell "absent"145	// from "missing key". An empty string means the save recorded none.146	id            string147	parent        string148	message       string149	timestamp     i64150	machine       string151	agent         string152	session       string153	is_checkpoint bool154	restores      string155	replayed_from string156}157158struct LogJson {159	head_id string160	saves   []SaveJson161}162163fn to_json(save history.Save) SaveJson {164	return SaveJson{165		id:            save.id166		parent:        save.parent167		message:       save.message168		timestamp:     save.created_at169		machine:       save.machine170		agent:         save.agent171		session:       save.session172		is_checkpoint: save.is_checkpoint173		restores:      save.restores174		replayed_from: save.replayed_from175	}176}177178fn log_line(save history.Save, is_head bool, flags map[string]events.Flag) string {179	marker := if is_head { fmt.amber('▸') } else { ' ' }180	mut line := '  ${marker} ${fmt.dim('[' + save.id + ']')} ${fmt.stamp(save.created_at)}  ${save.message}'181	mut tags := []string{}182	if save.agent != '' || save.session != '' {183		mut inner := []string{}184		if save.agent != '' {185			inner << save.agent186		}187		if save.session != '' {188			inner << save.session189		}190		tags << '[${inner.join(' · ')}]'191	}192	if save.is_checkpoint {193		tags << '◐'194	}195	if (flags[save.id] or {196		events.Flag{197			save_id: save.id198		}199	}).flagged {200		tags << '⚑'201	}202	if save.restores != '' {203		tags << '↩ ${save.restores}'204	}205	if save.replayed_from != '' {206		tags << 'replayed'207	}208	if tags.len > 0 {209		line += '  ${fmt.dim(tags.join('  '))}'210	}211	return line212}213214// parse_since turns the natural-language expressions Koh has always accepted215// into a unix timestamp.216pub fn parse_since(expr string) !i64 {217	now := time.now().unix()218	text := expr.trim_space().to_lower()219	match text {220		'today' { return start_of_day(now) }221		'yesterday' { return start_of_day(now) - 86400 }222		'last week' { return now - 7 * 86400 }223		'last month' { return now - 30 * 86400 }224		'last year' { return now - 365 * 86400 }225		else {}226	}227228	if date := parse_date(text) {229		return date230	}231	parts := text.split(' ')232	if parts.len >= 2 {233		amount := parts[0].int()234		if amount > 0 {235			if unit := unit_seconds(parts[1]) {236				return now - i64(amount) * unit237			}238		}239		return error('cannot understand "--since ${expr}"')240	}241	days := text.int()242	if days > 0 {243		return now - i64(days) * 86400244	}245	return error('cannot understand "--since ${expr}"')246}247248fn unit_seconds(unit string) ?i64 {249	return match unit {250		'm', 'min', 'mins', 'minute', 'minutes' { i64(60) }251		'h', 'hr', 'hrs', 'hour', 'hours' { i64(3600) }252		'd', 'day', 'days' { i64(86400) }253		'w', 'week', 'weeks' { i64(7 * 86400) }254		'month', 'months' { i64(30 * 86400) }255		'y', 'year', 'years' { i64(365 * 86400) }256		else { none }257	}258}259260fn parse_date(text string) ?i64 {261	parts := text.split('-')262	if parts.len != 3 || parts[0].len != 4 {263		return none264	}265	if parts[0].int() == 0 || parts[1].int() == 0 || parts[2].int() == 0 {266		return none267	}268	return midnight(parts[0].int(), parts[1].int(), parts[2].int())269}270271fn start_of_day(unix_seconds i64) i64 {272	moment := time.unix(unix_seconds).local()273	return midnight(moment.year, moment.month, moment.day)274}275276// midnight builds a local-time timestamp for a calendar day.277fn midnight(year int, month int, day int) i64 {278	return time.Time{279		year:     year280		month:    month281		day:      day282		is_local: true283	}.unix()284}