mod cli;
mod render;
use std::io::{self, Write};
use std::process::ExitCode;
use std::str::FromStr as _;
use clap::{CommandFactory as _, Parser};
use onetaskgraph_core::config::{self, Layer};
use onetaskgraph_core::{
CopyItems, CopyRequest, CopyScope, DependencyRequest, DocumentFilters, DocumentRequest, Engine,
Environment, Failure, FailureDocument, Filters, GlobalId, LabelRequest, Loaded, MatchBy,
OutputFormat, PageToken, Paging, ProjectRequest, ProjectSelector, QueryResponse, SearchRequest,
SourceFailure, TaskRequest,
};
use onetaskgraph_plugin_api::{
CommentBody, LabelFilter, NativeId, NewComment, SourceName, TextQuery,
};
use serde::Serialize;
use crate::cli::{
Cli, Command, CommentCommand, ConfigCommand, CopyArgs, DependencyArgs, DocumentCommand,
DocumentFilterArgs, FilterArgs, LabelCommand, PageArgs, ProjectCommand, SelectionArgs,
ShowArgs, SourcesCommand, TaskCommand,
};
const EXIT_OK: u8 = 0;
const EXIT_FAILURE: u8 = 1;
const EXIT_USAGE: u8 = 2;
const EXIT_PARTIAL: u8 = 4;
#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
let cli = Cli::parse();
if let Command::PluginServe { source } = &cli.command {
let input = io::BufReader::new(io::stdin().lock());
return match onetaskgraph_core::serve_plugin(input, io::stdout().lock(), *source).await {
Ok(()) => ExitCode::SUCCESS,
Err(error) => fail(&error.to_string(), EXIT_FAILURE),
};
}
let flags = match cli.overrides.layer() {
Ok(flags) => flags,
Err(message) => return fail(&message, EXIT_USAGE),
};
let environment = Environment::from_process();
let loaded = match load(&flags, &environment) {
Ok(loaded) => loaded,
Err(failure) => {
let asked = config::requested_output(
std::env::current_dir().ok().as_deref(),
&environment,
&flags,
);
return failed(&failure, asked);
}
};
match run(&cli.command, &loaded, &mut io::stdout().lock()).await {
Ok(code) => ExitCode::from(code),
Err(failure) => failed(&failure, loaded.config.output()),
}
}
fn fail(message: &str, code: u8) -> ExitCode {
eprintln!("onetaskgraph: {message}");
ExitCode::from(code)
}
fn failed(failure: &Failure, output: OutputFormat) -> ExitCode {
let code = fail(failure.message(), EXIT_FAILURE);
if output == OutputFormat::Json {
let document = FailureDocument {
failure: failure.clone(),
};
if let Ok(rendered) = json(&document, "the failure") {
let _ = emit(&mut io::stdout().lock(), &rendered, "the failure");
}
}
code
}
async fn run(command: &Command, loaded: &Loaded, out: &mut impl Write) -> Result<u8, Failure> {
match command {
Command::PluginServe { .. } => unreachable!("plugin serving is dispatched before config"),
Command::Schema => {
emit(out, schema_bundle()?.trim_end(), "the schema bundle")?;
Ok(EXIT_OK)
}
Command::Config {
command: ConfigCommand::Show,
} => {
emit(
out,
effective_config(loaded)?.trim_end(),
"the configuration",
)?;
Ok(EXIT_OK)
}
Command::Sources {
command: SourcesCommand::List,
} => {
let listings = engine(loaded).listing();
let rendered = match loaded.config.output() {
OutputFormat::Text => render::sources(&listings),
OutputFormat::Json => json(&listings, "the sources")?,
};
emit(out, rendered.trim_end(), "the sources")?;
Ok(EXIT_OK)
}
Command::Task {
command: TaskCommand::List(args),
} => {
let engine = engine(loaded);
let request = TaskRequest {
sources: selection(&args.selection)?,
filters: filters(&args.filters)?,
project: selector(&engine, args.project.as_deref(), args.no_project),
paging: paging(loaded, &args.paging)?,
};
let response = engine
.tasks(&request)
.await
.map_err(|error| Failure::from(&error))?;
respond(out, loaded, response, render::tasks, &args.paging, "tasks")
}
Command::Task {
command: TaskCommand::Show(args),
} => {
let detail = engine(loaded)
.task_detail(&qualified(&args.id)?)
.await
.map_err(|error| Failure::from(&error))?;
let comments = detail.comments.as_deref();
show_rendered(
out,
loaded,
&detail.response,
&detail,
|task| render::task_with_comments(task, comments),
args,
"task",
)
}
Command::Task {
command: TaskCommand::Comment { command },
} => comment(out, loaded, command).await,
Command::Task {
command: TaskCommand::Deps(args),
} => {
let request = dependency_request(loaded, args)?;
let response = engine(loaded)
.task_dependencies(&request)
.await
.map_err(|error| Failure::from(&error))?;
respond(
out,
loaded,
response,
render::edges,
&args.paging,
"dependencies",
)
}
Command::Task {
command: TaskCommand::Copy(args),
} => {
let request = copy_request(
args.id.iter().map(String::as_str),
CopyScope::Tasks,
&args.copy,
)?;
copy(out, loaded, &request).await
}
Command::Project {
command: ProjectCommand::Copy(args),
} => {
let members = args
.member
.iter()
.map(|member| qualified(member))
.collect::<Result<Vec<_>, _>>()?;
let scope = match CopyItems::new(members) {
Some(members) => CopyScope::Members(members),
None => CopyScope::Projects {
tasks: !args.no_tasks,
},
};
let request = copy_request(std::iter::once(args.id.as_str()), scope, &args.copy)?;
copy(out, loaded, &request).await
}
Command::Project {
command: ProjectCommand::List(args),
} => {
let request = ProjectRequest {
sources: selection(&args.selection)?,
filters: filters(&args.filters)?,
paging: paging(loaded, &args.paging)?,
};
let response = engine(loaded)
.projects(&request)
.await
.map_err(|error| Failure::from(&error))?;
respond(
out,
loaded,
response,
render::projects,
&args.paging,
"projects",
)
}
Command::Project {
command: ProjectCommand::Show(args),
} => {
let response = engine(loaded)
.project(&qualified(&args.id)?)
.await
.map_err(|error| Failure::from(&error))?;
show(
out,
loaded,
response,
render::project_detail,
args,
"project",
)
}
Command::Project {
command: ProjectCommand::Deps(args),
} => {
let request = dependency_request(loaded, args)?;
let response = engine(loaded)
.project_dependencies(&request)
.await
.map_err(|error| Failure::from(&error))?;
respond(
out,
loaded,
response,
render::edges,
&args.paging,
"dependencies",
)
}
Command::Document {
command: DocumentCommand::List(args),
} => {
let engine = engine(loaded);
let request = DocumentRequest {
sources: selection(&args.selection)?,
filters: document_filters(&args.filters),
project: selector(&engine, args.project.as_deref(), args.no_project),
paging: paging(loaded, &args.paging)?,
};
let response = engine
.documents(&request)
.await
.map_err(|error| Failure::from(&error))?;
respond(
out,
loaded,
response,
render::documents,
&args.paging,
"documents",
)
}
Command::Document {
command: DocumentCommand::Show(args),
} => {
let response = engine(loaded)
.document(&qualified(&args.id)?)
.await
.map_err(|error| Failure::from(&error))?;
show(
out,
loaded,
response,
render::document_detail,
args,
"document",
)
}
Command::Document {
command: DocumentCommand::Copy(args),
} => {
let request = copy_request(
args.id.iter().map(String::as_str),
CopyScope::Documents,
&args.copy,
)?;
copy(out, loaded, &request).await
}
Command::Label {
command: LabelCommand::List(args),
} => {
let request = LabelRequest {
sources: selection(&args.selection)?,
paging: paging(loaded, &args.paging)?,
};
let response = engine(loaded)
.labels(&request)
.await
.map_err(|error| Failure::from(&error))?;
respond(
out,
loaded,
response,
render::labels,
&args.paging,
"labels",
)
}
Command::Search(args) => {
let request = SearchRequest {
sources: selection(&args.selection)?,
text: TextQuery {
terms: args.text.clone(),
fields: args.fields.fields(),
},
kind: args.kind.kind(),
paging: paging(loaded, &args.paging)?,
};
let response = engine(loaded)
.search(&request)
.await
.map_err(|error| Failure::from(&error))?;
respond(
out,
loaded,
response,
render::hits,
&args.paging,
"the search",
)
}
}
}
fn engine(loaded: &Loaded) -> Engine {
Engine::build(&loaded.config, &loaded.secrets)
}
fn respond<T: Serialize>(
out: &mut impl Write,
loaded: &Loaded,
response: QueryResponse<T>,
text: impl FnOnce(&[T]) -> String,
paging: &PageArgs,
what: &str,
) -> Result<u8, Failure> {
let rendered = match loaded.config.output() {
OutputFormat::Text => {
let mut rendered = text(&response.items);
if paging.explain {
rendered.push('\n');
rendered.push_str(&render::plan(&response.plan));
}
if let Some(next) = &response.next {
rendered.push_str(&format!("\nnext page: --page {next}\n"));
}
rendered
}
OutputFormat::Json => json(&response, what)?,
};
emit(out, rendered.trim_end(), what)?;
Ok(report(&response.errors, paging.allow_partial))
}
fn show<T: Serialize>(
out: &mut impl Write,
loaded: &Loaded,
response: QueryResponse<T>,
text: impl FnOnce(&T) -> String,
args: &ShowArgs,
what: &str,
) -> Result<u8, Failure> {
show_rendered(out, loaded, &response, &response, text, args, what)
}
fn show_rendered<T>(
out: &mut impl Write,
loaded: &Loaded,
response: &QueryResponse<T>,
machine: &impl Serialize,
text: impl FnOnce(&T) -> String,
args: &ShowArgs,
what: &str,
) -> Result<u8, Failure> {
match (response.items.first(), response.errors.is_empty()) {
(None, true) => Err(Failure::decided(
"no-such-item",
format!(
"no {what} with that id\n\
next: check the id, or list what is there — `onetaskgraph {what} list` \
reports every {what} the configured sources hold."
),
)),
_ => {
let rendered = match loaded.config.output() {
OutputFormat::Text => {
let mut rendered = response.items.first().map(text).unwrap_or_default();
if args.explain {
rendered.push('\n');
rendered.push_str(&render::plan(&response.plan));
}
rendered
}
OutputFormat::Json => json(machine, what)?,
};
emit(out, rendered.trim_end(), what)?;
Ok(report(&response.errors, args.allow_partial))
}
}
}
async fn comment(
out: &mut impl Write,
loaded: &Loaded,
command: &CommentCommand,
) -> Result<u8, Failure> {
let rendered = match command {
CommentCommand::Add(args) => {
let comment = NewComment {
body: body(args.body_file.as_deref())?,
author: args.author.clone(),
};
let task = qualified(&args.id)?;
let added = engine(loaded)
.add_comment(&task, &comment)
.await
.map_err(|error| Failure::from(&error))?;
rendering(loaded, &added, render::comment, "the comment")?
}
CommentCommand::List(args) => {
let task = qualified(&args.id)?;
let listed = engine(loaded)
.comments(&task)
.await
.map_err(|error| Failure::from(&error))?;
rendering(loaded, &listed, render::comments, "the comments")?
}
CommentCommand::Edit(args) => {
let body = body(args.body_file.as_deref())?;
let task = qualified(&args.id)?;
let edited = engine(loaded)
.edit_comment(&task, &args.comment_id, &body)
.await
.map_err(|error| Failure::from(&error))?;
rendering(loaded, &edited, render::comment, "the comment")?
}
CommentCommand::Delete(args) => {
let task = qualified(&args.id)?;
let deleted = engine(loaded)
.delete_comment(&task, &args.comment_id)
.await
.map_err(|error| Failure::from(&error))?;
rendering(loaded, &deleted, render::deleted, "the deletion")?
}
};
emit(out, rendered.trim_end(), "the comment")?;
Ok(EXIT_OK)
}
fn rendering<T: Serialize>(
loaded: &Loaded,
value: &T,
text: impl FnOnce(&T) -> String,
what: &str,
) -> Result<String, Failure> {
match loaded.config.output() {
OutputFormat::Text => Ok(text(value)),
OutputFormat::Json => json(value, what),
}
}
fn body(path: Option<&std::path::Path>) -> Result<CommentBody, Failure> {
let (bytes, from) = match path {
Some(path) => (
std::fs::read(path).map_err(|error| {
Failure::decided(
"comment-body",
format!(
"--body-file {}: could not read it: {error}\n\
next: name a readable file, or leave --body-file out and pass the body on \
standard input.",
path.display()
),
)
})?,
format!("--body-file {}", path.display()),
),
None => {
let mut bytes = Vec::new();
io::Read::read_to_end(&mut io::stdin().lock(), &mut bytes).map_err(|error| {
Failure::decided(
"comment-body",
format!(
"could not read the comment body from standard input: {error}\n\
next: pass the body with --body-file PATH instead."
),
)
})?;
(bytes, "standard input".to_owned())
}
};
let text = String::from_utf8(bytes).map_err(|error| {
Failure::decided(
"comment-body",
format!(
"the comment body on {from} is not UTF-8 text: {error}\n\
next: save the body as UTF-8 and pass it again."
),
)
})?;
CommentBody::new(text).map_err(|_| {
Failure::decided(
"comment-body",
format!(
"the comment body on {from} is empty, and a comment has to say something\n\
next: write the comment's text to {from} and run the command again."
),
)
})
}
async fn copy(out: &mut impl Write, loaded: &Loaded, request: &CopyRequest) -> Result<u8, Failure> {
let report = engine(loaded)
.copy(request)
.await
.map_err(|error| Failure::from(&error))?;
let rendered = match loaded.config.output() {
OutputFormat::Text => render::copied(&report),
OutputFormat::Json => json(&report, "the copy")?,
};
emit(out, rendered.trim_end(), "the copy")?;
Ok(EXIT_OK)
}
fn copy_request<'a>(
ids: impl Iterator<Item = &'a str>,
scope: CopyScope,
args: &CopyArgs,
) -> Result<CopyRequest, Failure> {
let items = ids.map(qualified).collect::<Result<Vec<_>, _>>()?;
Ok(CopyRequest {
items: CopyItems::new(items).ok_or_else(|| {
Failure::decided(
"no-id",
"no id to copy\n\
next: name at least one qualified id — `onetaskgraph task list` reports them.",
)
})?,
scope,
destination: SourceName::new(args.to.clone()).map_err(|error| {
Failure::decided(
"invalid-source-name",
format!(
"--to {}: {error}\n\
next: name a configured source — `onetaskgraph sources list` reports \
them.",
args.to
),
)
})?,
match_by: args.match_by.as_deref().map(MatchBy::parse),
recreate: args.recreate,
dry_run: args.dry_run,
})
}
fn report(errors: &[SourceFailure], allow_partial: bool) -> u8 {
if errors.is_empty() {
return EXIT_OK;
}
for failure in errors {
eprintln!(
"onetaskgraph: source {} could not answer: {}",
failure.source, failure.error
);
}
if allow_partial {
eprintln!(
"onetaskgraph: the answer above is partial, and --allow-partial says that is \
acceptable."
);
return EXIT_OK;
}
eprintln!(
"onetaskgraph: next: fix the source(s) named above — `onetaskgraph sources list` \
reports each one's state — or re-run with --allow-partial to accept an answer \
without them."
);
EXIT_PARTIAL
}
fn selection(args: &SelectionArgs) -> Result<Vec<SourceName>, Failure> {
args.source
.iter()
.map(|name| {
SourceName::new(name.clone()).map_err(|error| {
Failure::decided(
"invalid-source-name",
format!(
"--source {name}: {error}\n\
next: name a configured source — `onetaskgraph sources list` \
reports them."
),
)
})
})
.collect()
}
fn filters(args: &FilterArgs) -> Result<Filters, Failure> {
Ok(Filters {
text: args.search.as_ref().map(|terms| TextQuery {
terms: terms.clone(),
fields: args.fields.fields(),
}),
labels: LabelFilter {
all_of: args.label.clone(),
none_of: args.not_label.clone(),
any_of: Vec::new(),
},
statuses: args.status.iter().map(|status| status.category()).collect(),
})
}
fn document_filters(args: &DocumentFilterArgs) -> DocumentFilters {
DocumentFilters {
text: args.search.as_ref().map(|terms| TextQuery {
terms: terms.clone(),
fields: args.fields.fields(),
}),
labels: LabelFilter {
all_of: args.label.clone(),
none_of: args.not_label.clone(),
any_of: Vec::new(),
},
}
}
fn selector(engine: &Engine, project: Option<&str>, orphans: bool) -> ProjectSelector {
if orphans {
return ProjectSelector::Orphans;
}
let Some(project) = project else {
return ProjectSelector::Any;
};
match GlobalId::from_str(project) {
Ok(id) if engine.has(&id.source) => ProjectSelector::Qualified(id),
Ok(_) | Err(_) => ProjectSelector::Native(NativeId::from(project)),
}
}
fn qualified(id: &str) -> Result<GlobalId, Failure> {
GlobalId::from_str(id).map_err(|error| {
Failure::decided(
"invalid-id",
format!(
"{error}\n\
next: qualify the id with the source it belongs to — `onetaskgraph sources \
list` reports the configured names."
),
)
})
}
fn paging(loaded: &Loaded, args: &PageArgs) -> Result<Paging, Failure> {
let limit = args.limit.unwrap_or_else(|| loaded.config.page_size());
let token = args
.page
.as_ref()
.map(|raw| {
PageToken::parse(raw.clone()).map_err(|error| {
Failure::decided(
"page-token",
format!(
"--page: {error}\n\
next: pass a token exactly as a previous page reported it, or drop \
--page to start the walk again."
),
)
})
})
.transpose()?;
Ok(Paging { limit, token })
}
fn dependency_request(
loaded: &Loaded,
args: &DependencyArgs,
) -> Result<DependencyRequest, Failure> {
Ok(DependencyRequest {
id: qualified(&args.id)?,
direction: args.direction.direction(),
paging: paging(loaded, &args.paging)?,
})
}
fn json(value: &impl Serialize, what: &str) -> Result<String, Failure> {
serde_json::to_string_pretty(value)
.map_err(|error| Failure::decided("render", format!("could not render {what}: {error}")))
}
fn schema_bundle() -> Result<String, Failure> {
let mut bundle = onetaskgraph_core::schema_bundle();
bundle["commands"] = serde_json::to_value(public_commands()?).map_err(|error| {
Failure::decided(
"render",
format!("could not render the command surface: {error}"),
)
})?;
json(&bundle, "the schema bundle")
}
#[derive(Serialize)]
#[serde(transparent)]
struct PublicCommand(String);
impl PublicCommand {
fn try_new(path: String) -> Result<Self, Failure> {
if path.is_empty() || path.split(' ').any(|part| part.is_empty()) {
return Err(Failure::decided(
"render",
format!("invalid public command path {path:?}"),
));
}
Ok(Self(path))
}
}
fn public_commands() -> Result<Vec<PublicCommand>, Failure> {
fn leaves(
command: &clap::Command,
prefix: &str,
commands: &mut Vec<PublicCommand>,
) -> Result<(), Failure> {
let visible: Vec<_> = command
.get_subcommands()
.filter(|child| !child.is_hide_set())
.collect();
if visible.is_empty() {
if !prefix.is_empty() {
commands.push(PublicCommand::try_new(prefix.to_owned())?);
}
return Ok(());
}
for child in visible {
let path = if prefix.is_empty() {
child.get_name().to_owned()
} else {
format!("{prefix} {}", child.get_name())
};
leaves(child, &path, commands)?;
}
Ok(())
}
let mut commands = Vec::new();
leaves(&Cli::command(), "", &mut commands)?;
Ok(commands)
}
fn effective_config(loaded: &Loaded) -> Result<String, Failure> {
match loaded.config.output() {
OutputFormat::Text => Ok(loaded.effective.render_text()),
OutputFormat::Json => json(&loaded.effective, "the configuration"),
}
}
fn load(flags: &Layer, environment: &Environment) -> Result<Loaded, Failure> {
let working_directory = std::env::current_dir().map_err(|error| {
Failure::decided(
"working-directory",
format!(
"could not read the working directory: {error}\n\
next: run this from a directory that still exists."
),
)
})?;
config::load(&working_directory, environment, flags).map_err(|error| Failure::from(&error))
}
fn emit(out: &mut impl Write, rendered: &str, what: &str) -> Result<(), Failure> {
let unwritten =
|error: io::Error| Failure::decided("write", format!("could not write {what}: {error}"));
writeln!(out, "{rendered}").map_err(unwritten)?;
out.flush().map_err(unwritten)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_schema_bundle(out: &mut impl Write) -> Result<(), Failure> {
emit(out, schema_bundle()?.trim_end(), "the schema bundle")
}
#[test]
fn the_schema_verb_writes_a_bundle_with_every_contract_root() {
let mut out = Vec::new();
write_schema_bundle(&mut out).expect("the bundle renders");
let bundle: serde_json::Value =
serde_json::from_slice(&out).expect("the bundle is valid JSON");
assert!(bundle["roots"]["Task"].is_object());
assert!(bundle["plugin_config"]["in-memory"].is_object());
assert_eq!(
bundle["commands"],
serde_json::json!([
"schema",
"config show",
"sources list",
"task list",
"task show",
"task deps",
"task copy",
"task comment add",
"task comment list",
"task comment edit",
"task comment delete",
"project list",
"project show",
"project deps",
"project copy",
"document list",
"document show",
"document copy",
"label list",
"search"
])
);
}
struct Failing {
fail_on_write: bool,
}
impl Write for Failing {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.fail_on_write {
Err(io::Error::other("the pipe is closed"))
} else {
Ok(buf.len())
}
}
fn flush(&mut self) -> io::Result<()> {
Err(io::Error::other("the pipe closed before the flush"))
}
}
#[test]
fn a_verb_reports_a_failed_write_rather_than_panicking() {
let mut sink = Failing {
fail_on_write: true,
};
let failure = write_schema_bundle(&mut sink).expect_err("writes refused");
let message = failure.message();
assert!(
message.contains("could not write the schema bundle"),
"{message}"
);
}
#[test]
fn a_verb_reports_a_failed_flush_rather_than_exiting_zero_on_a_truncated_document() {
let mut sink = Failing {
fail_on_write: false,
};
let failure = write_schema_bundle(&mut sink).expect_err("flushes refused");
let message = failure.message();
assert!(
message.contains("could not write the schema bundle"),
"{message}"
);
}
}