use std::collections::{BTreeMap, HashSet};
use std::io::{self, Read as _, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr as _;
use onetaskgraph_core::{
Body, DocumentCreate, EngineError, Failure, GlobalId, Loaded, OutputFormat, PlanMember,
PlannedProject, ProjectCreate, RenderRequest, RenderTemplate, RenderedRecord, TaskCreate,
Template, TemplateInput,
};
use onetaskgraph_plugin_api::{
AssetName, AssetPayload, MetadataKey, NativeId, Repository, SourceName,
};
use serde_json::Value;
use crate::cli::{
AnswersArgs, CreateBodyArgs, CreateItemArgs, DocumentCreateArgs, ProjectCreateArgs, RenderArgs,
TaskCreateArgs,
};
use crate::template::{Refusal, answers_given, ask, input, refused};
use crate::{EXIT_OK, delivery_exit, emit, engine, json, qualified, render};
pub(crate) async fn create_task(
out: &mut impl Write,
loaded: &Loaded,
args: &TaskCreateArgs,
) -> Result<u8, Failure> {
let item = match item(&args.item, loaded) {
Ok(item) => item,
Err(Refusal::Answers(message)) => return Ok(refused(&message)),
Err(Refusal::Failed(failure)) => return Err(failure),
};
let depends_on = ids(&args.depends_on)?;
let delivers = ids(&args.delivers)?;
let assets = asset_files(&args.assets.asset)?;
let engine = engine(loaded);
let created = engine
.create_task(&TaskCreate {
source: item.source,
project: item.project,
title: args.item.title.clone(),
body: item.body,
status: args.status.map(|status| status.category()),
labels: args.item.label.clone(),
repositories: item.repositories,
depends_on,
delivers,
metadata: item.metadata,
assets,
classification: classification(args.item.classification),
})
.await
.map_err(|error| Failure::from(&error))?;
let id = created.task.id;
match loaded.config.output() {
OutputFormat::Text => emit(
out,
&crate::sanitized(id.to_string(), OutputFormat::Text)?,
"the task's id",
)?,
OutputFormat::Json => {
let detail = engine
.task_detail(&id)
.await
.map_err(|error| Failure::from(&error))?;
let rendered = crate::sanitized(json(&detail, "the task")?, OutputFormat::Json)?;
emit(out, &rendered, "the task")?;
}
}
Ok(delivery_exit(&created.delivered))
}
pub(crate) async fn create_document(
out: &mut impl Write,
loaded: &Loaded,
args: &DocumentCreateArgs,
) -> Result<u8, Failure> {
let item = match item(&args.item, loaded) {
Ok(item) => item,
Err(Refusal::Answers(message)) => return Ok(refused(&message)),
Err(Refusal::Failed(failure)) => return Err(failure),
};
let assets = asset_files(&args.assets.asset)?;
let engine = engine(loaded);
let created = engine
.create_document(&DocumentCreate {
source: item.source,
project: item.project,
title: args.item.title.clone(),
id: args.id.clone(),
body: item.body,
labels: args.item.label.clone(),
repositories: item.repositories,
metadata: item.metadata,
assets,
classification: classification(args.item.classification),
})
.await
.map_err(|error| Failure::from(&error))?;
match loaded.config.output() {
OutputFormat::Text => emit(
out,
&crate::sanitized(created.id.to_string(), OutputFormat::Text)?,
"the document's id",
)?,
OutputFormat::Json => {
let detail = engine
.document_detail(&created.id)
.await
.map_err(|error| Failure::from(&error))?;
let rendered = crate::sanitized(json(&detail, "the document")?, OutputFormat::Json)?;
emit(out, &rendered, "the document")?;
}
}
Ok(EXIT_OK)
}
pub(crate) async fn create_project(
out: &mut impl Write,
loaded: &Loaded,
args: &ProjectCreateArgs,
) -> Result<u8, Failure> {
let (request, members) = match project(args, loaded) {
Ok(planned) => planned,
Err(Refusal::Answers(message)) => return Ok(refused(&message)),
Err(Refusal::Failed(failure)) => return Err(failure),
};
if args.dry_run {
let planned = PlannedProject::of(&request, &members);
let rendered = match loaded.config.output() {
OutputFormat::Text => render::planned(&planned),
OutputFormat::Json => json(&planned, "the plan")?,
};
emit(out, rendered.trim_end(), "the plan")?;
return Ok(EXIT_OK);
}
let engine = engine(loaded);
let (project, created) = if members.is_empty() {
let created = engine
.create_project(&request)
.await
.map_err(|error| Failure::from(&error))?;
(created.id, Vec::new())
} else {
let created = engine
.create_plan(&request, &members)
.await
.map_err(|error| Failure::from(&error))?;
(
created.project,
created.members.into_iter().map(|(_, id)| id).collect(),
)
};
match loaded.config.output() {
OutputFormat::Text => {
let lines: Vec<String> = std::iter::once(project.to_string())
.chain(created.iter().map(ToString::to_string))
.collect();
emit(
out,
&crate::sanitized(lines.join("\n"), OutputFormat::Text)?,
"the project's id",
)?;
}
OutputFormat::Json => {
let response = engine
.project(&project)
.await
.map_err(|error| Failure::from(&error))?;
let rendered = crate::sanitized(json(&response, "the project")?, OutputFormat::Json)?;
emit(out, rendered.trim_end(), "the project")?;
}
}
Ok(EXIT_OK)
}
pub(crate) async fn regenerate(
out: &mut impl Write,
loaded: &Loaded,
record: RenderedRecord,
args: &RenderArgs,
assets: &[PathBuf],
) -> Result<u8, Failure> {
let id = qualified(&args.id)?;
let assets = asset_files(assets)?;
match regenerated(out, loaded, record, &id, args, assets).await {
Ok(()) => Ok(EXIT_OK),
Err(Refusal::Answers(message)) => Ok(refused(&message)),
Err(Refusal::Failed(failure)) => Err(failure),
}
}
async fn regenerated(
out: &mut impl Write,
loaded: &Loaded,
record: RenderedRecord,
id: &GlobalId,
args: &RenderArgs,
assets: Vec<AssetPayload>,
) -> Result<(), Refusal> {
let template = input(
args.template.as_deref(),
&args.search_path,
args.template_loader.as_deref(),
args.answers.as_deref(),
)?;
let engine = engine(loaded);
let regeneration = engine
.regeneration(
record,
id,
&RenderRequest {
template: template.map_or_else(
|| RenderTemplate::Recorded {
search_path: args.search_path.clone(),
},
RenderTemplate::Given,
),
assets,
..RenderRequest::default()
},
)
.await
.map_err(refusal)?;
let declared: Vec<&str> = regeneration
.template()
.variables()
.iter()
.map(|variable| variable.name())
.collect();
if let Some(undeclared) = args
.unset
.iter()
.find(|name| !declared.contains(&name.as_str()))
{
return Err(Refusal::Answers(format!(
"--unset {undeclared}: the template declares no variable of that name, so there is \
no answer to drop\n\
next: unset one of {}, or leave the flag out.",
declared.join(", ")
)));
}
let mut given = answers_given(args.answers.as_deref(), &args.var)?;
for name in &args.unset {
given.unset(name.clone());
}
let settled: HashSet<String> = regeneration
.settled()
.map(str::to_owned)
.chain(args.unset.iter().cloned())
.collect();
let answers = ask(
loaded,
regeneration.template(),
regeneration.base().overlay(&given),
&settled,
)?;
let regenerated = engine
.regenerate(®eneration, &answers, args.dry_run)
.await
.map_err(|error| match refusal(error.clone()) {
Refusal::Failed(_) => Refusal::Failed(crate::write_failed(&engine, &error)),
other => other,
})?;
let rendered = match loaded.config.output() {
OutputFormat::Text => render::regenerated(®enerated, args.dry_run),
OutputFormat::Json => json(®enerated, "the rendering")?,
};
let rendered = crate::sanitized(rendered, loaded.config.output())?;
emit(out, rendered.trim_end(), "the rendering")?;
Ok(())
}
fn asset_files(paths: &[PathBuf]) -> Result<Vec<AssetPayload>, Failure> {
let mut assets: Vec<AssetPayload> = Vec::with_capacity(paths.len());
for path in paths {
let base = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
let name = AssetName::new(base).map_err(|problem| {
Failure::decided(
"asset-name",
format!(
"--asset {}: {problem}\n\
next: rename the file to a bare name ending in one of those extensions, \
and reference it as ``.",
path.display()
),
)
})?;
if let Some(earlier) = assets.iter().position(|held| held.name == name) {
return Err(Failure::decided(
"asset-given-twice",
format!(
"--asset {} and --asset {} have one base name, {name}, and an asset is \
stored under its base name\n\
next: rename one of the two files.",
paths[earlier].display(),
path.display()
),
));
}
let bytes = std::fs::read(path).map_err(|error| {
Failure::decided(
"asset-unreadable",
format!(
"--asset {}: the file could not be read: {error}\n\
next: name an image file this process can read.",
path.display()
),
)
})?;
assets.push(AssetPayload::of(name, bytes));
}
Ok(assets)
}
pub(crate) async fn stored(
out: &mut impl Write,
loaded: &Loaded,
record: RenderedRecord,
args: &AnswersArgs,
) -> Result<u8, Failure> {
let id = qualified(&args.id)?;
let answers = engine(loaded)
.template_answers(record, &id)
.await
.map_err(|error| Failure::from(&error))?;
let rendered = match loaded.config.output() {
OutputFormat::Json => json(&answers, "the answers")?,
OutputFormat::Text => answers.to_yaml().map_err(|error| {
Failure::decided("render", format!("could not render the answers: {error}"))
})?,
};
emit(out, rendered.trim_end(), "the answers")?;
Ok(EXIT_OK)
}
fn refusal(error: EngineError) -> Refusal {
match &error {
EngineError::MissingAnswers { .. } => Refusal::Answers(error.to_string()),
EngineError::Template { error } if error.refuses_answers() => {
Refusal::Answers(error.to_string())
}
_ => Refusal::Failed(Failure::from(&error)),
}
}
struct Item {
source: SourceName,
project: NativeId,
body: Body,
repositories: Vec<Repository>,
metadata: BTreeMap<MetadataKey, Value>,
}
fn item(args: &CreateItemArgs, loaded: &Loaded) -> Result<Item, Refusal> {
let source = source_name(&args.source)?;
let project = match GlobalId::from_str(&args.project) {
Ok(id) if id.source == source => id.native,
Ok(id) if loaded.config.sources().contains_key(&id.source) => {
return Err(Refusal::Failed(Failure::decided(
"project-in-another-source",
format!(
"--project {}: that project is in source {}, and this creates in {source}; an \
item is filed under a project of its own source\n\
next: name a project of {source}, or create the item in {}.",
args.project, id.source, id.source
),
)));
}
Ok(_) | Err(_) => NativeId::from(args.project.as_str()),
};
let repositories = repositories(&args.repository)?;
let metadata = metadata(&args.metadata)?;
let body = body(&args.body, loaded)?;
Ok(Item {
source,
project,
body,
repositories,
metadata,
})
}
fn project(
args: &ProjectCreateArgs,
loaded: &Loaded,
) -> Result<(ProjectCreate, Vec<PlanMember>), Refusal> {
let source = source_name(&args.source)?;
let repositories = repositories(&args.repository)?;
let metadata = metadata(&args.metadata)?;
let (body, template) = rendered_body(&args.body, loaded)?;
let members = match &template {
Some((input, template, answers)) if !template.members().is_empty() => {
let base = member_base(input, args.body.template_loader.as_deref());
template
.prepare_members(answers, &base)?
.into_iter()
.map(plan_member)
.collect::<Result<_, _>>()?
}
_ => Vec::new(),
};
Ok((
ProjectCreate {
source,
id: args.id.clone(),
title: args.title.clone(),
body,
status: args.status.map(|status| status.category()),
labels: (!args.label.is_empty()).then(|| args.label.clone()),
repositories: (!repositories.is_empty()).then_some(repositories),
metadata,
classification: args
.classification
.map(crate::cli::ClassificationArg::classification),
},
members,
))
}
fn member_base(input: &TemplateInput, loader: Option<&Path>) -> Vec<PathBuf> {
let (named, search_path): (Option<&Path>, &[PathBuf]) = match input {
TemplateInput::File { path, search_path } => (Some(path.as_path()), search_path),
TemplateInput::Loader(document) => (
loader.filter(|path| path.as_os_str() != "-"),
document.search_path(),
),
};
named
.and_then(Path::parent)
.map(|directory| {
if directory.as_os_str().is_empty() {
PathBuf::from(".")
} else {
directory.to_path_buf()
}
})
.into_iter()
.chain(search_path.iter().cloned())
.collect()
}
fn plan_member(
prepared: onetaskgraph_core::template::PreparedMember,
) -> Result<PlanMember, Refusal> {
let declaration = &prepared.declaration;
let named = |kind: &str, message: String| {
Refusal::Failed(Failure::decided(
kind,
format!(
"member {:?} of the project template: {message}",
declaration.id
),
))
};
let metadata = declaration
.metadata
.iter()
.map(|(key, value)| {
MetadataKey::new(key.as_str())
.map(|key| (key, value.clone()))
.map_err(|message| named("invalid-metadata-key", message))
})
.collect::<Result<_, _>>()?;
let repositories = declaration
.repositories
.iter()
.map(|origin| {
Repository::try_from(origin.clone()).map_err(|error| {
named(
"invalid-repository",
format!("repository {origin}: {error}"),
)
})
})
.collect::<Result<_, _>>()?;
Ok(PlanMember {
id: NativeId::from(declaration.id.as_str()),
title: declaration.title.clone(),
body: Body::rendered(&prepared.input, prepared.rendered.clone())?,
depends_on: declaration
.depends_on
.iter()
.map(|id| NativeId::from(id.as_str()))
.collect(),
metadata,
repositories,
})
}
fn classification(
given: Option<crate::cli::ClassificationArg>,
) -> onetaskgraph_plugin_api::Classification {
given.map_or_else(
Default::default,
crate::cli::ClassificationArg::classification,
)
}
fn source_name(name: &str) -> Result<SourceName, Refusal> {
SourceName::new(name.to_owned()).map_err(|error| {
Refusal::Failed(Failure::decided(
"invalid-source-name",
format!(
"{name}: {error}\n\
next: name a configured source — `onetaskgraph sources list` reports them."
),
))
})
}
fn repositories(given: &[String]) -> Result<Vec<Repository>, Refusal> {
given
.iter()
.map(|origin| {
Repository::try_from(origin.clone()).map_err(|error| {
Refusal::Failed(Failure::decided(
"invalid-repository",
format!(
"--repository {origin}: {error}\n\
next: name it by its normalized origin, such as \
github.com/owner/name."
),
))
})
})
.collect()
}
fn metadata(given: &[String]) -> Result<BTreeMap<MetadataKey, Value>, Refusal> {
given
.iter()
.map(|entry| metadata_entry(entry).map_err(Refusal::Failed))
.collect()
}
pub(crate) fn metadata_entry(entry: &str) -> Result<(MetadataKey, Value), Failure> {
let Some((key, value)) = entry.split_once('=') else {
return Err(Failure::decided(
"invalid-metadata-key",
format!(
"--metadata {entry}: that is not KEY=JSON\n\
next: write it as --metadata myapp.key='\"value\"'."
),
));
};
let key = MetadataKey::new(key).map_err(|message| {
Failure::decided(
"invalid-metadata-key",
format!("--metadata {entry}: {message}"),
)
})?;
let value = serde_json::from_str(value).map_err(|error| {
Failure::decided(
"invalid-metadata-value",
format!(
"--metadata {entry}: the value is not JSON: {error}\n\
next: pass exactly one JSON value — quote a string as '\"text\"'."
),
)
})?;
Ok((key, value))
}
fn body(args: &CreateBodyArgs, loaded: &Loaded) -> Result<Body, Refusal> {
rendered_body(args, loaded).map(|(body, _)| body)
}
type Rendering = (
TemplateInput,
Template,
std::collections::BTreeMap<String, Value>,
);
fn rendered_body(
args: &CreateBodyArgs,
loaded: &Loaded,
) -> Result<(Body, Option<Rendering>), Refusal> {
if let Some(input) = input(
args.template.as_deref(),
&args.search_path,
args.template_loader.as_deref(),
args.answers.as_deref(),
)? {
let template = input.load()?;
let answers = ask(
loaded,
&template,
answers_given(args.answers.as_deref(), &args.var)?,
&HashSet::new(),
)?;
let rendered = template.render(&answers)?;
let resolved = rendered.answers.clone();
let body = Body::rendered(&input, rendered)?;
return Ok((body, Some((input, template, resolved))));
}
plain(args.body_file.as_deref()).map(|text| (Body::plain(text), None))
}
fn plain(path: Option<&Path>) -> Result<String, Refusal> {
let (bytes, from) = match path {
Some(path) => (
std::fs::read(path).map_err(|error| {
Refusal::Failed(Failure::decided(
"body-file",
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::stdin().read_to_end(&mut bytes).map_err(|error| {
Refusal::Failed(Failure::decided(
"body-file",
format!(
"could not read the body from standard input: {error}\n\
next: pass the body with --body-file PATH, or render it with \
--template FILE."
),
))
})?;
(bytes, "standard input".to_owned())
}
};
String::from_utf8(bytes).map_err(|error| {
Refusal::Failed(Failure::decided(
"body-file",
format!(
"the body on {from} is not UTF-8 text: {error}\n\
next: save the body as UTF-8 and pass it again."
),
))
})
}
fn ids(given: &[String]) -> Result<Vec<GlobalId>, Failure> {
given.iter().map(|id| qualified(id)).collect()
}