#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkEnd {
Source,
Target,
}
pub const LINK_CYCLE_ERR_PREFIX: &str = "link refused: reflection cycle";
pub const LINK_PERMISSION_DENIED_ERR_PREFIX: &str = "link denied by permission rule";
#[derive(Debug, Clone)]
pub enum StorageError {
MemoryNotFound { id: String, role: Option<LinkEnd> },
PendingActionNotFound { pending_id: String },
AmbiguousIdPrefix {
prefix: String,
candidates: Vec<String>,
},
InvalidArgument { reason: String },
PendingActionStateInvalid {
#[allow(dead_code)] pending_id: String,
status: String,
},
LinkPermissionDenied { reason: String },
LinkReflectionCycle {
source_id: String,
target_id: String,
},
ApproverLaundering {
pending_id: String,
claimed: String,
requester: String,
},
UniqueConflict { reason: String },
ArchiveRestoreCollision { id: String },
ArchiveSupersedeFailed { archived_id: String },
SqlcipherMissingPassphrase,
}
impl std::fmt::Display for StorageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MemoryNotFound { id, role: None } => write!(f, "memory not found: {id}"),
Self::MemoryNotFound {
id,
role: Some(LinkEnd::Source),
} => write!(f, "source memory not found: {id}"),
Self::MemoryNotFound {
id,
role: Some(LinkEnd::Target),
} => write!(f, "target memory not found: {id}"),
Self::PendingActionNotFound { pending_id } => {
write!(f, "pending action not found: {pending_id}")
}
Self::AmbiguousIdPrefix { prefix, candidates } => write!(
f,
"ambiguous ID prefix '{prefix}': {n} matches\n{ids}",
n = candidates.len(),
ids = candidates.join("\n"),
),
Self::InvalidArgument { reason } => write!(f, "{reason}"),
Self::PendingActionStateInvalid { status, .. } => {
write!(f, "cannot execute non-approved action (status={status})")
}
Self::LinkPermissionDenied { reason } => {
write!(f, "{LINK_PERMISSION_DENIED_ERR_PREFIX}: {reason}")
}
Self::LinkReflectionCycle {
source_id,
target_id,
} => write!(
f,
"{LINK_CYCLE_ERR_PREFIX}: \
{source_id} --reflects_on--> {target_id} would close a cycle",
),
Self::ApproverLaundering {
pending_id,
claimed,
requester,
} => write!(
f,
"approver-on-behalf laundering refused: payload agent_id '{claimed}' \
!= requested_by '{requester}' (pending_id={pending_id})",
),
Self::UniqueConflict { reason } => write!(f, "{reason}"),
Self::ArchiveRestoreCollision { id } => write!(
f,
"cannot restore: memory {id} already exists in active table (would overwrite)",
),
Self::ArchiveSupersedeFailed { archived_id } => {
write!(f, "supersede archive failed for {archived_id}")
}
Self::SqlcipherMissingPassphrase => write!(
f,
"sqlcipher build requires AI_MEMORY_DB_PASSPHRASE \
(set via --db-passphrase-file <path>)",
),
}
}
}
impl std::error::Error for StorageError {}
impl StorageError {
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::MemoryNotFound { .. } => crate::errors::error_codes::NOT_FOUND,
Self::PendingActionNotFound { .. } => {
crate::errors::error_codes::PENDING_ACTION_NOT_FOUND
}
Self::AmbiguousIdPrefix { .. } => crate::errors::error_codes::AMBIGUOUS_ID_PREFIX,
Self::InvalidArgument { .. } => crate::errors::error_codes::INVALID_ARGUMENT,
Self::PendingActionStateInvalid { .. } => {
crate::errors::error_codes::PENDING_ACTION_STATE_INVALID
}
Self::LinkPermissionDenied { .. } => crate::errors::error_codes::LINK_PERMISSION_DENIED,
Self::LinkReflectionCycle { .. } => crate::errors::error_codes::LINK_REFLECTION_CYCLE,
Self::ApproverLaundering { .. } => crate::errors::error_codes::APPROVER_LAUNDERING,
Self::UniqueConflict { .. } => crate::errors::error_codes::UNIQUE_CONFLICT,
Self::ArchiveRestoreCollision { .. } => {
crate::errors::error_codes::ARCHIVE_RESTORE_COLLISION
}
Self::ArchiveSupersedeFailed { .. } => {
crate::errors::error_codes::ARCHIVE_SUPERSEDE_FAILED
}
Self::SqlcipherMissingPassphrase => {
crate::errors::error_codes::SQLCIPHER_MISSING_PASSPHRASE
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_memory_not_found_bare() {
let e = StorageError::MemoryNotFound {
id: "abc123".into(),
role: None,
};
assert_eq!(e.to_string(), "memory not found: abc123");
}
#[test]
fn display_memory_not_found_source() {
let e = StorageError::MemoryNotFound {
id: "src1".into(),
role: Some(LinkEnd::Source),
};
assert_eq!(e.to_string(), "source memory not found: src1");
}
#[test]
fn display_memory_not_found_target() {
let e = StorageError::MemoryNotFound {
id: "tgt1".into(),
role: Some(LinkEnd::Target),
};
assert_eq!(e.to_string(), "target memory not found: tgt1");
}
#[test]
fn display_pending_action_not_found() {
let e = StorageError::PendingActionNotFound {
pending_id: "pa-7".into(),
};
assert_eq!(e.to_string(), "pending action not found: pa-7");
}
#[test]
fn display_ambiguous_id_prefix_preserves_legacy_format() {
let e = StorageError::AmbiguousIdPrefix {
prefix: "ab".into(),
candidates: vec!["abc1".into(), "abc2".into()],
};
assert_eq!(
e.to_string(),
"ambiguous ID prefix 'ab': 2 matches\nabc1\nabc2",
);
}
#[test]
fn display_invalid_argument_passes_reason_through() {
let e = StorageError::InvalidArgument {
reason: "max_depth must be >= 1".into(),
};
assert_eq!(e.to_string(), "max_depth must be >= 1");
}
#[test]
fn display_pending_action_state_invalid() {
let e = StorageError::PendingActionStateInvalid {
pending_id: "pa-9".into(),
status: "rejected".into(),
};
assert_eq!(
e.to_string(),
"cannot execute non-approved action (status=rejected)",
);
}
#[test]
fn display_link_permission_denied_starts_with_canonical_prefix() {
let e = StorageError::LinkPermissionDenied {
reason: "rule R042 fired".into(),
};
let s = e.to_string();
assert!(
s.starts_with(LINK_PERMISSION_DENIED_ERR_PREFIX),
"expected canonical prefix, got: {s}",
);
assert_eq!(
s,
format!("{LINK_PERMISSION_DENIED_ERR_PREFIX}: rule R042 fired")
);
}
#[test]
fn display_link_reflection_cycle_starts_with_canonical_prefix() {
let e = StorageError::LinkReflectionCycle {
source_id: "a".into(),
target_id: "b".into(),
};
let s = e.to_string();
assert!(
s.starts_with(LINK_CYCLE_ERR_PREFIX),
"expected canonical prefix, got: {s}",
);
assert!(s.contains("a --reflects_on--> b"));
}
#[test]
fn display_approver_laundering_includes_all_fields() {
let e = StorageError::ApproverLaundering {
pending_id: "pa-1".into(),
claimed: "agent-x".into(),
requester: "agent-y".into(),
};
let s = e.to_string();
assert!(s.contains("'agent-x'"));
assert!(s.contains("'agent-y'"));
assert!(s.contains("pending_id=pa-1"));
}
#[test]
fn display_unique_conflict_passes_reason_through() {
let e = StorageError::UniqueConflict {
reason: "title 'X' already exists".into(),
};
assert_eq!(e.to_string(), "title 'X' already exists");
}
#[test]
fn display_archive_restore_collision_format() {
let e = StorageError::ArchiveRestoreCollision { id: "m1".into() };
assert_eq!(
e.to_string(),
"cannot restore: memory m1 already exists in active table (would overwrite)",
);
}
#[test]
fn display_archive_supersede_failed_format() {
let e = StorageError::ArchiveSupersedeFailed {
archived_id: "arch-7".into(),
};
assert_eq!(e.to_string(), "supersede archive failed for arch-7");
}
#[test]
fn display_sqlcipher_missing_passphrase_format() {
let e = StorageError::SqlcipherMissingPassphrase;
assert!(e.to_string().contains("AI_MEMORY_DB_PASSPHRASE"));
assert!(e.to_string().contains("--db-passphrase-file"));
}
#[test]
fn anyhow_from_storage_error_roundtrip_preserves_downcast() {
let e: anyhow::Error = anyhow::Error::new(StorageError::MemoryNotFound {
id: "id1".into(),
role: None,
});
let recovered = e
.downcast_ref::<StorageError>()
.expect("typed error must survive anyhow round-trip");
assert!(matches!(recovered, StorageError::MemoryNotFound { .. }));
}
}