cmd_log.v

6.8 KB · raw

1//! `koh log`, and its `saves` and `checkpoints` aliases.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)20}2122pub fn cmd_saves(raw []string) int {23	return history_command(raw, .saves)24}2526pub fn cmd_checkpoints(raw []string) int {27	return history_command(raw, .checkpoints)28}2930fn history_command(raw []string, tab view.Tab) int {31	verb := match tab {32		.all { 'log' }33		.saves { 'saves' }34		else { 'checkpoints' }35	}3637	args := parse(verb, raw, Options{38		values: ['since', 'session']39		flags:  ['json']40	}) or { return usage_error(err.msg()) }41	store := open_store() or { return refuse(args.json(), err.msg()) }42	defer {43		store.close()44	}45	// The view has its own search but no date filter, so a filtered log prints46	// plain text even on a terminal rather than quietly ignoring the filter.47	filtered := args.value('since') != '' || args.value('session') != ''48	if !args.json() && !filtered && on_a_terminal() {49		return history_view(store, tab)50	}51	mut cutoff := i64(0)52	if expr := args.value_or_none('since') {53		cutoff = parse_since(expr) or { return refuse(args.json(), err.msg()) }54	}55	session := args.value('session')56	head_id := history.head_id(store)57	flags := events.latest_flags(store) or {58		map[string]events.Flag{}59	}60	mut shown := []history.Save{}61	for save in line_for_tab(store, tab) or { return refuse(args.json(), err.msg()) } {62		if cutoff > 0 && save.created_at < cutoff {63			continue64		}65		if session != '' && save.session != session {66			continue67		}68		shown << save69	}70	if args.json() {71		emit_json(json2.encode(LogJson{72			head_id: head_id73			saves:   shown.map(to_json(it))74		}))75		return 076	}77	if shown.len == 0 {78		ok('no saves in that range')79		return 080	}81	for save in shown {82		println(log_line(save, save.id == head_id, flags))83	}84	return 085}8687// line_for_tab picks the saves a tab shows: `log` is the whole line, `saves`88// leaves checkpoints out, and `checkpoints` keeps only them, so the two lists89// never overlap.90fn line_for_tab(store &storage.Store, tab view.Tab) ![]history.Save {91	line := history.line(store)!92	if tab == .all {93		return line94	}95	want_checkpoints := tab == .checkpoints96	return line.filter(it.is_checkpoint == want_checkpoints)97}9899fn history_view(store &storage.Store, tab view.Tab) int {100	view.run(store, tab, history_view_action) or { return fail(err.msg()) }101	return 0102}103104// history_view_action is what the view calls when the user picks an action.105// The view presents; this changes history.106fn history_view_action(store &storage.Store, action view.Action) string {107	match action.kind {108		.load {109			target := history.by_id(store, action.save_id) or {110				return 'no such save: ${action.save_id}'111			}112			restore_now(store, target, 'load') or { return 'could not load: ${err.msg()}' }113			return 'loaded [${action.save_id}]'114		}115		.flag {116			events.flag(store, action.save_id, action.reason) or {117				return 'could not flag: ${err.msg()}'118			}119			return 'flagged [${action.save_id}]'120		}121		.clear_flag {122			events.clear_flag(store, action.save_id) or { return 'could not clear: ${err.msg()}' }123			return 'cleared the flag on [${action.save_id}]'124		}125		.none {126			return ''127		}128	}129}130131fn on_a_terminal() bool {132	return os.is_atty(0) != 0 && os.is_atty(1) != 0133}134135struct SaveJson {136	// Every field is always present, so a consumer never has to tell "absent"137	// from "missing key". An empty string means the save recorded none.138	id            string139	parent        string140	message       string141	timestamp     i64142	machine       string143	agent         string144	session       string145	is_checkpoint bool146	restores      string147	replayed_from string148}149150struct LogJson {151	head_id string152	saves   []SaveJson153}154155fn to_json(save history.Save) SaveJson {156	return SaveJson{157		id:            save.id158		parent:        save.parent159		message:       save.message160		timestamp:     save.created_at161		machine:       save.machine162		agent:         save.agent163		session:       save.session164		is_checkpoint: save.is_checkpoint165		restores:      save.restores166		replayed_from: save.replayed_from167	}168}169170fn log_line(save history.Save, is_head bool, flags map[string]events.Flag) string {171	marker := if is_head { fmt.amber('▸') } else { ' ' }172	mut line := '  ${marker} ${fmt.dim('[' + save.id + ']')} ${fmt.stamp(save.created_at)}  ${save.message}'173	mut tags := []string{}174	if save.agent != '' || save.session != '' {175		mut inner := []string{}176		if save.agent != '' {177			inner << save.agent178		}179		if save.session != '' {180			inner << save.session181		}182		tags << '[${inner.join(' · ')}]'183	}184	if save.is_checkpoint {185		tags << '◐'186	}187	if (flags[save.id] or {188		events.Flag{189			save_id: save.id190		}191	}).flagged {192		tags << '⚑'193	}194	if save.restores != '' {195		tags << '↩ ${save.restores}'196	}197	if save.replayed_from != '' {198		tags << 'replayed'199	}200	if tags.len > 0 {201		line += '  ${fmt.dim(tags.join('  '))}'202	}203	return line204}205206// parse_since turns the natural-language expressions Koh has always accepted207// into a unix timestamp.208pub fn parse_since(expr string) !i64 {209	now := time.now().unix()210	text := expr.trim_space().to_lower()211	match text {212		'today' { return start_of_day(now) }213		'yesterday' { return start_of_day(now) - 86400 }214		'last week' { return now - 7 * 86400 }215		'last month' { return now - 30 * 86400 }216		'last year' { return now - 365 * 86400 }217		else {}218	}219220	if date := parse_date(text) {221		return date222	}223	parts := text.split(' ')224	if parts.len >= 2 {225		amount := parts[0].int()226		if amount > 0 {227			if unit := unit_seconds(parts[1]) {228				return now - i64(amount) * unit229			}230		}231		return error('cannot understand "--since ${expr}"')232	}233	days := text.int()234	if days > 0 {235		return now - i64(days) * 86400236	}237	return error('cannot understand "--since ${expr}"')238}239240fn unit_seconds(unit string) ?i64 {241	return match unit {242		'm', 'min', 'mins', 'minute', 'minutes' { i64(60) }243		'h', 'hr', 'hrs', 'hour', 'hours' { i64(3600) }244		'd', 'day', 'days' { i64(86400) }245		'w', 'week', 'weeks' { i64(7 * 86400) }246		'month', 'months' { i64(30 * 86400) }247		'y', 'year', 'years' { i64(365 * 86400) }248		else { none }249	}250}251252fn parse_date(text string) ?i64 {253	parts := text.split('-')254	if parts.len != 3 || parts[0].len != 4 {255		return none256	}257	if parts[0].int() == 0 || parts[1].int() == 0 || parts[2].int() == 0 {258		return none259	}260	return midnight(parts[0].int(), parts[1].int(), parts[2].int())261}262263fn start_of_day(unix_seconds i64) i64 {264	moment := time.unix(unix_seconds).local()265	return midnight(moment.year, moment.month, moment.day)266}267268// midnight builds a local-time timestamp for a calendar day.269fn midnight(year int, month int, day int) i64 {270	return time.Time{271		year:     year272		month:    month273		day:      day274		is_local: true275	}.unix()276}