use std::num::NonZeroU32;
use std::str::FromStr;
use chrono::{DateTime, Utc};
use clap::{Args, Parser, Subcommand, ValueEnum};
use onetaskgraph_core::config::{Layer, Origin, Setting, SettingPath, value_from_text};
use onetaskgraph_core::{GlobalId, OutputFormat, PluginKind, SearchKind};
use onetaskgraph_plugin_api::{
Classification, Direction, MetadataMatch, NativeId, Priority, Repository, StatusCategory,
TextFields,
};
use serde_json::Value;
#[derive(Debug, Parser)]
#[command(name = "onetaskgraph", bin_name = "onetaskgraph", version)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
#[command(flatten)]
pub overrides: Overrides,
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(hide = true)]
PluginServe {
source: PluginKind,
},
Schema,
Config {
#[command(subcommand)]
command: ConfigCommand,
},
Sources {
#[command(subcommand)]
command: SourcesCommand,
},
Task {
#[command(subcommand)]
command: TaskCommand,
},
Project {
#[command(subcommand)]
command: ProjectCommand,
},
Document {
#[command(subcommand)]
command: DocumentCommand,
},
Label {
#[command(subcommand)]
command: LabelCommand,
},
Search(SearchArgs),
Template {
#[command(subcommand)]
command: TemplateCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum TemplateCommand {
Variables(TemplateVariablesArgs),
Render(TemplateRenderArgs),
}
#[derive(Debug, Args)]
#[command(group = clap::ArgGroup::new("template_source").required(true).args(["file", "template_loader"]))]
pub struct TemplateVariablesArgs {
#[arg(value_name = "FILE")]
pub file: Option<std::path::PathBuf>,
#[arg(
long = "search-path",
value_name = "DIR",
conflicts_with = "template_loader"
)]
pub search_path: Vec<std::path::PathBuf>,
#[arg(long = "template-loader", value_name = "FILE")]
pub template_loader: Option<std::path::PathBuf>,
}
#[derive(Debug, Args)]
#[command(group = clap::ArgGroup::new("template_source").required(true).args(["file", "template_loader"]))]
pub struct TemplateRenderArgs {
#[arg(value_name = "FILE")]
pub file: Option<std::path::PathBuf>,
#[arg(
long = "search-path",
value_name = "DIR",
conflicts_with = "template_loader"
)]
pub search_path: Vec<std::path::PathBuf>,
#[arg(long = "template-loader", value_name = "FILE")]
pub template_loader: Option<std::path::PathBuf>,
#[arg(long, value_name = "FILE")]
pub answers: Option<std::path::PathBuf>,
#[arg(
long = "var",
value_name = "NAME=VALUE",
allow_hyphen_values = true,
value_parser = var_assignment
)]
pub var: Vec<VarAssignment>,
}
#[derive(Debug, Clone)]
pub struct VarAssignment {
name: String,
value: String,
}
impl VarAssignment {
pub fn name(&self) -> &str {
&self.name
}
pub fn value(&self) -> &str {
&self.value
}
}
fn var_assignment(raw: &str) -> Result<VarAssignment, String> {
let (name, value) = raw.split_once('=').ok_or_else(|| {
"that is not NAME=VALUE; next: write it as --var NAME=VALUE, for example \
--var title=\"Ship it\""
.to_owned()
})?;
if !onetaskgraph_core::template::is_variable_name(name) {
return Err(format!(
"{name:?} is not a variable name, which matches ^[a-z][a-z0-9_]*$; next: name a \
variable the template declares — `onetaskgraph template variables` lists them"
));
}
Ok(VarAssignment {
name: name.to_owned(),
value: value.to_owned(),
})
}
#[derive(Debug, Subcommand)]
pub enum SourcesCommand {
List,
StatusOptions(SetupArgs),
Fields(SetupArgs),
Route(RouteArgs),
}
#[derive(Debug, Args)]
pub struct RouteArgs {
pub source: String,
#[arg(long = "repository", value_name = "R", value_parser = repository)]
pub repository: Vec<Repository>,
#[arg(long = "classification", value_name = "CLASSIFICATION")]
pub classification: Option<ClassificationArg>,
}
fn repository(value: &str) -> Result<Repository, String> {
Repository::try_from(value.to_owned())
}
#[derive(Debug, Args)]
pub struct SetupArgs {
pub source: String,
#[arg(long)]
pub apply: bool,
}
#[derive(Debug, Subcommand)]
pub enum TaskCommand {
List(TaskListArgs),
Show(TaskShowArgs),
ShowMany(TaskShowManyArgs),
Deps(DependencyArgs),
Copy(TaskCopyArgs),
Comment {
#[command(subcommand)]
command: CommentCommand,
},
Status {
#[command(subcommand)]
command: StatusCommand,
},
Priority {
#[command(subcommand)]
command: PriorityCommand,
},
Content {
#[command(subcommand)]
command: ContentCommand,
},
Metadata {
#[command(subcommand)]
command: MetadataCommand,
},
Update(TaskUpdateArgs),
Create(TaskCreateArgs),
Render(ItemRenderArgs),
Answers(AnswersArgs),
}
#[derive(Debug, Args)]
pub struct CreateBodyArgs {
#[arg(long, value_name = "FILE", conflicts_with_all = ["template_loader", "body_file"])]
pub template: Option<std::path::PathBuf>,
#[arg(long = "search-path", value_name = "DIR", requires = "template")]
pub search_path: Vec<std::path::PathBuf>,
#[arg(
long = "template-loader",
value_name = "FILE",
conflicts_with = "body_file"
)]
pub template_loader: Option<std::path::PathBuf>,
#[arg(long, value_name = "FILE", requires = "rendered")]
pub answers: Option<std::path::PathBuf>,
#[arg(
long = "var",
value_name = "NAME=VALUE",
allow_hyphen_values = true,
value_parser = var_assignment,
requires = "rendered"
)]
pub var: Vec<VarAssignment>,
#[arg(long = "body-file", value_name = "PATH")]
pub body_file: Option<std::path::PathBuf>,
}
#[derive(Debug, Args)]
pub struct CreateItemArgs {
#[arg(value_name = "SOURCE")]
pub source: String,
#[arg(long, value_name = "P")]
pub project: String,
#[arg(long, value_name = "TITLE")]
pub title: String,
#[arg(long = "label", value_name = "L")]
pub label: Vec<String>,
#[arg(long = "repository", value_name = "R")]
pub repository: Vec<String>,
#[arg(long = "metadata", value_name = "KEY=JSON", allow_hyphen_values = true)]
pub metadata: Vec<String>,
#[arg(long = "classification", value_name = "CLASSIFICATION")]
pub classification: Option<ClassificationArg>,
#[command(flatten)]
pub term_scope: TermScopeArgs,
#[command(flatten)]
pub body: CreateBodyArgs,
}
#[derive(Debug, Args)]
#[command(group = clap::ArgGroup::new("rendered").args(["template", "template_loader"]).multiple(false))]
pub struct TaskCreateArgs {
#[command(flatten)]
pub item: CreateItemArgs,
#[arg(long = "status", value_name = "CATEGORY")]
pub status: Option<StatusArg>,
#[arg(long = "depends-on", value_name = "ID")]
pub depends_on: Vec<String>,
#[arg(long = "delivers", value_name = "ID")]
pub delivers: Vec<String>,
#[command(flatten)]
pub assets: AssetArgs,
}
#[derive(Debug, Args)]
pub struct AssetArgs {
#[arg(long = "asset", value_name = "PATH")]
pub asset: Vec<std::path::PathBuf>,
}
#[derive(Debug, Args)]
pub struct ItemRenderArgs {
#[command(flatten)]
pub render: RenderArgs,
#[command(flatten)]
pub assets: AssetArgs,
}
#[derive(Debug, Args)]
#[command(group = clap::ArgGroup::new("rendered").args(["template", "template_loader"]).multiple(false))]
pub struct DocumentCreateArgs {
#[command(flatten)]
pub item: CreateItemArgs,
#[arg(long = "id", value_name = "DOC", value_parser = native_id)]
pub id: Option<NativeId>,
#[command(flatten)]
pub assets: AssetArgs,
}
#[derive(Debug, Args)]
#[command(group = clap::ArgGroup::new("rendered").args(["template", "template_loader"]).multiple(false))]
pub struct ProjectCreateArgs {
#[arg(value_name = "SOURCE")]
pub source: String,
#[arg(long = "id", value_name = "NATIVE-ID", value_parser = native_id)]
pub id: NativeId,
#[arg(long, value_name = "TITLE")]
pub title: String,
#[arg(long = "status", value_name = "CATEGORY")]
pub status: Option<StatusArg>,
#[arg(long = "label", value_name = "L")]
pub label: Vec<String>,
#[arg(long = "repository", value_name = "R")]
pub repository: Vec<String>,
#[arg(long = "metadata", value_name = "KEY=JSON", allow_hyphen_values = true)]
pub metadata: Vec<String>,
#[arg(long = "classification", value_name = "CLASSIFICATION")]
pub classification: Option<ClassificationArg>,
#[command(flatten)]
pub term_scope: TermScopeArgs,
#[command(flatten)]
pub body: CreateBodyArgs,
}
#[derive(Debug, Args)]
pub struct RenderArgs {
#[arg(value_name = "ID")]
pub id: String,
#[arg(long, value_name = "FILE", conflicts_with = "template_loader")]
pub template: Option<std::path::PathBuf>,
#[arg(
long = "search-path",
value_name = "DIR",
conflicts_with = "template_loader"
)]
pub search_path: Vec<std::path::PathBuf>,
#[arg(long = "template-loader", value_name = "FILE")]
pub template_loader: Option<std::path::PathBuf>,
#[arg(long, value_name = "FILE")]
pub answers: Option<std::path::PathBuf>,
#[arg(
long = "var",
value_name = "NAME=VALUE",
allow_hyphen_values = true,
value_parser = var_assignment
)]
pub var: Vec<VarAssignment>,
#[arg(long = "unset", value_name = "NAME", value_parser = variable_name)]
pub unset: Vec<String>,
#[arg(long = "dry-run")]
pub dry_run: bool,
#[command(flatten)]
pub term_scope: TermScopeArgs,
}
#[derive(Debug, Args)]
pub struct AnswersArgs {
#[arg(value_name = "ID")]
pub id: String,
}
fn variable_name(raw: &str) -> Result<String, String> {
if onetaskgraph_core::template::is_variable_name(raw) {
Ok(raw.to_owned())
} else {
Err(format!(
"{raw:?} is not a variable name, which matches ^[a-z][a-z0-9_]*$; next: name a \
variable the template declares — `onetaskgraph template variables` lists them"
))
}
}
#[derive(Debug, Args)]
pub struct TaskUpdateArgs {
#[arg(value_name = "ID")]
pub id: String,
#[arg(long, value_name = "TITLE")]
pub title: Option<String>,
#[arg(long = "body-file", value_name = "PATH")]
pub body_file: Option<std::path::PathBuf>,
#[arg(long = "status", value_name = "CATEGORY")]
pub status: Option<StatusArg>,
#[arg(long = "status-name", value_name = "NAME", requires = "status")]
pub status_name: Option<String>,
#[arg(long = "priority", value_name = "PRIORITY")]
pub priority: Option<PriorityArg>,
#[arg(long = "metadata", value_name = "KEY=JSON", allow_hyphen_values = true)]
pub metadata: Vec<String>,
#[arg(long = "remove-metadata", value_name = "KEY")]
pub remove_metadata: Vec<String>,
#[arg(long = "delivers", value_name = "ID", conflicts_with = "no_delivers")]
pub delivers: Vec<String>,
#[arg(long = "no-delivers")]
pub no_delivers: bool,
#[arg(
long = "depends-on",
value_name = "ID",
conflicts_with = "no_depends_on"
)]
pub depends_on: Vec<String>,
#[arg(long = "no-depends-on")]
pub no_depends_on: bool,
#[command(flatten)]
pub term_scope: TermScopeArgs,
}
#[derive(Debug, Subcommand)]
pub enum PriorityCommand {
Set(PrioritySetArgs),
}
#[derive(Debug, Args)]
pub struct PrioritySetArgs {
#[arg(value_name = "ID")]
pub id: String,
#[arg(value_name = "PRIORITY")]
pub priority: PriorityArg,
#[command(flatten)]
pub term_scope: TermScopeArgs,
}
#[derive(Debug, Subcommand)]
pub enum ContentCommand {
Set(ContentSetArgs),
}
#[derive(Debug, Args)]
pub struct ContentSetArgs {
#[arg(value_name = "ID")]
pub id: String,
#[arg(long, value_name = "PATH")]
pub file: std::path::PathBuf,
#[command(flatten)]
pub term_scope: TermScopeArgs,
}
#[derive(Debug, Subcommand)]
pub enum MetadataCommand {
Set(MetadataSetArgs),
}
#[derive(Debug, Args)]
pub struct MetadataSetArgs {
#[arg(value_name = "ID")]
pub id: String,
#[arg(value_name = "KEY")]
pub key: String,
#[arg(value_name = "VALUE", allow_hyphen_values = true)]
pub value: String,
#[command(flatten)]
pub term_scope: TermScopeArgs,
}
#[derive(Debug, Subcommand)]
pub enum StatusCommand {
Set(StatusSetArgs),
}
#[derive(Debug, Args)]
pub struct StatusSetArgs {
#[arg(value_name = "ID")]
pub id: String,
#[arg(value_name = "CATEGORY")]
pub category: StatusArg,
#[command(flatten)]
pub term_scope: TermScopeArgs,
}
#[derive(Debug, Subcommand)]
pub enum CommentCommand {
Add(CommentAddArgs),
List(CommentListArgs),
Edit(CommentEditArgs),
Delete(CommentDeleteArgs),
}
#[derive(Debug, Args)]
pub struct CommentAddArgs {
#[arg(value_name = "ID")]
pub id: String,
#[arg(long = "body-file", value_name = "PATH")]
pub body_file: Option<std::path::PathBuf>,
#[arg(long, value_name = "NAME")]
pub author: Option<String>,
#[command(flatten)]
pub term_scope: TermScopeArgs,
}
#[derive(Debug, Args)]
pub struct CommentListArgs {
#[arg(value_name = "ID")]
pub id: String,
}
#[derive(Debug, Args)]
pub struct CommentEditArgs {
#[arg(value_name = "ID")]
pub id: String,
#[arg(value_name = "COMMENT-ID", value_parser = native_id)]
pub comment_id: NativeId,
#[arg(long = "body-file", value_name = "PATH")]
pub body_file: Option<std::path::PathBuf>,
#[command(flatten)]
pub term_scope: TermScopeArgs,
}
#[derive(Debug, Args)]
pub struct CommentDeleteArgs {
#[arg(value_name = "ID")]
pub id: String,
#[arg(value_name = "COMMENT-ID", value_parser = native_id)]
pub comment_id: NativeId,
}
fn native_id(value: &str) -> Result<NativeId, std::convert::Infallible> {
Ok(NativeId::from(value))
}
#[derive(Debug, Subcommand)]
pub enum ProjectCommand {
List(ProjectListArgs),
Show(ShowArgs),
Deps(DependencyArgs),
#[command(after_long_help = GRAPH_CONTRACT)]
Graph(GraphArgs),
Copy(ProjectCopyArgs),
Metadata {
#[command(subcommand)]
command: MetadataCommand,
},
Create(ProjectCreateArgs),
Render(RenderArgs),
Answers(AnswersArgs),
}
#[derive(Debug, Subcommand)]
pub enum DocumentCommand {
List(DocumentListArgs),
Show(ShowArgs),
Copy(DocumentCopyArgs),
Metadata {
#[command(subcommand)]
command: MetadataCommand,
},
Create(DocumentCreateArgs),
Render(ItemRenderArgs),
Answers(AnswersArgs),
}
#[derive(Debug, Subcommand)]
pub enum LabelCommand {
List(LabelListArgs),
}
#[derive(Debug, Args)]
pub struct SelectionArgs {
#[arg(long = "source", value_name = "S")]
pub source: Vec<String>,
}
#[derive(Debug, Args)]
pub struct FilterArgs {
#[arg(long = "label", value_name = "L")]
pub label: Vec<String>,
#[arg(long = "not-label", value_name = "L")]
pub not_label: Vec<String>,
#[arg(long = "status", value_name = "S")]
pub status: Vec<StatusArg>,
#[arg(long, value_name = "TEXT")]
pub search: Option<String>,
#[arg(long = "in", value_name = "FIELDS", default_value = "both")]
pub fields: FieldsArg,
}
#[derive(Debug, Args)]
pub struct DocumentFilterArgs {
#[arg(long = "label", value_name = "L")]
pub label: Vec<String>,
#[arg(long = "not-label", value_name = "L")]
pub not_label: Vec<String>,
#[arg(long, value_name = "TEXT")]
pub search: Option<String>,
#[arg(long = "in", value_name = "FIELDS", default_value = "both")]
pub fields: FieldsArg,
}
#[derive(Debug, Args)]
pub struct PageArgs {
#[arg(long, value_name = "N")]
pub limit: Option<NonZeroU32>,
#[arg(long = "page", value_name = "TOKEN")]
pub page: Option<String>,
#[arg(long)]
pub explain: bool,
#[arg(long = "allow-partial")]
pub allow_partial: bool,
}
#[derive(Debug, Args)]
pub struct TaskListArgs {
#[command(flatten)]
pub selection: SelectionArgs,
#[command(flatten)]
pub filters: FilterArgs,
#[arg(long, value_name = "P", conflicts_with = "no_project")]
pub project: Option<String>,
#[arg(long = "no-project")]
pub no_project: bool,
#[arg(long = "members", requires = "project")]
pub members: bool,
#[arg(long = "priority", value_name = "PRIORITY")]
pub priority: Vec<PriorityArg>,
#[arg(long = "commented-since", value_name = "RFC3339", value_parser = instant)]
pub commented_since: Option<DateTime<Utc>>,
#[arg(long = "metadata", value_name = "KEY[/SEGMENT...]=VALUE", value_parser = metadata_match)]
pub metadata: Vec<MetadataMatch>,
#[arg(long = "origin", value_name = "SOURCE:ID", value_parser = origin)]
pub origin: Option<GlobalId>,
#[command(flatten)]
pub paging: PageArgs,
}
fn metadata_match(value: &str) -> Result<MetadataMatch, String> {
let Some((location, wanted)) = value.split_once('=') else {
return Err("no `=`; expected <KEY>[/<SEGMENT>...]=<VALUE>, such as \
orchestrator.follow-up/root_cause=stale-cache"
.to_owned());
};
let mut segments = location.split('/').map(str::to_owned);
let key = segments.next().unwrap_or_default();
MetadataMatch::new(key, segments.collect(), wanted).map_err(|refused| {
format!(
"{refused}; expected <KEY>[/<SEGMENT>...]=<VALUE>, such as \
orchestrator.follow-up/root_cause=stale-cache"
)
})
}
fn origin(value: &str) -> Result<GlobalId, String> {
GlobalId::from_str(value).map_err(|error| error.to_string())
}
fn instant(value: &str) -> Result<DateTime<Utc>, String> {
DateTime::parse_from_rfc3339(value)
.map(|parsed| parsed.with_timezone(&Utc))
.map_err(|error| {
format!(
"{error}; expected an RFC 3339 instant with its offset, such as \
2026-09-28T12:00:00Z or 2026-09-28T08:00:00-04:00"
)
})
}
#[derive(Debug, Args)]
pub struct ProjectListArgs {
#[command(flatten)]
pub selection: SelectionArgs,
#[command(flatten)]
pub filters: FilterArgs,
#[command(flatten)]
pub paging: PageArgs,
}
#[derive(Debug, Args)]
pub struct DocumentListArgs {
#[command(flatten)]
pub selection: SelectionArgs,
#[command(flatten)]
pub filters: DocumentFilterArgs,
#[arg(long, value_name = "P", conflicts_with = "no_project")]
pub project: Option<String>,
#[arg(long = "no-project")]
pub no_project: bool,
#[command(flatten)]
pub paging: PageArgs,
}
#[derive(Debug, Args)]
pub struct LabelListArgs {
#[command(flatten)]
pub selection: SelectionArgs,
#[command(flatten)]
pub paging: PageArgs,
}
#[derive(Debug, Args)]
pub struct TaskShowArgs {
#[command(flatten)]
pub item: ShowArgs,
#[arg(long)]
pub no_comments: bool,
}
#[derive(Debug, Args)]
pub struct TaskShowManyArgs {
#[arg(value_name = "ID", required = true)]
pub ids: Vec<String>,
#[arg(long)]
pub no_comments: bool,
}
#[derive(Debug, Args)]
pub struct ShowArgs {
#[arg(value_name = "ID")]
pub id: String,
#[arg(long)]
pub explain: bool,
#[arg(long = "allow-partial")]
pub allow_partial: bool,
}
#[derive(Debug, Args)]
pub struct DependencyArgs {
#[arg(value_name = "ID")]
pub id: String,
#[arg(long, value_name = "DIRECTION", default_value = "depends-on")]
pub direction: DirectionArg,
#[command(flatten)]
pub paging: PageArgs,
}
#[derive(Debug, Args)]
pub struct GraphArgs {
#[arg(value_name = "ID")]
pub id: String,
#[arg(long, value_name = "FORMAT", default_value = "mermaid")]
pub format: GraphFormat,
#[arg(long, value_name = "DIRECTION", default_value = "auto")]
pub direction: GraphDirectionArg,
#[arg(long = "group-by", value_name = "KEY", value_parser = group_key)]
pub group_by: Option<onetaskgraph_core::GroupKey>,
}
fn group_key(value: &str) -> Result<onetaskgraph_core::GroupKey, String> {
onetaskgraph_core::GroupKey::new(value)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum GraphFormat {
Mermaid,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum GraphDirectionArg {
Auto,
Td,
Lr,
}
impl GraphDirectionArg {
#[must_use]
pub const fn direction(self) -> Option<onetaskgraph_core::GraphDirection> {
match self {
Self::Auto => None,
Self::Td => Some(onetaskgraph_core::GraphDirection::Td),
Self::Lr => Some(onetaskgraph_core::GraphDirection::Lr),
}
}
}
const GRAPH_CONTRACT: &str = "\
The graph:
Every task of the project is drawn, across every page its source answers, with each
dependency a project task has on another task. A task's rank is 0 when it depends on no
task of the project, and otherwise one more than the highest rank among its prerequisites
in the project; tasks outside the project have no rank. The number of ranks is one more
than the highest rank, or 0 for a project with no tasks, and the widest rank is the most
project tasks sharing one rank.
Direction:
auto (the default) lays the graph out left to right (lr) when the widest rank holds more
tasks than there are ranks, and top-down (td) otherwise; td and lr are taken as given.
Mermaid form (the default), UTF-8, every line ending in a newline:
1. `flowchart TD` or `flowchart LR`, after auto is resolved.
2. One line ` n<k>[\"<label>\"]` per task of the project, in topological order: each
after every task of the project it depends on, ties broken by title and then by
qualified id, each compared byte by byte. <k> counts from 1 in that order.
3. One line ` x<k>[\"<label> (<qualified id>)\"]:::external` per task outside the
project that a project task depends on, ordered by title and then qualified id, <k>
counting from 1. The qualified id there is escaped as a label is.
4. One line ` <prerequisite> --> <dependent>` per dependency, so an arrow points from the
task depended on to the task that depends on it, ordered by the prerequisite's position
and then the dependent's, every n node before every x node.
5. Last, only when an x node was printed: ` classDef external stroke-dasharray: 5 5`.
A project with no tasks prints the first line alone, which auto makes `flowchart TD`.
Grouped (--group-by <KEY>):
A project task's group is the value its metadata holds under KEY when that is a non-empty
JSON string. A task without the key, or holding null or \"\" under it, has no group; any
other value is refused, naming the task's qualified id and the key, with nothing on
standard output. A task outside the project never has a group. Groups are ordered by the
position of their first member and numbered g<j> from 1 in that order. Grouping changes
no rank, no direction, no node number and no edge order. The Mermaid lines are
rearranged:
1. The first line, as above.
2. For each group in order: ` subgraph g<j>[\"<label of the group's value>\"]`; each
member's node line indented by four spaces, in <k> order; each edge whose two ends are
both members of this group, indented by four spaces, in edge order; then ` end`.
3. The node line of each project task with no group, in <k> order.
4. The x node lines.
5. Every remaining edge, in edge order.
6. The classDef line, as above.
Labels:
A task's label is its title with each line break (\\r\\n, \\n or \\r) written as one space;
then, in this order, # is written #35;, \" is written #quot;, < is written #lt; and > is
written #gt;. Nothing else is altered. A group's label is its value escaped the same way,
and the qualified id in an x line is escaped the same way too.
JSON form (--format json): one document and a newline, describing exactly the graph the
Mermaid form draws, and the form to read when a program has to know which task a node is:
{
\"schema_version\": 1,
\"project\": \"<qualified project id>\",
\"direction\": \"td\" | \"lr\",
\"group_by\": \"<the --group-by key>\" | null,
\"nodes\": [
{\"key\": \"n1\", \"id\": \"<qualified task id>\", \"title\": \"<the title, unescaped>\",
\"external\": false, \"group\": \"<the task's group>\" | null}
],
\"edges\": [
{\"from\": \"<qualified id of the prerequisite>\", \"to\": \"<qualified id of the dependent>\"}
]
}
direction is the resolved direction, never auto. nodes holds every n node and then every
x node, in the Mermaid form's order; key is the Mermaid node id, external is true exactly
for the x nodes, and group is null for a task with no group, for every external task, and
for every task without --group-by. edges follows the Mermaid form's edge order.
Determinism:
The output is a function of the project's task titles, their dependency edges, the
qualified ids of the tasks outside it, the direction and, grouped, the key and each
task's value under it. Two calls over an unchanged project print the same bytes.
Failure:
An id naming no project, a source that cannot answer a read, a dependency naming a task
its source does not hold, and tasks that depend on each other in a cycle each exit
non-zero with nothing on standard output and the problem on standard error; a graph is
never printed in part.
";
#[derive(Debug, Args)]
pub struct CopyArgs {
#[arg(long, value_name = "SOURCE")]
pub to: String,
#[arg(long = "match-by", value_name = "KEY")]
pub match_by: Option<String>,
#[arg(long)]
pub recreate: bool,
#[arg(long = "dry-run")]
pub dry_run: bool,
#[command(flatten)]
pub term_scope: TermScopeArgs,
}
#[derive(Debug, Args)]
pub struct TaskCopyArgs {
#[arg(value_name = "ID", required = true)]
pub id: Vec<String>,
#[arg(long)]
pub create: bool,
#[command(flatten)]
pub copy: CopyArgs,
}
#[derive(Debug, Args)]
pub struct ProjectCopyArgs {
#[arg(value_name = "ID")]
pub id: String,
#[arg(long = "no-tasks")]
pub no_tasks: bool,
#[arg(long = "member", value_name = "TASK-ID", conflicts_with = "no_tasks")]
pub member: Vec<String>,
#[command(flatten)]
pub copy: CopyArgs,
}
#[derive(Debug, Args)]
pub struct DocumentCopyArgs {
#[arg(value_name = "ID", required = true)]
pub id: Vec<String>,
#[command(flatten)]
pub copy: CopyArgs,
}
#[derive(Debug, Args)]
pub struct SearchArgs {
#[arg(value_name = "TEXT")]
pub text: String,
#[arg(long = "in", value_name = "FIELDS", default_value = "both")]
pub fields: FieldsArg,
#[arg(long, value_name = "KIND", default_value = "both")]
pub kind: KindArg,
#[command(flatten)]
pub selection: SelectionArgs,
#[command(flatten)]
pub paging: PageArgs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum StatusArg {
Draft,
Backlog,
Todo,
Queued,
InProgress,
Done,
Cancelled,
Unknown,
}
impl StatusArg {
#[must_use]
pub fn category(self) -> StatusCategory {
match self {
Self::Draft => StatusCategory::Draft,
Self::Backlog => StatusCategory::Backlog,
Self::Todo => StatusCategory::Todo,
Self::Queued => StatusCategory::Queued,
Self::InProgress => StatusCategory::InProgress,
Self::Done => StatusCategory::Done,
Self::Cancelled => StatusCategory::Cancelled,
Self::Unknown => StatusCategory::Unknown,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum PriorityArg {
None,
Urgent,
High,
Medium,
Low,
}
impl PriorityArg {
#[must_use]
pub fn priority(self) -> Priority {
match self {
Self::None => Priority::None,
Self::Urgent => Priority::Urgent,
Self::High => Priority::High,
Self::Medium => Priority::Medium,
Self::Low => Priority::Low,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ClassificationArg {
Public,
Private,
}
impl ClassificationArg {
#[must_use]
pub fn classification(self) -> Classification {
match self {
Self::Public => Classification::Public,
Self::Private => Classification::Private,
}
}
}
#[derive(Debug, Default, Args)]
pub struct TermScopeArgs {
#[arg(long = "term-scope", value_name = "REPOSITORY")]
pub term_scope: Vec<String>,
#[arg(long = "term-scope-empty", conflicts_with = "term_scope")]
pub term_scope_empty: bool,
}
impl TermScopeArgs {
#[must_use]
pub fn scope(&self) -> Option<Vec<String>> {
if self.term_scope_empty {
Some(Vec::new())
} else if self.term_scope.is_empty() {
None
} else {
Some(self.term_scope.clone())
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum FieldsArg {
Title,
Content,
Both,
}
impl FieldsArg {
#[must_use]
pub fn fields(self) -> TextFields {
match self {
Self::Title => TextFields::Title,
Self::Content => TextFields::Content,
Self::Both => TextFields::TitleOrContent,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum DirectionArg {
DependsOn,
DependedOnBy,
}
impl DirectionArg {
#[must_use]
pub fn direction(self) -> Direction {
match self {
Self::DependsOn => Direction::DependsOn,
Self::DependedOnBy => Direction::DependedOnBy,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum KindArg {
Task,
Project,
Both,
}
impl KindArg {
#[must_use]
pub fn kind(self) -> SearchKind {
match self {
Self::Task => SearchKind::Tasks,
Self::Project => SearchKind::Projects,
Self::Both => SearchKind::Both,
}
}
}
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
Show,
}
#[derive(Debug, Args)]
pub struct Overrides {
#[arg(long = "set", value_name = "PATH=VALUE", global = true)]
pub set: Vec<String>,
#[arg(
long,
value_name = "N",
global = true,
value_parser = clap::value_parser!(u32).range(1..)
)]
pub page_size: Option<u32>,
#[arg(long, value_name = "NAMES", value_delimiter = ',', global = true)]
pub default_sources: Option<Vec<String>>,
#[arg(long, value_name = "FORMAT", global = true, conflicts_with = "json")]
pub output: Option<Format>,
#[arg(long, global = true)]
pub json: bool,
#[arg(long, global = true, conflicts_with = "no_interactive")]
interactive: bool,
#[arg(long = "no-interactive", global = true)]
no_interactive: bool,
}
impl Overrides {
pub fn layer(&self) -> Result<Layer, String> {
let mut settings = Vec::new();
if let Some(page_size) = self.page_size {
settings.push(at("page_size", Value::from(page_size), "--page-size"));
}
if let Some(names) = &self.default_sources {
let names: Vec<Value> = names.iter().map(|name| Value::from(name.clone())).collect();
settings.push(at(
"default_sources",
Value::Array(names),
"--default-sources",
));
}
if let Some(format) = self.output {
settings.push(at("output", format.setting(), "--output"));
}
if self.json {
settings.push(at("output", Value::from("json"), "--json"));
}
if self.interactive {
settings.push(at("interactive", Value::Bool(true), "--interactive"));
}
if self.no_interactive {
settings.push(at("interactive", Value::Bool(false), "--no-interactive"));
}
for assignment in &self.set {
settings.push(assignment_setting(assignment)?);
}
Ok(Layer::new(settings))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Format {
Text,
Json,
}
impl Format {
#[must_use]
pub fn setting(self) -> Value {
let format = match self {
Self::Text => OutputFormat::Text,
Self::Json => OutputFormat::Json,
};
serde_json::to_value(format).expect("an output format renders as JSON")
}
}
fn at(key: &str, value: Value, flag: &str) -> Setting {
Setting {
key: SettingPath::parse(key).expect("a path this binary spells has no empty segment"),
value,
origin: Origin::Flag {
flag: flag.to_owned(),
},
}
}
fn assignment_setting(assignment: &str) -> Result<Setting, String> {
let Some((path, value)) = assignment.split_once('=') else {
return Err(format!(
"--set {assignment}: that is not an assignment\n\
next: write it as --set PATH=VALUE, for example --set page_size=10."
));
};
Ok(Setting {
key: SettingPath::parse(path).map_err(|error| format!("--set {error}"))?,
value: value_from_text(value),
origin: Origin::Flag {
flag: format!("--set {path}"),
},
})
}