#![expect(
clippy::disallowed_macros,
reason = "schemars' JsonSchema derive expands concat!; the ban is for hand-written strings"
)]
use std::path::{Component, Path};
use std::sync::LazyLock;
use garde::Validate;
use regex::Regex;
use schemars::{JsonSchema, schema_for};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
pub const VERSION: u32 = 4;
const SCHEMA_ID: &str =
"https://raw.githubusercontent.com/victor-software-house/qctl/main/schema/tasks.schema.json";
const PLAN_PATTERN: &str = r"^(?![/\\])(?!.*(?:^|[/\\])\.\.(?:[/\\]|$)).+\.md$";
static LINK_ITEMS: LazyLock<Value> = LazyLock::new(|| json!({"type": "string", "format": "uri"}));
macro_rules! pattern {
($name:ident = $source:literal, $what:literal) => {
#[doc = $what]
static $name: LazyLock<Regex> =
LazyLock::new(|| Regex::new($source).expect("a pattern! literal compiles"));
};
}
pattern!(
PREFIX = r"^[A-Z][A-Z0-9]{1,7}$",
"A repository's id prefix: PST, KAI, OMX."
);
pattern!(
TASK_ID = r"^[A-Z][A-Z0-9]{1,7}-[0-9]{3,6}$",
"A task id: that prefix, then three to six digits."
);
pattern!(
PATCH = r"^[a-z][a-z0-9-]*$",
"A changeset name, which is a file stem on disk."
);
pattern!(
INSTANT = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$",
"A moment to the second, in the zone the ledger declares. One shape, so stamps sort as they happened."
);
pattern!(
ZONE = r"^[+-][0-9]{2}:[0-9]{2}$",
"A fixed offset from UTC, as the ledger declares it: -03:00, +00:00."
);
#[derive(Debug, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
#[schemars(
title = "qctl work queue",
extend("$id" = SCHEMA_ID)
)]
pub struct Ledger {
#[garde(custom(the_one_version))]
#[schemars(extend("const" = VERSION))]
pub schema_version: u32,
#[garde(pattern(*PREFIX))]
pub prefix: String,
#[serde(default)]
#[garde(dive)]
pub style: Style,
#[garde(inner(pattern(*TASK_ID)))]
#[schemars(required, pattern(*TASK_ID), extend("type" = ["string", "null"]))]
pub active: Option<String>,
#[garde(dive)]
pub queue: Vec<QueuedTask>,
#[garde(dive)]
pub archive: Vec<ArchivedTask>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[garde(dive)]
pub horizon: Vec<HorizonTask>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct QueuedTask {
#[garde(pattern(*TASK_ID))]
pub id: String,
#[garde(length(min = 1))]
pub title: String,
#[garde(length(min = 1))]
pub scope: String,
#[garde(length(min = 1))]
pub outcome: String,
#[garde(inner(pattern(*TASK_ID)))]
#[schemars(extend("uniqueItems" = true))]
pub blocked_by: Vec<String>,
#[garde(length(min = 1), inner(length(min = 1)))]
#[schemars(extend("uniqueItems" = true))]
pub acceptance: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(inner(pattern(*PATCH)))]
#[schemars(pattern(*PATCH))]
pub patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(inner(custom(inside_the_repo)))]
#[schemars(pattern(PLAN_PATTERN))]
pub plan: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[garde(inner(length(min = 1)))]
#[schemars(extend("uniqueItems" = true, "items" = *LINK_ITEMS))]
pub links: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[garde(inner(length(min = 1)))]
#[schemars(extend("uniqueItems" = true))]
pub notes: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct ArchivedTask {
#[garde(pattern(*TASK_ID))]
pub id: String,
#[garde(length(min = 1))]
pub title: String,
#[garde(length(min = 1))]
pub scope: String,
#[garde(pattern(*INSTANT), custom(a_real_instant))]
pub completed: String,
#[garde(length(min = 1))]
pub outcome: String,
#[garde(length(min = 1), inner(length(min = 1)))]
#[schemars(extend("uniqueItems" = true))]
pub evidence: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(skip)]
pub disposition: Option<Disposition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(inner(pattern(*PATCH)))]
#[schemars(pattern(*PATCH))]
pub patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(inner(custom(inside_the_repo)))]
#[schemars(pattern(PLAN_PATTERN))]
pub plan: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[garde(inner(length(min = 1)))]
#[schemars(extend("uniqueItems" = true, "items" = *LINK_ITEMS))]
pub links: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[garde(inner(length(min = 1)))]
#[schemars(extend("uniqueItems" = true))]
pub notes: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct HorizonTask {
#[garde(pattern(*TASK_ID))]
pub id: String,
#[garde(length(min = 1))]
pub title: String,
#[garde(length(min = 1))]
pub scope: String,
#[garde(length(min = 1))]
pub outcome: String,
#[garde(skip)]
pub kind: Kind,
#[garde(length(min = 1))]
pub open: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(inner(pattern(*PATCH)))]
#[schemars(pattern(*PATCH))]
pub patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(inner(custom(inside_the_repo)))]
#[schemars(pattern(PLAN_PATTERN))]
pub plan: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[garde(inner(length(min = 1)))]
#[schemars(extend("uniqueItems" = true, "items" = *LINK_ITEMS))]
pub links: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[garde(inner(length(min = 1)))]
#[schemars(extend("uniqueItems" = true))]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields, default)]
pub struct Style {
#[garde(pattern(*ZONE))]
pub timezone: String,
#[garde(custom(all_three_lists))]
#[schemars(extend("uniqueItems" = true, "minItems" = 3, "maxItems" = 3))]
pub section_order: Vec<Section>,
#[garde(range(min = 1, max = 8))]
pub indent: u8,
#[garde(skip)]
pub archive_order: ArchiveOrder,
#[garde(skip)]
pub row_spacing: RowSpacing,
#[garde(skip)]
pub normalize_on_write: bool,
}
impl Default for Style {
fn default() -> Self {
Self {
timezone: "+00:00".to_owned(),
section_order: vec![Section::Queue, Section::Archive, Section::Horizon],
indent: 2,
archive_order: ArchiveOrder::NewestFirst,
row_spacing: RowSpacing::AsWritten,
normalize_on_write: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Section {
Queue,
Archive,
Horizon,
}
impl Section {
pub const ALL: [Self; 3] = [Self::Queue, Self::Archive, Self::Horizon];
#[must_use]
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "public API: a by-value receiver breaks iter().map(Self::key) callers"
)]
pub fn key(&self) -> &'static str {
match self {
Self::Queue => "queue",
Self::Archive => "archive",
Self::Horizon => "horizon",
}
}
}
impl ArchiveOrder {
#[must_use]
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "public API: a by-value receiver breaks iter().map(Self::key) callers"
)]
pub fn key(&self) -> &'static str {
match self {
Self::NewestFirst => "newest_first",
Self::AsWritten => "as_written",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ArchiveOrder {
#[default]
NewestFirst,
AsWritten,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RowSpacing {
#[default]
AsWritten,
BlankLine,
}
fn all_three_lists<C>(order: &[Section], _: &C) -> garde::Result {
let mut seen: Vec<Section> = order.to_vec();
seen.sort_unstable_by_key(|section| format!("{section:?}"));
seen.dedup();
if seen.len() == 3 {
return Ok(());
}
Err(garde::Error::new(
"must name queue, archive and horizon, once each",
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Disposition {
Completed,
Dropped,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Research,
Evaluation,
Deferred,
}
impl std::fmt::Display for Kind {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let word = match self {
Self::Research => "research",
Self::Evaluation => "evaluation",
Self::Deferred => "deferred",
};
formatter.write_str(word)
}
}
pub(crate) fn inside_the_repo<C>(path: &str, _: &C) -> garde::Result {
let normalized = path.replace('\\', "/");
let candidate = Path::new(&normalized);
if candidate.is_absolute()
|| normalized.starts_with('/')
|| candidate
.components()
.any(|part| matches!(part, Component::ParentDir))
{
return Err(garde::Error::new("must stay inside the repository"));
}
if candidate.extension().is_none_or(|kind| kind != "md") {
return Err(garde::Error::new("must be a markdown document"));
}
Ok(())
}
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "garde hands a custom validator a reference, whatever the type"
)]
fn the_one_version<C>(value: &u32, _: &C) -> garde::Result {
if *value != VERSION {
return Err(garde::Error::new(format!("must be {VERSION}")));
}
Ok(())
}
pub fn a_real_instant<C>(value: &str, _: &C) -> garde::Result {
if is_a_real_instant(value) {
return Ok(());
}
Err(garde::Error::new("must be a moment that exists"))
}
#[must_use]
pub fn is_a_real_instant(value: &str) -> bool {
let format = time::macros::format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]");
time::PrimitiveDateTime::parse(value, format).is_ok()
}
pub const COMMITTED: &str = "schema/tasks.schema.json";
pub fn generated() -> anyhow::Result<String> {
let schema = serde_json::to_value(schema_for!(Ledger))?;
let mut out = Vec::new();
let mut serializer = serde_json::Serializer::with_formatter(
&mut out,
serde_json::ser::PrettyFormatter::with_indent(b"\t"),
);
schema.serialize(&mut serializer)?;
out.push(b'\n');
Ok(String::from_utf8(out)?)
}
pub fn write(args: &crate::cli::SchemaArgs) -> anyhow::Result<crate::report::Report> {
let path = args
.out
.clone()
.unwrap_or_else(|| std::path::PathBuf::from(COMMITTED));
std::fs::write(&path, generated()?)?;
Ok(crate::report::Report::SchemaWritten {
path: path.display().to_string(),
})
}