use crate::error::Error;
use crate::graph::NodeRef;
use crate::handle::Memory;
use crate::memory::{MemoryId, MemoryRef};
use crate::partition::PartitionPath;
use crate::summary::SummaryId;
use crate::summary::content::DataPointRef;
const SCHEME: &str = "kiromi://";
impl Memory {
pub fn resolve_anchor(s: &str) -> Result<NodeRef, Error> {
Self::resolve_anchor_with_range(s).map(|(n, _)| n)
}
pub fn resolve_anchor_with_range(s: &str) -> Result<(NodeRef, Option<DataPointRef>), Error> {
let body = s
.strip_prefix(SCHEME)
.ok_or_else(|| Error::InvalidAnchor(format!("missing scheme: {s}")))?;
let (kind, rest) = body
.split_once('/')
.ok_or_else(|| Error::InvalidAnchor(format!("missing kind: {s}")))?;
match kind {
"memory" => parse_memory(rest),
"partition" => parse_partition(rest),
"summary" => parse_summary(rest),
other => Err(Error::InvalidAnchor(format!("unknown kind: {other}"))),
}
}
}
fn parse_memory(rest: &str) -> Result<(NodeRef, Option<DataPointRef>), Error> {
let (head, time_range) = match rest.split_once('@') {
Some((h, t)) => (h, Some(parse_time(t)?)),
None => (rest, None),
};
let (id_part, line_range, byte_range) = if let Some((idp, sub)) = head.split_once('/') {
let (lr, br) = parse_line_or_byte(sub)?;
(idp, lr, br)
} else {
(head, None, None)
};
let mid: MemoryId = id_part
.parse()
.map_err(|e: ulid::DecodeError| Error::InvalidAnchor(format!("invalid ulid: {e}")))?;
let partition: PartitionPath = "<anchor>"
.parse()
.map_err(|e| Error::InvalidAnchor(format!("partition sentinel failed to parse: {e}")))?;
let mref = MemoryRef { id: mid, partition };
let dp = if line_range.is_some() || byte_range.is_some() || time_range.is_some() {
Some(DataPointRef {
memory_id: mid,
byte_range,
line_range,
time_range_ms: time_range,
note: None,
})
} else {
None
};
Ok((NodeRef::Memory(mref), dp))
}
type SubRanges = (Option<std::ops::Range<u32>>, Option<std::ops::Range<u32>>);
fn parse_line_or_byte(sub: &str) -> Result<SubRanges, Error> {
if let Some(rest) = sub.strip_prefix('L') {
let r = parse_u32_range(rest, "line")?;
Ok((Some(r), None))
} else if let Some(rest) = sub.strip_prefix('B') {
let r = parse_u32_range(rest, "byte")?;
Ok((None, Some(r)))
} else {
Err(Error::InvalidAnchor(format!(
"unknown sub-range tag: {sub}"
)))
}
}
fn parse_u32_range(s: &str, label: &str) -> Result<std::ops::Range<u32>, Error> {
let (lo, hi) = s
.split_once('-')
.ok_or_else(|| Error::InvalidAnchor(format!("malformed {label} range: {s}")))?;
let lo: u32 = lo
.parse()
.map_err(|e| Error::InvalidAnchor(format!("malformed {label} start: {e}")))?;
let hi: u32 = hi
.parse()
.map_err(|e| Error::InvalidAnchor(format!("malformed {label} end: {e}")))?;
Ok(lo..hi)
}
fn parse_time(s: &str) -> Result<std::ops::Range<u32>, Error> {
if let Some((lo, hi)) = s.split_once('-') {
let lo = strip_s(lo)?;
let hi = strip_s(hi)?;
Ok(lo * 1000..hi * 1000)
} else {
let lo = strip_s(s)?;
Ok(lo * 1000..lo * 1000 + 1000)
}
}
fn strip_s(t: &str) -> Result<u32, Error> {
let inner = t
.strip_suffix('s')
.ok_or_else(|| Error::InvalidAnchor(format!("missing 's' suffix: {t}")))?;
inner
.parse()
.map_err(|e| Error::InvalidAnchor(format!("malformed timestamp: {e}")))
}
fn parse_partition(rest: &str) -> Result<(NodeRef, Option<DataPointRef>), Error> {
let decoded = percent_encoding::percent_decode_str(rest)
.decode_utf8()
.map_err(|e| Error::InvalidAnchor(format!("partition path not utf-8: {e}")))?
.into_owned();
let path: PartitionPath = decoded
.parse()
.map_err(|e| Error::InvalidAnchor(format!("invalid partition path: {e}")))?;
Ok((NodeRef::Partition(path), None))
}
fn parse_summary(rest: &str) -> Result<(NodeRef, Option<DataPointRef>), Error> {
let id: SummaryId = rest.parse().map_err(|e: ulid::DecodeError| {
Error::InvalidAnchor(format!("invalid summary ulid: {e}"))
})?;
let s_ref = crate::summary::SummaryRef {
id,
subject: crate::summary::SummarySubject::Tenant,
style: crate::summarizer::SummaryStyle::Compact,
version: 0,
};
Ok((NodeRef::Summary(s_ref), None))
}
#[cfg(test)]
mod tests {
use super::*;
const ULID: &str = "01JCXYZABCDEFGHJKMNPQRSTVW";
#[test]
fn parses_bare_memory() {
let s = format!("kiromi://memory/{ULID}");
let (node, dp) = Memory::resolve_anchor_with_range(&s).unwrap();
match node {
NodeRef::Memory(r) => assert_eq!(r.id.to_string(), ULID),
_ => panic!("expected Memory"),
}
assert!(dp.is_none());
}
#[test]
fn parses_memory_with_line_range() {
let s = format!("kiromi://memory/{ULID}/L42-44");
let (node, dp) = Memory::resolve_anchor_with_range(&s).unwrap();
let dp = dp.unwrap();
assert_eq!(dp.line_range, Some(42..44));
assert!(dp.byte_range.is_none());
assert!(dp.time_range_ms.is_none());
assert!(matches!(node, NodeRef::Memory(_)));
}
#[test]
fn parses_memory_with_byte_range() {
let s = format!("kiromi://memory/{ULID}/B0-128");
let (_, dp) = Memory::resolve_anchor_with_range(&s).unwrap();
let dp = dp.unwrap();
assert_eq!(dp.byte_range, Some(0..128));
assert!(dp.line_range.is_none());
}
#[test]
fn parses_memory_with_time_point() {
let s = format!("kiromi://memory/{ULID}@127s");
let (_, dp) = Memory::resolve_anchor_with_range(&s).unwrap();
let dp = dp.unwrap();
assert_eq!(dp.time_range_ms, Some(127_000..128_000));
}
#[test]
fn parses_memory_with_time_range() {
let s = format!("kiromi://memory/{ULID}@127s-130s");
let (_, dp) = Memory::resolve_anchor_with_range(&s).unwrap();
let dp = dp.unwrap();
assert_eq!(dp.time_range_ms, Some(127_000..130_000));
}
#[test]
fn parses_memory_with_line_and_time() {
let s = format!("kiromi://memory/{ULID}/L42-44@127s");
let (node, dp) = Memory::resolve_anchor_with_range(&s).unwrap();
let dp = dp.unwrap();
assert_eq!(dp.line_range, Some(42..44));
assert_eq!(dp.time_range_ms, Some(127_000..128_000));
match node {
NodeRef::Memory(r) => assert_eq!(r.id.to_string(), ULID),
_ => panic!("expected Memory"),
}
}
#[test]
fn parses_partition_path() {
let s = "kiromi://partition/user=alex/year=2026";
let (node, _) = Memory::resolve_anchor_with_range(s).unwrap();
match node {
NodeRef::Partition(p) => assert_eq!(p.as_str(), "user=alex/year=2026"),
_ => panic!("expected Partition"),
}
}
#[test]
fn parses_summary_id() {
let s = format!("kiromi://summary/{ULID}");
let (node, _) = Memory::resolve_anchor_with_range(&s).unwrap();
match node {
NodeRef::Summary(s) => assert_eq!(s.id.to_string(), ULID),
_ => panic!("expected Summary"),
}
}
#[test]
fn rejects_bad_scheme() {
let r = Memory::resolve_anchor_with_range("http://memory/x");
assert!(matches!(r, Err(Error::InvalidAnchor(_))));
}
#[test]
fn rejects_unknown_kind() {
let r = Memory::resolve_anchor_with_range("kiromi://nope/x");
assert!(matches!(r, Err(Error::InvalidAnchor(_))));
}
#[test]
fn rejects_malformed_ranges() {
let s = format!("kiromi://memory/{ULID}/L42");
let r = Memory::resolve_anchor_with_range(&s);
assert!(matches!(r, Err(Error::InvalidAnchor(_))));
let s = format!("kiromi://memory/{ULID}/Q1-2");
let r = Memory::resolve_anchor_with_range(&s);
assert!(matches!(r, Err(Error::InvalidAnchor(_))));
let s = format!("kiromi://memory/{ULID}@127");
let r = Memory::resolve_anchor_with_range(&s);
assert!(matches!(r, Err(Error::InvalidAnchor(_))));
}
#[test]
fn resolve_anchor_drops_range() {
let s = format!("kiromi://memory/{ULID}/L1-2");
let node = Memory::resolve_anchor(&s).unwrap();
assert!(matches!(node, NodeRef::Memory(_)));
}
}