use std::num::NonZeroU32;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use onetaskgraph_core::{
Config, ConfiguredSource, CopyAction, CopyItems, CopyOutcome, CopyRequest, CopyScope,
DependencyRequest, Engine, EngineError, GlobalId, MatchBy, Paging, ResolvedSource, TaskRequest,
};
use onetaskgraph_plugin_api::{
Capabilities, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind, DependencySupport,
Direction, Document, DocumentQuery, Health, ItemKind, ItemWrite, Label, NativeId, Page,
PageRequest, Project, ProjectQuery, SecretResolver, SourceError, SourceName, SourcePlugin,
Status, StatusCategory, Support, Task, TaskQuery, TaskSource, WriteSupport, documentless,
};
use secrecy::SecretString;
use serde_json::{Value, json};
struct NoSecrets;
impl SecretResolver for NoSecrets {
fn get(&self, _var: &str) -> Option<SecretString> {
None
}
}
fn name(value: &str) -> SourceName {
SourceName::new(value).expect("a valid source name")
}
fn id(value: &str) -> GlobalId {
value.parse().expect("a qualified id")
}
fn engine_over(sources: Value) -> Engine {
let config =
Config::from_document(json!({ "sources": sources })).expect("a valid configuration");
Engine::build(&config, &NoSecrets)
}
fn task(id: &str, title: &str) -> Value {
json!({
"id": id,
"title": title,
"content": "the engine core",
"status": {"category": "todo", "name": "Todo"},
"labels": [{"id": "L-1", "name": "bug"}],
"metadata": {"caller.shape": {"nested": [1, true, null]}},
"repositories": ["github.com/nickderobertis/onetaskgraph"]
})
}
fn pair() -> Engine {
engine_over(json!({
"from": {"plugin": "in-memory", "config": {"tasks": [task("T-1", "Alpha engine")]}},
"into": {"plugin": "in-memory", "config": {}},
}))
}
fn one(item: &str) -> CopyRequest {
many(&[item], CopyScope::Tasks)
}
fn many(items: &[&str], scope: CopyScope) -> CopyRequest {
CopyRequest {
items: CopyItems::new(items.iter().map(|item| id(item)).collect())
.expect("a copy names at least one item"),
scope,
destination: name("into"),
match_by: None,
recreate: false,
dry_run: false,
}
}
fn landed(outcome: &CopyOutcome) -> (Option<String>, String) {
(
outcome.destination().map(ToString::to_string),
outcome.action.name(),
)
}
async fn listed(engine: &Engine, source: &str) -> Vec<String> {
let response = engine
.tasks(&TaskRequest {
sources: vec![name(source)],
filters: onetaskgraph_core::Filters::default(),
project: onetaskgraph_core::ProjectSelector::Any,
paging: Paging {
limit: NonZeroU32::new(50).expect("a non-zero limit"),
token: None,
},
})
.await
.expect("the list verb answers");
response
.items
.into_iter()
.map(|task| task.id.to_string())
.collect()
}
#[tokio::test]
async fn a_rust_caller_creates_then_updates_the_same_destination_item() {
let engine = pair();
let created = engine.copy(&one("from:T-1")).await.expect("the copy runs");
assert_eq!(created.items.len(), 1);
assert_eq!(created.items[0].source, id("from:T-1"));
assert_eq!(
landed(&created.items[0]),
(Some("into:T-1".to_owned()), "created".to_owned())
);
let copied = engine
.task(&id("into:T-1"))
.await
.expect("the show verb answers");
let copied = &copied.items[0].item;
assert_eq!(copied.title, "Alpha engine");
assert_eq!(
copied.metadata["caller.shape"],
json!({"nested": [1, true, null]})
);
assert_eq!(
copied.metadata[GlobalId::ORIGIN_KEY],
Value::String("from:T-1".to_owned())
);
assert_eq!(
copied.repositories[0].as_str(),
"github.com/nickderobertis/onetaskgraph"
);
let again = engine.copy(&one("from:T-1")).await.expect("the copy runs");
assert_eq!(
landed(&again.items[0]),
(Some("into:T-1".to_owned()), "unchanged".to_owned())
);
assert_eq!(listed(&engine, "into").await, vec!["into:T-1".to_owned()]);
let back = engine
.copy(&CopyRequest {
destination: name("from"),
..one("into:T-1")
})
.await
.expect("the copy runs");
assert_eq!(
landed(&back.items[0]),
(Some("from:T-1".to_owned()), "unchanged".to_owned())
);
assert_eq!(listed(&engine, "from").await, vec!["from:T-1".to_owned()]);
let original = engine
.task(&id("from:T-1"))
.await
.expect("the show verb answers");
assert!(
!original.items[0]
.item
.metadata
.contains_key(GlobalId::ORIGIN_KEY),
"a copy back does not stamp the original with the id of the copy that came from it"
);
}
#[tokio::test]
async fn a_rust_caller_copying_back_leaves_the_destination_its_own_origin() {
let engine = engine_over(json!({
"authoring": {"plugin": "in-memory", "config": {"tasks": [task("T-1", "Alpha engine")]}},
"plans": {"plugin": "in-memory", "config": {}},
"run": {"plugin": "in-memory", "config": {"tasks": [{
"id": "T-1", "title": "Alpha engine, settled",
"content": "the engine core",
"status": {"category": "todo", "name": "Todo"},
"labels": [{"id": "L-1", "name": "bug"}],
"metadata": {
"caller.shape": {"nested": [1, true, null]},
GlobalId::ORIGIN_KEY: "plans:T-1",
},
"repositories": ["github.com/nickderobertis/onetaskgraph"],
}]}},
}));
let into = |destination: &str, item: &str| CopyRequest {
destination: name(destination),
..one(item)
};
async fn origin(engine: &Engine, item: &str) -> Value {
engine
.task(&id(item))
.await
.expect("the show verb answers")
.items[0]
.item
.metadata
.get(GlobalId::ORIGIN_KEY)
.cloned()
.unwrap_or(Value::Null)
}
let forward = engine
.copy(&into("plans", "authoring:T-1"))
.await
.expect("the copy runs");
assert_eq!(
landed(&forward.items[0]),
(Some("plans:T-1".to_owned()), "created".to_owned())
);
assert_eq!(origin(&engine, "plans:T-1").await, json!("authoring:T-1"));
let back = engine
.copy(&into("plans", "run:T-1"))
.await
.expect("the copy runs");
assert_eq!(
landed(&back.items[0]),
(Some("plans:T-1".to_owned()), "updated".to_owned())
);
assert_eq!(
engine
.task(&id("plans:T-1"))
.await
.expect("the show verb answers")
.items[0]
.item
.title,
"Alpha engine, settled",
"the settled title landed"
);
assert_eq!(
origin(&engine, "plans:T-1").await,
json!("authoring:T-1"),
"and the plan still says where it itself came from"
);
let repeated = engine
.copy(&into("plans", "run:T-1"))
.await
.expect("the copy runs");
assert_eq!(
landed(&repeated.items[0]),
(Some("plans:T-1".to_owned()), "unchanged".to_owned())
);
let again = engine
.copy(&into("plans", "authoring:T-1"))
.await
.expect("the copy runs");
assert_eq!(
landed(&again.items[0]),
(Some("plans:T-1".to_owned()), "updated".to_owned())
);
assert_eq!(listed(&engine, "plans").await, vec!["plans:T-1".to_owned()]);
}
#[tokio::test]
async fn a_rust_caller_is_refused_by_a_destination_configured_with_no_write_side() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {"tasks": [task("T-1", "Alpha engine")]}},
"into": {
"plugin": "in-memory",
"config": {"capabilities": {"writes": "unsupported"}},
},
}));
let Err(refusal) = engine.copy(&one("from:T-1")).await else {
panic!("a destination with no write side must refuse");
};
assert!(
matches!(&refusal, EngineError::NotWritable { name, kind }
if name == "into" && kind == "in-memory"),
"{refusal:?}"
);
let rendered = refusal.to_string();
assert!(
rendered.contains("source into cannot be written"),
"{rendered}"
);
assert!(rendered.contains("its plugin is in-memory"), "{rendered}");
}
#[tokio::test]
async fn a_dry_run_reads_everything_and_writes_nothing() {
let engine = pair();
let planned = engine
.copy(&CopyRequest {
dry_run: true,
..one("from:T-1")
})
.await
.expect("the copy runs");
assert_eq!(
planned.items[0].action,
CopyAction::Created { destination: None }
);
assert!(listed(&engine, "into").await.is_empty());
}
#[tokio::test]
async fn an_id_that_names_nothing_and_a_destination_nothing_configures_are_both_refused() {
let engine = pair();
let Err(missing) = engine.copy(&one("from:absent")).await else {
panic!("an id naming nothing must refuse");
};
assert!(
matches!(&missing, EngineError::NoSuchItem { id } if id == "from:absent"),
"{missing:?}"
);
let Err(unknown) = engine
.copy(&CopyRequest {
destination: name("nowhere"),
..one("from:T-1")
})
.await
else {
panic!("a destination nothing configures must refuse");
};
assert!(
matches!(&unknown, EngineError::UnknownSource { name, .. } if name == "nowhere"),
"{unknown:?}"
);
}
#[tokio::test]
async fn a_stale_origin_refuses_until_recreate_says_to_create_instead() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {"tasks": [{
"id": "T-1", "title": "Alpha engine",
"status": {"category": "todo", "name": "Todo"}, "labels": [],
"metadata": {GlobalId::ORIGIN_KEY: "into:GONE"},
}]}},
"into": {"plugin": "in-memory", "config": {}},
}));
let Err(stale) = engine.copy(&one("from:T-1")).await else {
panic!("an origin naming nothing at the destination must refuse");
};
assert!(
matches!(&stale, EngineError::StaleOrigin { item, origin }
if item == "from:T-1" && origin == "into:GONE"),
"{stale:?}"
);
assert!(stale.to_string().contains("--recreate"), "{stale}");
assert!(listed(&engine, "into").await.is_empty());
let created = engine
.copy(&CopyRequest {
recreate: true,
..one("from:T-1")
})
.await
.expect("--recreate falls through to the search rule");
assert_eq!(
landed(&created.items[0]),
(Some("into:T-1".to_owned()), "created".to_owned())
);
}
#[tokio::test]
async fn a_lost_origin_creates_a_second_item_until_match_by_re_establishes_it() {
let engine = pair();
engine.copy(&one("from:T-1")).await.expect("the copy runs");
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {"tasks": [task("T-1", "Alpha engine")]}},
"into": {"plugin": "in-memory", "config": {"tasks": [task("T-1", "Alpha engine")]}},
}));
let duplicated = engine.copy(&one("from:T-1")).await.expect("the copy runs");
assert_eq!(
landed(&duplicated.items[0]),
(Some("into:T-1-2".to_owned()), "created".to_owned())
);
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {"tasks": [task("T-1", "Alpha engine")]}},
"into": {"plugin": "in-memory", "config": {"tasks": [task("OTHER", "Alpha engine")]}},
}));
let matched = engine
.copy(&CopyRequest {
match_by: Some(MatchBy::parse("title")),
..one("from:T-1")
})
.await
.expect("the copy runs");
assert_eq!(
landed(&matched.items[0]),
(Some("into:OTHER".to_owned()), "updated".to_owned())
);
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {"tasks": [task("T-1", "Alpha engine")]}},
"into": {"plugin": "in-memory", "config": {"tasks": [task("OTHER", "Renamed")]}},
}));
let matched = engine
.copy(&CopyRequest {
match_by: Some(MatchBy::parse("caller.shape")),
..one("from:T-1")
})
.await
.expect("the copy runs");
assert_eq!(
landed(&matched.items[0]),
(Some("into:OTHER".to_owned()), "updated".to_owned())
);
}
#[tokio::test]
async fn a_destination_that_cannot_carry_a_key_refuses_the_write_naming_it() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {"tasks": [task("T-1", "Alpha engine")]}},
"into": {
"plugin": "in-memory",
"config": {"capabilities": {"unwritable_metadata_keys": ["caller.shape"]}},
},
}));
let Err(refused) = engine.copy(&one("from:T-1")).await else {
panic!("a destination that cannot carry a key must refuse the write");
};
let rendered = refused.to_string();
assert!(
rendered.contains("source into could not do it"),
"{rendered}"
);
assert!(rendered.contains("caller.shape"), "{rendered}");
assert!(listed(&engine, "into").await.is_empty());
}
#[tokio::test]
async fn copying_a_project_carries_its_tasks_and_reports_one_the_source_no_longer_holds() {
let held = |tasks: Value| {
json!({"plugin": "in-memory", "config": {
"projects": [{"id": "P-1", "title": "Engine",
"status": {"category": "todo", "name": "Todo"}, "labels": []}],
"tasks": tasks,
}})
};
let member = |id: &str| {
json!({"id": id, "title": id, "status": {"category": "todo", "name": "Todo"},
"labels": [], "project": "P-1"})
};
let engine = engine_over(json!({
"from": held(json!([member("T-1"), member("T-2")])),
"into": {"plugin": "in-memory", "config": {}},
}));
let project = many(&["from:P-1"], CopyScope::Projects { tasks: true });
let copied = engine.copy(&project).await.expect("the copy runs");
assert_eq!(
copied
.items
.iter()
.map(|outcome| (outcome.source.to_string(), outcome.action.name()))
.collect::<Vec<_>>(),
vec![
("from:P-1".to_owned(), "created".to_owned()),
("from:T-1".to_owned(), "created".to_owned()),
("from:T-2".to_owned(), "created".to_owned()),
]
);
let again = engine.copy(&project).await.expect("the copy runs");
assert!(
again
.items
.iter()
.all(|outcome| outcome.action.name() == "unchanged"),
"{again:?}"
);
assert_eq!(
listed(&engine, "into").await,
vec!["into:T-1".to_owned(), "into:T-2".to_owned()]
);
let alone = engine
.copy(&many(&["from:P-1"], CopyScope::Projects { tasks: false }))
.await
.expect("the copy runs");
assert_eq!(alone.items.len(), 1);
assert_eq!(alone.items[0].source, id("from:P-1"));
}
#[tokio::test]
async fn a_destination_item_the_source_no_longer_holds_is_left_alone_and_reported() {
let copied = |native: &str, origin: &str| {
json!({"id": native, "title": native,
"status": {"category": "todo", "name": "Todo"}, "labels": [],
"project": "P-1", "metadata": {GlobalId::ORIGIN_KEY: origin}})
};
let project = |native: &str, origin: Value| {
json!({"id": native, "title": "Engine",
"status": {"category": "todo", "name": "Todo"}, "labels": [],
"metadata": {GlobalId::ORIGIN_KEY: origin}})
};
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {
"projects": [{"id": "P-1", "title": "Engine",
"status": {"category": "todo", "name": "Todo"}, "labels": []}],
"tasks": [{"id": "T-1", "title": "T-1",
"status": {"category": "todo", "name": "Todo"}, "labels": [],
"project": "P-1"}],
}},
"into": {"plugin": "in-memory", "config": {
"projects": [project("P-1", json!("from:P-1"))],
"tasks": [copied("T-1", "from:T-1"), copied("T-2", "from:T-2")],
}},
}));
let report = engine
.copy(&many(&["from:P-1"], CopyScope::Projects { tasks: true }))
.await
.expect("the copy runs");
let orphan = report
.items
.iter()
.find(|outcome| outcome.action.name() == "orphaned")
.unwrap_or_else(|| panic!("no orphan was reported: {report:?}"));
assert_eq!(orphan.source, id("from:T-2"));
assert_eq!(orphan.destination(), Some(&id("into:T-2")));
let held = engine
.task(&id("into:T-2"))
.await
.expect("the show verb answers");
assert_eq!(held.items[0].item.title, "T-2");
}
#[tokio::test]
async fn the_edges_a_copy_read_are_written_and_a_far_end_that_leaves_the_set_is_qualified() {
let member = |id: &str| {
json!({"id": id, "title": id, "status": {"category": "todo", "name": "Todo"},
"labels": []})
};
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {
"tasks": [member("T-1"), member("T-2"), member("T-3")],
"task_dependencies": [
{"from": "T-1", "to": "T-2", "kind": "blocks"},
{"from": "T-1", "to": "T-3", "kind": "related"},
{"from": {"id": "T-1", "kind": "task"},
"to": {"id": "elsewhere:P-9", "kind": "project"}, "kind": "blocks"},
],
}},
"into": {"plugin": "in-memory", "config": {}},
}));
let copied = engine
.copy(&many(&["from:T-1", "from:T-2"], CopyScope::Tasks))
.await
.expect("the copy runs");
assert_eq!(copied.items.len(), 2);
let edges = engine
.task_dependencies(&onetaskgraph_core::DependencyRequest {
id: id("into:T-1"),
direction: onetaskgraph_plugin_api::Direction::DependsOn,
paging: Paging {
limit: NonZeroU32::new(50).expect("a non-zero limit"),
token: None,
},
})
.await
.expect("the dependency verb answers");
let mut ends: Vec<String> = edges
.items
.iter()
.map(|edge| edge.to.id.to_string())
.collect();
ends.sort();
assert_eq!(
ends,
vec![
"into:T-2".to_owned(),
"elsewhere:P-9".to_owned(),
"from:T-3".to_owned(),
]
.into_iter()
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
);
let back = engine
.copy(&CopyRequest {
destination: name("from"),
..one("into:T-1")
})
.await
.expect("the copy runs");
assert_eq!(back.items[0].destination(), Some(&id("from:T-1")));
let edges = engine
.task_dependencies(&onetaskgraph_core::DependencyRequest {
id: id("from:T-1"),
direction: onetaskgraph_plugin_api::Direction::DependsOn,
paging: Paging {
limit: NonZeroU32::new(50).expect("a non-zero limit"),
token: None,
},
})
.await
.expect("the dependency verb answers");
assert!(
edges.items.iter().any(|edge| edge.to.id == id("from:T-3")),
"{edges:?}"
);
}
#[tokio::test]
async fn a_task_copied_on_its_own_is_filed_under_the_destinations_own_counterpart() {
let filed = json!({"id": "T-1", "title": "Alpha",
"status": {"category": "todo", "name": "Todo"},
"labels": [], "project": "P-1"});
let project = |id: &str, origin: Option<&str>| {
let mut project = json!({"id": id, "title": "Engine",
"status": {"category": "todo", "name": "Todo"}, "labels": []});
if let Some(origin) = origin {
project["metadata"] = json!({GlobalId::ORIGIN_KEY: origin});
}
project
};
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {
"projects": [project("P-1", None)], "tasks": [filed],
}},
"into": {"plugin": "in-memory", "config": {
"projects": [project("LOCAL-7", Some("from:P-1"))],
}},
}));
engine.copy(&one("from:T-1")).await.expect("the copy runs");
let copied = engine
.task(&id("into:T-1"))
.await
.expect("the show verb answers");
assert_eq!(
copied.items[0].item.project,
Some(onetaskgraph_plugin_api::NativeId::from("LOCAL-7"))
);
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {
"projects": [project("P-1", None)], "tasks": [filed],
}},
"into": {"plugin": "in-memory", "config": {}},
}));
engine.copy(&one("from:T-1")).await.expect("the copy runs");
let copied = engine
.task(&id("into:T-1"))
.await
.expect("the show verb answers");
assert_eq!(
copied.items[0].item.project,
Some(onetaskgraph_plugin_api::NativeId::from("P-1"))
);
}
#[tokio::test]
async fn a_project_origin_that_still_names_something_updates_that_project_directly() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {"projects": [{
"id": "P-1", "title": "Renamed", "status": {"category": "todo", "name": "Todo"},
"labels": [], "metadata": {GlobalId::ORIGIN_KEY: "into:BOARD"},
}]}},
"into": {"plugin": "in-memory", "config": {"projects": [{
"id": "BOARD", "title": "Engine", "status": {"category": "todo", "name": "Todo"},
"labels": [],
}]}},
}));
let copied = engine
.copy(&many(&["from:P-1"], CopyScope::Projects { tasks: false }))
.await
.expect("the copy runs");
assert_eq!(
landed(&copied.items[0]),
(Some("into:BOARD".to_owned()), "updated".to_owned())
);
assert_eq!(
engine
.project(&id("into:BOARD"))
.await
.expect("the show verb answers")
.items[0]
.item
.title,
"Renamed"
);
}
#[tokio::test]
async fn a_destination_that_could_not_be_built_and_a_source_that_could_not_be_read_both_refuse() {
let broken = json!({"plugin": "local-md", "config": {"root": "/onetaskgraph/not/a/folder"}});
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {"tasks": [task("T-1", "Alpha engine")]}},
"into": broken,
}));
let Err(unavailable) = engine.copy(&one("from:T-1")).await else {
panic!("a destination that could not be built must refuse");
};
assert!(
matches!(&unavailable, EngineError::DestinationUnavailable { name, .. } if name == "into"),
"{unavailable:?}"
);
assert!(
unavailable.to_string().contains("could not be built"),
"{unavailable}"
);
let engine = engine_over(json!({
"from": broken,
"into": {"plugin": "in-memory", "config": {}},
}));
let Err(unreadable) = engine.copy(&one("from:T-1")).await else {
panic!("a source that could not be built must refuse");
};
assert!(
matches!(&unreadable, EngineError::SourceRefused { name, .. } if name == "from"),
"{unreadable:?}"
);
}
#[tokio::test]
async fn the_scan_that_finds_a_counterpart_walks_the_destination_a_page_at_a_time() {
let held = |id: &str, origin: &str| {
json!({"id": id, "title": id, "status": {"category": "todo", "name": "Todo"},
"labels": [], "metadata": {GlobalId::ORIGIN_KEY: origin}})
};
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {"tasks": [task("T-1", "Alpha engine")]}},
"into": {"plugin": "in-memory", "config": {
"capabilities": {"max_page_size": 1},
"tasks": [
held("A", "somewhere:1"),
held("B", "somewhere:2"),
held("C", "from:T-1"),
],
}},
}));
let copied = engine.copy(&one("from:T-1")).await.expect("the copy runs");
assert_eq!(
landed(&copied.items[0]),
(Some("into:C".to_owned()), "updated".to_owned())
);
}
fn interlinked() -> Value {
json!({"plugin": "in-memory", "config": {
"projects": [
{"id": "P-1", "title": "Engine",
"status": {"category": "todo", "name": "Todo"}, "labels": []},
{"id": "P-2", "title": "Docs",
"status": {"category": "todo", "name": "Todo"}, "labels": []},
],
"tasks": [
{"id": "T-1", "title": "Alpha engine",
"status": {"category": "todo", "name": "Todo"}, "labels": [], "project": "P-1"},
{"id": "T-2", "title": "Beta",
"status": {"category": "todo", "name": "Todo"}, "labels": [], "project": "P-1"},
{"id": "T-3", "title": "Gamma",
"status": {"category": "todo", "name": "Todo"}, "labels": [], "project": "P-2"},
],
"task_dependencies": [
{"from": "T-1", "to": "T-2", "kind": "blocks"},
{"from": "T-1", "to": "T-3", "kind": "blocks"},
],
"project_dependencies": [
{"from": "P-1", "to": "P-2", "kind": "blocks"},
],
}})
}
async fn depends_on(engine: &Engine, near: &str) -> Vec<String> {
let response = engine
.task_dependencies(&DependencyRequest {
id: id(near),
direction: Direction::DependsOn,
paging: Paging {
limit: NonZeroU32::new(50).expect("a non-zero limit"),
token: None,
},
})
.await
.expect("the dependency verb answers");
assert!(
response.errors.is_empty(),
"a dependency read must not fail: {:?}",
response.errors
);
response
.items
.into_iter()
.map(|edge| format!("{} {:?}", edge.to.id, edge.to.kind))
.collect()
}
#[tokio::test]
async fn a_copy_resolves_a_dependency_on_an_item_it_created_in_the_same_run() {
let engine = engine_over(json!({
"from": interlinked(),
"into": {"plugin": "in-memory", "config": {}},
}));
let report = engine
.copy(&many(
&["from:P-1", "from:P-2"],
CopyScope::Projects { tasks: true },
))
.await
.expect("the copy runs");
assert!(
report
.items
.iter()
.all(|outcome| outcome.action.name() == "created"),
"{report:?}"
);
assert_eq!(
depends_on(&engine, "into:T-1").await,
vec!["into:T-2 Task".to_owned(), "into:T-3 Task".to_owned()]
);
let projects = engine
.project_dependencies(&DependencyRequest {
id: id("into:P-1"),
direction: Direction::DependsOn,
paging: Paging {
limit: NonZeroU32::new(50).expect("a non-zero limit"),
token: None,
},
})
.await
.expect("the dependency verb answers");
assert_eq!(
projects
.items
.iter()
.map(|edge| edge.to.id.to_string())
.collect::<Vec<_>>(),
vec!["into:P-2".to_owned()]
);
}
#[tokio::test]
async fn a_copy_that_cannot_finish_leaves_the_destination_as_it_found_it() {
let engine = engine_over(json!({
"from": interlinked(),
"into": {"plugin": "in-memory", "config": {
"capabilities": {"uncreatable_titles": ["Beta"]},
}},
}));
let Err(refused) = engine
.copy(&many(&["from:P-1"], CopyScope::Projects { tasks: true }))
.await
else {
panic!("a destination that will not create an item must refuse the copy");
};
let rendered = refused.to_string();
assert!(rendered.contains("Beta"), "{rendered}");
assert!(
!rendered.contains("could not be undone"),
"the destination can be put back, so the copy must not report otherwise: {rendered}"
);
assert!(listed(&engine, "into").await.is_empty());
let projects = engine
.projects(&onetaskgraph_core::ProjectRequest {
sources: vec![name("into")],
filters: onetaskgraph_core::Filters::default(),
paging: Paging {
limit: NonZeroU32::new(50).expect("a non-zero limit"),
token: None,
},
})
.await
.expect("the project list answers");
assert!(projects.items.is_empty(), "{:?}", projects.items);
}
#[tokio::test]
async fn a_copy_that_cannot_be_undone_names_what_it_left_behind() {
let engine = engine_over(json!({
"from": interlinked(),
"into": {"plugin": "in-memory", "config": {
"capabilities": {
"uncreatable_titles": ["Beta"],
"undeletable_ids": ["P-1"],
},
}},
}));
let Err(refused) = engine
.copy(&many(&["from:P-1"], CopyScope::Projects { tasks: true }))
.await
else {
panic!("the copy must refuse");
};
let rendered = refused.to_string();
assert!(rendered.contains("could not be undone"), "{rendered}");
assert!(rendered.contains("Beta"), "{rendered}");
assert!(rendered.contains("will not remove P-1"), "{rendered}");
assert!(rendered.contains("into:P-1"), "{rendered}");
let projects = engine
.projects(&onetaskgraph_core::ProjectRequest {
sources: vec![name("into")],
filters: onetaskgraph_core::Filters::default(),
paging: Paging {
limit: NonZeroU32::new(50).expect("a non-zero limit"),
token: None,
},
})
.await
.expect("the project list answers");
assert_eq!(
projects
.items
.iter()
.map(|project| project.id.to_string())
.collect::<Vec<_>>(),
vec!["into:P-1".to_owned()]
);
assert!(listed(&engine, "into").await.is_empty());
}
fn counterpart(id: &str, origin: &str, project: Option<&str>) -> Value {
let mut item = json!({
"id": id,
"title": format!("{id} as it was"),
"content": "as it was",
"status": {"category": "todo", "name": "Todo"},
"labels": [],
"metadata": {GlobalId::ORIGIN_KEY: origin},
});
if let Some(project) = project {
item["project"] = json!(project);
}
item
}
async fn held(engine: &Engine, source: &str) -> Vec<String> {
let paging = || Paging {
limit: NonZeroU32::new(50).expect("a non-zero limit"),
token: None,
};
let projects = engine
.projects(&onetaskgraph_core::ProjectRequest {
sources: vec![name(source)],
filters: onetaskgraph_core::Filters::default(),
paging: paging(),
})
.await
.expect("the project list answers");
let tasks = engine
.tasks(&TaskRequest {
sources: vec![name(source)],
filters: onetaskgraph_core::Filters::default(),
project: onetaskgraph_core::ProjectSelector::Any,
paging: paging(),
})
.await
.expect("the task list answers");
projects
.items
.into_iter()
.map(|project| format!("{} {}", project.id, project.item.title))
.chain(
tasks
.items
.into_iter()
.map(|task| format!("{} {}", task.id, task.item.title)),
)
.collect()
}
fn already_holding() -> Value {
json!({
"projects": [
counterpart("D-P1", "from:P-1", None),
counterpart("D-P2", "from:P-2", None),
],
"tasks": [
counterpart("D-T1", "from:T-1", Some("D-P1")),
counterpart("D-T2", "from:T-2", Some("D-P1")),
],
})
}
#[tokio::test]
async fn a_second_copy_updates_every_counterpart_and_repairs_the_edges_among_them() {
let engine = engine_over(json!({
"from": interlinked(),
"into": {"plugin": "in-memory", "config": already_holding()},
}));
let report = engine
.copy(&many(
&["from:P-1", "from:P-2"],
CopyScope::Projects { tasks: true },
))
.await
.expect("the copy runs");
assert_eq!(
report
.items
.iter()
.map(|outcome| (outcome.source.to_string(), outcome.action.name()))
.collect::<Vec<_>>(),
vec![
("from:P-1".to_owned(), "updated".to_owned()),
("from:T-1".to_owned(), "updated".to_owned()),
("from:T-2".to_owned(), "updated".to_owned()),
("from:P-2".to_owned(), "updated".to_owned()),
("from:T-3".to_owned(), "created".to_owned()),
]
);
assert_eq!(
depends_on(&engine, "into:D-T1").await,
vec!["into:D-T2 Task".to_owned(), "into:T-3 Task".to_owned()]
);
}
#[tokio::test]
async fn a_copy_that_cannot_finish_puts_back_the_items_it_overwrote() {
let mut into = already_holding();
into["capabilities"] = json!({"uncreatable_titles": ["Gamma"]});
let engine = engine_over(json!({
"from": interlinked(),
"into": {"plugin": "in-memory", "config": into},
}));
let before = held(&engine, "into").await;
assert_eq!(
before,
vec![
"into:D-P1 D-P1 as it was".to_owned(),
"into:D-P2 D-P2 as it was".to_owned(),
"into:D-T1 D-T1 as it was".to_owned(),
"into:D-T2 D-T2 as it was".to_owned(),
]
);
let Err(refused) = engine
.copy(&many(
&["from:P-1", "from:P-2"],
CopyScope::Projects { tasks: true },
))
.await
else {
panic!("a destination that will not create an item must refuse the copy");
};
assert!(refused.to_string().contains("Gamma"), "{refused}");
assert!(
!refused.to_string().contains("could not be undone"),
"this destination takes its items back: {refused}"
);
assert_eq!(
held(&engine, "into").await,
before,
"every item this copy overwrote reads as it did before it started"
);
}
fn one_project_of_two_tasks() -> Value {
json!({"plugin": "in-memory", "config": {
"projects": [
{"id": "P-1", "title": "Engine",
"status": {"category": "todo", "name": "Todo"}, "labels": []},
],
"tasks": [
{"id": "T-1", "title": "Alpha engine",
"status": {"category": "todo", "name": "Todo"}, "labels": [], "project": "P-1"},
{"id": "T-9", "title": "Gamma",
"status": {"category": "todo", "name": "Todo"}, "labels": [], "project": "P-1"},
],
}})
}
fn sharing_one_id() -> Value {
let mut project = counterpart("SHARED", "from:P-1", None);
project["title"] = json!("the project as it was");
let mut task = counterpart("SHARED", "from:T-1", Some("SHARED"));
task["title"] = json!("the task as it was");
json!({
"projects": [project],
"tasks": [task],
"capabilities": {"uncreatable_titles": ["Gamma"]},
})
}
#[tokio::test]
async fn an_undo_tells_a_task_from_a_project_sharing_one_destination_id() {
let engine = engine_over(json!({
"from": one_project_of_two_tasks(),
"into": {"plugin": "in-memory", "config": sharing_one_id()},
}));
let before = held(&engine, "into").await;
assert_eq!(
before,
vec![
"into:SHARED the project as it was".to_owned(),
"into:SHARED the task as it was".to_owned(),
]
);
let Err(refused) = engine
.copy(&many(&["from:P-1"], CopyScope::Projects { tasks: true }))
.await
else {
panic!("a destination that will not create an item must refuse the copy");
};
assert!(refused.to_string().contains("Gamma"), "{refused}");
assert!(
!refused.to_string().contains("could not be undone"),
"this destination takes its items back: {refused}"
);
assert_eq!(
held(&engine, "into").await,
before,
"both items sharing that id are put back, not whichever of them was journalled first"
);
}
fn a_task_named_like_the_destinations_project() -> Value {
json!({"plugin": "in-memory", "config": {
"projects": [
{"id": "P-1", "title": "Engine",
"status": {"category": "todo", "name": "Todo"}, "labels": []},
],
"tasks": [
{"id": "SHARED", "title": "Alpha engine",
"status": {"category": "todo", "name": "Todo"}, "labels": [], "project": "P-1"},
{"id": "T-9", "title": "Gamma",
"status": {"category": "todo", "name": "Todo"}, "labels": [], "project": "P-1"},
],
}})
}
fn holding_only_the_project() -> Value {
let mut project = counterpart("SHARED", "from:P-1", None);
project["title"] = json!("the project as it was");
json!({
"projects": [project],
"capabilities": {"uncreatable_titles": ["Gamma"]},
})
}
#[tokio::test]
async fn an_item_created_under_one_kind_does_not_hold_back_the_others_restore() {
let engine = engine_over(json!({
"from": a_task_named_like_the_destinations_project(),
"into": {"plugin": "in-memory", "config": holding_only_the_project()},
}));
let before = held(&engine, "into").await;
assert_eq!(before, vec!["into:SHARED the project as it was".to_owned()]);
let Err(refused) = engine
.copy(&many(&["from:P-1"], CopyScope::Projects { tasks: true }))
.await
else {
panic!("a destination that will not create an item must refuse the copy");
};
assert!(refused.to_string().contains("Gamma"), "{refused}");
assert!(
!refused.to_string().contains("could not be undone"),
"this destination takes its items back: {refused}"
);
assert_eq!(
held(&engine, "into").await,
before,
"the project is restored, and the task this copy created under its id is gone"
);
}
#[tokio::test]
async fn a_copy_that_stops_part_way_through_an_update_puts_that_item_back_too() {
let mut into = already_holding();
into["capabilities"] = json!({"half_written_titles": ["Beta"]});
let engine = engine_over(json!({
"from": interlinked(),
"into": {"plugin": "in-memory", "config": into},
}));
let before = held(&engine, "into").await;
assert_eq!(
before,
vec![
"into:D-P1 D-P1 as it was".to_owned(),
"into:D-P2 D-P2 as it was".to_owned(),
"into:D-T1 D-T1 as it was".to_owned(),
"into:D-T2 D-T2 as it was".to_owned(),
]
);
let Err(refused) = engine
.copy(&many(&["from:P-1"], CopyScope::Projects { tasks: true }))
.await
else {
panic!("a destination that stops part way through a write must refuse the copy");
};
let rendered = refused.to_string();
assert!(rendered.contains("Beta"), "{rendered}");
assert!(
!rendered.contains("could not be undone"),
"this destination takes its items back: {rendered}"
);
assert_eq!(
held(&engine, "into").await,
before,
"the item the write had already changed reads as it did before the copy started"
);
}
#[tokio::test]
async fn a_restore_the_destination_refuses_names_the_item_left_holding_this_copys_writing() {
let mut into = already_holding();
into["tasks"][1]["metadata"]["reviewed-by"] = json!("a person at the destination");
into["capabilities"] = json!({
"uncreatable_titles": ["Gamma"],
"unwritable_metadata_keys": ["reviewed-by"],
});
let engine = engine_over(json!({
"from": interlinked(),
"into": {"plugin": "in-memory", "config": into},
}));
let Err(refused) = engine
.copy(&many(
&["from:P-1", "from:P-2"],
CopyScope::Projects { tasks: true },
))
.await
else {
panic!("a destination that will not create an item must refuse the copy");
};
let EngineError::CopyNotUndone { left_behind, .. } = &refused else {
panic!("a refused restore must report the copy as not undone: {refused}");
};
assert_eq!(
left_behind
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
vec!["into:D-T2".to_owned()],
"only the item the destination refused is still this copy's"
);
let rendered = refused.to_string();
assert!(rendered.contains("Gamma"), "{rendered}");
assert!(rendered.contains("reviewed-by"), "{rendered}");
assert!(rendered.contains("into:D-T2"), "{rendered}");
assert_eq!(
held(&engine, "into").await,
vec![
"into:D-P1 D-P1 as it was".to_owned(),
"into:D-P2 D-P2 as it was".to_owned(),
"into:D-T1 D-T1 as it was".to_owned(),
"into:D-T2 Beta".to_owned(),
]
);
}
fn a_document(id: &str, title: &str) -> Value {
json!({
"id": id,
"title": title,
"content": "why the store holds a document",
"labels": [{"id": "L-1", "name": "spec"}],
"project": null,
"location": {"path": "/srv/notes/D-1.md"},
"metadata": {"caller.shape": {"nested": [1, true, null]}, "onepipeline.turn_budget": 12},
"repositories": ["github.com/nickderobertis/onetaskgraph"]
})
}
fn document_pair() -> Engine {
engine_over(json!({
"from": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"documents": [a_document("D-1", "Design review")],
}},
"into": {"plugin": "in-memory", "config": {"capabilities": {"documents": "native"}}},
}))
}
#[tokio::test]
async fn a_document_copies_into_another_source_whole_and_a_second_copy_updates_it() {
let engine = document_pair();
let first = engine
.copy(&many(&["from:D-1"], CopyScope::Documents))
.await
.expect("a document-bearing destination takes a document");
assert_eq!(
first.items.iter().map(landed).collect::<Vec<_>>(),
[(Some("into:D-1".to_owned()), "created".to_owned())]
);
let landed_document = engine
.document(&id("into:D-1"))
.await
.expect("the destination is configured");
let item = &landed_document.items[0].item;
assert_eq!(item.title, "Design review");
assert_eq!(
item.content.as_deref(),
Some("why the store holds a document")
);
assert_eq!(item.labels[0].name, "spec");
assert_eq!(
item.metadata["caller.shape"],
json!({"nested": [1, true, null]})
);
assert_eq!(item.metadata["onepipeline.turn_budget"], json!(12));
assert_eq!(item.metadata[GlobalId::ORIGIN_KEY], json!("from:D-1"));
assert_eq!(
item.repositories
.iter()
.map(|repository| repository.as_str().to_owned())
.collect::<Vec<_>>(),
["github.com/nickderobertis/onetaskgraph"]
);
assert_eq!(item.location, None);
let second = engine
.copy(&many(&["from:D-1"], CopyScope::Documents))
.await
.expect("a second copy is an update, not a duplicate");
assert_eq!(
second.items.iter().map(landed).collect::<Vec<_>>(),
[(Some("into:D-1".to_owned()), "unchanged".to_owned())]
);
let held = engine
.documents(&onetaskgraph_core::DocumentRequest {
sources: vec![name("into")],
filters: onetaskgraph_core::DocumentFilters::default(),
project: onetaskgraph_core::ProjectSelector::Any,
paging: Paging {
limit: NonZeroU32::new(20).expect("a non-zero limit"),
token: None,
},
})
.await
.expect("the destination is configured");
assert_eq!(
held.items.len(),
1,
"exactly one where there was one before"
);
}
#[tokio::test]
async fn a_document_copy_naming_a_destination_with_no_documents_is_refused_before_anything_is_read()
{
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"documents": [a_document("D-1", "Design review")],
}},
"into": {"plugin": "in-memory", "config": {}},
}));
let refusal = engine
.copy(&many(&["from:D-1"], CopyScope::Documents))
.await
.expect_err("a destination with no documents has nowhere to put one");
let EngineError::NoDocuments { name: named, kind } = refusal else {
panic!("a destination with no documents is refused as one: {refusal:?}");
};
assert_eq!(named, "into");
assert_eq!(kind, "in-memory");
assert!(
engine
.document(&id("into:D-1"))
.await
.expect("the destination is configured")
.items
.is_empty()
);
}
#[tokio::test]
async fn a_document_copy_out_of_a_source_with_no_documents_is_refused_naming_that_source() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {}},
"into": {"plugin": "in-memory", "config": {"capabilities": {"documents": "native"}}},
}));
let refusal = engine
.copy(&many(&["from:D-1"], CopyScope::Documents))
.await
.expect_err("a source with no documents holds nothing to copy out");
let EngineError::NoDocuments { name: named, kind } = refusal else {
panic!("a source with no documents is refused as one: {refusal:?}");
};
assert_eq!(named, "from");
assert_eq!(kind, "in-memory");
}
#[tokio::test]
async fn a_document_copy_that_cannot_finish_leaves_the_destination_as_it_found_it() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"documents": [a_document("D-1", "Design review"), a_document("D-2", "Refused")],
}},
"into": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native", "uncreatable_titles": ["Refused"]},
}},
}));
let refusal = engine
.copy(&many(&["from:D-1", "from:D-2"], CopyScope::Documents))
.await
.expect_err("a destination that refuses one document fails the whole copy");
let EngineError::SourceRefused { name: named, .. } = refusal else {
panic!("the destination's own refusal reaches the caller: {refusal:?}");
};
assert_eq!(named, "into");
let held = engine
.documents(&onetaskgraph_core::DocumentRequest {
sources: vec![name("into")],
filters: onetaskgraph_core::DocumentFilters::default(),
project: onetaskgraph_core::ProjectSelector::Any,
paging: Paging {
limit: NonZeroU32::new(20).expect("a non-zero limit"),
token: None,
},
})
.await
.expect("the destination is configured");
assert!(
held.items.is_empty(),
"the copy undid its own writes: {:?}",
held.items
);
}
#[derive(Debug, Clone, Copy)]
enum Fault {
RepeatsTheCursor,
OverrunsThePage,
CyclesItsCursors,
}
const FULL_HALF: &str = "the-page-holding-the-members";
const EMPTY_HALF: &str = "the-page-holding-none";
#[derive(Debug, Clone, Copy, PartialEq)]
enum At {
Tasks,
Projects,
Documents,
TaskEdges,
ProjectEdges,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Onset {
FirstRead,
SecondRead,
FirstWrite,
}
struct Misbehaving {
at: At,
fault: Fault,
onset: Onset,
project: bool,
documents: bool,
ceiling: u32,
pages_served: Arc<AtomicU32>,
tasks_written: AtomicU32,
reads_at_fault: AtomicU32,
}
impl Misbehaving {
fn new(at: At, fault: Fault, onset: Onset) -> Self {
Self {
at,
fault,
onset,
project: false,
documents: false,
ceiling: 2,
pages_served: Arc::new(AtomicU32::new(0)),
tasks_written: AtomicU32::new(0),
reads_at_fault: AtomicU32::new(0),
}
}
fn holding_a_project(self) -> Self {
Self {
project: true,
ceiling: 100,
..self
}
}
fn with_documents(self) -> Self {
Self {
documents: true,
..self
}
}
fn misbehaves_at(&self, at: At) -> bool {
if self.at != at {
return false;
}
match self.onset {
Onset::FirstRead => true,
Onset::SecondRead => self.reads_at_fault.fetch_add(1, Ordering::Relaxed) > 0,
Onset::FirstWrite => self.tasks_written.load(Ordering::Relaxed) > 0,
}
}
fn faulted<T>(&self, page: &PageRequest, row: impl Fn() -> T) -> Page<T> {
match self.fault {
Fault::RepeatsTheCursor => Page {
items: Vec::new(),
next: Some(page.cursor.clone().unwrap_or(Cursor("start".to_owned()))),
},
Fault::OverrunsThePage => Page::last((0..=page.limit).map(|_| row()).collect()),
Fault::CyclesItsCursors => panic!("an edge walk is not what cycles"),
}
}
fn cycled(&self, page: &PageRequest) -> Page<Task> {
if page.cursor.as_ref().map(|cursor| cursor.0.as_str()) == Some(EMPTY_HALF) {
return Page {
items: Vec::new(),
next: Some(Cursor(FULL_HALF.to_owned())),
};
}
Page {
items: (0..page.limit)
.map(|row| member(&NativeId::from(format!("T-{row}"))))
.collect(),
next: Some(Cursor(EMPTY_HALF.to_owned())),
}
}
}
fn reported(id: &NativeId) -> Task {
Task {
id: id.clone(),
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(),
}
}
fn member(id: &NativeId) -> Task {
Task {
project: Some(NativeId::from("P-1")),
..reported(id)
}
}
fn held_document(id: &NativeId) -> Document {
Document {
id: id.clone(),
title: "Design review".to_owned(),
content: None,
project: None,
labels: Vec::new(),
url: None,
location: None,
created_at: None,
updated_at: None,
metadata: std::collections::BTreeMap::new(),
repositories: Vec::new(),
}
}
fn authored_document(id: &NativeId) -> Document {
Document {
content: Some("Alpha is at `/srv/from/plans/P-1/A.md`.".to_owned()),
project: Some(NativeId::from("P-1")),
..held_document(id)
}
}
fn edge(near: &NativeId, kind: ItemKind) -> DependencyEdge {
DependencyEdge {
from: DependencyEndpoint::from_native(near.clone(), kind),
to: DependencyEndpoint::from_native(NativeId::from("T-9"), kind),
kind: DependencyKind::Blocks,
}
}
fn held_project(id: &NativeId) -> Project {
Project {
id: id.clone(),
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(),
}
}
#[async_trait::async_trait]
impl TaskSource for Misbehaving {
fn kind(&self) -> &'static str {
"misbehaving"
}
fn capabilities(&self) -> Capabilities {
Capabilities {
projects: Support::Native,
documents: if self.documents {
Support::Native
} else {
Support::Unsupported
},
orphan_tasks: Support::Native,
filter_by_label: Support::Native,
filter_by_status: Support::Native,
search_title: Support::Native,
search_content: Support::Native,
task_dependencies: DependencySupport::BothDirections,
project_dependencies: DependencySupport::BothDirections,
max_page_size: self.ceiling,
}
}
fn writes(&self) -> WriteSupport {
WriteSupport::Supported
}
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(Some(reported(id)))
}
async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
Ok(self.project.then(|| held_project(id)))
}
async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
if !self.documents {
return Err(documentless(self.kind()));
}
Ok(Some(authored_document(id)))
}
async fn query_tasks(
&self,
_query: &TaskQuery,
page: &PageRequest,
) -> Result<Page<Task>, SourceError> {
self.pages_served.fetch_add(1, Ordering::Relaxed);
if self.misbehaves_at(At::Tasks) {
return Ok(match self.fault {
Fault::CyclesItsCursors => self.cycled(page),
_ => self.faulted(page, || reported(&NativeId::from("H-1"))),
});
}
Ok(Page::last(Vec::new()))
}
async fn query_projects(
&self,
_query: &ProjectQuery,
page: &PageRequest,
) -> Result<Page<Project>, SourceError> {
self.pages_served.fetch_add(1, Ordering::Relaxed);
if self.misbehaves_at(At::Projects) {
return Ok(self.faulted(page, || held_project(&NativeId::from("H-1"))));
}
Ok(Page::last(Vec::new()))
}
async fn query_documents(
&self,
_query: &DocumentQuery,
page: &PageRequest,
) -> Result<Page<Document>, SourceError> {
if !self.documents {
return Err(documentless(self.kind()));
}
self.pages_served.fetch_add(1, Ordering::Relaxed);
if self.misbehaves_at(At::Documents) {
return Ok(self.faulted(page, || held_document(&NativeId::from("H-1"))));
}
Ok(Page::last(Vec::new()))
}
async fn labels(&self, _page: &PageRequest) -> Result<Page<Label>, SourceError> {
self.pages_served.fetch_add(1, Ordering::Relaxed);
Ok(Page::last(Vec::new()))
}
async fn task_dependencies(
&self,
id: &NativeId,
_direction: Direction,
page: &PageRequest,
) -> Result<Page<DependencyEdge>, SourceError> {
self.pages_served.fetch_add(1, Ordering::Relaxed);
if self.misbehaves_at(At::TaskEdges) {
let near = id.clone();
return Ok(self.faulted(page, || edge(&near, ItemKind::Task)));
}
Ok(Page::last(Vec::new()))
}
async fn project_dependencies(
&self,
id: &NativeId,
_direction: Direction,
page: &PageRequest,
) -> Result<Page<DependencyEdge>, SourceError> {
self.pages_served.fetch_add(1, Ordering::Relaxed);
if self.misbehaves_at(At::ProjectEdges) {
let near = id.clone();
return Ok(self.faulted(page, || edge(&near, ItemKind::Project)));
}
Ok(Page::last(Vec::new()))
}
async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
self.tasks_written.fetch_add(1, Ordering::Relaxed);
Ok(write.target.clone().unwrap_or(NativeId::from("W-1")))
}
async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
Ok(write.target.clone().unwrap_or(NativeId::from("W-9")))
}
async fn delete_task(&self, _id: &NativeId) -> Result<(), SourceError> {
Ok(())
}
async fn delete_project(&self, _id: &NativeId) -> Result<(), SourceError> {
Ok(())
}
async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
Ok(write.target.clone().unwrap_or(NativeId::from("W-D")))
}
async fn delete_document(&self, _id: &NativeId) -> Result<(), SourceError> {
Ok(())
}
}
fn in_memory(source: &str, config: Value) -> ConfiguredSource {
let built = onetaskgraph_in_memory::Plugin
.build(&name(source), &config, &NoSecrets)
.expect("the in-memory plugin builds");
ConfiguredSource::Ready(ResolvedSource::adopt(name(source), built))
}
fn into_misbehaving_over(from: Value, source: Misbehaving) -> (Engine, Arc<AtomicU32>) {
let pages = Arc::clone(&source.pages_served);
let engine = Engine::new(
vec![
in_memory("from", from),
ConfiguredSource::Ready(ResolvedSource::adopt(name("into"), Box::new(source))),
],
vec![name("from"), name("into")],
);
(engine, pages)
}
fn into_misbehaving(source: Misbehaving) -> (Engine, Arc<AtomicU32>) {
into_misbehaving_over(
json!({
"projects": [{"id": "P-1", "title": "Engine",
"status": {"category": "todo", "name": "Todo"}, "labels": []}],
"tasks": [{"id": "T-1", "title": "Alpha engine",
"status": {"category": "todo", "name": "Todo"},
"labels": [], "project": "P-1"}],
}),
source,
)
}
fn from_misbehaving(source: Misbehaving) -> (Engine, Arc<AtomicU32>) {
from_misbehaving_into(source, json!({}))
}
fn from_misbehaving_documentary(source: Misbehaving) -> (Engine, Arc<AtomicU32>) {
from_misbehaving_into(source, json!({"capabilities": {"documents": "native"}}))
}
fn from_misbehaving_into(source: Misbehaving, into: Value) -> (Engine, Arc<AtomicU32>) {
let pages = Arc::clone(&source.pages_served);
let engine = Engine::new(
vec![
ConfiguredSource::Ready(ResolvedSource::adopt(name("from"), Box::new(source))),
in_memory("into", into),
],
vec![name("from"), name("into")],
);
(engine, pages)
}
async fn refusal(engine: &Engine, request: &CopyRequest) -> String {
let outcome = tokio::time::timeout(Duration::from_secs(10), engine.copy(request))
.await
.expect("the copy stops instead of running away");
match outcome {
Err(error) => error.to_string(),
Ok(report) => panic!("a misbehaving source must be refused: {report:?}"),
}
}
#[tokio::test]
async fn a_destination_that_repeats_its_cursor_stops_the_scan_for_a_counterpart() {
let (engine, pages) = into_misbehaving(Misbehaving::new(
At::Tasks,
Fault::RepeatsTheCursor,
Onset::FirstRead,
));
let refused = refusal(&engine, &one("from:T-1")).await;
assert!(refused.contains("source into could not do it"), "{refused}");
assert!(
refused.contains(
"the source returned the cursor it was given while the destination was being \
scanned for the item to update"
),
"{refused}"
);
assert!(pages.load(Ordering::Relaxed) <= 3, "the scan stopped early");
}
#[tokio::test]
async fn a_destination_that_overruns_its_page_stops_the_scan_for_a_counterpart() {
let (engine, pages) = into_misbehaving(Misbehaving::new(
At::Tasks,
Fault::OverrunsThePage,
Onset::FirstRead,
));
let refused = refusal(&engine, &one("from:T-1")).await;
assert!(
refused.contains("the source returned 3 rows for a page of at most 2"),
"{refused}"
);
assert_eq!(pages.load(Ordering::Relaxed), 1, "the scan stopped at once");
}
#[tokio::test]
async fn a_destination_that_repeats_its_cursor_stops_the_walk_for_what_a_copy_left_behind() {
let (engine, _) = into_misbehaving(Misbehaving::new(
At::Tasks,
Fault::RepeatsTheCursor,
Onset::FirstWrite,
));
let refused = refusal(
&engine,
&many(&["from:P-1"], CopyScope::Projects { tasks: true }),
)
.await;
assert!(
refused.contains(
"the source returned the cursor it was given while the destination was being \
read for items the copy left behind"
),
"{refused}"
);
}
#[tokio::test]
async fn a_destination_that_overruns_its_page_stops_the_walk_for_what_a_copy_left_behind() {
let (engine, _) = into_misbehaving(Misbehaving::new(
At::Tasks,
Fault::OverrunsThePage,
Onset::FirstWrite,
));
let refused = refusal(
&engine,
&many(&["from:P-1"], CopyScope::Projects { tasks: true }),
)
.await;
assert!(refused.contains("source into could not do it"), "{refused}");
assert!(
refused.contains("the source returned 3 rows for a page of at most 2"),
"{refused}"
);
}
#[tokio::test]
async fn a_destination_that_overruns_its_page_stops_the_scan_for_a_project() {
let (engine, _) = into_misbehaving(Misbehaving::new(
At::Projects,
Fault::OverrunsThePage,
Onset::FirstRead,
));
let refused = refusal(
&engine,
&many(&["from:P-1"], CopyScope::Projects { tasks: false }),
)
.await;
assert!(refused.contains("source into could not do it"), "{refused}");
assert!(
refused.contains("the source returned 3 rows for a page of at most 2"),
"{refused}"
);
}
#[tokio::test]
async fn a_destination_that_overruns_its_page_stops_the_scan_for_a_document() {
let (engine, _) = into_misbehaving_over(
json!({
"capabilities": {"documents": "native"},
"documents": [a_document("D-1", "Design review")],
}),
Misbehaving::new(At::Documents, Fault::OverrunsThePage, Onset::FirstRead).with_documents(),
);
let refused = refusal(&engine, &many(&["from:D-1"], CopyScope::Documents)).await;
assert!(refused.contains("source into could not do it"), "{refused}");
assert!(
refused.contains("the source returned 3 rows for a page of at most 2"),
"{refused}"
);
}
#[tokio::test]
async fn a_source_that_repeats_its_cursor_stops_the_walk_of_a_projects_own_edges() {
let (engine, _) = from_misbehaving(
Misbehaving::new(At::ProjectEdges, Fault::RepeatsTheCursor, Onset::FirstRead)
.holding_a_project(),
);
let refused = refusal(
&engine,
&many(&["from:P-1"], CopyScope::Projects { tasks: false }),
)
.await;
assert!(refused.contains("source from could not do it"), "{refused}");
assert!(
refused.contains(
"the source returned the cursor it was given while an item's dependencies \
were being read for a copy"
),
"{refused}"
);
}
#[tokio::test]
async fn a_source_whose_cursors_cycle_stops_the_walk_of_a_projects_members() {
let (engine, _) = from_misbehaving(
Misbehaving::new(At::Tasks, Fault::CyclesItsCursors, Onset::FirstRead).holding_a_project(),
);
let refused = refusal(
&engine,
&many(&["from:P-1"], CopyScope::Projects { tasks: true }),
)
.await;
assert!(refused.contains("source from could not do it"), "{refused}");
assert!(
refused.contains(
"the source returned the cursor it was given while the tasks of a project \
were being read for a copy"
),
"{refused}"
);
}
#[tokio::test]
async fn a_source_that_overruns_its_page_stops_the_walk_of_a_projects_members() {
let (engine, _) = from_misbehaving(
Misbehaving::new(At::Tasks, Fault::OverrunsThePage, Onset::FirstRead).holding_a_project(),
);
let refused = refusal(
&engine,
&many(&["from:P-1"], CopyScope::Projects { tasks: true }),
)
.await;
assert!(refused.contains("source from could not do it"), "{refused}");
assert!(refused.contains("rows for a page of at most"), "{refused}");
}
#[tokio::test]
async fn a_source_that_repeats_its_cursor_stops_the_walk_of_the_edges_a_copy_reads() {
let (engine, pages) = from_misbehaving(Misbehaving::new(
At::TaskEdges,
Fault::RepeatsTheCursor,
Onset::FirstRead,
));
let refused = refusal(&engine, &one("from:T-1")).await;
assert!(refused.contains("source from could not do it"), "{refused}");
assert!(
refused.contains(
"the source returned the cursor it was given while an item's dependencies \
were being read for a copy"
),
"{refused}"
);
assert!(pages.load(Ordering::Relaxed) <= 3, "the walk stopped early");
}
#[tokio::test]
async fn a_source_that_overruns_its_page_stops_the_walk_of_the_edges_a_copy_reads() {
let (engine, pages) = from_misbehaving(Misbehaving::new(
At::TaskEdges,
Fault::OverrunsThePage,
Onset::FirstRead,
));
let refused = refusal(&engine, &one("from:T-1")).await;
assert!(
refused.contains("the source returned 3 rows for a page of at most 2"),
"{refused}"
);
assert_eq!(pages.load(Ordering::Relaxed), 1, "the walk stopped at once");
}
#[tokio::test]
async fn a_well_behaved_copy_still_walks_every_page_of_every_loop_it_has() {
let held = |native: &str, origin: &str| {
json!({"id": native, "title": native,
"status": {"category": "todo", "name": "Todo"}, "labels": [],
"project": "P-1", "metadata": {GlobalId::ORIGIN_KEY: origin}})
};
let member = |native: &str| {
json!({"id": native, "title": native,
"status": {"category": "todo", "name": "Todo"}, "labels": [],
"project": "P-1"})
};
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {
"capabilities": {"max_page_size": 1},
"projects": [{"id": "P-1", "title": "Engine",
"status": {"category": "todo", "name": "Todo"}, "labels": []}],
"tasks": [member("T-1"), member("T-2"),
{"id": "T-3", "title": "T-3",
"status": {"category": "todo", "name": "Todo"}, "labels": []}],
"task_dependencies": [
{"from": "T-1", "to": "T-2", "kind": "blocks"},
{"from": "T-1", "to": "T-3", "kind": "related"},
],
}},
"into": {"plugin": "in-memory", "config": {
"capabilities": {"max_page_size": 1},
"projects": [{"id": "P-1", "title": "Engine",
"status": {"category": "todo", "name": "Todo"}, "labels": [],
"metadata": {GlobalId::ORIGIN_KEY: "from:P-1"}}],
"tasks": [held("T-8", "from:T-8"), held("T-9", "from:T-9")],
}},
}));
let report = engine
.copy(&many(&["from:P-1"], CopyScope::Projects { tasks: true }))
.await
.expect("the copy runs");
assert_eq!(
report
.items
.iter()
.map(|outcome| (outcome.source.to_string(), outcome.action.name()))
.collect::<Vec<_>>(),
vec![
("from:P-1".to_owned(), "unchanged".to_owned()),
("from:T-1".to_owned(), "created".to_owned()),
("from:T-2".to_owned(), "created".to_owned()),
("from:T-8".to_owned(), "orphaned".to_owned()),
("from:T-9".to_owned(), "orphaned".to_owned()),
]
);
assert_eq!(
depends_on(&engine, "into:T-1").await,
vec!["into:T-2 Task".to_owned(), "from:T-3 Task".to_owned()]
);
}
fn located(id: &str, title: &str, path: &str, origin: Option<&str>) -> Value {
let mut metadata = serde_json::Map::new();
metadata.insert(
"caller.shape".to_owned(),
json!({"nested": [1, true, null]}),
);
if let Some(origin) = origin {
metadata.insert(GlobalId::ORIGIN_KEY.to_owned(), json!(origin));
}
json!({
"id": id,
"title": title,
"status": {"category": "todo", "name": "Todo"},
"labels": [],
"project": "P-1",
"location": {"path": path},
"metadata": Value::Object(metadata),
})
}
fn located_project(path: &str, origin: Option<&str>) -> Value {
let mut project = located("P-1", "The plan", path, origin);
project
.as_object_mut()
.expect("a record is an object")
.remove("project");
project
}
fn plan_document(content: &str) -> Value {
json!({
"id": "D-1",
"title": "Design review",
"content": content,
"project": "P-1",
"labels": [],
"location": {"path": "/srv/from/plans/P-1/D-1.md"},
"metadata": {"caller.shape": {"nested": [1, true, null]}},
})
}
const AUTHORED: &str = "# Plan\n\n\
| Task | Where |\n\
| --- | --- |\n\
| Alpha | `/srv/from/plans/P-1/A.md` |\n\
| Beta | `/srv/from/plans/P-1/B.md` |\n\n\
Everything lives under `/srv/from/plans/P-1`, and `/srv/from/plans/P-1/A.md.bak` is a \
backup.\n";
fn authoring_store(origins: Option<(&str, &str, &str)>) -> Value {
let (project, alpha, beta) = match origins {
Some((project, alpha, beta)) => (Some(project), Some(alpha), Some(beta)),
None => (None, None, None),
};
json!({
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/from/plans/P-1", project)],
"tasks": [
located("A", "Alpha", "/srv/from/plans/P-1/A.md", alpha),
located("B", "Beta", "/srv/from/plans/P-1/B.md", beta),
],
"documents": [plan_document(AUTHORED)],
})
}
async fn body(engine: &Engine, id: &str) -> String {
engine
.document(&self::id(id))
.await
.expect("the show verb answers")
.items[0]
.item
.content
.clone()
.expect("the document has a body")
}
async fn copy_document(engine: &Engine, item: &str) -> onetaskgraph_core::CopyReport {
engine
.copy(&many(&[item], CopyScope::Documents))
.await
.expect("the document copy runs")
}
fn figures(report: &onetaskgraph_core::CopyReport) -> (u64, u64, u64) {
(
report.references_rewritten,
report.references_unresolved,
report.references_ambiguous,
)
}
#[tokio::test]
async fn a_document_arrives_naming_the_destinations_own_records_across_a_one_level_fan_out() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": authoring_store(Some((
"root:P-1", "root:A", "root:B",
)))},
"into": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/into/board", Some("root:P-1"))],
"tasks": [
located("A", "Alpha", "/srv/into/board/A.md", Some("root:A")),
located("B", "Beta", "/srv/into/board/B.md", Some("root:B")),
],
}},
}));
let first = copy_document(&engine, "from:D-1").await;
assert_eq!(
first.items.iter().map(landed).collect::<Vec<_>>(),
[(Some("into:D-1".to_owned()), "created".to_owned())]
);
assert_eq!(figures(&first), (3, 0, 0));
assert_eq!(
body(&engine, "into:D-1").await,
"# Plan\n\n\
| Task | Where |\n\
| --- | --- |\n\
| Alpha | `/srv/into/board/A.md` |\n\
| Beta | `/srv/into/board/B.md` |\n\n\
Everything lives under `/srv/into/board`, and `/srv/from/plans/P-1/A.md.bak` is a \
backup.\n"
);
let landed_document = &engine
.document(&id("into:D-1"))
.await
.expect("the destination is configured")
.items[0]
.item;
assert_eq!(
landed_document.metadata["caller.shape"],
json!({"nested": [1, true, null]})
);
assert_eq!(
landed_document.metadata[GlobalId::ORIGIN_KEY],
json!("from:D-1")
);
let before = body(&engine, "into:D-1").await;
let again = copy_document(&engine, "from:D-1").await;
assert_eq!(
again.items.iter().map(landed).collect::<Vec<_>>(),
[(Some("into:D-1".to_owned()), "unchanged".to_owned())]
);
assert_eq!(figures(&again), (3, 0, 0));
assert_eq!(body(&engine, "into:D-1").await, before);
}
#[tokio::test]
async fn a_history_the_two_keys_cannot_prove_is_left_byte_for_byte_and_counted_unresolved() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": authoring_store(None)},
"into": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/into/board", Some("mid:P-1"))],
"tasks": [
located("A", "Alpha", "/srv/into/board/A.md", Some("mid:A")),
located("B", "Beta", "/srv/into/board/B.md", Some("mid:B")),
],
}},
}));
let report = copy_document(&engine, "from:D-1").await;
assert_eq!(figures(&report), (0, 3, 0));
assert_eq!(
body(&engine, "into:D-1").await,
AUTHORED,
"a history the two keys cannot prove is left byte-for-byte, never guessed at"
);
}
#[tokio::test]
async fn a_destination_holding_two_records_for_one_referent_is_ambiguous_and_scan_still_answers() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": authoring_store(Some((
"root:P-1", "root:A", "root:B",
)))},
"into": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/into/board", Some("root:P-1"))],
"tasks": [
located("A-by-id", "Alpha", "/srv/into/board/A-by-id.md", Some("from:A")),
located("A-by-origin", "Alpha", "/srv/into/board/A-by-origin.md", Some("root:A")),
located("B", "Beta", "/srv/into/board/B.md", Some("root:B")),
],
}},
}));
let report = copy_document(&engine, "from:D-1").await;
assert_eq!(figures(&report), (2, 1, 1));
assert!(
body(&engine, "into:D-1")
.await
.contains("`/srv/from/plans/P-1/A.md`"),
"an ambiguous reference is left byte-for-byte, and no record is chosen"
);
let copied = engine
.copy(&one("from:A"))
.await
.expect("the task copy runs");
assert_eq!(
copied.items[0].destination().map(ToString::to_string),
Some("into:A-by-id".to_owned()),
"the copy's own target lookup takes the first record recording the id it is \
copying, exactly as it did before the reference rewrite existed"
);
}
#[tokio::test]
async fn two_referents_reporting_one_location_leave_every_occurrence_of_it_alone() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/from/plans/P-1", Some("root:P-1"))],
"tasks": [
located("A", "Alpha", "/srv/from/plans/P-1/shared.md", Some("root:A")),
located("B", "Beta", "/srv/from/plans/P-1/shared.md", Some("root:B")),
],
"documents": [plan_document(
"Both rows point at `/srv/from/plans/P-1/shared.md` today.\n",
)],
}},
"into": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/into/board", Some("root:P-1"))],
"tasks": [
located("A", "Alpha", "/srv/into/board/A.md", Some("root:A")),
located("B", "Beta", "/srv/into/board/B.md", Some("root:B")),
],
}},
}));
let report = copy_document(&engine, "from:D-1").await;
assert_eq!(figures(&report), (0, 1, 1));
assert_eq!(
body(&engine, "into:D-1").await,
"Both rows point at `/srv/from/plans/P-1/shared.md` today.\n"
);
}
#[tokio::test]
async fn a_counterpart_the_destination_does_not_hold_or_reports_no_location_for_is_left_alone() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": authoring_store(Some((
"root:P-1", "root:A", "root:B",
)))},
"into": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/into/board", Some("root:P-1"))],
"tasks": [{
"id": "A",
"title": "Alpha",
"status": {"category": "todo", "name": "Todo"},
"labels": [],
"project": "P-1",
"metadata": {GlobalId::ORIGIN_KEY: "root:A"},
}],
}},
}));
let report = copy_document(&engine, "from:D-1").await;
assert_eq!(figures(&report), (1, 2, 0));
let landed_body = body(&engine, "into:D-1").await;
assert!(landed_body.contains("`/srv/from/plans/P-1/A.md`"));
assert!(landed_body.contains("`/srv/from/plans/P-1/B.md`"));
assert!(landed_body.contains("`/srv/into/board`"));
}
#[tokio::test]
async fn a_dry_run_reports_the_references_it_would_have_rewritten_and_writes_nothing() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": authoring_store(Some((
"root:P-1", "root:A", "root:B",
)))},
"into": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/into/board", Some("root:P-1"))],
"tasks": [
located("A", "Alpha", "/srv/into/board/A.md", Some("root:A")),
located("B", "Beta", "/srv/into/board/B.md", Some("root:B")),
],
}},
}));
let planned = engine
.copy(&CopyRequest {
dry_run: true,
..many(&["from:D-1"], CopyScope::Documents)
})
.await
.expect("the dry run reads everything");
assert_eq!(figures(&planned), (3, 0, 0));
assert_eq!(
planned.items.iter().map(landed).collect::<Vec<_>>(),
[(None, "created".to_owned())]
);
assert_eq!(
engine
.documents(&onetaskgraph_core::DocumentRequest {
sources: vec![name("into")],
filters: onetaskgraph_core::DocumentFilters::default(),
project: onetaskgraph_core::ProjectSelector::Any,
paging: Paging {
limit: NonZeroU32::new(20).expect("a non-zero limit"),
token: None,
},
})
.await
.expect("the destination is configured")
.items
.len(),
0,
"a dry run writes nothing"
);
}
struct Counting {
inner: Box<dyn TaskSource>,
task_pages: Arc<AtomicU32>,
}
#[async_trait::async_trait]
impl TaskSource for Counting {
fn kind(&self) -> &'static str {
self.inner.kind()
}
fn capabilities(&self) -> Capabilities {
self.inner.capabilities()
}
fn writes(&self) -> WriteSupport {
self.inner.writes()
}
async fn health(&self) -> Result<Health, SourceError> {
self.inner.health().await
}
async fn get_task(&self, id: &NativeId) -> Result<Option<Task>, SourceError> {
self.inner.get_task(id).await
}
async fn get_project(&self, id: &NativeId) -> Result<Option<Project>, SourceError> {
self.inner.get_project(id).await
}
async fn get_document(&self, id: &NativeId) -> Result<Option<Document>, SourceError> {
self.inner.get_document(id).await
}
async fn query_tasks(
&self,
query: &TaskQuery,
page: &PageRequest,
) -> Result<Page<Task>, SourceError> {
self.task_pages.fetch_add(1, Ordering::Relaxed);
self.inner.query_tasks(query, page).await
}
async fn query_projects(
&self,
query: &ProjectQuery,
page: &PageRequest,
) -> Result<Page<Project>, SourceError> {
self.inner.query_projects(query, page).await
}
async fn query_documents(
&self,
query: &DocumentQuery,
page: &PageRequest,
) -> Result<Page<Document>, SourceError> {
self.inner.query_documents(query, page).await
}
async fn labels(&self, page: &PageRequest) -> Result<Page<Label>, SourceError> {
self.inner.labels(page).await
}
async fn task_dependencies(
&self,
id: &NativeId,
direction: Direction,
page: &PageRequest,
) -> Result<Page<DependencyEdge>, SourceError> {
self.inner.task_dependencies(id, direction, page).await
}
async fn project_dependencies(
&self,
id: &NativeId,
direction: Direction,
page: &PageRequest,
) -> Result<Page<DependencyEdge>, SourceError> {
self.inner.project_dependencies(id, direction, page).await
}
async fn write_task(&self, write: &ItemWrite<Task>) -> Result<NativeId, SourceError> {
self.inner.write_task(write).await
}
async fn write_project(&self, write: &ItemWrite<Project>) -> Result<NativeId, SourceError> {
self.inner.write_project(write).await
}
async fn write_document(&self, write: &ItemWrite<Document>) -> Result<NativeId, SourceError> {
self.inner.write_document(write).await
}
async fn delete_task(&self, id: &NativeId) -> Result<(), SourceError> {
self.inner.delete_task(id).await
}
async fn delete_project(&self, id: &NativeId) -> Result<(), SourceError> {
self.inner.delete_project(id).await
}
async fn delete_document(&self, id: &NativeId) -> Result<(), SourceError> {
self.inner.delete_document(id).await
}
}
fn into_counting(from: Value, into: Value) -> (Engine, Arc<AtomicU32>) {
let task_pages = Arc::new(AtomicU32::new(0));
let inner = onetaskgraph_in_memory::Plugin
.build(&name("into"), &into, &NoSecrets)
.expect("the in-memory plugin builds");
let counting = Counting {
inner,
task_pages: Arc::clone(&task_pages),
};
let engine = Engine::new(
vec![
in_memory("from", from),
ConfiguredSource::Ready(ResolvedSource::adopt(name("into"), Box::new(counting))),
],
vec![name("from"), name("into")],
);
(engine, task_pages)
}
fn two_documents_of_one_project() -> Value {
let mut store = authoring_store(Some(("root:P-1", "root:A", "root:B")));
let mut second = plan_document(AUTHORED);
second["id"] = json!("D-2");
second["location"] = json!({"path": "/srv/from/plans/P-1/D-2.md"});
store["documents"] = json!([plan_document(AUTHORED), second]);
store
}
fn board_holding_counterparts() -> Value {
json!({
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/into/board", Some("root:P-1"))],
"tasks": [
located("A", "Alpha", "/srv/into/board/A.md", Some("root:A")),
located("B", "Beta", "/srv/into/board/B.md", Some("root:B")),
],
})
}
#[tokio::test]
async fn the_destination_is_walked_once_for_a_whole_invocation_and_not_at_all_for_nothing() {
let (engine, task_pages) =
into_counting(two_documents_of_one_project(), board_holding_counterparts());
let report = engine
.copy(&many(&["from:D-1", "from:D-2"], CopyScope::Documents))
.await
.expect("the document copy runs");
assert_eq!(figures(&report), (6, 0, 0));
assert_eq!(
task_pages.load(Ordering::Relaxed),
1,
"the destination is walked for counterparts once per copy invocation, not once \
per document"
);
let mut quiet = authoring_store(Some(("root:P-1", "root:A", "root:B")));
quiet["documents"] = json!([plan_document("Nothing here names a record.\n")]);
let (engine, task_pages) = into_counting(quiet, board_holding_counterparts());
let report = copy_document(&engine, "from:D-1").await;
assert_eq!(figures(&report), (0, 0, 0));
assert_eq!(
task_pages.load(Ordering::Relaxed),
0,
"a copy that recognises no reference asks the destination for no task page"
);
}
#[tokio::test]
async fn a_task_sharing_the_documents_own_id_is_still_a_referent() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/from/plans/P-1", Some("root:P-1"))],
"tasks": [located("D-1", "Alpha", "/srv/from/plans/P-1/A.md", Some("root:A"))],
"documents": [{
"id": "D-1",
"title": "Design review",
"content": "Alpha is at `/srv/from/plans/P-1/A.md` today.\n",
"project": "P-1",
"labels": [],
"location": {"path": "/srv/from/plans/P-1/D-1.md"},
"metadata": {},
}],
}},
"into": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/into/board", Some("root:P-1"))],
"tasks": [located("A", "Alpha", "/srv/into/board/A.md", Some("root:A"))],
}},
}));
let report = copy_document(&engine, "from:D-1").await;
assert_eq!(figures(&report), (1, 0, 0));
assert_eq!(
body(&engine, "into:D-1").await,
"Alpha is at `/srv/into/board/A.md` today.\n"
);
}
fn linked(id: &str, title: &str, url: &str, origin: Option<&str>) -> Value {
let mut record = located(id, title, "unused", origin);
record["location"] = json!({ "url": url });
record
}
#[tokio::test]
async fn a_reference_reported_as_a_link_is_rewritten_and_not_inside_a_longer_link() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/from/plans/P-1", Some("root:P-1"))],
"tasks": [
linked("A", "Alpha", "https://example.invalid/from/issues/1", Some("root:A")),
linked("B", "Beta", "https://example.invalid/from/issues/12", Some("root:B")),
],
"documents": [plan_document(
"Alpha is `https://example.invalid/from/issues/1` and Beta is \
`https://example.invalid/from/issues/12`.\n",
)],
}},
"into": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/into/board", Some("root:P-1"))],
"tasks": [
linked("A", "Alpha", "https://example.invalid/board/issues/7", Some("root:A")),
linked("B", "Beta", "https://example.invalid/board/issues/8", Some("root:B")),
],
}},
}));
let report = copy_document(&engine, "from:D-1").await;
assert_eq!(figures(&report), (2, 0, 0));
assert_eq!(
body(&engine, "into:D-1").await,
"Alpha is `https://example.invalid/board/issues/7` and Beta is \
`https://example.invalid/board/issues/8`.\n",
"the shorter link is rewritten as itself and never inside the longer one"
);
}
#[tokio::test]
async fn a_location_before_a_full_stop_is_not_recognised_and_comes_through_byte_for_byte() {
let authored = "Alpha is at `/srv/from/plans/P-1/A.md`.\n\n\
The same file, written bare, is at /srv/from/plans/P-1/A.md.\n";
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/from/plans/P-1", Some("root:P-1"))],
"tasks": [located("A", "Alpha", "/srv/from/plans/P-1/A.md", Some("root:A"))],
"documents": [plan_document(authored)],
}},
"into": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/into/board", Some("root:P-1"))],
"tasks": [located("A", "Alpha", "/srv/into/board/A.md", Some("root:A"))],
}},
}));
let report = copy_document(&engine, "from:D-1").await;
assert_eq!(
figures(&report),
(1, 0, 0),
"an unrecognised occurrence is not an unresolved one: the figures report what the \
copy recognised, not a census of what the document holds"
);
assert_eq!(
body(&engine, "into:D-1").await,
"Alpha is at `/srv/into/board/A.md`.\n\n\
The same file, written bare, is at /srv/from/plans/P-1/A.md.\n"
);
}
#[tokio::test]
async fn a_document_naming_another_document_of_its_project_is_rewritten_too() {
let engine = engine_over(json!({
"from": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/from/plans/P-1", Some("root:P-1"))],
"documents": [
plan_document("The runbook is `/srv/from/plans/P-1/D-2.md`.\n"),
{
"id": "D-2",
"title": "Runbook",
"content": "how to read the plan",
"project": "P-1",
"labels": [],
"location": {"path": "/srv/from/plans/P-1/D-2.md"},
"metadata": {GlobalId::ORIGIN_KEY: "root:D-2"},
},
],
}},
"into": {"plugin": "in-memory", "config": {
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/into/board", Some("root:P-1"))],
"documents": [{
"id": "D-2",
"title": "Runbook",
"content": "how to read the plan",
"project": "P-1",
"labels": [],
"location": {"path": "/srv/into/board/D-2.md"},
"metadata": {GlobalId::ORIGIN_KEY: "root:D-2"},
}],
}},
}));
let report = copy_document(&engine, "from:D-1").await;
assert_eq!(figures(&report), (1, 0, 0));
assert_eq!(
body(&engine, "into:D-1").await,
"The runbook is `/srv/into/board/D-2.md`.\n"
);
}
fn naming_every_level() -> Value {
json!({
"capabilities": {"documents": "native"},
"projects": [located_project("/srv/from/plans/P-1", Some("root:P-1"))],
"tasks": [located("A", "Alpha", "/srv/from/plans/P-1/A.md", Some("root:A"))],
"documents": [
plan_document(
"Alpha is `/srv/from/plans/P-1/A.md`, the runbook is \
`/srv/from/plans/P-1/D-2.md`, and it all lives under \
`/srv/from/plans/P-1`.\n",
),
{
"id": "D-2",
"title": "Runbook",
"content": "how to read the plan",
"project": "P-1",
"labels": [],
"location": {"path": "/srv/from/plans/P-1/D-2.md"},
"metadata": {GlobalId::ORIGIN_KEY: "root:D-2"},
},
],
})
}
async fn document_refusal(engine: &Engine) -> String {
refusal(engine, &many(&["from:D-1"], CopyScope::Documents)).await
}
#[tokio::test]
async fn a_destination_that_overruns_its_page_stops_the_walk_for_a_documents_references() {
let (engine, pages) = into_misbehaving_over(
naming_every_level(),
Misbehaving::new(At::Tasks, Fault::OverrunsThePage, Onset::FirstRead).with_documents(),
);
let refused = document_refusal(&engine).await;
assert!(
refused.contains("the source returned 3 rows for a page of at most 2"),
"{refused}"
);
assert!(pages.load(Ordering::Relaxed) <= 3, "the walk stopped early");
}
#[tokio::test]
async fn a_destination_that_repeats_its_cursor_stops_the_walk_for_a_documents_references() {
let (engine, pages) = into_misbehaving_over(
naming_every_level(),
Misbehaving::new(At::Tasks, Fault::RepeatsTheCursor, Onset::FirstRead).with_documents(),
);
let refused = document_refusal(&engine).await;
assert!(
refused.contains("the destination was being walked for the records a document's"),
"the refusal names what the walk was doing: {refused}"
);
assert!(pages.load(Ordering::Relaxed) <= 4, "the walk stopped early");
}
#[tokio::test]
async fn a_destination_that_overruns_its_page_stops_that_walk_at_the_project_level() {
let (engine, pages) = into_misbehaving_over(
naming_every_level(),
Misbehaving::new(At::Projects, Fault::OverrunsThePage, Onset::FirstRead).with_documents(),
);
let refused = document_refusal(&engine).await;
assert!(
refused.contains("the source returned 3 rows for a page of at most 2"),
"{refused}"
);
assert!(pages.load(Ordering::Relaxed) <= 3, "the walk stopped early");
}
#[tokio::test]
async fn a_destination_that_overruns_its_page_stops_that_walk_at_the_document_level() {
let (engine, pages) = into_misbehaving_over(
naming_every_level(),
Misbehaving::new(At::Documents, Fault::OverrunsThePage, Onset::SecondRead).with_documents(),
);
let refused = document_refusal(&engine).await;
assert!(
refused.contains("the source returned 3 rows for a page of at most 2"),
"{refused}"
);
assert!(pages.load(Ordering::Relaxed) <= 4, "the walk stopped early");
}
#[tokio::test]
async fn a_source_that_overruns_its_page_stops_the_read_of_a_documents_own_project_members() {
let (engine, pages) = from_misbehaving_documentary(
Misbehaving::new(At::Tasks, Fault::OverrunsThePage, Onset::FirstRead)
.holding_a_project()
.with_documents(),
);
let refused = refusal(&engine, &many(&["from:D-1"], CopyScope::Documents)).await;
assert!(refused.contains("rows for a page of at most"), "{refused}");
assert!(pages.load(Ordering::Relaxed) <= 2, "the walk stopped early");
}
#[tokio::test]
async fn a_source_that_overruns_its_page_stops_the_read_of_a_documents_own_project_documents() {
let (engine, pages) = from_misbehaving_documentary(
Misbehaving::new(At::Documents, Fault::OverrunsThePage, Onset::FirstRead)
.holding_a_project()
.with_documents(),
);
let refused = refusal(&engine, &many(&["from:D-1"], CopyScope::Documents)).await;
assert!(refused.contains("rows for a page of at most"), "{refused}");
assert!(pages.load(Ordering::Relaxed) <= 3, "the walk stopped early");
}
#[tokio::test]
async fn the_reference_figures_are_absent_when_zero_and_read_back_as_zero_when_absent() {
let engine = pair();
let quiet = engine.copy(&one("from:T-1")).await.expect("the copy runs");
let emitted = serde_json::to_value(&quiet).expect("a copy report serialises");
let keys: Vec<&String> = emitted
.as_object()
.expect("a report is an object")
.keys()
.collect();
assert_eq!(keys, ["items"], "a figure of zero is absent, not nought");
let older: onetaskgraph_core::CopyReport = serde_json::from_value(json!({
"items": [{"source": "from:T-1", "action": "created", "destination": "into:T-1"}]
}))
.expect("a report without the figures still reads");
assert_eq!(figures(&older), (0, 0, 0));
let reported = onetaskgraph_core::CopyReport {
items: Vec::new(),
references_rewritten: 3,
references_unresolved: 2,
references_ambiguous: 1,
};
let wire = serde_json::to_value(&reported).expect("a copy report serialises");
assert_eq!(
wire,
json!({
"items": [],
"references_rewritten": 3,
"references_unresolved": 2,
"references_ambiguous": 1,
})
);
let back: onetaskgraph_core::CopyReport = serde_json::from_value(wire).expect("it reads back");
assert_eq!(back, reported);
assert!(back.references_ambiguous <= back.references_unresolved);
let partial = onetaskgraph_core::CopyReport {
items: Vec::new(),
references_rewritten: 2,
references_unresolved: 0,
references_ambiguous: 0,
};
assert_eq!(
serde_json::to_value(&partial).expect("it serialises"),
json!({"items": [], "references_rewritten": 2})
);
}
#[tokio::test]
async fn a_destination_whose_cursors_cycle_stops_the_walk_for_a_documents_references() {
let (engine, pages) = into_misbehaving_over(
naming_every_level(),
Misbehaving::new(At::Tasks, Fault::CyclesItsCursors, Onset::FirstRead).with_documents(),
);
let refused = document_refusal(&engine).await;
assert!(
refused.contains("returned a cursor it had already been given"),
"the refusal says the walk would never end: {refused}"
);
assert!(
refused.contains("a document's references name"),
"and names what it was walking for: {refused}"
);
assert!(
pages.load(Ordering::Relaxed) <= 6,
"the walk stopped early rather than cycling"
);
}