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, Delivered, DeliveryOutcome, 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, MetadataKey, MetadataRecord, NativeId, NewComment, SourceName,
TextQuery,
};
use onetaskgraph_status_options::{GitHubProjectsConfig, StatusOptionsMode, StatusOptionsReport};
use serde::Serialize;
use crate::cli::{
Cli, Command, CommentCommand, ConfigCommand, CopyArgs, DependencyArgs, DocumentCommand,
DocumentFilterArgs, FilterArgs, LabelCommand, MetadataCommand, MetadataSetArgs, PageArgs,
ProjectCommand, SelectionArgs, ShowArgs, SourcesCommand, StatusCommand, 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::Sources {
command: SourcesCommand::StatusOptions(args),
} => {
let name = SourceName::try_from(args.source.clone())
.map_err(|message| Failure::decided("invalid-source", message.to_string()))?;
let source = loaded.config.sources().get(&name).ok_or_else(|| {
Failure::decided(
"status-options",
format!("no configured source is named {name}"),
)
})?;
if source.plugin() != onetaskgraph_core::PluginKind::GithubProjects {
return Err(Failure::decided(
"status-options",
format!(
"source {name} uses plugin {}, not github-projects; status-options is only available for github-projects sources",
source.plugin()
),
));
}
let config: GitHubProjectsConfig = serde_json::from_value(source.config().clone())
.map_err(|error| {
Failure::decided("status-options", format!("source {name}: {error}"))
})?;
let mode = if args.apply {
StatusOptionsMode::Apply
} else {
StatusOptionsMode::Plan
};
let report =
onetaskgraph_status_options::reconcile(&name, config, &loaded.secrets, mode)
.await
.map_err(|error| {
Failure::decided("status-options", format!("source {name}: {error}"))
})?;
let rendered = match loaded.config.output() {
OutputFormat::Json => json(&report, "the status-options report")?,
OutputFormat::Text => render::status_options(&report),
};
emit(out, rendered.trim_end(), "the status-options report")?;
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::Status {
command: StatusCommand::Set(args),
},
} => {
let task = qualified(&args.id)?;
let set = engine(loaded)
.set_task_status(&task, args.category.category())
.await
.map_err(|error| Failure::from(&error))?;
let rendered = rendering(loaded, &set, render::status_set, "the status")?;
emit(out, rendered.trim_end(), "the status")?;
Ok(delivery_exit(&set.delivered))
}
Command::Task {
command:
TaskCommand::Metadata {
command: MetadataCommand::Set(args),
},
} => metadata_set(out, loaded, MetadataRecord::Task, args).await,
Command::Project {
command:
ProjectCommand::Metadata {
command: MetadataCommand::Set(args),
},
} => metadata_set(out, loaded, MetadataRecord::Project, args).await,
Command::Document {
command:
DocumentCommand::Metadata {
command: MetadataCommand::Set(args),
},
} => metadata_set(out, loaded, MetadataRecord::Document, args).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(delivery_exit(&report.delivered))
}
async fn metadata_set(
out: &mut impl Write,
loaded: &Loaded,
record: MetadataRecord,
args: &MetadataSetArgs,
) -> Result<u8, Failure> {
let id = qualified(&args.id)?;
let key = metadata_key(&args.key)?;
let value = metadata_value(&args.value)?;
let engine = engine(loaded);
let set = match record {
MetadataRecord::Task => engine.set_task_metadata(&id, &key, &value).await,
MetadataRecord::Project => engine.set_project_metadata(&id, &key, &value).await,
MetadataRecord::Document => engine.set_document_metadata(&id, &key, &value).await,
}
.map_err(|error| Failure::from(&error))?;
let rendered = rendering(loaded, &set, render::metadata_set, "the metadata key")?;
emit(out, rendered.trim_end(), "the metadata key")?;
Ok(EXIT_OK)
}
fn metadata_key(key: &str) -> Result<MetadataKey, Failure> {
MetadataKey::new(key).map_err(|message| Failure::decided("invalid-metadata-key", message))
}
fn metadata_value(value: &str) -> Result<serde_json::Value, Failure> {
serde_json::from_str(value).map_err(|error| {
Failure::decided(
"invalid-metadata-value",
format!(
"the value {value:?} is not JSON: {error}\n\
next: pass exactly one JSON value — quote a string as '\"text\"', and write \
true, false, null, a number, an array or an object as JSON spells them."
),
)
})
}
fn delivery_exit(delivered: &[Delivered]) -> u8 {
let mut failed = false;
for entry in delivered {
if let DeliveryOutcome::Failed { failure, .. } = &entry.outcome {
failed = true;
eprintln!(
"onetaskgraph: task {} could not be kept in step with {}: {}",
entry.ticket,
entry.deliverer,
failure.message()
);
}
}
if !failed {
return EXIT_OK;
}
eprintln!(
"onetaskgraph: next: the write itself landed; fix what each task above names, then run \
the same command again — every write of the task re-evaluates what it delivers."
);
EXIT_PARTIAL
}
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 json_value(value: impl Serialize, what: &str) -> Result<serde_json::Value, Failure> {
serde_json::to_value(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["roots"]["StatusOptionsReport"] = json_value(
schemars::schema_for!(StatusOptionsReport),
"the status-options schema",
)?;
bundle["commands"] = json_value(public_commands()?, "the command surface")?;
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::*;
struct RefusesSerialization;
impl Serialize for RefusesSerialization {
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
Err(serde::ser::Error::custom("serialization refused"))
}
}
#[test]
fn a_json_value_reports_a_serialization_failure() {
let failure =
json_value(RefusesSerialization, "the test value").expect_err("serialization fails");
assert_eq!(
failure.message(),
"could not render the test value: serialization refused"
);
}
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["roots"]["StatusOptionsReport"].is_object());
assert!(bundle["plugin_config"]["in-memory"].is_object());
assert_eq!(
bundle["commands"],
serde_json::json!([
"schema",
"config show",
"sources list",
"sources status-options",
"task list",
"task show",
"task deps",
"task copy",
"task comment add",
"task comment list",
"task comment edit",
"task comment delete",
"task status set",
"task metadata set",
"project list",
"project show",
"project deps",
"project copy",
"project metadata set",
"document list",
"document show",
"document copy",
"document metadata set",
"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}"
);
}
}