use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Context, Result};
use time::OffsetDateTime;
use git_meta_lib::db::types::{Authorship, SerializableEntry};
use git_meta_lib::types::{MetaValue, Target, TargetType, ValueType};
use git_meta_lib::Session;
use crate::commands::get::resolve_git_ref;
use crate::commands::inspect::{decode_string_value, fuzzy_matches};
pub(super) struct MetaSnapshot {
pub(super) entries: Vec<SerializableEntry>,
pub(super) promised_counts: BTreeMap<TargetType, u64>,
}
pub(super) struct TypeRow {
pub(super) target_type: TargetType,
pub(super) key_count: u64,
pub(super) target_count: usize,
pub(super) promised: u64,
}
pub(super) struct TargetRow {
pub(super) target_value: String,
pub(super) key_count: usize,
pub(super) last_timestamp: i64,
}
pub(super) struct KeyRow {
pub(super) key: String,
pub(super) value: String,
pub(super) value_type: ValueType,
pub(super) is_git_ref: bool,
pub(super) last_timestamp: i64,
}
impl MetaSnapshot {
pub(super) fn load(session: &Session) -> Result<Self> {
let entries = session.store().get_all_metadata()?;
let mut promised_counts = BTreeMap::new();
for (type_str, count) in session.store().count_promised_keys()? {
if let Ok(target_type) = type_str.parse::<TargetType>() {
promised_counts.insert(target_type, count);
}
}
Ok(Self {
entries,
promised_counts,
})
}
pub(super) fn is_empty(&self) -> bool {
self.entries.is_empty() && self.promised_counts.is_empty()
}
pub(super) fn type_rows(&self) -> Vec<TypeRow> {
let mut stats: BTreeMap<TargetType, (u64, BTreeSet<&str>)> = BTreeMap::new();
for e in &self.entries {
let slot = stats.entry(e.target_type.clone()).or_default();
slot.0 += 1;
slot.1.insert(e.target_value.as_str());
}
for target_type in self.promised_counts.keys() {
stats.entry(target_type.clone()).or_default();
}
stats
.into_iter()
.map(|(target_type, (key_count, targets))| TypeRow {
promised: self.promised_counts.get(&target_type).copied().unwrap_or(0),
target_count: targets.len(),
key_count,
target_type,
})
.collect()
}
pub(super) fn target_rows(&self, target_type: &TargetType, filter: &str) -> Vec<TargetRow> {
let term = filter.to_lowercase();
let mut by_target: BTreeMap<&str, (usize, i64, bool)> = BTreeMap::new();
for e in self
.entries
.iter()
.filter(|e| &e.target_type == target_type)
{
let slot = by_target
.entry(e.target_value.as_str())
.or_insert((0, i64::MIN, false));
slot.0 += 1;
slot.1 = slot.1.max(e.last_timestamp);
if !term.is_empty() && !slot.2 {
slot.2 = fuzzy_matches(&term, &e.target_value) || fuzzy_matches(&term, &e.key);
}
}
by_target
.into_iter()
.filter(|(_, (_, _, matched))| term.is_empty() || *matched)
.map(|(target_value, (key_count, last_timestamp, _))| TargetRow {
target_value: target_value.to_string(),
key_count,
last_timestamp,
})
.collect()
}
pub(super) fn key_tree_rows(
&self,
target_type: &TargetType,
target_value: &str,
prefix: &str,
filter: &str,
) -> Vec<KeyTreeRow> {
let term = filter.to_lowercase();
let prefix_colon = if prefix.is_empty() {
String::new()
} else {
format!("{prefix}:")
};
#[derive(Default)]
struct Slot {
leaf: Option<KeyRow>,
child_keys: usize,
last_timestamp: i64,
matched: bool,
}
let mut by_segment: BTreeMap<String, Slot> = BTreeMap::new();
for e in self
.entries
.iter()
.filter(|e| &e.target_type == target_type && e.target_value == target_value)
{
let Some(rest) = e.key.strip_prefix(&prefix_colon) else {
continue;
};
let matched = term.is_empty()
|| fuzzy_matches(&term, rest)
|| (e.value_type == ValueType::String
&& fuzzy_matches(&term, &decode_string_value(&e.value)));
let (segment, is_leaf) = match rest.split_once(':') {
Some((segment, _)) => (segment, false),
None => (rest, true),
};
let slot = by_segment.entry(segment.to_string()).or_default();
slot.matched |= matched;
slot.last_timestamp = slot.last_timestamp.max(e.last_timestamp);
if is_leaf {
slot.leaf = Some(KeyRow {
key: e.key.clone(),
value: e.value.clone(),
value_type: e.value_type.clone(),
is_git_ref: e.is_git_ref,
last_timestamp: e.last_timestamp,
});
} else {
slot.child_keys += 1;
}
}
let mut rows = Vec::new();
for (segment, slot) in by_segment {
if !slot.matched {
continue;
}
if let Some(row) = slot.leaf {
rows.push(KeyTreeRow::Leaf {
segment: segment.clone(),
row,
});
}
if slot.child_keys > 0 {
rows.push(KeyTreeRow::Namespace {
segment,
key_count: slot.child_keys,
last_timestamp: slot.last_timestamp,
});
}
}
rows
}
}
pub(super) enum KeyTreeRow {
Namespace {
segment: String,
key_count: usize,
last_timestamp: i64,
},
Leaf {
segment: String,
row: KeyRow,
},
}
pub(super) fn join_prefix(prefix: &str, segment: &str) -> String {
if prefix.is_empty() {
segment.to_string()
} else {
format!("{prefix}:{segment}")
}
}
pub(super) struct SearchRow {
pub(super) target_type: TargetType,
pub(super) target_value: String,
pub(super) key: String,
pub(super) is_git_ref: bool,
pub(super) last_timestamp: i64,
pub(super) path: String,
}
pub(super) fn key_path(target_type: &TargetType, target_value: &str, key: &str) -> String {
if *target_type == TargetType::Project {
format!("project {key}")
} else {
format!("{target_type}:{target_value} {key}")
}
}
impl MetaSnapshot {
pub(super) fn search_rows(&self, query: &str) -> Vec<SearchRow> {
let lowered = query.to_lowercase();
let words: Vec<&str> = lowered.split_whitespace().collect();
self.entries
.iter()
.filter_map(|e| {
let path = key_path(&e.target_type, &e.target_value, &e.key);
words
.iter()
.all(|word| fuzzy_matches(word, &path))
.then(|| SearchRow {
target_type: e.target_type.clone(),
target_value: e.target_value.clone(),
key: e.key.clone(),
is_git_ref: e.is_git_ref,
last_timestamp: e.last_timestamp,
path,
})
})
.collect()
}
}
pub(super) struct DetailData {
pub(super) value: MetaValue,
pub(super) last_timestamp: i64,
pub(super) authorship: Option<Authorship>,
}
pub(super) fn load_detail(
session: &Session,
target_type: &TargetType,
target_value: &str,
key: &str,
is_git_ref: bool,
last_timestamp: i64,
) -> Result<DetailData> {
let target = if *target_type == TargetType::Project {
Target::project()
} else {
Target::from_parts(target_type.clone(), Some(target_value.to_string()))
};
let handle = session.target(&target);
let mut value = handle
.get_value(key)?
.with_context(|| format!("key '{key}' not found"))?;
if is_git_ref {
if let MetaValue::String(sha) = &value {
let content = resolve_git_ref(session.repo(), sha)
.unwrap_or_else(|_| format!("[git blob {sha} unavailable]"));
value = MetaValue::String(content);
}
}
let authorship = handle.get_authorship(key)?;
Ok(DetailData {
value,
last_timestamp,
authorship,
})
}
pub(super) fn format_timestamp(ms: i64) -> String {
let Ok(dt) = OffsetDateTime::from_unix_timestamp_nanos(i128::from(ms) * 1_000_000) else {
return "?".to_string();
};
time::format_description::parse("[year]-[month]-[day] [hour]:[minute]")
.ok()
.and_then(|fmt| dt.format(&fmt).ok())
.unwrap_or_else(|| "?".to_string())
}
pub(super) fn format_relative(ms: i64, now_ms: i64) -> String {
let delta = time::Duration::milliseconds(now_ms.saturating_sub(ms));
if delta < time::Duration::minutes(1) {
"just now".to_string()
} else if delta < time::Duration::hours(1) {
format!("{}m ago", delta.whole_minutes())
} else if delta < time::Duration::days(1) {
format!("{}h ago", delta.whole_hours())
} else if delta < time::Duration::days(30) {
format!("{}d ago", delta.whole_days())
} else {
let Ok(dt) = OffsetDateTime::from_unix_timestamp_nanos(i128::from(ms) * 1_000_000) else {
return "?".to_string();
};
time::format_description::parse("[year]-[month]-[day]")
.ok()
.and_then(|fmt| dt.format(&fmt).ok())
.unwrap_or_else(|| "?".to_string())
}
}
#[cfg(test)]
pub(super) fn test_entry(
target_type: TargetType,
target_value: &str,
key: &str,
value: &str,
value_type: ValueType,
last_timestamp: i64,
) -> SerializableEntry {
SerializableEntry {
target_type,
target_value: target_value.to_string(),
key: key.to_string(),
value: value.to_string(),
value_type,
last_timestamp,
is_git_ref: false,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use super::test_entry as entry;
fn snapshot() -> MetaSnapshot {
MetaSnapshot {
entries: vec![
entry(
TargetType::Commit,
"aaa111",
"agent:model",
"\"claude\"",
ValueType::String,
1_000,
),
entry(
TargetType::Commit,
"aaa111",
"review:status",
"\"approved\"",
ValueType::String,
3_000,
),
entry(
TargetType::Commit,
"bbb222",
"agent:model",
"\"codex\"",
ValueType::String,
2_000,
),
entry(
TargetType::Project,
"",
"ci:url",
"\"https://ci.example\"",
ValueType::String,
500,
),
],
promised_counts: BTreeMap::from([(TargetType::Branch, 4)]),
}
}
#[test]
fn type_rows_aggregate_counts_and_include_promised_only_types() {
let rows = snapshot().type_rows();
assert_eq!(rows.len(), 3);
let commit = rows
.iter()
.find(|r| r.target_type == TargetType::Commit)
.unwrap();
assert_eq!(commit.key_count, 3);
assert_eq!(commit.target_count, 2);
assert_eq!(commit.promised, 0);
let branch = rows
.iter()
.find(|r| r.target_type == TargetType::Branch)
.unwrap();
assert_eq!(branch.key_count, 0);
assert_eq!(branch.promised, 4);
}
#[test]
fn target_rows_group_and_track_latest_timestamp() {
let rows = snapshot().target_rows(&TargetType::Commit, "");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].target_value, "aaa111");
assert_eq!(rows[0].key_count, 2);
assert_eq!(rows[0].last_timestamp, 3_000);
}
#[test]
fn target_rows_filter_matches_target_value_or_key() {
let snap = snapshot();
let rows = snap.target_rows(&TargetType::Commit, "bbb");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].target_value, "bbb222");
let rows = snap.target_rows(&TargetType::Commit, "review");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].target_value, "aaa111");
}
#[test]
fn key_tree_groups_by_namespace_level() {
let snap = snapshot();
let rows = snap.key_tree_rows(&TargetType::Commit, "aaa111", "", "");
assert_eq!(rows.len(), 2);
match &rows[0] {
KeyTreeRow::Namespace {
segment, key_count, ..
} => {
assert_eq!(segment, "agent");
assert_eq!(*key_count, 1);
}
KeyTreeRow::Leaf { .. } => panic!("expected namespace row"),
}
let rows = snap.key_tree_rows(&TargetType::Commit, "aaa111", "agent", "");
assert_eq!(rows.len(), 1);
match &rows[0] {
KeyTreeRow::Leaf { segment, row } => {
assert_eq!(segment, "model");
assert_eq!(row.key, "agent:model");
}
KeyTreeRow::Namespace { .. } => panic!("expected leaf row"),
}
}
#[test]
fn key_tree_segment_can_be_both_leaf_and_namespace() {
let snap = MetaSnapshot {
entries: vec![
entry(
TargetType::Project,
"",
"ci",
"\"top\"",
ValueType::String,
1,
),
entry(
TargetType::Project,
"",
"ci:url",
"\"https://ci.example\"",
ValueType::String,
2,
),
],
promised_counts: BTreeMap::new(),
};
let rows = snap.key_tree_rows(&TargetType::Project, "", "", "");
assert_eq!(rows.len(), 2);
assert!(matches!(&rows[0], KeyTreeRow::Leaf { row, .. } if row.key == "ci"));
assert!(
matches!(&rows[1], KeyTreeRow::Namespace { segment, key_count, last_timestamp }
if segment == "ci" && *key_count == 1 && *last_timestamp == 2)
);
}
#[test]
fn key_tree_filter_matches_key_path_or_string_value() {
let snap = snapshot();
let rows = snap.key_tree_rows(&TargetType::Commit, "aaa111", "", "approved");
assert_eq!(rows.len(), 1);
assert!(matches!(&rows[0], KeyTreeRow::Namespace { segment, .. } if segment == "review"));
let rows = snap.key_tree_rows(&TargetType::Commit, "aaa111", "", "status");
assert_eq!(rows.len(), 1);
assert!(matches!(&rows[0], KeyTreeRow::Namespace { segment, .. } if segment == "review"));
}
#[test]
fn search_rows_match_full_key_paths() {
let snap = snapshot();
assert_eq!(snap.search_rows("").len(), 4);
for query in ["aaa review", "review aaa"] {
let rows = snap.search_rows(query);
assert_eq!(rows.len(), 1, "query {query:?}");
assert_eq!(rows[0].key, "review:status");
assert_eq!(rows[0].path, "commit:aaa111 review:status");
}
let rows = snap.search_rows("proj ci");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].target_type, TargetType::Project);
assert_eq!(rows[0].path, "project ci:url");
assert!(snap.search_rows("no-such-thing").is_empty());
}
#[test]
fn relative_timestamps_scale_with_age() {
let minute = 60_000;
let now = 100 * 24 * 60 * minute;
assert_eq!(format_relative(now - 30_000, now), "just now");
assert_eq!(format_relative(now - 5 * minute, now), "5m ago");
assert_eq!(format_relative(now - 3 * 60 * minute, now), "3h ago");
assert_eq!(format_relative(now - 2 * 24 * 60 * minute, now), "2d ago");
assert!(format_relative(0, now).starts_with("1970-"));
}
}