use anyhow::{anyhow, Result};
use rusqlite::Connection;
use super::{extract_temporal, search_by_time_filtered, TemporalConstraint, TemporalField};
use crate::migrate::MIGRATIONS;
mod boundaries;
fn setup_conn() -> Connection {
let conn = Connection::open_in_memory().expect("in-memory db should open");
conn.execute_batch(MIGRATIONS[0].sql)
.expect("baseline schema should load");
conn.execute_batch("ALTER TABLE memories ADD COLUMN expires_at_epoch INTEGER;")
.expect("expires column should load");
conn
}
#[test]
fn parse_yesterday() {
assert!(extract_temporal("yesterday's decisions").is_some());
assert!(extract_temporal("昨天的决策").is_some());
}
#[test]
fn parse_last_week() {
assert!(extract_temporal("last week we discussed").is_some());
assert!(extract_temporal("上周讨论的").is_some());
}
#[test]
fn latin_temporal_phrases_require_word_boundaries() {
for query in [
"TodayWidget",
"todayish",
"last weekend",
"metrics/today/value",
"config.recently.value",
"config_今天_notes",
"今天Widget",
"今天2",
"最近3天Status",
"7天前Status",
] {
assert!(extract_temporal(query).is_none(), "{query}");
assert_eq!(
TemporalConstraint::query_without_temporal_expression(query),
query
);
}
assert!(extract_temporal("yesterday's decisions").is_some());
}
#[test]
fn parse_n_days_ago_en() {
for (query, days, preserved) in [
("3 days ago", 3, ""),
("service 42, 30 days ago", 30, "service 42"),
("service 42 30 days ago", 30, "service 42"),
("service 42\t30 days ago", 30, "service 42"),
("service 42\n30 days ago", 30, "service 42"),
("service 42 30 days ago", 30, "service 42"),
("incident 17—3 days ago", 3, "incident 17"),
("incident 17–3 days ago", 3, "incident 17"),
] {
let constraint = extract_temporal(query).expect("temporal query should parse");
let now = chrono::Utc::now().timestamp();
let semantic_query = TemporalConstraint::query_without_temporal_expression(query);
assert!((now - constraint.start_epoch - days * 86_400).abs() < 2);
assert!(semantic_query.contains(preserved), "{semantic_query:?}");
assert!(!semantic_query.contains("days ago"), "{semantic_query:?}");
}
}
#[test]
fn parse_last_n_days_consumes_adjacent_temporal_number() {
let query = "What changed for service 42 in the last 30 days?";
let constraint = extract_temporal(query).expect("temporal query should parse");
let now = chrono::Utc::now().timestamp();
let semantic_query = TemporalConstraint::query_without_temporal_expression(query);
assert!((now - constraint.start_epoch - 30 * 86_400).abs() < 2);
assert!(semantic_query.contains("service 42"));
assert!(!semantic_query.contains("last 30 days"));
}
#[test]
fn malformed_or_unsafe_day_counts_are_not_parsed_or_removed() {
for query in [
"last 29.5 days",
"29.5 days ago",
"2.5 days ago",
"v2.5 days ago",
"last v2.5 days",
"last 2,000 days",
"2'030 days ago",
"2\u{a0}030 days ago",
"2\u{202f}030 days ago",
"2\u{2007}030 days ago",
"last 29/5 days",
"last 29_5 days",
"v2-5 days ago",
"last 0 days",
"0 days ago",
"+3 days ago",
"3+ days ago",
"last +3 days",
"last 3+ days",
"+3 days ago",
"﹢3 days ago",
"3﹢ days ago",
"last ﹢3 days",
"last +3 days",
"last 3+ days",
"last -3 days",
"-3 days ago",
"last - 3 days",
"- 3 days ago",
"last -3 days",
"-3 days ago",
"last ﹣3 days",
"﹣3 days ago",
"last 9223372036854775807 days",
"9223372036854775807 days ago",
"最近0天",
"最近百天",
"最近十一天",
"最近v2.5天",
"最近29.5天",
"最近2天",
"十三天前",
"一百三天前",
"2.5天前",
"0天前",
"+3天前",
"+3天前",
"﹢3天前",
"最近-3天",
"-3天前",
"- 3天前",
"-3天前",
"-三天前",
"﹣3天前",
"﹣三天前",
] {
assert!(extract_temporal(query).is_none(), "{query}");
assert_eq!(
TemporalConstraint::query_without_temporal_expression(query),
query
);
}
}
#[test]
fn quantified_recent_ranges_fail_closed() {
for query in ["最近3到5天", "最近3至5天"] {
assert!(extract_temporal(query).is_none(), "{query}");
assert_eq!(
TemporalConstraint::query_without_temporal_expression(query),
query
);
}
}
#[test]
fn parse_n_days_ago_cn() {
for (query, days) in [("三天前", 3), ("两天前", 2), ("十天前", 10)] {
let constraint = extract_temporal(query).expect("single Chinese day count should parse");
let now = chrono::Utc::now().timestamp();
assert!((now - constraint.start_epoch - days * 86_400).abs() < 2);
assert!(TemporalConstraint::query_without_temporal_expression(query)
.trim()
.is_empty());
}
assert!(extract_temporal("7天前").is_some());
}
#[test]
fn parse_recently() {
assert!(extract_temporal("最近的修改").is_some());
assert!(extract_temporal("recently changed").is_some());
assert_eq!(
extract_temporal("recently changed").map(|constraint| constraint.field),
Some(TemporalField::UpdatedAt)
);
assert_eq!(
extract_temporal("recently SQLite").map(|constraint| constraint.field),
Some(TemporalField::EventTime)
);
for query in [
"最近的天气",
"最近每天",
"最近的部署每天",
"最近 v2 版本的天气",
"最近42号服务的天气",
"最近一次的天气",
] {
let constraint = extract_temporal(query).expect("generic recent query should parse");
let now = chrono::Utc::now().timestamp();
assert!((now - constraint.start_epoch - 3 * 86_400).abs() < 2);
}
}
#[test]
fn consumed_span_strips_cjk_temporal_expression() {
for (query, expected) in [
("今天有什么变化", "有什么变化"),
("2026年5月4日有什么变化", "有什么变化"),
("最近30天有什么变化", "有什么变化"),
("最近三天有什么变化", "有什么变化"),
("最近两天有什么变化", "有什么变化"),
("最近十天有什么变化", "有什么变化"),
] {
assert_eq!(
TemporalConstraint::query_without_temporal_expression(query).trim(),
expected
);
}
for (query, days) in [
("最近30天有什么变化", 30),
("最近三天有什么变化", 3),
("最近两天有什么变化", 2),
("最近十天有什么变化", 10),
] {
let constraint = extract_temporal(query).expect("recent range should parse");
let now = chrono::Utc::now().timestamp();
assert!((now - constraint.start_epoch - days * 86_400).abs() < 2);
}
}
#[test]
fn consumed_span_preserves_unicode_prefix_byte_offsets() {
for (query, temporal_text) in [
("İ today changed", "today"),
("İ changed in the last 30 days", "last 30 days"),
] {
let semantic_query = TemporalConstraint::query_without_temporal_expression(query);
assert!(semantic_query.starts_with("İ"), "{semantic_query:?}");
assert!(semantic_query.contains("changed"), "{semantic_query:?}");
assert!(
!semantic_query.contains(temporal_text),
"{semantic_query:?}"
);
}
for (query, temporal_text) in [
("İ changed in the last 30 days—please", "last 30 days"),
("İ changed in the last 30 days,please", "last 30 days"),
("İ changed 3 days ago—please", "3 days ago"),
] {
let semantic_query = TemporalConstraint::query_without_temporal_expression(query);
assert!(semantic_query.starts_with("İ"), "{semantic_query:?}");
assert!(semantic_query.contains("changed"), "{semantic_query:?}");
assert!(semantic_query.contains("please"), "{semantic_query:?}");
assert!(
!semantic_query.contains(temporal_text),
"{semantic_query:?}"
);
}
}
#[test]
fn parse_exact_dates() -> Result<()> {
let expected_start = chrono::NaiveDate::from_ymd_opt(2026, 5, 4)
.ok_or_else(|| anyhow!("valid date should construct"))?
.and_hms_opt(0, 0, 0)
.ok_or_else(|| anyhow!("valid time should construct"))?
.and_utc()
.timestamp();
for query in [
"notes from 2026-05-04",
"notes from May 4, 2026",
"notes from 4 May 2026",
"notes from 2026 May 4",
"notes from 2026年5月4日",
] {
let constraint =
extract_temporal(query).ok_or_else(|| anyhow!("exact date should parse: {query}"))?;
assert_eq!(constraint.start_epoch, expected_start);
assert_eq!(constraint.end_epoch, expected_start + 86_400 - 1);
}
Ok(())
}
#[test]
fn exact_date_consumes_the_parsed_occurrence() {
let query = "Service42 changed on 4 May 2026";
assert!(extract_temporal(query).is_some());
assert_eq!(
TemporalConstraint::query_without_temporal_expression(query).trim(),
"Service42 changed on"
);
}
#[test]
fn date_shaped_identifiers_are_not_temporal_expressions() {
for query in [
"release_2026-05-04_notes",
"release2026-05-04notes",
"release_2026/05/04_notes",
"release_2026.05.04_notes",
"版本_2026-05-04_说明",
"版本2026-05-04说明",
"版本2026年5月4日说明",
"2026-05-042",
"release_2026年5月4日_notes",
"发布_2026年5月4日_说明",
] {
assert!(extract_temporal(query).is_none(), "{query}");
assert_eq!(
TemporalConstraint::query_without_temporal_expression(query),
query
);
}
assert!(extract_temporal("release 2026-05-04 notes").is_some());
}
#[test]
fn date_shaped_identifier_does_not_hide_later_exact_date() -> Result<()> {
let expected_start = chrono::NaiveDate::from_ymd_opt(2026, 6, 5)
.ok_or_else(|| anyhow!("valid date should construct"))?
.and_hms_opt(0, 0, 0)
.ok_or_else(|| anyhow!("valid time should construct"))?
.and_utc()
.timestamp();
for (query, expected_semantic_query) in [
(
"release_2026-05-04_notes changed on 2026-06-05",
"release_2026-05-04_notes changed on",
),
(
"release_2026年5月4日_notes changed on 2026年6月5日",
"release_2026年5月4日_notes changed on",
),
] {
let constraint =
extract_temporal(query).ok_or_else(|| anyhow!("later exact date should parse"))?;
assert_eq!(constraint.start_epoch, expected_start, "{query}");
assert_eq!(
TemporalConstraint::query_without_temporal_expression(query).trim(),
expected_semantic_query
);
}
Ok(())
}
#[test]
fn snake_case_identifiers_are_not_temporal_expressions() {
for query in [
"last_30_days",
"3_days_ago",
"metrics/last/30/days",
"config.3.days.ago",
"metrics/last-30-days",
"config:3:days:ago",
"metrics/last 30 days",
"config.3 days ago",
"config_最近3天_notes",
"config_7天前_notes",
"config_三天前",
] {
assert!(extract_temporal(query).is_none(), "{query}");
assert_eq!(
TemporalConstraint::query_without_temporal_expression(query),
query
);
}
}
#[test]
fn parse_month_year_dates() -> Result<()> {
let expected_start = chrono::NaiveDate::from_ymd_opt(2026, 5, 1)
.ok_or_else(|| anyhow!("valid date should construct"))?
.and_hms_opt(0, 0, 0)
.ok_or_else(|| anyhow!("valid time should construct"))?
.and_utc()
.timestamp();
let expected_end = chrono::NaiveDate::from_ymd_opt(2026, 6, 1)
.ok_or_else(|| anyhow!("valid date should construct"))?
.and_hms_opt(0, 0, 0)
.ok_or_else(|| anyhow!("valid time should construct"))?
.and_utc()
.timestamp()
- 1;
for query in [
"notes from May 2026",
"notes from 2026 May",
"2026年5月的决策",
"2026年5月份",
] {
let constraint =
extract_temporal(query).ok_or_else(|| anyhow!("month/year should parse: {query}"))?;
assert_eq!(constraint.start_epoch, expected_start);
assert_eq!(constraint.end_epoch, expected_end);
}
Ok(())
}
#[test]
fn no_temporal_in_normal_query() {
assert!(extract_temporal("FTS5 search optimization").is_none());
assert!(extract_temporal("数据库加密").is_none());
}
#[test]
fn search_by_time_filtered_respects_filters() {
let conn = setup_conn();
let now = chrono::Utc::now().timestamp();
let start = now - 100;
let end = now + 100;
conn.execute(
"INSERT INTO memories
(id, session_id, project, topic_key, title, content, memory_type, files, created_at_epoch,
updated_at_epoch, status, branch, scope)
VALUES
(1, NULL, 'alpha', NULL, 't1', 'c1', 'decision', NULL, ?1, ?1, 'active', 'main', 'project'),
(2, NULL, 'alpha', NULL, 't2', 'c2', 'decision', NULL, ?2, ?2, 'active', NULL, 'project'),
(3, NULL, 'alpha', NULL, 't3', 'c3', 'decision', NULL, ?3, ?3, 'archived', 'main', 'project'),
(4, NULL, 'beta', NULL, 't4', 'c4', 'decision', NULL, ?4, ?4, 'active', 'main', 'project')",
rusqlite::params![now - 10, now - 20, now - 30, now - 40],
)
.expect("memories should insert");
let ids = search_by_time_filtered(
&conn,
&TemporalConstraint {
start_epoch: start,
end_epoch: end,
field: TemporalField::EventTime,
},
Some("alpha"),
Some("decision"),
Some("main"),
10,
false,
)
.expect("time search should succeed");
assert_eq!(ids, vec![1, 2]);
}
#[test]
fn search_by_time_uses_created_at_for_event_time() -> Result<()> {
let conn = setup_conn();
let now = chrono::Utc::now().timestamp();
let event_time = now - 30 * 86_400;
conn.execute(
"INSERT INTO memories
(id, session_id, project, topic_key, title, content, memory_type, files, created_at_epoch,
updated_at_epoch, status, branch, scope)
VALUES
(1, NULL, 'alpha', NULL, 'old event', 'backdated event', 'decision', NULL, ?1, ?2, 'active', 'main', 'project')",
rusqlite::params![event_time, now],
)?;
let event_ids = search_by_time_filtered(
&conn,
&TemporalConstraint {
start_epoch: event_time - 10,
end_epoch: event_time + 10,
field: TemporalField::EventTime,
},
Some("alpha"),
Some("decision"),
Some("main"),
10,
false,
)?;
assert_eq!(event_ids, vec![1]);
let today_event_ids = search_by_time_filtered(
&conn,
&TemporalConstraint {
start_epoch: now - 10,
end_epoch: now + 10,
field: TemporalField::EventTime,
},
Some("alpha"),
Some("decision"),
Some("main"),
10,
false,
)?;
assert!(today_event_ids.is_empty());
let updated_ids = search_by_time_filtered(
&conn,
&TemporalConstraint {
start_epoch: now - 10,
end_epoch: now + 10,
field: TemporalField::UpdatedAt,
},
Some("alpha"),
Some("decision"),
Some("main"),
10,
false,
)?;
assert_eq!(updated_ids, vec![1]);
Ok(())
}
#[test]
fn search_by_time_uses_reference_time_when_available() -> Result<()> {
let conn = setup_conn();
conn.execute_batch("ALTER TABLE memories ADD COLUMN reference_time_epoch INTEGER;")?;
let now = chrono::Utc::now().timestamp();
let reference_time = now - 90 * 86_400;
conn.execute(
"INSERT INTO memories
(id, session_id, project, topic_key, title, content, memory_type, files,
created_at_epoch, updated_at_epoch, reference_time_epoch, status, branch, scope)
VALUES
(1, NULL, 'alpha', NULL, 'imported now', 'historical episode', 'decision', NULL,
?1, ?1, ?2, 'active', 'main', 'project')",
rusqlite::params![now, reference_time],
)?;
let historical_ids = search_by_time_filtered(
&conn,
&TemporalConstraint {
start_epoch: reference_time - 10,
end_epoch: reference_time + 10,
field: TemporalField::EventTime,
},
Some("alpha"),
Some("decision"),
Some("main"),
10,
false,
)?;
assert_eq!(historical_ids, vec![1]);
let today_ids = search_by_time_filtered(
&conn,
&TemporalConstraint {
start_epoch: now - 10,
end_epoch: now + 10,
field: TemporalField::EventTime,
},
Some("alpha"),
Some("decision"),
Some("main"),
10,
false,
)?;
assert!(today_ids.is_empty());
Ok(())
}
#[test]
fn search_by_time_prefers_temporal_fact_event_time() -> Result<()> {
let conn = setup_conn();
conn.execute_batch(MIGRATIONS[12].sql)?;
let now = chrono::Utc::now().timestamp();
let fact_time = now - 45 * 86_400;
conn.execute(
"INSERT INTO memories
(id, session_id, project, topic_key, title, content, memory_type, files, created_at_epoch,
updated_at_epoch, status, branch, scope)
VALUES
(1, NULL, 'alpha', NULL, 'imported today', 'historical fact', 'decision', NULL, ?1, ?1, 'active', 'main', 'project')",
rusqlite::params![now],
)?;
conn.execute(
"INSERT INTO memory_facts
(project, subject, predicate, object, valid_from_epoch, valid_to_epoch,
learned_at_epoch, source_memory_id, source_observation_id, source_event_ids,
confidence, supersedes_fact_id, status, created_at_epoch, updated_at_epoch)
VALUES
('alpha', 'historical fact', 'affects_project', 'alpha', ?1, NULL,
?2, 1, NULL, '[]', 0.9, NULL, 'active', ?2, ?2)",
rusqlite::params![fact_time, now],
)?;
let fact_ids = search_by_time_filtered(
&conn,
&TemporalConstraint {
start_epoch: fact_time - 10,
end_epoch: fact_time + 10,
field: TemporalField::EventTime,
},
Some("alpha"),
Some("decision"),
Some("main"),
10,
false,
)?;
assert_eq!(fact_ids, vec![1]);
let today_ids = search_by_time_filtered(
&conn,
&TemporalConstraint {
start_epoch: now - 10,
end_epoch: now + 10,
field: TemporalField::EventTime,
},
Some("alpha"),
Some("decision"),
Some("main"),
10,
false,
)?;
assert_eq!(today_ids, vec![1]);
Ok(())
}
#[test]
fn search_by_time_treats_fact_intervals_as_open_ended_and_exclusive() -> Result<()> {
let conn = setup_conn();
conn.execute_batch(MIGRATIONS[12].sql)?;
let now = chrono::Utc::now().timestamp();
let may_start = chrono::NaiveDate::from_ymd_opt(2026, 5, 1)
.ok_or_else(|| anyhow!("valid date should construct"))?
.and_hms_opt(0, 0, 0)
.ok_or_else(|| anyhow!("valid time should construct"))?
.and_utc()
.timestamp();
let june_start = chrono::NaiveDate::from_ymd_opt(2026, 6, 1)
.ok_or_else(|| anyhow!("valid date should construct"))?
.and_hms_opt(0, 0, 0)
.ok_or_else(|| anyhow!("valid time should construct"))?
.and_utc()
.timestamp();
let july_start = chrono::NaiveDate::from_ymd_opt(2026, 7, 1)
.ok_or_else(|| anyhow!("valid date should construct"))?
.and_hms_opt(0, 0, 0)
.ok_or_else(|| anyhow!("valid time should construct"))?
.and_utc()
.timestamp();
conn.execute(
"INSERT INTO memories
(id, session_id, project, topic_key, title, content, memory_type, files, created_at_epoch,
updated_at_epoch, status, branch, scope)
VALUES
(1, NULL, 'alpha', NULL, 'open ended fact', 'active since May', 'decision', NULL, ?1, ?1, 'active', 'main', 'project'),
(2, NULL, 'alpha', NULL, 'ended fact', 'ended before June', 'decision', NULL, ?1, ?1, 'active', 'main', 'project')",
rusqlite::params![now],
)?;
conn.execute(
"INSERT INTO memory_facts
(project, subject, predicate, object, valid_from_epoch, valid_to_epoch,
learned_at_epoch, source_memory_id, source_observation_id, source_event_ids,
confidence, supersedes_fact_id, status, created_at_epoch, updated_at_epoch)
VALUES
('alpha', 'open ended fact', 'affects_project', 'alpha', ?1, NULL,
?3, 1, NULL, '[]', 0.9, NULL, 'active', ?3, ?3),
('alpha', 'ended fact', 'affects_project', 'alpha', ?1, ?2,
?3, 2, NULL, '[]', 0.9, NULL, 'active', ?3, ?3)",
rusqlite::params![may_start, june_start, now],
)?;
let june_ids = search_by_time_filtered(
&conn,
&TemporalConstraint {
start_epoch: june_start,
end_epoch: july_start - 1,
field: TemporalField::EventTime,
},
Some("alpha"),
Some("decision"),
Some("main"),
10,
false,
)?;
assert!(june_ids.contains(&1), "{june_ids:?}");
assert!(!june_ids.contains(&2), "{june_ids:?}");
Ok(())
}
#[test]
fn search_by_time_ignores_stale_temporal_facts() -> Result<()> {
let conn = setup_conn();
conn.execute_batch(MIGRATIONS[12].sql)?;
let now = chrono::Utc::now().timestamp();
let stale_fact_time = now - 45 * 86_400;
conn.execute(
"INSERT INTO memories
(id, session_id, project, topic_key, title, content, memory_type, files, created_at_epoch,
updated_at_epoch, status, branch, scope)
VALUES
(1, NULL, 'alpha', NULL, 'current event', 'stale fact should not override created_at', 'decision', NULL, ?1, ?1, 'active', 'main', 'project')",
rusqlite::params![now],
)?;
conn.execute(
"INSERT INTO memory_facts
(project, subject, predicate, object, valid_from_epoch, valid_to_epoch,
learned_at_epoch, source_memory_id, source_observation_id, source_event_ids,
confidence, supersedes_fact_id, status, created_at_epoch, updated_at_epoch)
VALUES
('alpha', 'old stale fact', 'affects_project', 'alpha', ?1, NULL,
?2, 1, NULL, '[]', 0.9, NULL, 'stale', ?2, ?2)",
rusqlite::params![stale_fact_time, now],
)?;
let stale_fact_ids = search_by_time_filtered(
&conn,
&TemporalConstraint {
start_epoch: stale_fact_time - 10,
end_epoch: stale_fact_time + 10,
field: TemporalField::EventTime,
},
Some("alpha"),
Some("decision"),
Some("main"),
10,
false,
)?;
assert!(stale_fact_ids.is_empty());
let created_at_ids = search_by_time_filtered(
&conn,
&TemporalConstraint {
start_epoch: now - 10,
end_epoch: now + 10,
field: TemporalField::EventTime,
},
Some("alpha"),
Some("decision"),
Some("main"),
10,
false,
)?;
assert_eq!(created_at_ids, vec![1]);
Ok(())
}
#[test]
fn search_by_time_ignores_invalidated_active_temporal_facts() -> Result<()> {
let conn = Connection::open_in_memory()?;
crate::migrate::run_migrations(&conn)?;
let now = chrono::Utc::now().timestamp();
let invalidated_fact_time = now - 45 * 86_400;
conn.execute(
"INSERT INTO memories
(id, session_id, project, topic_key, title, content, memory_type, files, created_at_epoch,
updated_at_epoch, status, branch, scope)
VALUES
(1, NULL, 'alpha', NULL, 'current event', 'invalidated fact should not override created_at', 'decision', NULL, ?1, ?1, 'active', 'main', 'project')",
rusqlite::params![now],
)?;
conn.execute(
"INSERT INTO memory_facts
(project, subject, predicate, object, valid_from_epoch, valid_to_epoch,
learned_at_epoch, source_memory_id, source_observation_id, source_event_ids,
confidence, supersedes_fact_id, status, invalidated_at_epoch, created_at_epoch,
updated_at_epoch)
VALUES
('alpha', 'old invalidated fact', 'affects_project', 'alpha', ?1, NULL,
?2, 1, NULL, '[]', 0.9, NULL, 'active', ?2, ?2, ?2)",
rusqlite::params![invalidated_fact_time, now - 10],
)?;
let invalidated_fact_ids = search_by_time_filtered(
&conn,
&TemporalConstraint {
start_epoch: invalidated_fact_time - 10,
end_epoch: invalidated_fact_time + 10,
field: TemporalField::EventTime,
},
Some("alpha"),
Some("decision"),
Some("main"),
10,
false,
)?;
assert!(invalidated_fact_ids.is_empty());
let created_at_ids = search_by_time_filtered(
&conn,
&TemporalConstraint {
start_epoch: now - 10,
end_epoch: now + 10,
field: TemporalField::EventTime,
},
Some("alpha"),
Some("decision"),
Some("main"),
10,
false,
)?;
assert_eq!(created_at_ids, vec![1]);
Ok(())
}