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::{
Direction, MetadataMatch, NativeId, Priority, 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),
}
#[derive(Debug, Args)]
pub struct SetupArgs {
pub source: String,
#[arg(long)]
pub apply: bool,
}
#[derive(Debug, Subcommand)]
pub enum TaskCommand {
List(TaskListArgs),
Show(ShowArgs),
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(RenderArgs),
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>,
#[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>,
}
#[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>,
}
#[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,
}
#[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,
}
#[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,
}
#[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,
}
#[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,
}
#[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,
}
#[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>,
}
#[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>,
}
#[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),
Copy(ProjectCopyArgs),
Metadata {
#[command(subcommand)]
command: MetadataCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum DocumentCommand {
List(DocumentListArgs),
Show(ShowArgs),
Copy(DocumentCopyArgs),
Metadata {
#[command(subcommand)]
command: MetadataCommand,
},
Create(DocumentCreateArgs),
Render(RenderArgs),
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 = "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 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 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,
}
#[derive(Debug, Args)]
pub struct TaskCopyArgs {
#[arg(value_name = "ID", required = true)]
pub id: Vec<String>,
#[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 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}"),
},
})
}