#![allow(clippy::module_name_repetitions)]
use async_trait::async_trait;
use crate::record::{Record, RecordId};
use crate::taint::Untainted;
use crate::Result;
pub mod sim;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum BackendFeature {
Delete,
Transitions,
StrongVersioning,
BulkEdit,
Workflows,
Hierarchy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
#[allow(clippy::struct_excessive_bools)]
pub struct BackendCapabilities {
pub read: bool,
pub create: bool,
pub update: bool,
pub delete: bool,
pub comments: CommentSupport,
pub versioning: VersioningModel,
}
impl BackendCapabilities {
#[allow(clippy::fn_params_excessive_bools)]
#[must_use]
pub const fn new(
read: bool,
create: bool,
update: bool,
delete: bool,
comments: CommentSupport,
versioning: VersioningModel,
) -> Self {
Self {
read,
create,
update,
delete,
comments,
versioning,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CommentSupport {
InBody,
SeparateApi,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum VersioningModel {
Strong,
Etag,
Timestamp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DeleteReason {
Completed,
NotPlanned,
Duplicate,
Abandoned,
}
#[async_trait]
pub trait BackendConnector: Send + Sync {
fn name(&self) -> &'static str;
fn supports(&self, feature: BackendFeature) -> bool;
async fn list_records(&self, project: &str) -> Result<Vec<Record>>;
async fn list_changed_since(
&self,
project: &str,
since: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<RecordId>> {
let all = self.list_records(project).await?;
Ok(all
.into_iter()
.filter(|i| i.updated_at > since)
.map(|i| i.id)
.collect())
}
async fn get_record(&self, project: &str, id: RecordId) -> Result<Record>;
async fn create_record(&self, project: &str, issue: Untainted<Record>) -> Result<Record>;
async fn update_record(
&self,
project: &str,
id: RecordId,
patch: Untainted<Record>,
expected_version: Option<u64>,
) -> Result<Record>;
async fn delete_or_close(
&self,
project: &str,
id: RecordId,
reason: DeleteReason,
) -> Result<()>;
fn root_collection_name(&self) -> &'static str {
"issues"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(dead_code)]
fn _assert_dyn_compatible(_: &dyn BackendConnector) {}
#[test]
fn backend_feature_is_copy() {
let f = BackendFeature::Delete;
assert_eq!(f, BackendFeature::Delete);
assert_eq!(f, BackendFeature::Delete);
}
#[test]
fn delete_reason_is_copy() {
let r = DeleteReason::Completed;
assert_eq!(r, DeleteReason::Completed);
assert_eq!(r, DeleteReason::Completed);
}
#[test]
fn backend_feature_hierarchy_is_a_variant() {
let f = BackendFeature::Hierarchy;
assert_eq!(f, BackendFeature::Hierarchy);
}
#[test]
fn default_root_collection_name_is_issues() {
use crate::taint::Untainted;
struct Stub;
#[async_trait]
impl BackendConnector for Stub {
fn name(&self) -> &'static str {
"stub"
}
fn supports(&self, _: BackendFeature) -> bool {
false
}
async fn list_records(&self, _: &str) -> Result<Vec<Record>> {
Ok(vec![])
}
async fn get_record(&self, _: &str, _: RecordId) -> Result<Record> {
unimplemented!()
}
async fn create_record(&self, _: &str, _: Untainted<Record>) -> Result<Record> {
unimplemented!()
}
async fn update_record(
&self,
_: &str,
_: RecordId,
_: Untainted<Record>,
_: Option<u64>,
) -> Result<Record> {
unimplemented!()
}
async fn delete_or_close(&self, _: &str, _: RecordId, _: DeleteReason) -> Result<()> {
Ok(())
}
}
assert_eq!(Stub.root_collection_name(), "issues");
assert!(!Stub.supports(BackendFeature::Hierarchy));
}
#[tokio::test]
async fn default_list_changed_since_filters_via_list_issues() {
use crate::record::{Record, RecordStatus};
use crate::taint::Untainted;
use chrono::{TimeZone, Utc};
struct TwoIssues;
#[async_trait]
impl BackendConnector for TwoIssues {
fn name(&self) -> &'static str {
"two"
}
fn supports(&self, _: BackendFeature) -> bool {
false
}
async fn list_records(&self, _: &str) -> Result<Vec<Record>> {
let t1 = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let t2 = Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap();
Ok(vec![
Record {
id: RecordId(1),
title: "old".into(),
status: RecordStatus::Open,
assignee: None,
labels: vec![],
created_at: t1,
updated_at: t1,
version: 1,
body: String::new(),
parent_id: None,
extensions: std::collections::BTreeMap::new(),
},
Record {
id: RecordId(2),
title: "new".into(),
status: RecordStatus::Open,
assignee: None,
labels: vec![],
created_at: t1,
updated_at: t2,
version: 1,
body: String::new(),
parent_id: None,
extensions: std::collections::BTreeMap::new(),
},
])
}
async fn get_record(&self, _: &str, _: RecordId) -> Result<Record> {
unimplemented!()
}
async fn create_record(&self, _: &str, _: Untainted<Record>) -> Result<Record> {
unimplemented!()
}
async fn update_record(
&self,
_: &str,
_: RecordId,
_: Untainted<Record>,
_: Option<u64>,
) -> Result<Record> {
unimplemented!()
}
async fn delete_or_close(&self, _: &str, _: RecordId, _: DeleteReason) -> Result<()> {
Ok(())
}
}
let backend = TwoIssues;
let cutoff = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap();
let got = backend.list_changed_since("demo", cutoff).await.unwrap();
assert_eq!(got, vec![RecordId(2)]);
}
}