use std::collections::BTreeMap;
use std::io::{BufRead, Write};
use onetaskgraph_plugin_api::{
Page, Project, SecretResolver, SourceError, SourceName, Status, StatusCategory, Task,
TaskSource,
};
use secrecy::SecretString;
use serde::Deserialize;
use serde_json::{Value, json};
use super::connection::{Line, MAX_LINE, read_line};
use super::wire::{
AddCommentParams, CommentsParams, ContentParams, DeleteCommentParams, DeleteParams,
DeliveredByParams, DependencyParams, DocumentDir, DocumentQueryParams, DocumentWriteParams,
EditCommentParams, HandshakePluginKind, IdParams, InitializeParams, InitializeResult,
LabelParams, MetadataParams, PROTOCOL_VERSION, PriorityParams, ProjectQueryParams,
ProjectWriteParams, Request, Response, StatusParams, TaskQueryParams, TaskWriteParams,
after_the_first_vocabulary, knows_every_category, vocabulary,
};
use crate::config::rebased;
use crate::registry::PluginKind;
#[derive(Debug, Clone, Deserialize)]
struct HostedSettings {
kind: PluginKind,
#[serde(default)]
config: Value,
}
struct Hosted {
source: Box<dyn TaskSource>,
engine_knows_every_category: bool,
}
pub async fn serve(input: impl BufRead, output: impl Write) -> std::io::Result<()> {
serve_kind(input, output, None).await
}
pub async fn serve_plugin(
input: impl BufRead,
output: impl Write,
kind: PluginKind,
) -> std::io::Result<()> {
serve_kind(input, output, Some(kind)).await
}
async fn serve_kind(
mut input: impl BufRead,
mut output: impl Write,
kind: Option<PluginKind>,
) -> std::io::Result<()> {
let mut source: Option<Hosted> = None;
loop {
let line = match read_line(&mut input) {
Line::Read(line) => line,
Line::Ended => return Ok(()),
Line::Failed(error) => return Err(error),
Line::TooLong => {
eprintln!(
"onetaskgraph-source: a request ran past {MAX_LINE} bytes without \
ending its line; closing the connection"
);
return Ok(());
}
};
if line.trim().is_empty() {
continue;
}
let Some(id) = addressed(&line) else {
eprintln!("onetaskgraph-source: ignoring a line with no request id: {line}");
continue;
};
let response = match serde_json::from_str::<Request>(&line) {
Ok(request) => answer(&mut source, request, kind).await,
Err(error) => Response::failed(
id,
SourceError::Malformed {
message: format!("that is not a request envelope: {error}"),
},
),
};
let finished = ended_the_connection(&response);
writeln!(
output,
"{}",
serde_json::to_string(&response).expect("a response is plain data")
)?;
output.flush()?;
if finished {
return Ok(());
}
}
}
fn addressed(line: &str) -> Option<String> {
serde_json::from_str::<Value>(line)
.ok()?
.get("id")?
.as_str()
.map(str::to_owned)
}
fn ended_the_connection(response: &Response) -> bool {
matches!(
response.error.as_ref(),
Some(SourceError::Config { message }) if message.starts_with(VERSION_REFUSAL)
)
}
const VERSION_REFUSAL: &str = "protocol version ";
async fn answer(
source: &mut Option<Hosted>,
request: Request,
kind: Option<PluginKind>,
) -> Response {
let Request { id, method, params } = request;
if method == "initialize" {
return match source {
Some(_) => Response::failed(
id,
SourceError::Malformed {
message: "this connection was already initialized".to_owned(),
},
),
None => initialize(source, id, params, kind).await,
};
}
let Some(built) = source.as_ref() else {
return Response::failed(
id,
SourceError::Malformed {
message: format!("{method} arrived before the handshake"),
},
);
};
match dispatch(
built.source.as_ref(),
built.engine_knows_every_category,
&method,
params,
)
.await
{
Ok(result) => Response::ok(id, result),
Err(error) => Response::failed(id, error),
}
}
async fn initialize(
source: &mut Option<Hosted>,
id: String,
params: Value,
kind: Option<PluginKind>,
) -> Response {
let params: InitializeParams = match serde_json::from_value(params) {
Ok(params) => params,
Err(error) => {
return Response::failed(
id,
SourceError::Config {
message: format!("that is not an initialize request: {error}"),
},
);
}
};
if params.protocol_version != PROTOCOL_VERSION {
return Response::failed(
id,
SourceError::Config {
message: format!(
"{VERSION_REFUSAL}{} is not supported by this plugin; it speaks \
version {PROTOCOL_VERSION}",
params.protocol_version
),
},
);
}
match build(¶ms, kind) {
Ok(built) => {
let kind = match HandshakePluginKind::new(built.kind()) {
Ok(kind) => kind,
Err(error) => {
return Response::failed(
id,
SourceError::Malformed {
message: format!("the hosted plugin reported an invalid kind: {error}"),
},
);
}
};
let result = InitializeResult {
protocol_version: Some(PROTOCOL_VERSION),
kind,
capabilities: built.capabilities(),
writes: Some(built.writes()),
meters: matches!(built.metering().await, Ok(Some(_))),
statuses: Some(vocabulary()),
task_updates: true,
metadata_updates: true,
content_updates: true,
};
*source = Some(Hosted {
source: built,
engine_knows_every_category: knows_every_category(params.statuses.as_deref()),
});
Response::ok(
id,
serde_json::to_value(&result).expect("a result is plain data"),
)
}
Err(error) => Response::failed(id, error),
}
}
fn build(
params: &InitializeParams,
selected: Option<PluginKind>,
) -> Result<Box<dyn TaskSource>, SourceError> {
let (kind, config) = match selected {
Some(kind) => (kind, ¶ms.config),
None => {
let settings: HostedSettings = serde_json::from_value(params.config.clone()).map_err(
|error| SourceError::Config {
message: format!(
"this host serves a plugin of this build, and its settings must name one \
as {{\"kind\": …, \"config\": …}}: {error}"
),
},
)?;
return build_plugin(params, settings.kind, &settings.config);
}
};
build_plugin(params, kind, config)
}
fn build_plugin(
params: &InitializeParams,
kind: PluginKind,
config: &Value,
) -> Result<Box<dyn TaskSource>, SourceError> {
let name = SourceName::new(params.source_name.clone())?;
let plugin = kind.plugin();
let config = measured_from_document(
params.document_dir.as_ref(),
plugin.document_relative_paths(),
config,
);
plugin.build(&name, &config, &Handshake(¶ms.secrets))
}
fn measured_from_document(
document_dir: Option<&DocumentDir>,
fields: &[&str],
config: &Value,
) -> Value {
let mut config = config.clone();
let Some(directory) = document_dir.map(DocumentDir::as_path) else {
return config;
};
for field in fields {
let Some(value) = field
.split('.')
.try_fold(&mut config, |cursor, segment| cursor.get_mut(segment))
else {
continue;
};
let Some(rebased) = value.as_str().and_then(|raw| rebased(directory, raw)) else {
continue;
};
let rebased = rebased
.into_os_string()
.into_string()
.expect("a path joined from two strings is a string");
*value = Value::String(rebased);
}
config
}
struct Handshake<'a>(&'a BTreeMap<String, String>);
impl SecretResolver for Handshake<'_> {
fn get(&self, var: &str) -> Option<SecretString> {
self.0
.get(var)
.map(|value| SecretString::from(value.clone()))
}
}
fn told(status: Status, known: bool) -> Status {
if known || !after_the_first_vocabulary(status.category) {
return status;
}
Status {
category: StatusCategory::Unknown,
name: status.name,
}
}
fn told_task(task: Task, known: bool) -> Task {
Task {
status: told(task.status.clone(), known),
..task
}
}
fn told_project(project: Project, known: bool) -> Project {
Project {
status: told(project.status.clone(), known),
..project
}
}
async fn dispatch(
source: &dyn TaskSource,
known: bool,
method: &str,
params: Value,
) -> Result<Value, SourceError> {
match method {
"health" => encode(source.health().await?),
"get_task" => {
let params: IdParams = decode(method, params)?;
let task = source.get_task(¶ms.id).await?;
encode(json!({ "task": task.map(|task| told_task(task, known)) }))
}
"get_project" => {
let params: IdParams = decode(method, params)?;
let project = source.get_project(¶ms.id).await?;
encode(json!({ "project": project.map(|project| told_project(project, known)) }))
}
"query_tasks" => {
let params: TaskQueryParams = decode(method, params)?;
let page = source.query_tasks(¶ms.query, ¶ms.page).await?;
encode(Page {
items: page
.items
.into_iter()
.map(|task| told_task(task, known))
.collect(),
next: page.next,
})
}
"query_projects" => {
let params: ProjectQueryParams = decode(method, params)?;
let page = source.query_projects(¶ms.query, ¶ms.page).await?;
encode(Page {
items: page
.items
.into_iter()
.map(|project| told_project(project, known))
.collect(),
next: page.next,
})
}
"set_task_status" => {
let params: StatusParams = decode(method, params)?;
let status = source.set_task_status(¶ms.id, params.category).await?;
encode(json!({ "status": status.map(|status| told(status, known)) }))
}
"set_task_priority" => {
let params: PriorityParams = decode(method, params)?;
let priority = source
.set_task_priority(¶ms.id, params.priority)
.await?;
encode(json!({ "priority": priority }))
}
"set_task_content" => {
let params: ContentParams = decode(method, params)?;
let written = source.set_task_content(¶ms.id, ¶ms.content).await?;
encode(json!({ "id": written.map(|()| params.id) }))
}
"set_task_metadata" => {
let params: MetadataParams = decode(method, params)?;
let task = source
.set_task_metadata(¶ms.id, ¶ms.key, ¶ms.value)
.await?;
encode(json!({ "task": task.map(|task| told_task(task, known)) }))
}
"set_project_metadata" => {
let params: MetadataParams = decode(method, params)?;
let project = source
.set_project_metadata(¶ms.id, ¶ms.key, ¶ms.value)
.await?;
encode(json!({ "project": project.map(|project| told_project(project, known)) }))
}
"set_document_metadata" => {
let params: MetadataParams = decode(method, params)?;
let document = source
.set_document_metadata(¶ms.id, ¶ms.key, ¶ms.value)
.await?;
encode(json!({ "document": document }))
}
"set_delivered_by" => {
let params: DeliveredByParams = decode(method, params)?;
let held = source
.set_delivered_by(¶ms.id, ¶ms.delivered_by)
.await?;
encode(json!({ "delivered_by": held.map(|()| params.delivered_by) }))
}
"labels" => {
let params: LabelParams = decode(method, params)?;
encode(source.labels(¶ms.page).await?)
}
"task_dependencies" => {
let params: DependencyParams = decode(method, params)?;
encode(
source
.task_dependencies(¶ms.id, params.direction, ¶ms.page)
.await?,
)
}
"project_dependencies" => {
let params: DependencyParams = decode(method, params)?;
encode(
source
.project_dependencies(¶ms.id, params.direction, ¶ms.page)
.await?,
)
}
"write_task" => {
let params: TaskWriteParams = decode(method, params)?;
encode(json!({ "id": source.write_task(¶ms.write).await? }))
}
"write_project" => {
let params: ProjectWriteParams = decode(method, params)?;
encode(json!({ "id": source.write_project(¶ms.write).await? }))
}
"delete_task" => {
let params: DeleteParams = decode(method, params)?;
source.delete_task(¶ms.id).await?;
encode(json!({}))
}
"delete_project" => {
let params: DeleteParams = decode(method, params)?;
source.delete_project(¶ms.id).await?;
encode(json!({}))
}
"get_document" => {
let params: IdParams = decode(method, params)?;
encode(json!({ "document": source.get_document(¶ms.id).await? }))
}
"query_documents" => {
let params: DocumentQueryParams = decode(method, params)?;
encode(source.query_documents(¶ms.query, ¶ms.page).await?)
}
"write_document" => {
let params: DocumentWriteParams = decode(method, params)?;
encode(json!({ "id": source.write_document(¶ms.write).await? }))
}
"delete_document" => {
let params: DeleteParams = decode(method, params)?;
source.delete_document(¶ms.id).await?;
encode(json!({}))
}
"task_comments" => {
let params: CommentsParams = decode(method, params)?;
encode(json!({ "page": source.task_comments(¶ms.task, ¶ms.page).await? }))
}
"add_comment" => {
let params: AddCommentParams = decode(method, params)?;
encode(json!({ "comment": source.add_comment(¶ms.task, ¶ms.comment).await? }))
}
"edit_comment" => {
let params: EditCommentParams = decode(method, params)?;
encode(json!({
"comment": source
.edit_comment(¶ms.task, ¶ms.comment, ¶ms.body)
.await?
}))
}
"delete_comment" => {
let params: DeleteCommentParams = decode(method, params)?;
encode(json!({
"deleted": source.delete_comment(¶ms.task, ¶ms.comment).await?
}))
}
"metering" => encode(json!({ "metering": source.metering().await? })),
other => Err(SourceError::Malformed {
message: format!("protocol version {PROTOCOL_VERSION} has no method called {other:?}"),
}),
}
}
fn decode<T: for<'de> Deserialize<'de>>(method: &str, params: Value) -> Result<T, SourceError> {
serde_json::from_value(params).map_err(|error| SourceError::Malformed {
message: format!("the parameters of {method} are not the shape it takes: {error}"),
})
}
fn encode<T: serde::Serialize>(value: T) -> Result<Value, SourceError> {
serde_json::to_value(value).map_err(|error| SourceError::Malformed {
message: format!("this source returned data that will not serialize: {error}"),
})
}