use aristo_core::canon::cache::CanonMatchesFile;
use crate::commands::canon::{accept, suggestions};
use crate::session::kind::SessionKind;
use crate::session::rejections::{self, RejectionEntry};
use crate::session::types::{ItemRef, NestingPolicy};
use crate::workspace::Workspace;
use crate::{CliError, CliResult};
pub struct IntentReviewSession;
enum IntentItem {
Match {
annotation_id: String,
canon_id: String,
},
Cluster { key: String },
Sibling { key: String, canon_id: String },
}
impl IntentItem {
fn parse(item_ref: &ItemRef) -> CliResult<Self> {
let s = item_ref.as_str();
if let Some(rest) = s.strip_prefix("match:") {
let (annotation_id, canon_id) =
rest.rsplit_once('#').ok_or_else(|| bad_ref(item_ref))?;
return Ok(IntentItem::Match {
annotation_id: annotation_id.to_string(),
canon_id: canon_id.to_string(),
});
}
if let Some(rest) = s.strip_prefix("sibling:") {
let (key, canon_id) = rest.rsplit_once('#').ok_or_else(|| bad_ref(item_ref))?;
return Ok(IntentItem::Sibling {
key: key.to_string(),
canon_id: canon_id.to_string(),
});
}
if let Some(key) = s.strip_prefix("cluster:") {
return Ok(IntentItem::Cluster {
key: key.to_string(),
});
}
Err(bad_ref(item_ref))
}
}
fn bad_ref(item_ref: &ItemRef) -> CliError {
CliError::Other {
message: format!(
"intent-review item ref `{item_ref}` is not in `match:<id>#<canon-id>`, \
`cluster:<key>`, or `sibling:<key>#<canon-id>` form"
),
exit_code: 2,
}
}
impl SessionKind for IntentReviewSession {
fn name(&self) -> &'static str {
suggestions::INTENT_REVIEW_KIND
}
fn nesting_policy(&self) -> NestingPolicy {
NestingPolicy::Disallow
}
fn on_accept(&self, item_ref: &ItemRef, _note: Option<&str>, ws: &Workspace) -> CliResult<()> {
match IntentItem::parse(item_ref)? {
IntentItem::Match {
annotation_id,
canon_id,
} => {
let now = now_rfc3339();
accept::apply_acceptance(ws, &annotation_id, &canon_id, &now)
}
IntentItem::Cluster { key } => {
validate_cluster_exists(ws, &key)?;
Ok(())
}
IntentItem::Sibling { key, canon_id } => {
validate_sibling_exists(ws, &key, &canon_id)?;
Ok(())
}
}
}
fn on_reject(
&self,
item_ref: &ItemRef,
note: Option<&str>,
ws: &Workspace,
) -> CliResult<serde_json::Value> {
match IntentItem::parse(item_ref)? {
IntentItem::Match { canon_id, .. } => Ok(suggestions::rejection_fingerprint(&canon_id)),
IntentItem::Sibling { canon_id, .. } => {
Ok(suggestions::rejection_fingerprint(&canon_id))
}
IntentItem::Cluster { key } => {
let obj_canon_id = discard_dragged_in_only(ws, &key, note)?;
Ok(suggestions::rejection_fingerprint(&obj_canon_id))
}
}
}
fn on_pending(
&self,
item_ref: &ItemRef,
_note: Option<&str>,
_ws: &Workspace,
) -> CliResult<serde_json::Value> {
match IntentItem::parse(item_ref)? {
IntentItem::Match {
annotation_id,
canon_id,
} => Ok(serde_json::json!({
"kind": "match",
"annotation_id": annotation_id,
"canon_id": canon_id,
})),
IntentItem::Cluster { key } => Ok(serde_json::json!({
"kind": "cluster",
"objective": key,
})),
IntentItem::Sibling { key, canon_id } => Ok(serde_json::json!({
"kind": "sibling",
"objective": key,
"canon_id": canon_id,
})),
}
}
fn matches_prior_rejection(
&self,
item_ref: &ItemRef,
prior_fingerprint: &serde_json::Value,
) -> bool {
let Ok(item) = IntentItem::parse(item_ref) else {
return false;
};
let canon_id = match item {
IntentItem::Match { canon_id, .. } | IntentItem::Sibling { canon_id, .. } => canon_id,
IntentItem::Cluster { .. } => return false,
};
prior_fingerprint.as_str() == Some(canon_id.as_str())
}
}
fn discard_dragged_in_only(ws: &Workspace, key: &str, note: Option<&str>) -> CliResult<String> {
let (mut task, path) =
suggestions::find_task_by_key(ws, key)?.ok_or_else(|| CliError::Other {
message: format!(
"no queued suggestion cluster `{key}` to reject.\n\
hint: list clusters with `aristo canon suggestions`."
),
exit_code: 2,
})?;
let obj_canon_id = task.key().to_string();
let cache = CanonMatchesFile::read(&ws.canon_matches_path()).map_err(CliError::Io)?;
let now = now_rfc3339();
let mut kept = Vec::new();
let mut discarded = Vec::new();
for sibling in std::mem::take(&mut task.siblings) {
if suggestions::member_independently_held(ws, &cache, &sibling.canon_id)? {
kept.push(sibling);
} else {
discarded.push(sibling);
}
}
for sibling in &discarded {
rejections::append(
ws,
&RejectionEntry {
ts: now.clone(),
kind: suggestions::INTENT_REVIEW_KIND.to_string(),
item_ref: suggestions::rejection_item_ref(&sibling.canon_id),
note: note.map(str::to_string),
fingerprint: suggestions::rejection_fingerprint(&sibling.canon_id),
},
)?;
}
if kept.is_empty() {
std::fs::remove_file(&path).map_err(CliError::Io)?;
} else {
task.siblings = kept;
task.objective = None;
let toml_text = toml::to_string_pretty(&task).map_err(|e| CliError::Other {
message: format!("serialize suggestion task: {e}"),
exit_code: 1,
})?;
std::fs::write(&path, toml_text).map_err(CliError::Io)?;
}
Ok(obj_canon_id)
}
fn validate_cluster_exists(ws: &Workspace, key: &str) -> CliResult<()> {
suggestions::find_task_by_key(ws, key)?
.map(|_| ())
.ok_or_else(|| CliError::Other {
message: format!(
"no queued suggestion cluster `{key}`.\n\
hint: list clusters with `aristo canon suggestions`."
),
exit_code: 2,
})
}
fn validate_sibling_exists(ws: &Workspace, key: &str, canon_id: &str) -> CliResult<()> {
let (task, _) = suggestions::find_task_by_key(ws, key)?.ok_or_else(|| CliError::Other {
message: format!("no queued suggestion cluster `{key}`."),
exit_code: 2,
})?;
if task.siblings.iter().any(|s| s.canon_id == canon_id) {
Ok(())
} else {
Err(CliError::Other {
message: format!("cluster `{key}` has no sibling `{canon_id}`."),
exit_code: 2,
})
}
}
fn now_rfc3339() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock is post-1970");
crate::session::id_gen::format_rfc3339(now.as_secs())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_match_cluster_and_sibling_refs() {
match IntentItem::parse(&ItemRef::from_opaque("match:aristos:foo#some_canon")).unwrap() {
IntentItem::Match {
annotation_id,
canon_id,
} => {
assert_eq!(annotation_id, "aristos:foo");
assert_eq!(canon_id, "some_canon");
}
_ => panic!("expected Match"),
}
match IntentItem::parse(&ItemRef::from_opaque("cluster:wal_protocol")).unwrap() {
IntentItem::Cluster { key } => assert_eq!(key, "wal_protocol"),
_ => panic!("expected Cluster"),
}
match IntentItem::parse(&ItemRef::from_opaque("sibling:wal_protocol#wal_find")).unwrap() {
IntentItem::Sibling { key, canon_id } => {
assert_eq!(key, "wal_protocol");
assert_eq!(canon_id, "wal_find");
}
_ => panic!("expected Sibling"),
}
}
#[test]
fn rejects_unprefixed_ref() {
assert!(IntentItem::parse(&ItemRef::from_opaque("just_an_id")).is_err());
assert!(IntentItem::parse(&ItemRef::from_opaque("match:no_hash")).is_err());
assert!(IntentItem::parse(&ItemRef::from_opaque("sibling:no_hash")).is_err());
}
#[test]
fn matches_prior_rejection_compares_bare_canon_id() {
let kind = IntentReviewSession;
let fp = suggestions::rejection_fingerprint("wal_find");
assert!(kind.matches_prior_rejection(&ItemRef::from_opaque("sibling:obj#wal_find"), &fp,));
assert!(kind.matches_prior_rejection(&ItemRef::from_opaque("match:ann#wal_find"), &fp,));
assert!(!kind.matches_prior_rejection(&ItemRef::from_opaque("sibling:obj#other"), &fp,));
assert!(!kind.matches_prior_rejection(&ItemRef::from_opaque("cluster:wal_find"), &fp));
}
}