use std::collections::BTreeMap;
use std::path::Path;
use onetaskgraph_plugin_api::{
AssetWrite, Capabilities, Comment, CommentBody, Direction, Document, DocumentQuery, ItemWrite,
MetadataKey, Metering, NativeId, NewComment, Page, PageRequest, Priority, Project,
ProjectQuery, SourceError, Status, StatusCategory, Task, TaskQuery, TaskRef, TaskUpdate,
TaskUpdateOutcome, WriteSupport,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub(crate) const PROTOCOL_VERSION: u32 = 2;
pub(crate) const STATUS_VOCABULARY: [StatusCategory; 8] = [
StatusCategory::Draft,
StatusCategory::Backlog,
StatusCategory::Todo,
StatusCategory::Queued,
StatusCategory::InProgress,
StatusCategory::Done,
StatusCategory::Cancelled,
StatusCategory::Unknown,
];
pub(crate) const fn after_the_first_vocabulary(category: StatusCategory) -> bool {
match category {
StatusCategory::Queued => true,
StatusCategory::Draft
| StatusCategory::Backlog
| StatusCategory::Todo
| StatusCategory::InProgress
| StatusCategory::Done
| StatusCategory::Cancelled
| StatusCategory::Unknown => false,
}
}
pub(crate) fn spelled(category: StatusCategory) -> String {
serde_json::to_value(category)
.ok()
.and_then(|word| word.as_str().map(str::to_owned))
.expect("a status category serialises as its own word")
}
pub(crate) fn vocabulary() -> Vec<String> {
STATUS_VOCABULARY.into_iter().map(spelled).collect()
}
pub(crate) fn knows_every_category(statuses: Option<&[String]>) -> bool {
let Some(listed) = statuses else {
return false;
};
STATUS_VOCABULARY
.into_iter()
.filter(|category| after_the_first_vocabulary(*category))
.all(|category| listed.contains(&spelled(category)))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Request {
pub(crate) id: String,
pub(crate) method: String,
pub(crate) params: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Response {
pub(crate) id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) result: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) error: Option<SourceError>,
}
impl Response {
pub(crate) fn ok(id: String, result: Value) -> Self {
Self {
id,
result: Some(result),
error: None,
}
}
pub(crate) fn failed(id: String, error: SourceError) -> Self {
Self {
id,
result: None,
error: Some(error),
}
}
pub(crate) fn outcome(self) -> Option<Result<Value, SourceError>> {
match (self.result, self.error) {
(Some(result), None) => Some(Ok(result)),
(None, Some(error)) => Some(Err(error)),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct InitializeParams {
pub(crate) protocol_version: u32,
pub(crate) engine: EngineIdentity,
pub(crate) source_name: String,
pub(crate) config: Value,
pub(crate) secrets: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) statuses: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) document_dir: Option<DocumentDir>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub(crate) struct DocumentDir(String);
impl DocumentDir {
pub(crate) fn new(directory: &Path) -> Result<Self, String> {
let spelled = directory.to_str().ok_or_else(|| {
format!(
"the document directory {} is not valid UTF-8, so it cannot be written into \
the handshake",
directory.display()
)
})?;
if !directory.is_absolute() {
return Err(format!(
"the document directory {spelled:?} is not an absolute path, so there is no \
one directory to measure this source's relative paths from"
));
}
Ok(Self(spelled.to_owned()))
}
pub(crate) fn as_path(&self) -> &Path {
Path::new(&self.0)
}
}
impl TryFrom<String> for DocumentDir {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(Path::new(&value))
}
}
impl From<DocumentDir> for String {
fn from(value: DocumentDir) -> Self {
value.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct EngineIdentity {
pub(crate) name: String,
pub(crate) version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct InitializeResult {
#[serde(default)]
pub(crate) protocol_version: Option<u32>,
pub(crate) kind: HandshakePluginKind,
pub(crate) capabilities: Capabilities,
#[serde(default)]
pub(crate) writes: Option<WriteSupport>,
#[serde(default)]
pub(crate) meters: bool,
#[serde(default)]
pub(crate) statuses: Option<Vec<String>>,
#[serde(default)]
pub(crate) task_updates: bool,
#[serde(default)]
pub(crate) metadata_updates: bool,
#[serde(default)]
pub(crate) content_updates: bool,
#[serde(default)]
pub(crate) targeted_updates: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub(crate) ends_commands: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct MeteringResult {
#[serde(default)]
pub(crate) metering: Option<Metering>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub(crate) struct HandshakePluginKind(String);
impl HandshakePluginKind {
pub(crate) fn new(kind: impl Into<String>) -> Result<Self, &'static str> {
let kind = kind.into();
if kind.trim().is_empty() {
Err("plugin kind must contain a non-whitespace character")
} else {
Ok(Self(kind))
}
}
pub(crate) fn into_string(self) -> String {
self.0
}
}
impl TryFrom<String> for HandshakePluginKind {
type Error = &'static str;
fn try_from(kind: String) -> Result<Self, Self::Error> {
Self::new(kind)
}
}
impl From<HandshakePluginKind> for String {
fn from(kind: HandshakePluginKind) -> Self {
kind.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct IdParams {
pub(crate) id: NativeId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct TaskResult {
#[serde(default)]
pub(crate) task: Option<Task>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ProjectResult {
#[serde(default)]
pub(crate) project: Option<Project>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DocumentResult {
#[serde(default)]
pub(crate) document: Option<Document>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct TaskQueryParams {
pub(crate) query: TaskQuery,
pub(crate) page: PageRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ProjectQueryParams {
pub(crate) query: ProjectQuery,
pub(crate) page: PageRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct LabelParams {
pub(crate) page: PageRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DocumentQueryParams {
pub(crate) query: DocumentQuery,
pub(crate) page: PageRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DependencyParams {
pub(crate) id: NativeId,
pub(crate) direction: Direction,
pub(crate) page: PageRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct TaskWriteParams {
pub(crate) write: ItemWrite<Task>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct TaskAssetWriteParams {
pub(crate) write: ItemWrite<Task>,
#[serde(flatten)]
pub(crate) assets: AssetWrite,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DocumentAssetWriteParams {
pub(crate) write: ItemWrite<Document>,
#[serde(flatten)]
pub(crate) assets: AssetWrite,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(
try_from = "RawServedWrite<T>",
bound(deserialize = "T: Deserialize<'de>")
)]
pub(crate) struct ServedWriteParams<T> {
pub(crate) write: ItemWrite<T>,
pub(crate) assets: Option<AssetWrite>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(bound(deserialize = "T: Deserialize<'de>"))]
pub(crate) struct RawServedWrite<T> {
write: ItemWrite<T>,
#[serde(default, deserialize_with = "not_null")]
assets: Option<Vec<onetaskgraph_plugin_api::AssetPayload>>,
#[serde(default, deserialize_with = "not_null")]
recorded_assets: Option<onetaskgraph_plugin_api::AssetUploads>,
}
fn not_null<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
T::deserialize(deserializer).map(Some)
}
impl<T> TryFrom<RawServedWrite<T>> for ServedWriteParams<T> {
type Error = String;
fn try_from(raw: RawServedWrite<T>) -> Result<Self, Self::Error> {
let assets = match (raw.assets, raw.recorded_assets) {
(None, Some(_)) => {
return Err(
"a write carrying `recorded_assets` names its assets in `assets` \
(docs/plugin-protocol.md §4.9a)"
.to_owned(),
);
}
(None, None) => None,
(Some(assets), recorded_assets) => Some(AssetWrite {
assets,
recorded_assets,
}),
};
Ok(Self {
write: raw.write,
assets,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ProjectWriteParams {
pub(crate) write: ItemWrite<Project>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DocumentWriteParams {
pub(crate) write: ItemWrite<Document>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DeleteParams {
pub(crate) id: NativeId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct CommentsParams {
pub(crate) task: NativeId,
pub(crate) page: PageRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct CommentsResult {
#[serde(default)]
pub(crate) page: Option<Page<Comment>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct AddCommentParams {
pub(crate) task: NativeId,
pub(crate) comment: NewComment,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct EditCommentParams {
pub(crate) task: NativeId,
pub(crate) comment: NativeId,
pub(crate) body: CommentBody,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DeleteCommentParams {
pub(crate) task: NativeId,
pub(crate) comment: NativeId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct CommentResult {
#[serde(default)]
pub(crate) comment: Option<Comment>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DeletedCommentResult {
#[serde(default)]
pub(crate) deleted: Option<NativeId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct StatusParams {
pub(crate) id: NativeId,
pub(crate) category: StatusCategory,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct StatusResult {
#[serde(default)]
pub(crate) status: Option<Status>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PriorityParams {
pub(crate) id: NativeId,
pub(crate) priority: Priority,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PriorityResult {
#[serde(deserialize_with = "present")]
pub(crate) priority: Option<Priority>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ContentParams {
pub(crate) id: NativeId,
pub(crate) content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ContentResult {
#[serde(deserialize_with = "present")]
pub(crate) id: Option<NativeId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct UpdateParams {
pub(crate) id: NativeId,
pub(crate) update: TaskUpdate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct UpdateResult {
#[serde(deserialize_with = "present")]
pub(crate) outcome: Option<TaskUpdateOutcome>,
}
fn present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<T>::deserialize(deserializer)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DeliveredByParams {
pub(crate) id: NativeId,
pub(crate) delivered_by: Vec<TaskRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DeliveredByResult {
#[serde(default)]
pub(crate) delivered_by: Option<Vec<TaskRef>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "UncheckedMetadataParams")]
pub(crate) struct MetadataParams {
id: NativeId,
key: MetadataKey,
value: Value,
}
impl MetadataParams {
pub(crate) fn new(id: NativeId, key: MetadataKey, value: Value) -> Result<Self, String> {
if key.is_copies()
&& let Some(malformed) = crate::engine::malformed_links(&value)
{
return Err(malformed);
}
Ok(Self { id, key, value })
}
pub(crate) fn id(&self) -> &NativeId {
&self.id
}
pub(crate) fn key(&self) -> &MetadataKey {
&self.key
}
pub(crate) fn value(&self) -> &Value {
&self.value
}
}
#[derive(Deserialize)]
struct UncheckedMetadataParams {
id: NativeId,
#[serde(deserialize_with = "caller_or_copies")]
key: MetadataKey,
value: Value,
}
impl TryFrom<UncheckedMetadataParams> for MetadataParams {
type Error = String;
fn try_from(params: UncheckedMetadataParams) -> Result<Self, Self::Error> {
Self::new(params.id, params.key, params.value)
}
}
fn caller_or_copies<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<MetadataKey, D::Error> {
let key = String::deserialize(deserializer)?;
if key == MetadataKey::COPIES_KEY {
return Ok(MetadataKey::copies());
}
MetadataKey::new(key).map_err(serde::de::Error::custom)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WriteResult {
pub(crate) id: NativeId,
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use onetaskgraph_plugin_api::{
AssetName, AssetPayload, AssetUpload, AssetUploads, AssetWrite, AssetsWritten, Document,
ItemWrite, NativeId,
};
use serde_json::{Value, json};
use super::{DocumentAssetWriteParams, TaskAssetWriteParams};
fn members(value: &Value) -> BTreeSet<String> {
value
.as_object()
.expect("an object")
.keys()
.cloned()
.collect()
}
fn declared(root: &str) -> BTreeSet<String> {
members(&crate::schema_bundle()["roots"][root]["properties"])
}
fn whole() -> AssetWrite {
let name = AssetName::new("before.png").expect("a name");
AssetWrite {
assets: vec![AssetPayload::of(name.clone(), vec![1, 2, 3])],
recorded_assets: Some(AssetUploads(BTreeMap::from([(
name,
AssetUpload {
sha256: "00".to_owned(),
url: "https://example.invalid/before.png".to_owned(),
},
)]))),
}
}
#[test]
fn a_writes_asset_members_are_the_trait_arguments_and_the_emitted_schemas() {
let document: Document = serde_json::from_value(json!({
"id": "D-1", "title": "Design", "content": "", "labels": []
}))
.expect("a document");
let wire = serde_json::to_value(DocumentAssetWriteParams {
write: ItemWrite {
target: Some(NativeId::from("D-1")),
item: document,
depends_on: Vec::new(),
},
assets: whole(),
})
.expect("serializes");
let mut beside_the_write = members(&wire);
assert!(beside_the_write.remove("write"));
assert_eq!(
beside_the_write,
members(&serde_json::to_value(whole()).unwrap())
);
assert_eq!(beside_the_write, declared("AssetWrite"));
assert_eq!(members(&wire["assets"][0]), declared("AssetPayload"));
assert_eq!(
members(&wire["recorded_assets"]["before.png"]),
declared("AssetUpload")
);
let answered = serde_json::to_value(AssetsWritten {
id: NativeId::from("D-1"),
content: Some("".to_owned()),
})
.unwrap();
assert_eq!(members(&answered), declared("AssetsWritten"));
let task = serde_json::to_value(TaskAssetWriteParams {
write: ItemWrite {
target: None,
item: serde_json::from_value(json!({
"id": "T-1", "title": "Alpha", "content": null,
"status": {"category": "todo", "name": "Todo"}, "labels": []
}))
.expect("a task"),
depends_on: Vec::new(),
},
assets: whole(),
})
.unwrap();
let mut task_members = members(&task);
assert!(task_members.remove("write"));
assert_eq!(task_members, beside_the_write);
for sent in [wire, task] {
let served: super::ServedWriteParams<Value> =
serde_json::from_value(sent).expect("the host reads the write");
assert_eq!(served.assets, Some(whole()));
}
let plain: super::ServedWriteParams<Value> = serde_json::from_value(json!({
"write": {"target": null, "item": {}, "depends_on": []}
}))
.expect("the host reads a plain write");
assert!(plain.assets.is_none());
for null in ["assets", "recorded_assets"] {
let mut sent = json!({"write": {"target": null, "item": {}, "depends_on": []}});
sent[null] = Value::Null;
assert!(
serde_json::from_value::<super::ServedWriteParams<Value>>(sent).is_err(),
"{null}: null is refused rather than read as absent"
);
}
let bundle = crate::schema_bundle();
let bytes = &bundle["roots"]["AssetPayload"]["properties"]["bytes"];
assert_eq!(bytes["type"], json!("string"), "{bytes}");
let recorded = bundle["roots"]["AssetWrite"]["properties"]["recorded_assets"].to_string();
assert!(!recorded.contains("null"), "{recorded}");
let mut payload = serde_json::to_value(&whole().assets[0]).unwrap();
payload["bytes"] = Value::Null;
assert!(serde_json::from_value::<AssetPayload>(payload).is_err());
assert!(
serde_json::from_value::<AssetWrite>(json!({"assets": [], "recorded_assets": null}))
.is_err()
);
let orphaned = serde_json::from_value::<super::ServedWriteParams<Value>>(json!({
"write": {"target": null, "item": {}, "depends_on": []},
"recorded_assets": {}
}))
.expect_err("recorded uploads without assets are refused");
assert!(
orphaned.to_string().contains("recorded_assets"),
"{orphaned}"
);
}
}