use chrono::{TimeZone as _, Utc};
use onetaskgraph_plugin_api::{
Capabilities, Cursor, DependencyEdge, DependencyKind, DependencySupport, Direction, Document,
DocumentQuery, Health, ItemKind, ItemWrite, Label, LabelFilter, Location, NativeId, Page,
PageRequest, Project, ProjectFilter, ProjectQuery, SOURCE_NAME_PATTERN, SecretResolver,
SourceError, SourceName, SourcePlugin, Status, StatusCategory, Support, Task, TaskQuery,
TaskSource, TextFields, TextQuery, WriteSupport,
};
use schemars::{Schema, schema_for};
use secrecy::{ExposeSecret as _, SecretString};
struct Silent(&'static str);
#[async_trait::async_trait]
impl TaskSource for Silent {
fn kind(&self) -> &'static str {
self.0
}
fn capabilities(&self) -> Capabilities {
Capabilities {
projects: Support::Native,
documents: Support::Unsupported,
orphan_tasks: Support::Native,
filter_by_label: Support::Unsupported,
filter_by_status: Support::Native,
search_title: Support::Native,
search_content: Support::Unsupported,
task_dependencies: DependencySupport::ForwardOnly,
project_dependencies: DependencySupport::BothDirections,
max_page_size: 25,
}
}
async fn health(&self) -> Result<Health, SourceError> {
Ok(Health {
reachable: true,
detail: None,
})
}
async fn get_task(&self, _id: &NativeId) -> Result<Option<Task>, SourceError> {
Ok(None)
}
async fn get_project(&self, _id: &NativeId) -> Result<Option<Project>, SourceError> {
Ok(None)
}
async fn query_tasks(
&self,
_query: &TaskQuery,
_page: &PageRequest,
) -> Result<Page<Task>, SourceError> {
Ok(Page::last(Vec::new()))
}
async fn query_projects(
&self,
_query: &ProjectQuery,
_page: &PageRequest,
) -> Result<Page<Project>, SourceError> {
Ok(Page::last(Vec::new()))
}
async fn labels(&self, _page: &PageRequest) -> Result<Page<Label>, SourceError> {
Ok(Page::last(Vec::new()))
}
async fn task_dependencies(
&self,
_id: &NativeId,
_direction: Direction,
_page: &PageRequest,
) -> Result<Page<DependencyEdge>, SourceError> {
Ok(Page::last(Vec::new()))
}
async fn project_dependencies(
&self,
_id: &NativeId,
_direction: Direction,
_page: &PageRequest,
) -> Result<Page<DependencyEdge>, SourceError> {
Ok(Page::last(Vec::new()))
}
}
struct Refusing;
#[async_trait::async_trait]
impl TaskSource for Refusing {
fn kind(&self) -> &'static str {
"refusing"
}
fn capabilities(&self) -> Capabilities {
Silent("x").capabilities()
}
async fn health(&self) -> Result<Health, SourceError> {
Err(SourceError::Unavailable {
message: "no route to host".to_owned(),
})
}
async fn get_task(&self, _id: &NativeId) -> Result<Option<Task>, SourceError> {
Err(SourceError::RateLimited {
retry_after_seconds: Some(30),
message: None,
})
}
async fn get_project(&self, _id: &NativeId) -> Result<Option<Project>, SourceError> {
Ok(None)
}
async fn query_tasks(
&self,
_query: &TaskQuery,
_page: &PageRequest,
) -> Result<Page<Task>, SourceError> {
Ok(Page::last(Vec::new()))
}
async fn query_projects(
&self,
_query: &ProjectQuery,
_page: &PageRequest,
) -> Result<Page<Project>, SourceError> {
Ok(Page::last(Vec::new()))
}
async fn labels(&self, _page: &PageRequest) -> Result<Page<Label>, SourceError> {
Ok(Page::last(Vec::new()))
}
async fn task_dependencies(
&self,
_id: &NativeId,
_direction: Direction,
_page: &PageRequest,
) -> Result<Page<DependencyEdge>, SourceError> {
Ok(Page::last(Vec::new()))
}
async fn project_dependencies(
&self,
_id: &NativeId,
_direction: Direction,
_page: &PageRequest,
) -> Result<Page<DependencyEdge>, SourceError> {
Ok(Page::last(Vec::new()))
}
}
#[tokio::test]
async fn the_engine_can_hold_a_heterogeneous_collection_of_boxed_sources() {
let sources: Vec<Box<dyn TaskSource>> = vec![Box::new(Silent("silent")), Box::new(Refusing)];
let kinds: Vec<&str> = sources.iter().map(|source| source.kind()).collect();
assert_eq!(kinds, ["silent", "refusing"]);
let health: Vec<bool> = {
let mut out = Vec::new();
for source in &sources {
out.push(source.health().await.is_ok());
}
out
};
assert_eq!(health, [true, false]);
let page = PageRequest {
cursor: None,
limit: 10,
};
let first = sources[0]
.query_tasks(&TaskQuery::default(), &page)
.await
.expect("the silent source answers");
assert!(first.items.is_empty());
assert!(first.next.is_none());
}
#[tokio::test]
async fn a_source_reports_its_own_capability_declaration() {
let source: Box<dyn TaskSource> = Box::new(Silent("silent"));
let declared = source.capabilities();
assert!(declared.filter_by_status.is_native());
assert!(!declared.filter_by_label.is_native());
assert!(declared.project_dependencies.answers_reverse());
assert!(!declared.task_dependencies.answers_reverse());
assert_eq!(declared.max_page_size, 25);
}
#[tokio::test]
async fn every_remaining_trait_method_is_reachable_through_dyn() {
let source: Box<dyn TaskSource> = Box::new(Silent("silent"));
let id = NativeId::from("t-1");
let page = PageRequest {
cursor: Some(Cursor("0".to_owned())),
limit: 5,
};
assert!(source.get_task(&id).await.expect("answers").is_none());
assert!(source.get_project(&id).await.expect("answers").is_none());
assert!(
source
.query_projects(&ProjectQuery::default(), &page)
.await
.expect("answers")
.items
.is_empty()
);
assert!(
source
.labels(&page)
.await
.expect("answers")
.items
.is_empty()
);
assert!(
source
.task_dependencies(&id, Direction::DependsOn, &page)
.await
.expect("answers")
.items
.is_empty()
);
assert!(
source
.project_dependencies(&id, Direction::DependedOnBy, &page)
.await
.expect("answers")
.items
.is_empty()
);
let refusing: Box<dyn TaskSource> = Box::new(Refusing);
let error = refusing.get_task(&id).await.expect_err("rate-limited");
assert_eq!(
error,
SourceError::RateLimited {
retry_after_seconds: Some(30),
message: None,
}
);
assert!(
refusing
.query_tasks(&TaskQuery::default(), &page)
.await
.expect("answers")
.items
.is_empty()
);
assert!(refusing.get_project(&id).await.expect("answers").is_none());
assert!(
refusing
.query_projects(&ProjectQuery::default(), &page)
.await
.expect("answers")
.items
.is_empty()
);
assert!(
refusing
.labels(&page)
.await
.expect("answers")
.items
.is_empty()
);
assert!(
refusing
.task_dependencies(&id, Direction::DependsOn, &page)
.await
.expect("answers")
.items
.is_empty()
);
assert!(
refusing
.project_dependencies(&id, Direction::DependsOn, &page)
.await
.expect("answers")
.items
.is_empty()
);
assert_eq!(refusing.capabilities().max_page_size, 25);
}
struct Table(Vec<(&'static str, &'static str)>);
impl SecretResolver for Table {
fn get(&self, var: &str) -> Option<SecretString> {
self.0
.iter()
.find(|(name, _)| *name == var)
.map(|(_, value)| SecretString::from((*value).to_owned()))
}
}
struct Gated;
impl SourcePlugin for Gated {
fn kind(&self) -> &'static str {
"gated"
}
fn config_schema(&self) -> Schema {
schema_for!(TaskQuery)
}
fn build(
&self,
name: &SourceName,
config: &serde_json::Value,
secrets: &dyn SecretResolver,
) -> Result<Box<dyn TaskSource>, SourceError> {
let var = config["api_key_env"]
.as_str()
.ok_or_else(|| SourceError::Config {
message: format!("source {name}: config.api_key_env must be a string"),
})?;
let key = secrets.get(var).ok_or_else(|| SourceError::Auth {
message: format!("source {name}: nothing defines {var}"),
})?;
assert!(!key.expose_secret().is_empty());
Ok(Box::new(Silent("gated")))
}
}
#[test]
fn a_plugin_builds_a_source_from_a_config_block_and_a_named_credential() {
let name = SourceName::new("work").expect("a valid name");
let secrets = Table(vec![("LINEAR_API_KEY", "lin_api_live")]);
let built = Gated
.build(
&name,
&serde_json::json!({ "api_key_env": "LINEAR_API_KEY" }),
&secrets,
)
.expect("the credential resolves");
assert_eq!(built.kind(), "gated");
assert_eq!(Gated.kind(), "gated");
assert!(Gated.config_schema().as_value().is_object());
}
#[test]
fn a_plugin_refuses_when_the_named_credential_is_absent() {
let name = SourceName::new("work").expect("a valid name");
let secrets = Table(Vec::new());
let Err(error) = Gated.build(
&name,
&serde_json::json!({ "api_key_env": "LINEAR_API_KEY" }),
&secrets,
) else {
panic!("nothing defines the variable, so build must refuse");
};
assert_eq!(
error,
SourceError::Auth {
message: "source work: nothing defines LINEAR_API_KEY".to_owned()
}
);
assert!(secrets.get("ABSENT").is_none());
}
#[test]
fn a_plugin_refuses_a_config_block_of_the_wrong_shape() {
let name = SourceName::new("work").expect("a valid name");
let Err(error) = Gated.build(&name, &serde_json::json!({}), &Table(Vec::new())) else {
panic!("the config block is the wrong shape, so build must refuse");
};
assert!(
matches!(&error, SourceError::Config { message } if message.contains("api_key_env")),
"{error:?}"
);
}
#[test]
fn a_source_name_accepts_the_documented_pattern_and_rejects_everything_else() {
for good in ["work", "notes", "gh-main", "s3", "0"] {
let name = SourceName::new(good).expect("a valid name");
assert_eq!(name.as_str(), good);
assert_eq!(name.to_string(), good);
assert_eq!(String::from(name.clone()), good);
assert_eq!(SourceName::try_from(good.to_owned()).expect("valid"), name);
}
for bad in ["gh_main", "Work", "-lead", "", "notes!", "a b"] {
let Err(error) = SourceName::new(bad) else {
panic!("{bad:?} is not a usable source name");
};
let SourceError::Config { message } = error else {
panic!("a bad name is a configuration error");
};
assert!(message.contains(SOURCE_NAME_PATTERN), "{message}");
}
}
#[test]
fn a_source_name_round_trips_through_json_and_rejects_a_bad_one_at_the_boundary() {
let name = SourceName::new("gh-main").expect("valid");
let encoded = serde_json::to_string(&name).expect("encodes");
assert_eq!(encoded, "\"gh-main\"");
assert_eq!(
serde_json::from_str::<SourceName>(&encoded).expect("decodes"),
name
);
assert!(serde_json::from_str::<SourceName>("\"gh_main\"").is_err());
let schema = serde_json::to_value(schema_for!(SourceName)).expect("renders");
assert_eq!(schema["pattern"], SOURCE_NAME_PATTERN);
}
#[test]
fn a_native_id_carries_whatever_the_source_says_including_colons() {
let id = NativeId::from("urn:task:7");
assert_eq!(id.as_str(), "urn:task:7");
assert_eq!(id.to_string(), "urn:task:7");
assert_eq!(NativeId::from("urn:task:7".to_owned()), id);
assert_eq!(
serde_json::to_string(&id).expect("encodes"),
"\"urn:task:7\""
);
}
#[test]
fn repository_origins_accept_only_the_normalized_public_identity() {
let repository =
onetaskgraph_plugin_api::Repository::try_from("github.com/example/work".to_owned())
.expect("normalized origin");
assert_eq!(repository.as_str(), "github.com/example/work");
assert_eq!(String::from(repository), "github.com/example/work");
for invalid in [
"",
"github.com/example",
"https://github.com/example/work",
"github.com/example/work.git",
"github.com/example/work tree",
"github.com//work",
"github.com/../work",
] {
let error = onetaskgraph_plugin_api::Repository::try_from(invalid.to_owned())
.expect_err("non-normalized origin is rejected");
assert!(error.contains("normalized repository origin"), "{error}");
}
}
#[test]
fn repository_origins_are_read_from_the_one_reserved_key_they_are_recorded_under() {
let metadata = [(
onetaskgraph_plugin_api::Repository::METADATA_KEY.to_owned(),
serde_json::json!(["github.com/example/work", "github.com/example/docs"]),
)]
.into();
let origins = onetaskgraph_plugin_api::Repository::from_metadata(&metadata)
.expect("a list of normalized origins");
assert_eq!(
origins
.iter()
.map(onetaskgraph_plugin_api::Repository::as_str)
.collect::<Vec<_>>(),
["github.com/example/work", "github.com/example/docs"]
);
assert!(
onetaskgraph_plugin_api::Repository::from_metadata(&Default::default())
.expect("an item recording none")
.is_empty()
);
for (value, expected) in [
(serde_json::json!("github.com/example/work"), "not a list"),
(
serde_json::json!(["github.com/example/work", "github.com/example/work"]),
"listed twice",
),
(serde_json::json!(["work"]), "normalized repository origin"),
] {
let metadata = [(
onetaskgraph_plugin_api::Repository::METADATA_KEY.to_owned(),
value,
)]
.into();
let error = onetaskgraph_plugin_api::Repository::from_metadata(&metadata)
.expect_err("a repository list this interface cannot represent");
assert!(error.contains(expected), "{error}");
}
}
#[test]
fn a_repeated_repository_origin_is_refused_wherever_a_work_item_is_decoded() {
let repeated = serde_json::json!({
"id": "ENG-1", "title": "Ship", "content": null,
"status": {"category": "todo", "name": "Todo"}, "labels": [], "project": null,
"url": null, "created_at": null, "updated_at": null,
"repositories": ["github.com/example/work", "github.com/example/work"]
});
let error = serde_json::from_value::<Task>(repeated.clone()).expect_err("a repeat is refused");
assert!(error.to_string().contains("listed twice"), "{error}");
let mut project = repeated;
project
.as_object_mut()
.expect("an object")
.remove("project");
assert!(serde_json::from_value::<Project>(project).is_err());
}
fn near_source() -> SourceName {
SourceName::new("work").expect("a usable source name")
}
#[test]
fn a_near_item_records_the_far_ends_its_backend_cannot_name() {
let metadata = [(
DependencyEdge::RECORDED_KEY.to_owned(),
serde_json::json!(["T-2", {"id": "elsewhere:P-9", "kind": "project"}]),
)]
.into();
let edges = DependencyEdge::recorded(
&metadata,
&NativeId::from("T-1"),
ItemKind::Task,
&near_source(),
None,
)
.expect("a list of endpoints");
assert_eq!(edges.len(), 2);
assert_eq!(edges[0].from.id(), "T-1");
assert_eq!(edges[0].to.id(), "T-2");
assert!(!edges[0].to.is_qualified());
assert_eq!(edges[0].kind, DependencyKind::Blocks);
assert_eq!(edges[1].to.id(), "elsewhere:P-9");
assert!(edges[1].to.is_qualified());
assert_eq!(edges[1].to.kind, ItemKind::Project);
assert!(
DependencyEdge::recorded(
&Default::default(),
&NativeId::from("T-1"),
ItemKind::Task,
&near_source(),
Some(ItemKind::Task)
)
.expect("an item recording nothing")
.is_empty()
);
let malformed = [(
DependencyEdge::RECORDED_KEY.to_owned(),
serde_json::json!({"id": "elsewhere:P-9"}),
)]
.into();
let error = DependencyEdge::recorded(
&malformed,
&NativeId::from("T-1"),
ItemKind::Task,
&near_source(),
None,
)
.expect_err("a mapping is not a list of endpoints");
assert!(error.contains(DependencyEdge::RECORDED_KEY), "{error}");
}
#[test]
fn a_far_end_the_near_backend_could_have_named_is_refused_rather_than_read() {
use onetaskgraph_plugin_api::ItemKind;
let same_kind = [(
DependencyEdge::RECORDED_KEY.to_owned(),
serde_json::json!(["T-2"]),
)]
.into();
let error = DependencyEdge::recorded(
&same_kind,
&NativeId::from("T-1"),
ItemKind::Task,
&near_source(),
Some(ItemKind::Task),
)
.expect_err("a task naming a task of this source is the backend's own edge");
assert!(error.contains("T-2"), "{error}");
assert!(error.contains("relate natively"), "{error}");
assert!(error.contains(DependencyEdge::RECORDED_KEY), "{error}");
let qualified = [(
DependencyEdge::RECORDED_KEY.to_owned(),
serde_json::json!([{"id": "elsewhere:T-9", "kind": "task"}]),
)]
.into();
assert_eq!(
DependencyEdge::recorded(
&qualified,
&NativeId::from("T-1"),
ItemKind::Task,
&near_source(),
Some(ItemKind::Task)
)
.expect("a far end in another source")
.len(),
1
);
let other_kind = [(
DependencyEdge::RECORDED_KEY.to_owned(),
serde_json::json!([{"id": "P-9", "kind": "project"}]),
)]
.into();
assert_eq!(
DependencyEdge::recorded(
&other_kind,
&NativeId::from("T-1"),
ItemKind::Task,
&near_source(),
Some(ItemKind::Task)
)
.expect("a level this backend cannot relate across")
.len(),
1
);
}
#[test]
fn a_far_end_qualified_to_the_near_source_is_refused_like_a_bare_one() {
use onetaskgraph_plugin_api::ItemKind;
let own_source = [(
DependencyEdge::RECORDED_KEY.to_owned(),
serde_json::json!([{"id": "work:T-2", "kind": "task"}]),
)]
.into();
let error = DependencyEdge::recorded(
&own_source,
&NativeId::from("T-1"),
ItemKind::Task,
&near_source(),
Some(ItemKind::Task),
)
.expect_err("a task naming a task of its own source is the backend's own edge");
assert!(error.contains("work:T-2"), "{error}");
assert!(error.contains("relate natively"), "{error}");
for far in ["elsewhere:T-9", "work-two:T-9"] {
let elsewhere = [(
DependencyEdge::RECORDED_KEY.to_owned(),
serde_json::json!([{"id": far, "kind": "task"}]),
)]
.into();
let edges = DependencyEdge::recorded(
&elsewhere,
&NativeId::from("T-1"),
ItemKind::Task,
&near_source(),
Some(ItemKind::Task),
)
.expect("a far end in another source");
assert_eq!(edges.len(), 1, "{far}");
assert_eq!(edges[0].to.id(), far);
assert_eq!(edges[0].to.source(), Some(far.split(':').next().unwrap()));
}
let other_level = [(
DependencyEdge::RECORDED_KEY.to_owned(),
serde_json::json!([{"id": "work:P-9", "kind": "project"}]),
)]
.into();
let edges = DependencyEdge::recorded(
&other_level,
&NativeId::from("T-1"),
ItemKind::Task,
&near_source(),
Some(ItemKind::Task),
)
.expect("a level this backend cannot relate across");
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].to.source(), Some("work"));
assert_eq!(
DependencyEdge::recorded(
&own_source,
&NativeId::from("T-1"),
ItemKind::Task,
&near_source(),
None,
)
.expect("a backend with nothing to relate through")
.len(),
1
);
}
#[test]
fn the_three_reserved_keys_are_spelled_once_and_under_this_products_prefix() {
for key in [
onetaskgraph_plugin_api::Repository::METADATA_KEY,
DependencyEdge::RECORDED_KEY,
ItemKind::METADATA_KEY,
] {
assert!(key.starts_with("onetaskgraph."), "{key}");
}
}
#[test]
fn the_item_kind_marker_accepts_exactly_its_two_spellings() {
let marked = |value: serde_json::Value| {
std::collections::BTreeMap::from([(ItemKind::METADATA_KEY.to_owned(), value)])
};
assert_eq!(ItemKind::Project.marker(), "project");
assert_eq!(ItemKind::Task.marker(), "task");
assert_eq!(
ItemKind::from_metadata(&marked(serde_json::json!("project"))),
Ok(Some(ItemKind::Project))
);
assert_eq!(
ItemKind::from_metadata(&marked(serde_json::json!("task"))),
Ok(Some(ItemKind::Task))
);
assert_eq!(
ItemKind::from_metadata(&std::collections::BTreeMap::new()),
Ok(None),
"an unmarked item carries no kind rather than a wrong one"
);
for malformed in [
serde_json::json!("Project"),
serde_json::json!("issue"),
serde_json::json!(""),
serde_json::json!(1),
serde_json::json!(["project"]),
serde_json::Value::Null,
] {
let refusal = ItemKind::from_metadata(&marked(malformed.clone()))
.expect_err(&format!("{malformed} is not a marker"));
assert!(refusal.contains(ItemKind::METADATA_KEY), "{refusal}");
assert!(
refusal.contains("project") && refusal.contains("task"),
"{refusal}"
);
}
}
#[test]
fn dependency_endpoints_validate_and_preserve_qualified_ids() {
let endpoint: onetaskgraph_plugin_api::DependencyEndpoint =
serde_json::from_value(serde_json::json!({"id":"other:P-9", "kind":"project"}))
.expect("qualified endpoint");
assert_eq!(endpoint.to_string(), "other:P-9");
assert_ne!(endpoint, NativeId::from("P-9"));
let native = onetaskgraph_plugin_api::DependencyEndpoint::from_native(
NativeId::from("urn:task:7"),
onetaskgraph_plugin_api::ItemKind::Task,
);
assert_eq!(native.id(), "urn:task:7");
assert_eq!(native.into_id(), "urn:task:7");
let unqualified = onetaskgraph_plugin_api::DependencyEndpoint::new(
"T-2".to_owned(),
onetaskgraph_plugin_api::ItemKind::Task,
)
.expect("an unqualified endpoint");
assert!(!unqualified.is_qualified());
assert_eq!(unqualified, NativeId::from("T-2"));
for invalid in [
serde_json::json!({"id":"", "kind":"task"}),
serde_json::json!({"id":"bad source:T-1", "kind":"task"}),
serde_json::json!({"id":"other:", "kind":"project"}),
serde_json::json!(""),
] {
assert!(
serde_json::from_value::<onetaskgraph_plugin_api::DependencyEndpoint>(invalid).is_err()
);
}
}
#[test]
fn a_task_round_trips_through_json_with_every_field_populated() {
let task = Task {
id: NativeId::from("ENG-1"),
title: "Ship the contract".to_owned(),
content: Some("Two crates, one direction.".to_owned()),
status: Status {
category: StatusCategory::InProgress,
name: "In Review".to_owned(),
},
labels: vec![Label {
id: NativeId::from("l-1"),
name: "infra".to_owned(),
color: Some("#336699".to_owned()),
}],
project: Some(NativeId::from("P-1")),
url: Some("https://example.invalid/ENG-1".to_owned()),
location: Some(Location::Url("https://example.invalid/ENG-1".to_owned())),
created_at: Some(Utc.with_ymd_and_hms(2026, 8, 22, 9, 0, 0).unwrap()),
updated_at: None,
metadata: [("onepipeline.turn_budget".to_owned(), serde_json::json!(12))].into(),
repositories: vec![
onetaskgraph_plugin_api::Repository::try_from("github.com/example/work".to_owned())
.expect("normalized origin"),
],
};
let encoded = serde_json::to_string(&task).expect("encodes");
assert_eq!(
serde_json::from_str::<Task>(&encoded).expect("decodes"),
task
);
assert_eq!(task.status.name, "In Review");
assert_eq!(task.status.category, StatusCategory::InProgress);
}
#[test]
fn a_project_and_an_orphan_task_round_trip_through_json() {
let project = Project {
id: NativeId::from("P-1"),
title: "Foundation".to_owned(),
content: None,
status: Status {
category: StatusCategory::Backlog,
name: "Planned".to_owned(),
},
labels: Vec::new(),
url: None,
location: None,
created_at: None,
updated_at: Some(Utc.with_ymd_and_hms(2026, 8, 22, 9, 0, 0).unwrap()),
metadata: Default::default(),
repositories: Vec::new(),
};
let encoded = serde_json::to_string(&project).expect("encodes");
assert_eq!(
serde_json::from_str::<Project>(&encoded).expect("decodes"),
project
);
let orphan: Task = serde_json::from_value(serde_json::json!({
"id": "T-9",
"title": "Loose end",
"content": null,
"status": { "category": "todo", "name": "Todo" },
"labels": [],
"project": null,
"url": null,
"created_at": null,
"updated_at": null,
}))
.expect("decodes");
assert!(orphan.project.is_none());
assert_eq!(orphan.status.category, StatusCategory::Todo);
}
#[test]
fn the_normalised_vocabularies_serialise_as_kebab_case() {
let categories = [
(StatusCategory::Draft, "draft"),
(StatusCategory::Backlog, "backlog"),
(StatusCategory::Todo, "todo"),
(StatusCategory::InProgress, "in-progress"),
(StatusCategory::Done, "done"),
(StatusCategory::Cancelled, "cancelled"),
(StatusCategory::Unknown, "unknown"),
];
for (value, wire) in categories {
assert_eq!(
serde_json::to_value(value).expect("encodes"),
serde_json::json!(wire)
);
assert_eq!(
serde_json::from_value::<StatusCategory>(serde_json::json!(wire)).expect("decodes"),
value
);
}
assert_eq!(
serde_json::to_value(DependencyKind::Blocks).expect("encodes"),
serde_json::json!("blocks")
);
assert_eq!(
serde_json::to_value(DependencyKind::Related).expect("encodes"),
serde_json::json!("related")
);
assert_eq!(
serde_json::to_value(Direction::DependedOnBy).expect("encodes"),
serde_json::json!("depended-on-by")
);
assert_eq!(
serde_json::to_value(Direction::DependsOn).expect("encodes"),
serde_json::json!("depends-on")
);
assert_eq!(
serde_json::to_value(TextFields::TitleOrContent).expect("encodes"),
serde_json::json!("title-or-content")
);
assert_eq!(
serde_json::to_value(TextFields::Title).expect("encodes"),
serde_json::json!("title")
);
assert_eq!(
serde_json::to_value(TextFields::Content).expect("encodes"),
serde_json::json!("content")
);
assert_eq!(
serde_json::to_value(Support::Unsupported).expect("encodes"),
serde_json::json!("unsupported")
);
assert_eq!(
serde_json::to_value(DependencySupport::ForwardOnly).expect("encodes"),
serde_json::json!("forward-only")
);
}
#[test]
fn a_dependency_edge_round_trips_through_json() {
let edge = DependencyEdge {
from: onetaskgraph_plugin_api::DependencyEndpoint::new(
"source:A".into(),
onetaskgraph_plugin_api::ItemKind::Task,
)
.expect("valid endpoint"),
to: onetaskgraph_plugin_api::DependencyEndpoint::new(
"other:B".into(),
onetaskgraph_plugin_api::ItemKind::Project,
)
.expect("valid endpoint"),
kind: DependencyKind::Blocks,
};
let encoded = serde_json::to_string(&edge).expect("encodes");
assert_eq!(
serde_json::from_str::<DependencyEdge>(&encoded).expect("decodes"),
edge
);
let legacy: DependencyEdge = serde_json::from_value(serde_json::json!({
"from":"A", "to":"B", "kind":"related"
}))
.expect("legacy native endpoints still decode");
assert_eq!(legacy.from.to_string(), "A");
assert_eq!(legacy.from.kind, onetaskgraph_plugin_api::ItemKind::Task);
}
#[test]
fn an_empty_label_filter_constrains_nothing_and_a_populated_one_does() {
assert!(LabelFilter::default().is_empty());
assert!(
!LabelFilter {
none_of: vec!["wontfix".to_owned()],
..LabelFilter::default()
}
.is_empty()
);
assert!(
!LabelFilter {
any_of: vec!["infra".to_owned()],
..LabelFilter::default()
}
.is_empty()
);
assert!(
!LabelFilter {
all_of: vec!["infra".to_owned()],
..LabelFilter::default()
}
.is_empty()
);
}
#[test]
fn a_query_round_trips_with_every_filter_populated() {
let query = TaskQuery {
text: Some(TextQuery {
terms: "contract".to_owned(),
fields: TextFields::TitleOrContent,
}),
labels: LabelFilter {
any_of: vec!["infra".to_owned()],
all_of: vec!["p1".to_owned()],
none_of: vec!["wontfix".to_owned()],
},
statuses: vec![StatusCategory::Todo, StatusCategory::InProgress],
project: ProjectFilter::Is(NativeId::from("P-1")),
};
let encoded = serde_json::to_string(&query).expect("encodes");
assert_eq!(
serde_json::from_str::<TaskQuery>(&encoded).expect("decodes"),
query
);
let projects = ProjectQuery {
text: None,
labels: LabelFilter::default(),
statuses: vec![StatusCategory::Done],
};
let encoded = serde_json::to_string(&projects).expect("encodes");
assert_eq!(
serde_json::from_str::<ProjectQuery>(&encoded).expect("decodes"),
projects
);
assert_eq!(ProjectFilter::default(), ProjectFilter::Any);
for filter in [
ProjectFilter::Any,
ProjectFilter::Orphans,
ProjectFilter::Is(NativeId::from("P-2")),
] {
let encoded = serde_json::to_string(&filter).expect("encodes");
assert_eq!(
serde_json::from_str::<ProjectFilter>(&encoded).expect("decodes"),
filter
);
}
}
#[test]
fn a_page_carries_a_cursor_only_while_the_walk_continues() {
let exhausted = Page::last(vec![1_u8, 2, 3]);
assert!(exhausted.next.is_none());
let more = Page {
items: vec![1_u8],
next: Some(Cursor("3".to_owned())),
};
let encoded = serde_json::to_string(&more).expect("encodes");
assert_eq!(
serde_json::from_str::<Page<u8>>(&encoded).expect("decodes"),
more
);
let request = PageRequest {
cursor: Some(Cursor("3".to_owned())),
limit: 50,
};
let encoded = serde_json::to_string(&request).expect("encodes");
assert_eq!(
serde_json::from_str::<PageRequest>(&encoded).expect("decodes"),
request
);
}
#[test]
fn a_page_request_for_no_rows_is_refused_where_the_request_is_read() {
let error = serde_json::from_str::<PageRequest>(r#"{"cursor":null,"limit":0}"#)
.expect_err("a zero limit is not a page size");
assert!(
error.to_string().contains("limit must be at least 1"),
"{error}"
);
let smallest: PageRequest =
serde_json::from_str(r#"{"cursor":null,"limit":1}"#).expect("one row is a page");
assert_eq!(smallest.limit, 1);
}
#[test]
fn health_round_trips_in_both_shapes() {
for health in [
Health {
reachable: true,
detail: Some("200 OK".to_owned()),
},
Health {
reachable: false,
detail: None,
},
] {
let encoded = serde_json::to_string(&health).expect("encodes");
assert_eq!(
serde_json::from_str::<Health>(&encoded).expect("decodes"),
health
);
}
}
#[test]
fn every_error_variant_renders_a_message_and_survives_the_stdio_boundary() {
let cases = [
(
SourceError::Config {
message: "team is required".to_owned(),
},
"configuration for this source is invalid: team is required",
"config",
),
(
SourceError::Auth {
message: "token rejected".to_owned(),
},
"authentication for this source failed: token rejected",
"auth",
),
(
SourceError::Refused {
message: "forbidden".to_owned(),
},
"the source refused the request: forbidden",
"refused",
),
(
SourceError::RateLimited {
retry_after_seconds: Some(30),
message: None,
},
"the source rate-limited the request",
"rate-limited",
),
(
SourceError::RateLimited {
retry_after_seconds: None,
message: Some("the burst limiter refused this, and nothing reports it".to_owned()),
},
"the source rate-limited the request: the burst limiter refused this, and nothing \
reports it",
"rate-limited",
),
(
SourceError::Unavailable {
message: "no route".to_owned(),
},
"the source could not be reached: no route",
"unavailable",
),
(
SourceError::Malformed {
message: "not a date".to_owned(),
},
"the source returned data this interface cannot represent: not a date",
"malformed",
),
];
for (error, rendered, tag) in cases {
assert_eq!(error.to_string(), rendered);
let encoded = serde_json::to_value(&error).expect("encodes");
assert_eq!(encoded["kind"], tag);
assert_eq!(
serde_json::from_value::<SourceError>(encoded).expect("decodes"),
error
);
}
}
#[test]
fn a_rate_limits_optional_message_is_omitted_when_absent_and_read_back_when_it_is_not() {
let silent = SourceError::RateLimited {
retry_after_seconds: Some(30),
message: None,
};
let encoded = serde_json::to_value(&silent).expect("encodes");
assert_eq!(
encoded,
serde_json::json!({"kind":"rate-limited","retry_after_seconds":30}),
"a rate limit with nothing to add is not the shape it was before the member existed"
);
assert_eq!(
serde_json::from_value::<SourceError>(
serde_json::json!({"kind":"rate-limited","retry_after_seconds":null})
)
.expect("an old peer's rate limit still decodes"),
SourceError::RateLimited {
retry_after_seconds: None,
message: None,
}
);
let said = SourceError::RateLimited {
retry_after_seconds: None,
message: Some("the secondary limiter refused this while creating an issue".to_owned()),
};
let encoded = serde_json::to_value(&said).expect("encodes");
assert_eq!(
encoded["message"],
"the secondary limiter refused this while creating an issue"
);
assert_eq!(
serde_json::from_value::<SourceError>(encoded).expect("decodes"),
said,
"the diagnostic did not survive the stdio boundary"
);
let schema = serde_json::to_value(schema_for!(SourceError)).expect("a schema");
let text = schema.to_string();
assert!(text.contains("rate-limited"), "{text}");
assert!(
!text.contains(r#""required":["kind","message","retry_after_seconds"]"#),
"the added member is required, which refuses every peer written before it: {text}"
);
}
#[test]
fn every_contract_root_generates_a_json_schema() {
for schema in [
schema_for!(Task),
schema_for!(Project),
schema_for!(Label),
schema_for!(Capabilities),
schema_for!(TaskQuery),
schema_for!(ProjectQuery),
schema_for!(Page<Task>),
schema_for!(PageRequest),
schema_for!(Health),
schema_for!(SourceError),
schema_for!(DependencyEdge),
schema_for!(Document),
schema_for!(Location),
schema_for!(DocumentQuery),
schema_for!(Page<Document>),
] {
assert!(schema.as_value().is_object());
}
}
fn expand_class(body: &str) -> Vec<char> {
let chars: Vec<char> = body.chars().collect();
let mut out = Vec::new();
let mut index = 0;
while index < chars.len() {
if index + 2 < chars.len() && chars[index + 1] == '-' {
out.extend(chars[index]..=chars[index + 2]);
index += 3;
} else {
out.push(chars[index]);
index += 1;
}
}
out
}
fn matches_published_pattern(value: &str) -> bool {
let body = SOURCE_NAME_PATTERN
.strip_prefix('^')
.and_then(|rest| rest.strip_suffix('$'))
.and_then(|rest| rest.strip_suffix('*'))
.expect("the pattern is anchored and its tail repeats");
let (first, rest) = body.split_once("][").expect("the pattern has two classes");
let first = expand_class(first.strip_prefix('[').expect("a class opens the pattern"));
let rest = expand_class(rest.strip_suffix(']').expect("a class closes the pattern"));
let mut chars = value.chars();
let Some(head) = chars.next() else {
return false;
};
first.contains(&head) && chars.all(|c| rest.contains(&c))
}
#[test]
fn source_name_validation_agrees_with_the_pattern_it_publishes() {
let mut corpus: Vec<String> = vec![
String::new(),
"work".to_owned(),
"gh-main".to_owned(),
"a1-b2-c3".to_owned(),
"0".to_owned(),
"-leading".to_owned(),
"trailing-".to_owned(),
"Work".to_owned(),
"work_name".to_owned(),
"wörk".to_owned(),
"a".repeat(200),
];
for byte in 0u8..=127 {
let c = char::from(byte);
corpus.push(c.to_string());
corpus.push(format!("a{c}"));
}
for name in corpus {
assert_eq!(
SourceName::new(name.clone()).is_ok(),
matches_published_pattern(&name),
"SourceName::new and SOURCE_NAME_PATTERN ({SOURCE_NAME_PATTERN}) disagree \
about {name:?}. They are one rule in two places — change both together, or \
a configuration the published schema accepts is refused at load."
);
}
}
fn outgoing() -> Task {
Task {
id: NativeId::from("T-1"),
title: "Alpha engine".to_owned(),
content: None,
status: Status {
category: StatusCategory::Todo,
name: "Todo".to_owned(),
},
labels: Vec::new(),
project: None,
url: None,
location: None,
created_at: None,
updated_at: None,
metadata: std::collections::BTreeMap::new(),
repositories: Vec::new(),
}
}
#[tokio::test]
async fn a_source_that_implements_only_the_read_methods_declares_no_write_side() {
let source: Box<dyn TaskSource> = Box::new(Silent("read-only"));
assert_eq!(source.writes(), WriteSupport::Unsupported);
assert!(!source.writes().is_supported());
assert!(WriteSupport::Supported.is_supported());
for refusal in [
source
.write_task(&ItemWrite {
target: None,
item: outgoing(),
depends_on: Vec::new(),
})
.await,
source
.write_project(&ItemWrite {
target: Some(NativeId::from("P-1")),
item: Project {
id: NativeId::from("P-1"),
title: "Engine".to_owned(),
content: None,
status: Status {
category: StatusCategory::Todo,
name: "Todo".to_owned(),
},
labels: Vec::new(),
url: None,
location: None,
created_at: None,
updated_at: None,
metadata: std::collections::BTreeMap::new(),
repositories: Vec::new(),
},
depends_on: Vec::new(),
})
.await
.map(|_| NativeId::from("unreachable")),
] {
let Err(SourceError::Refused { message }) = refusal else {
panic!("a source with no write side must refuse a write: {refusal:?}");
};
assert_eq!(message, "the read-only plugin cannot be written");
}
}
#[test]
fn an_item_write_round_trips_through_json_with_its_edges_and_an_absent_target() {
let write = ItemWrite {
target: None,
item: outgoing(),
depends_on: vec![DependencyEdge {
from: onetaskgraph_plugin_api::DependencyEndpoint::from_native(
NativeId::from("T-1"),
onetaskgraph_plugin_api::ItemKind::Task,
),
to: onetaskgraph_plugin_api::DependencyEndpoint::new(
"other:P-9".to_owned(),
onetaskgraph_plugin_api::ItemKind::Project,
)
.expect("a qualified endpoint"),
kind: DependencyKind::Blocks,
}],
};
let encoded = serde_json::to_value(&write).expect("encodes");
assert_eq!(encoded["target"], serde_json::Value::Null);
assert_eq!(encoded["depends_on"][0]["to"]["id"], "other:P-9");
assert_eq!(
serde_json::from_value::<ItemWrite<Task>>(encoded).expect("decodes"),
write
);
let bare: ItemWrite<Task> = serde_json::from_value(
serde_json::json!({"target": "ENG-1", "item": serde_json::to_value(outgoing()).unwrap()}),
)
.expect("decodes without depends_on");
assert_eq!(bare.target, Some(NativeId::from("ENG-1")));
assert!(bare.depends_on.is_empty());
}
fn filed() -> Document {
Document {
id: NativeId::from("D-1"),
title: "Why the store holds a document".to_owned(),
content: Some("A person cannot review a plan node by node.".to_owned()),
project: Some(NativeId::from("P-1")),
labels: vec![Label {
id: NativeId::from("l-1"),
name: "design".to_owned(),
color: None,
}],
url: Some("https://example.invalid/D-1".to_owned()),
location: Some(Location::Path("/home/someone/notes/design.md".to_owned())),
created_at: Some(Utc.with_ymd_and_hms(2026, 8, 31, 9, 0, 0).unwrap()),
updated_at: None,
metadata: [("onepipeline.review".to_owned(), serde_json::json!(true))].into(),
repositories: vec![
onetaskgraph_plugin_api::Repository::try_from("github.com/example/work".to_owned())
.expect("normalized origin"),
],
}
}
#[test]
fn a_document_round_trips_through_json_with_every_field_populated() {
let document = filed();
let encoded = serde_json::to_value(&document).expect("encodes");
assert!(encoded.get("status").is_none(), "{encoded:#}");
assert!(encoded.get("depends_on").is_none(), "{encoded:#}");
assert_eq!(
serde_json::from_value::<Document>(encoded).expect("decodes"),
document
);
}
#[test]
fn a_location_is_told_apart_by_which_key_is_present_on_all_three_entities() {
assert_eq!(
serde_json::to_value(Location::Url("https://example.invalid/D-1".to_owned()))
.expect("encodes"),
serde_json::json!({"url": "https://example.invalid/D-1"})
);
assert_eq!(
serde_json::to_value(Location::Path("/home/someone/notes/design.md".to_owned()))
.expect("encodes"),
serde_json::json!({"path": "/home/someone/notes/design.md"})
);
let task = Task {
location: Some(Location::Url("https://example.invalid/ENG-1".to_owned())),
..outgoing()
};
let project = Project {
id: NativeId::from("P-1"),
title: "Foundation".to_owned(),
content: None,
status: Status {
category: StatusCategory::Backlog,
name: "Planned".to_owned(),
},
labels: Vec::new(),
url: None,
location: Some(Location::Path("/home/someone/notes/P-1".to_owned())),
created_at: None,
updated_at: None,
metadata: Default::default(),
repositories: Vec::new(),
};
for (encoded, expected) in [
(
serde_json::to_value(&task).expect("encodes"),
serde_json::json!({"url": "https://example.invalid/ENG-1"}),
),
(
serde_json::to_value(&project).expect("encodes"),
serde_json::json!({"path": "/home/someone/notes/P-1"}),
),
(
serde_json::to_value(filed()).expect("encodes"),
serde_json::json!({"path": "/home/someone/notes/design.md"}),
),
] {
assert_eq!(encoded["location"], expected, "{encoded:#}");
}
assert_eq!(
serde_json::to_value(&task).expect("encodes")["url"],
serde_json::Value::Null
);
assert_eq!(
serde_json::to_value(filed()).expect("encodes")["url"],
serde_json::json!("https://example.invalid/D-1")
);
}
#[test]
fn an_entity_that_does_not_say_where_it_is_decodes_as_absent_rather_than_failing() {
let mut without = serde_json::to_value(outgoing()).expect("encodes");
without
.as_object_mut()
.expect("a task is an object")
.remove("location")
.expect("the field was there to remove");
assert_eq!(
serde_json::from_value::<Task>(without)
.expect("decodes without a location")
.location,
None
);
let mut document = serde_json::to_value(filed()).expect("encodes");
document
.as_object_mut()
.expect("a document is an object")
.remove("location");
assert_eq!(
serde_json::from_value::<Document>(document)
.expect("decodes without a location")
.location,
None
);
}
#[test]
fn a_plugin_that_predates_documents_is_read_as_the_document_free_source_it_is() {
let mut declared = serde_json::to_value(Silent("older").capabilities()).expect("encodes");
declared
.as_object_mut()
.expect("a capability value is an object")
.remove("documents")
.expect("the field was there to remove");
let read: Capabilities = serde_json::from_value(declared).expect("decodes without documents");
assert_eq!(read.documents, Support::Unsupported);
assert!(!read.documents.is_native());
assert_eq!(read, Silent("older").capabilities());
}
#[tokio::test]
async fn a_source_with_no_documents_refuses_a_document_read_rather_than_answering_an_empty_page() {
let source: Box<dyn TaskSource> = Box::new(Silent("read-only"));
assert_eq!(source.capabilities().documents, Support::Unsupported);
let reads = [
source
.get_document(&NativeId::from("D-1"))
.await
.map(|found| format!("{found:?}")),
source
.query_documents(
&DocumentQuery {
text: Some(TextQuery {
terms: "design".to_owned(),
fields: TextFields::TitleOrContent,
}),
labels: LabelFilter::default(),
project: ProjectFilter::Is(NativeId::from("P-1")),
},
&PageRequest {
cursor: None,
limit: 10,
},
)
.await
.map(|page| format!("{page:?}")),
];
for refusal in reads {
let Err(SourceError::Refused { message }) = refusal else {
panic!("a source with no documents must refuse a document read: {refusal:?}");
};
assert_eq!(message, "the read-only plugin has no documents");
}
let writes = [
source
.write_document(&ItemWrite {
target: None,
item: filed(),
depends_on: Vec::new(),
})
.await
.map(|id| format!("{id:?}")),
source
.delete_document(&NativeId::from("D-1"))
.await
.map(|()| String::new()),
];
for refusal in writes {
let Err(SourceError::Refused { message }) = refusal else {
panic!("a source with no write side must refuse a document write: {refusal:?}");
};
assert_eq!(message, "the read-only plugin cannot be written");
}
}