use crate::cli::{ClaimKindArg, ContractsCommand, ForgetReasonArg, ReviewDecisionArg};
use crate::contracts::args::parse_kind;
use crate::slash::SlashParseError;
#[derive(Debug)]
pub(crate) struct RememberSpec {
pub table: String,
pub kind: ClaimKindArg,
pub value: String,
pub column: Option<String>,
pub reason: Option<String>,
}
fn is_column_kind(kind: ClaimKindArg) -> bool {
matches!(
kind,
ClaimKindArg::ColumnDescription | ClaimKindArg::ColumnRole
)
}
pub(crate) fn parse_remember(arg: &str) -> Result<RememberSpec, SlashParseError> {
let mut parts = arg.split_whitespace();
let table = parts
.next()
.ok_or_else(|| SlashParseError(usage_remember()))?;
let kind_word = parts
.next()
.ok_or_else(|| SlashParseError(usage_remember()))?;
let kind = parse_kind(kind_word).ok_or_else(|| SlashParseError(usage_remember()))?;
let (column, value) = if is_column_kind(kind) {
let column = parts
.next()
.ok_or_else(|| SlashParseError(usage_remember()))?;
(Some(column.to_string()), rest_after(parts))
} else {
(None, rest_after(parts))
};
let raw_value = value.ok_or_else(|| SlashParseError(usage_remember()))?;
let (value, reason) = split_reason(&raw_value, kind);
Ok(RememberSpec {
table: table.to_string(),
kind,
value,
column,
reason,
})
}
fn rest_after<'a, I: Iterator<Item = &'a str>>(mut parts: I) -> Option<String> {
let first = parts.next()?;
let mut value = first.to_string();
for token in parts {
value.push(' ');
value.push_str(token);
}
Some(value)
}
fn split_reason(value: &str, kind: ClaimKindArg) -> (String, Option<String>) {
if !is_directive_kind(kind) {
return (value.to_string(), None);
}
let lower = value.to_ascii_lowercase();
let Some(idx) = find_standalone(&lower, "because") else {
return (value.to_string(), None);
};
let reason = value[idx + "because".len()..].trim();
let value_part = value[..idx].trim_end();
if reason.is_empty() || value_part.is_empty() {
return (value.to_string(), None);
}
(value_part.to_string(), Some(reason.to_string()))
}
fn is_directive_kind(kind: ClaimKindArg) -> bool {
matches!(
kind,
ClaimKindArg::Grain | ClaimKindArg::TimeColumn | ClaimKindArg::ColumnRole
)
}
fn find_standalone(haystack: &str, needle: &str) -> Option<usize> {
let mut start = 0;
while let Some(idx) = haystack[start..].find(needle) {
let abs = start + idx;
let before_ok = abs == 0 || haystack.as_bytes().get(abs - 1) == Some(&b' ');
let after = abs + needle.len();
let after_ok = after >= haystack.len() || haystack.as_bytes().get(after) == Some(&b' ');
if before_ok && after_ok {
return Some(abs);
}
start = abs + needle.len();
}
None
}
fn usage_remember() -> String {
"/remember <catalog.schema.object> <kind> <value…> [because <reason…>]\n\
kinds: description, alias, grain, time-column, column-description <column> <value…>, \
column-role <column> <role>\n\
`because <reason…>` is optional, and only the directive kinds (grain, time-column, \
column-role) carry it"
.into()
}
fn usage_contracts() -> String {
"/contracts [catalog.schema.object]".into()
}
fn usage_forget() -> String {
"/forget <claim-id>".into()
}
fn usage_queue() -> String {
"/queue [limit]".into()
}
fn usage_decide() -> String {
"/confirm|/reject|/use <claim-id-prefix>".into()
}
fn parse_decide(
_name: &str,
arg: &str,
decision: ReviewDecisionArg,
) -> Result<Option<ContractsCommand>, SlashParseError> {
let tokens: Vec<&str> = arg.split_whitespace().collect();
let prefix = tokens
.first()
.ok_or_else(|| SlashParseError(usage_decide()))?;
if tokens.len() != 1 {
return Err(SlashParseError(usage_decide()));
}
Ok(Some(ContractsCommand::Decide {
prefix: prefix.to_string(),
decision,
profile: None,
}))
}
pub(crate) fn parse_contract_command(
name: &str,
arg: &str,
) -> Result<Option<ContractsCommand>, SlashParseError> {
match name {
"contracts" | "contract" => {
let tokens: Vec<&str> = arg.split_whitespace().collect();
match tokens.len() {
0 => Ok(Some(ContractsCommand::List { profile: None })),
1 => Ok(Some(ContractsCommand::Show {
table: tokens[0].to_string(),
profile: None,
})),
_ => Err(SlashParseError(usage_contracts())),
}
}
"remember" => {
let spec = parse_remember(arg)?;
Ok(Some(ContractsCommand::Remember {
table: spec.table,
kind: spec.kind,
value: spec.value,
column: spec.column,
reason: spec.reason,
profile: None,
}))
}
"forget" => {
let tokens: Vec<&str> = arg.split_whitespace().collect();
let id = tokens
.first()
.ok_or_else(|| SlashParseError(usage_forget()))?;
if tokens.len() != 1 {
return Err(SlashParseError(usage_forget()));
}
Ok(Some(ContractsCommand::Forget {
claim_id: id.to_string(),
reason: ForgetReasonArg::UserRequest,
}))
}
"queue" => {
let tokens: Vec<&str> = arg.split_whitespace().collect();
let limit = match tokens.len() {
0 => None,
1 => Some(
tokens[0]
.parse::<usize>()
.map_err(|_| SlashParseError(usage_queue()))?,
),
_ => return Err(SlashParseError(usage_queue())),
};
Ok(Some(ContractsCommand::Queue {
profile: None,
limit,
}))
}
"confirm" => parse_decide(name, arg, ReviewDecisionArg::Confirm),
"reject" => parse_decide(name, arg, ReviewDecisionArg::Reject),
_ => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn s13_the_one_letter_no_longer_selects_a_different_operation() {
assert_eq!(
parse_contract_command("contracts", "analytics.public.orders")
.unwrap()
.unwrap(),
ContractsCommand::Show {
table: "analytics.public.orders".into(),
profile: None,
}
);
assert_eq!(
parse_contract_command("contract", "").unwrap().unwrap(),
ContractsCommand::List { profile: None }
);
assert_eq!(
parse_contract_command("contracts", "").unwrap().unwrap(),
parse_contract_command("contract", "").unwrap().unwrap()
);
assert_eq!(
parse_contract_command("contracts", "analytics.public.orders")
.unwrap()
.unwrap(),
parse_contract_command("contract", "analytics.public.orders")
.unwrap()
.unwrap()
);
}
#[test]
fn parse_contract_contracts_no_arg() {
let cmd = parse_contract_command("contracts", "").unwrap().unwrap();
assert_eq!(cmd, ContractsCommand::List { profile: None });
assert_eq!(
parse_contract_command("contracts", "analytics.public.orders")
.unwrap()
.unwrap(),
ContractsCommand::Show {
table: "analytics.public.orders".into(),
profile: None,
}
);
assert!(parse_contract_command("contracts", "a b").is_err());
}
#[test]
fn contracts_one_name_arg_selects_the_operation() {
assert_eq!(
parse_contract_command("contracts", "").unwrap().unwrap(),
ContractsCommand::List { profile: None }
);
assert_eq!(
parse_contract_command("contracts", "analytics.public.orders")
.unwrap()
.unwrap(),
ContractsCommand::Show {
table: "analytics.public.orders".into(),
profile: None,
}
);
let bad = parse_contract_command("contracts", "a b").unwrap_err();
assert!(!bad.0.contains("a b"));
assert!(!bad.0.is_empty());
assert_eq!(
parse_contract_command("contracts", " analytics.public.orders ")
.unwrap()
.unwrap(),
ContractsCommand::Show {
table: "analytics.public.orders".into(),
profile: None,
}
);
}
#[test]
fn contract_is_a_silent_alias_of_the_merged_command() {
assert_eq!(
parse_contract_command("contract", "analytics.public.orders")
.unwrap()
.unwrap(),
parse_contract_command("contracts", "analytics.public.orders")
.unwrap()
.unwrap()
);
assert_eq!(
parse_contract_command("contract", "").unwrap().unwrap(),
parse_contract_command("contracts", "").unwrap().unwrap()
);
assert!(parse_contract_command("contract", "a b").is_err());
assert!(parse_contract_command("contracts", "a b").is_err());
}
#[test]
fn parse_remember_table_kind_value() {
let spec = parse_remember("a.b.c alias customers").unwrap();
assert_eq!(spec.table, "a.b.c");
assert_eq!(spec.kind, ClaimKindArg::Alias);
assert_eq!(spec.value, "customers");
assert_eq!(spec.column, None);
assert_eq!(spec.reason, None);
}
#[test]
fn parse_remember_directive_kind_carries_a_because_reason() {
let spec = parse_remember(
"pagila.public.rental time-column return_date because a rental only counts once it comes back",
)
.unwrap();
assert_eq!(spec.kind, ClaimKindArg::TimeColumn);
assert_eq!(spec.value, "return_date");
assert_eq!(
spec.reason.as_deref(),
Some("a rental only counts once it comes back")
);
let grain =
parse_remember("a.b.c grain one row per order because orders ship separately").unwrap();
assert_eq!(grain.value, "one row per order");
assert_eq!(grain.reason.as_deref(), Some("orders ship separately"));
let role =
parse_remember("a.b.c column-role amount measure because money the customer paid")
.unwrap();
assert_eq!(role.value, "measure");
assert_eq!(role.reason.as_deref(), Some("money the customer paid"));
}
#[test]
fn parse_remember_because_is_not_split_for_non_directive_kinds() {
let spec =
parse_remember("a.b.c description returns because the warehouse closes").unwrap();
assert_eq!(spec.kind, ClaimKindArg::Description);
assert_eq!(spec.value, "returns because the warehouse closes");
assert_eq!(spec.reason, None);
}
#[test]
fn parse_remember_because_substring_is_not_a_split() {
let spec = parse_remember("a.b.c time-column created_at probecause_marker").unwrap();
assert_eq!(spec.value, "created_at probecause_marker");
assert_eq!(spec.reason, None);
let bare = parse_remember("a.b.c time-column created_at because").unwrap();
assert_eq!(bare.value, "created_at because");
assert_eq!(bare.reason, None);
}
#[test]
fn parse_remember_directive_without_because_has_no_reason() {
let spec = parse_remember("a.b.c time-column created_at").unwrap();
assert_eq!(spec.value, "created_at");
assert_eq!(spec.reason, None);
}
#[test]
fn parse_remember_value_keeps_spaces() {
let spec = parse_remember("a.b.c description orders fact table").unwrap();
assert_eq!(spec.kind, ClaimKindArg::Description);
assert_eq!(spec.value, "orders fact table");
assert_eq!(spec.column, None);
}
#[test]
fn parse_remember_column_kind_takes_column_then_value() {
let spec = parse_remember("a.b.c column-description amount order total").unwrap();
assert_eq!(spec.kind, ClaimKindArg::ColumnDescription);
assert_eq!(spec.column.as_deref(), Some("amount"));
assert_eq!(spec.value, "order total");
}
#[test]
fn parse_remember_column_role() {
let spec = parse_remember("a.b.c column-role amount measure").unwrap();
assert_eq!(spec.kind, ClaimKindArg::ColumnRole);
assert_eq!(spec.column.as_deref(), Some("amount"));
assert_eq!(spec.value, "measure");
}
#[test]
fn parse_remember_time_column_is_table_scoped() {
let spec = parse_remember("a.b.c time-column created_at").unwrap();
assert_eq!(spec.kind, ClaimKindArg::TimeColumn);
assert_eq!(spec.value, "created_at");
assert_eq!(spec.column, None);
}
#[test]
fn parse_remember_kind_aliases_accepted() {
let spec = parse_remember("a.b.c column_description amount note").unwrap();
assert_eq!(spec.kind, ClaimKindArg::ColumnDescription);
}
#[test]
fn parse_remember_unknown_kind_is_usage_error_without_echo() {
let bad = parse_remember("a.b.c not-a-kind value").unwrap_err();
assert!(!bad.0.contains("not-a-kind"));
assert!(!bad.0.contains("a.b.c"));
assert!(bad.0.contains("kind"));
}
#[test]
fn parse_remember_too_few_args_is_usage_error() {
assert!(parse_remember("").is_err());
assert!(parse_remember("a.b.c").is_err());
assert!(parse_remember("a.b.c alias").is_err());
assert!(parse_remember("a.b.c column-description").is_err());
assert!(parse_remember("a.b.c column-description amount").is_err());
}
#[test]
fn parse_forget_one_id() {
let cmd = parse_contract_command("forget", "abc-123")
.unwrap()
.unwrap();
assert_eq!(
cmd,
ContractsCommand::Forget {
claim_id: "abc-123".into(),
reason: ForgetReasonArg::UserRequest,
}
);
assert!(parse_contract_command("forget", "").is_err());
assert!(parse_contract_command("forget", "a b").is_err());
}
#[test]
fn parse_queue_no_arg_is_default_limit() {
let cmd = parse_contract_command("queue", "").unwrap().unwrap();
assert_eq!(
cmd,
ContractsCommand::Queue {
profile: None,
limit: None
}
);
}
#[test]
fn parse_queue_optional_numeric_limit() {
let cmd = parse_contract_command("queue", "20").unwrap().unwrap();
assert_eq!(
cmd,
ContractsCommand::Queue {
profile: None,
limit: Some(20),
}
);
let bad = parse_contract_command("queue", "lots").unwrap_err();
assert!(!bad.0.contains("lots"));
assert!(parse_contract_command("queue", "1 2").is_err());
}
#[test]
fn parse_confirm_one_prefix() {
let cmd = parse_contract_command("confirm", "c-a86a3f")
.unwrap()
.unwrap();
assert_eq!(
cmd,
ContractsCommand::Decide {
prefix: "c-a86a3f".into(),
decision: ReviewDecisionArg::Confirm,
profile: None,
}
);
let bad = parse_contract_command("confirm", "").unwrap_err();
assert!(!bad.0.contains("c-"));
assert!(!bad.0.is_empty());
let bad = parse_contract_command("confirm", "c-abc c-def").unwrap_err();
assert!(!bad.0.contains("c-abc"));
assert!(!bad.0.contains("c-def"));
}
#[test]
fn parse_reject_translates_to_decide() {
assert_eq!(
parse_contract_command("reject", "c-1").unwrap().unwrap(),
ContractsCommand::Decide {
prefix: "c-1".into(),
decision: ReviewDecisionArg::Reject,
profile: None,
}
);
assert!(parse_contract_command("reject", "").is_err());
assert!(parse_contract_command("reject", "a b").is_err());
}
#[test]
fn use_is_not_a_contract_command_until_the_admission_is_threaded() {
assert!(
parse_contract_command("use", "c-1").unwrap().is_none(),
"/use must not parse while it cannot admit anything"
);
}
#[test]
fn parse_unknown_name_returns_none() {
assert!(parse_contract_command("sql", "select 1").unwrap().is_none());
}
}