Files
Kigi-CLI/crates/codegen/kigi-pager-render/src/util.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

443 lines
14 KiB
Rust

//! Shared utility functions.
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub use kigi_config::kigi_home;
/// Path to `$KIGI_SHARE_DIR/pager.toml`.
pub fn pager_toml_path() -> PathBuf {
kigi_home().join("pager.toml")
}
/// User-facing label for the user kigi directory (``~/.kigi`` or ``$KIGI_SHARE_DIR``).
///
/// Derived from resolved [`kigi_home()`] vs `kigi_config::default_kigi_home()`,
/// not from whether `KIGI_SHARE_DIR` is set in the environment.
pub fn display_kigi_home_prefix() -> String {
if kigi_home() == kigi_config::default_kigi_home() {
"~/.kigi".to_string()
} else {
"$KIGI_SHARE_DIR".to_string()
}
}
/// User-facing path under [`kigi_home()`], e.g. ``~/.kigi/config.toml``.
pub fn display_user_kigi_path(relative: impl AsRef<Path>) -> String {
let rel = relative.as_ref();
let prefix = display_kigi_home_prefix();
if rel.as_os_str().is_empty() {
return prefix;
}
format!("{prefix}/{}", rel.display())
}
/// Abbreviate an absolute path for display: prefer [`kigi_home()`], then `$HOME`.
pub fn abbreviate_path(path: &str) -> Cow<'_, str> {
let path_buf = Path::new(path);
let kigi = kigi_home();
if let Ok(rest) = path_buf.strip_prefix(&kigi) {
let prefix = display_kigi_home_prefix();
if rest.as_os_str().is_empty() {
return Cow::Owned(prefix);
}
return Cow::Owned(format!("{prefix}/{}", rest.display()));
}
if let Ok(home) = std::env::var("HOME")
&& !home.is_empty()
&& let Some(rest) = path.strip_prefix(&home)
{
if rest.is_empty() {
return Cow::Borrowed("~");
}
if rest.starts_with('/') {
return Cow::Owned(format!("~{rest}"));
}
}
Cow::Borrowed(path)
}
/// True when `path` is under user [`kigi_home()`] (not project `{cwd}/.kigi`).
pub fn is_under_user_kigi_home(path: &Path) -> bool {
path.starts_with(kigi_home())
}
/// Format a duration as a compact human-friendly string.
///
/// Uses consistent rounding for visual stability:
/// - Under 10s: `"5.2s"` (one decimal for granularity)
/// - 10-59s: `"32s"` (no decimal)
/// - 1m-59m: `"2m5s"`
/// - 1h+: `"1h2m"`
pub fn format_duration(d: Duration) -> String {
let total_secs = d.as_secs();
if total_secs < 10 {
return format!("{:.1}s", d.as_secs_f64());
}
if total_secs < 60 {
return format!("{total_secs}s");
}
let mins = total_secs / 60;
let secs = total_secs % 60;
if mins < 60 {
return format!("{mins}m{secs}s");
}
let hours = mins / 60;
let remaining_mins = mins % 60;
format!("{hours}h{remaining_mins}m")
}
/// Format a duration as a coarse recency string for "time ago" / age
/// displays (e.g. dashboard row age column and peek panel prefix).
///
/// Buckets chosen for the agent dashboard so the column stays compact
/// and doesn't distract with second-level churn:
/// - < 1 minute: `"just now"`
/// - minutes: `"1m"` … `"59m"`
/// - hours: `"1h"` … `"23h"`
/// - days: `"1d"` … `"29d"`
/// - months (≈30d+): `"1mo"` … `"11mo"`
/// - years (≈365d+): `"1y"` …
pub fn format_time_ago(d: Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
return "just now".to_string();
}
if secs < 3600 {
let mins = secs / 60;
return format!("{mins}m");
}
if secs < 86400 {
let hours = secs / 3600;
return format!("{hours}h");
}
let days = secs / 86400;
if days < 30 {
return format!("{days}d");
}
if days < 365 {
let months = days / 30;
return format!("{months}mo");
}
let years = days / 365;
format!("{years}y")
}
/// Convert unix-epoch millis into a wall-clock [`SystemTime`].
///
/// Used for dashboard recency that originates as a wall-clock timestamp (the
/// leader roster's `last_change_unix_ms`). A non-positive value — the
/// `#[serde(default)]` `0` sentinel for a missing roster timestamp — falls
/// back to "now".
pub fn system_time_from_unix_ms(unix_ms: i64) -> SystemTime {
if unix_ms <= 0 {
return SystemTime::now();
}
UNIX_EPOCH
.checked_add(Duration::from_millis(unix_ms as u64))
.unwrap_or_else(SystemTime::now)
}
/// Project a monotonic [`Instant`] onto the wall clock as the [`SystemTime`]
/// it corresponds to (`SystemTime::now() - instant.elapsed()`).
///
/// The dashboard stores row recency as a wall-clock `SystemTime` so on-disk
/// roster timestamps (which can predate this process — even the machine's
/// boot — and so are unrepresentable as a monotonic `Instant`) sit in the same
/// comparable space as local rows. Local rows hold live `Instant` anchors;
/// this maps them across. A fixed anchor ages correctly because only `now`
/// advances, and the sub-millisecond skew between the two `now()` samples is
/// invisible to the minute-granularity [`format_time_ago`] buckets.
pub fn system_time_from_instant(instant: Instant) -> SystemTime {
SystemTime::now()
.checked_sub(instant.elapsed())
.unwrap_or_else(SystemTime::now)
}
/// Decode common HTML entities (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&#39;`)
/// that may appear in LLM-generated session summaries.
pub fn decode_html_entities(s: &str) -> std::borrow::Cow<'_, str> {
if !s.contains('&') {
return std::borrow::Cow::Borrowed(s);
}
let mut out = s.to_string();
out = out.replace("&amp;", "&");
out = out.replace("&lt;", "<");
out = out.replace("&gt;", ">");
out = out.replace("&quot;", "\"");
out = out.replace("&#39;", "'");
out = out.replace("&#x27;", "'");
out = out.replace("&apos;", "'");
std::borrow::Cow::Owned(out)
}
pub fn parse_schedule_interval_secs(human: &str) -> Option<u64> {
let s = human.trim_start();
if !s.starts_with("every ") {
return None;
}
let rest = s[6..].trim_start();
let (num_str, unit) = if let Some(sp) = rest.find(char::is_whitespace) {
(&rest[..sp], &rest[sp + 1..])
} else if rest.len() >= 2 {
let (d, u) = rest.split_at(rest.len() - 1);
(d, u)
} else {
return None;
};
let n: u64 = num_str.parse().ok()?;
let unit = unit.trim();
let secs_per = match unit {
"s" | "second" | "seconds" => 1,
"m" | "minute" | "minutes" => 60,
"h" | "hour" | "hours" => 3600,
"d" | "day" | "days" => 86400,
_ => return None,
};
Some(n * secs_per)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subsecond() {
assert_eq!(format_duration(Duration::from_millis(500)), "0.5s");
}
#[test]
fn under_ten_seconds() {
assert_eq!(format_duration(Duration::from_secs_f64(5.23)), "5.2s");
}
#[test]
fn ten_seconds_no_decimal() {
assert_eq!(format_duration(Duration::from_secs(10)), "10s");
}
#[test]
fn seconds_no_decimal() {
assert_eq!(format_duration(Duration::from_secs_f64(12.3)), "12s");
}
#[test]
fn thirty_seconds() {
assert_eq!(format_duration(Duration::from_secs(30)), "30s");
}
#[test]
fn minutes() {
assert_eq!(format_duration(Duration::from_secs(125)), "2m5s");
}
#[test]
fn hours() {
assert_eq!(format_duration(Duration::from_secs(3725)), "1h2m");
}
#[test]
fn time_ago_just_now() {
assert_eq!(format_time_ago(Duration::from_secs(0)), "just now");
assert_eq!(format_time_ago(Duration::from_secs(30)), "just now");
assert_eq!(format_time_ago(Duration::from_secs(59)), "just now");
}
#[test]
fn time_ago_minutes() {
assert_eq!(format_time_ago(Duration::from_secs(60)), "1m");
assert_eq!(format_time_ago(Duration::from_secs(125)), "2m");
assert_eq!(format_time_ago(Duration::from_secs(3599)), "59m");
}
#[test]
fn time_ago_hours() {
assert_eq!(format_time_ago(Duration::from_secs(3600)), "1h");
assert_eq!(format_time_ago(Duration::from_secs(7200)), "2h");
assert_eq!(format_time_ago(Duration::from_secs(86399)), "23h");
}
#[test]
fn time_ago_days() {
assert_eq!(format_time_ago(Duration::from_secs(86400)), "1d");
assert_eq!(format_time_ago(Duration::from_secs(172800)), "2d");
assert_eq!(format_time_ago(Duration::from_secs(2_592_000 - 1)), "29d"); // just under 30d
}
#[test]
fn time_ago_months() {
assert_eq!(format_time_ago(Duration::from_secs(2_592_000)), "1mo"); // 30d
assert_eq!(format_time_ago(Duration::from_secs(5_184_000)), "2mo");
// 359d is still 11mo (359/30=11); 360d would be 12mo.
assert_eq!(format_time_ago(Duration::from_secs(359 * 86400)), "11mo");
}
#[test]
fn time_ago_years() {
assert_eq!(format_time_ago(Duration::from_secs(31_536_000)), "1y"); // 365d
assert_eq!(format_time_ago(Duration::from_secs(63_072_000)), "2y");
}
fn now_unix_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
/// A real past timestamp survives the round-trip and renders its true age —
/// including ages beyond the machine's uptime, which a monotonic `Instant`
/// could not represent (its floor is system boot).
#[test]
fn system_time_from_unix_ms_renders_real_age() {
let two_hours_ago = now_unix_ms() - 2 * 3_600_000;
let elapsed = system_time_from_unix_ms(two_hours_ago)
.elapsed()
.unwrap_or_default();
assert_eq!(format_time_ago(elapsed), "2h");
let forty_five_days_ago = now_unix_ms() - 45 * 86_400_000;
let elapsed = system_time_from_unix_ms(forty_five_days_ago)
.elapsed()
.unwrap_or_default();
assert_eq!(format_time_ago(elapsed), "1mo");
}
/// A zero / missing timestamp (the `#[serde(default)]` sentinel) falls back
/// to "now" rather than the unix epoch (1970).
#[test]
fn system_time_from_unix_ms_zero_falls_back_to_now() {
let elapsed = system_time_from_unix_ms(0).elapsed().unwrap_or_default();
assert!(elapsed.as_secs() < 5, "zero sentinel must fall back to now");
}
/// A future timestamp (clock skew) renders as "just now": `elapsed()` errors
/// on a future `SystemTime`, and callers default that to a zero duration.
#[test]
fn system_time_from_unix_ms_future_renders_just_now() {
let future = now_unix_ms() + 10_000_000;
let elapsed = system_time_from_unix_ms(future)
.elapsed()
.unwrap_or_default();
assert_eq!(format_time_ago(elapsed), "just now");
}
/// A fixed `Instant` projects to a stable wall-clock moment, so its age
/// reflects time-since-anchor (here ~10m) rather than re-anchoring to now.
#[test]
fn system_time_from_instant_reflects_elapsed() {
let ten_min_ago = Instant::now() - Duration::from_secs(600);
let elapsed = system_time_from_instant(ten_min_ago)
.elapsed()
.unwrap_or_default();
assert_eq!(format_time_ago(elapsed), "10m");
}
#[test]
fn parses_every_5_minutes() {
assert_eq!(parse_schedule_interval_secs("every 5 minutes"), Some(300));
}
#[test]
fn parses_every_5m_short() {
assert_eq!(parse_schedule_interval_secs("every 5m"), Some(300));
}
#[test]
fn parses_every_10s() {
assert_eq!(parse_schedule_interval_secs("every 10s"), Some(10));
}
#[test]
fn parses_every_1_hour() {
assert_eq!(parse_schedule_interval_secs("every 1 hour"), Some(3600));
}
#[test]
fn parses_every_1_day() {
assert_eq!(parse_schedule_interval_secs("every 1 day"), Some(86400));
}
#[test]
fn decode_html_entities_no_entities() {
let s = "hello world";
let out = decode_html_entities(s);
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
assert_eq!(out.as_ref(), s);
}
#[test]
fn decode_html_entities_amp() {
assert_eq!(decode_html_entities("foo &amp; bar").as_ref(), "foo & bar");
}
#[test]
fn decode_html_entities_multiple() {
assert_eq!(
decode_html_entities("1 &lt; 2 &amp;&amp; 3 &gt; 2").as_ref(),
"1 < 2 && 3 > 2"
);
}
#[test]
fn decode_html_entities_quotes() {
assert_eq!(
decode_html_entities("&quot;hello&quot; &amp; &#39;world&#39;").as_ref(),
"\"hello\" & 'world'"
);
}
#[test]
fn unknown_schedule_returns_none() {
assert_eq!(parse_schedule_interval_secs("foo bar"), None);
assert_eq!(parse_schedule_interval_secs("every foo"), None);
assert_eq!(parse_schedule_interval_secs("every 5x"), None);
}
#[test]
fn display_kigi_home_prefix_default_install() {
if std::env::var("KIGI_SHARE_DIR").is_ok() {
return;
}
assert_eq!(display_kigi_home_prefix(), "~/.kigi");
}
#[test]
fn display_user_kigi_path_joins_relative() {
let path = display_user_kigi_path("config.toml");
assert!(path.ends_with("/config.toml") || path.ends_with("\\config.toml"));
assert!(path.contains(".kigi") || path.contains("$KIGI_SHARE_DIR"));
}
#[test]
fn abbreviate_path_uses_home_when_under_default_kigi() {
if let Ok(home) = std::env::var("HOME") {
if home.is_empty() {
return;
}
let full = format!("{home}/.kigi/memory/MEMORY.md");
let abbreviated = abbreviate_path(&full);
assert!(
abbreviated.contains("memory/MEMORY.md"),
"got {abbreviated}"
);
}
}
#[test]
#[serial_test::serial]
fn abbreviate_path_empty_home_does_not_fake_tilde() {
let prev = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", "");
}
assert_eq!(abbreviate_path("/foo").as_ref(), "/foo");
match prev {
Some(home) => unsafe { std::env::set_var("HOME", home) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}