use crate::error::{EngineError, Result};
use serde::{Deserialize, Serialize};
use std::io::Read as _;
use std::path::{Path, PathBuf};
const DEFAULT_PRIORITY: u8 = 2;
const TASK_CLASS_HEADING: &str = "## Task class\n";
pub const MAX_TICKET_BUDGET_USD: f64 = 100.0;
const MAX_TASK_CLASS_LEN: usize = 64;
const KNOWN_FRONTMATTER_KEYS: &[&str] = &[
"title",
"priority",
"repo-refs",
"reporefs",
"blocked-by",
"blockedby",
"task-class",
"taskclass",
"review-artifact",
"reviewartifact",
"review-output",
"reviewoutput",
"trigger",
"traced-from-mission",
"tracedfrommission",
"defer-until",
"deferuntil",
"schedule",
"state",
"state-note",
"statenote",
"max-budget-usd",
"maxbudgetusd",
];
pub fn is_known_frontmatter_key(key: &str) -> bool {
KNOWN_FRONTMATTER_KEYS.contains(&key)
}
fn parse_task_class_value(slug: &str, raw: &str) -> Option<String> {
let value = crate::routing::normalize_task_class(raw);
if value.is_empty() {
return None;
}
let well_formed = value.len() <= MAX_TASK_CLASS_LEN
&& value.starts_with(|c: char| c.is_ascii_alphanumeric())
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if !well_formed {
tracing::warn!(slug, value = %value, "invalid ticket task-class; ignoring");
return None;
}
Some(value)
}
pub fn parse_task_class_from_goal(goal: &str) -> Option<String> {
let idx = goal.rfind(TASK_CLASS_HEADING)?;
let rest = &goal[idx + TASK_CLASS_HEADING.len()..];
let line = rest.lines().next()?.trim();
(!line.is_empty()).then(|| line.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum Schedule {
#[default]
Once,
Nightly,
Weekly,
}
impl Schedule {
fn parse(raw: &str) -> Schedule {
match raw.trim().to_ascii_lowercase().as_str() {
"once" => Schedule::Once,
"nightly" => Schedule::Nightly,
"weekly" => Schedule::Weekly,
other => {
tracing::warn!(schedule = %other, "unknown ticket schedule; defaulting to once");
Schedule::Once
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TicketLifecycle {
#[default]
Open,
Done,
Superseded,
Wontfix,
}
impl TicketLifecycle {
pub fn as_str(self) -> &'static str {
match self {
TicketLifecycle::Open => "open",
TicketLifecycle::Done => "done",
TicketLifecycle::Superseded => "superseded",
TicketLifecycle::Wontfix => "wontfix",
}
}
fn parse(slug: &str, raw: &str) -> Result<TicketLifecycle> {
match raw.trim().to_ascii_lowercase().as_str() {
"open" => Ok(TicketLifecycle::Open),
"done" => Ok(TicketLifecycle::Done),
"superseded" => Ok(TicketLifecycle::Superseded),
"wontfix" => Ok(TicketLifecycle::Wontfix),
other => Err(EngineError::Config(format!(
"ticket {slug}: invalid state '{other}' (expected open, done, \
superseded, or wontfix)"
))),
}
}
fn terminal_pipeline_state(self) -> Option<TicketState> {
match self {
TicketLifecycle::Open => None,
TicketLifecycle::Done => Some(TicketState::Done),
TicketLifecycle::Superseded => Some(TicketState::Superseded),
TicketLifecycle::Wontfix => Some(TicketState::Wontfix),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Ticket {
pub slug: String,
pub title: String,
pub priority: u8,
pub repo_refs: Vec<String>,
pub schedule: Schedule,
pub max_budget_usd: Option<f64>,
pub goal: String,
pub context: String,
pub scoping_answers: Vec<String>,
pub acceptance_hints: Vec<String>,
pub blocked_by: Vec<String>,
pub task_class: Option<String>,
pub review_artifact: Option<String>,
pub review_output: Option<String>,
pub trigger: Option<String>,
pub traced_from_mission: Option<String>,
pub defer_until: Option<chrono::DateTime<chrono::Utc>>,
pub lifecycle: Option<TicketLifecycle>,
pub state_note: Option<String>,
pub raw_body: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum TicketState {
#[default]
New,
Drafting,
NeedsContext,
WrongPlan,
Review,
Queued,
Running,
Done,
Failed,
Parked,
Superseded,
Wontfix,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct StatusFile {
state: TicketState,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mission_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StateDivergence {
pub frontmatter: TicketState,
pub sidecar: TicketState,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedTicketState {
pub state: TicketState,
pub divergence: Option<StateDivergence>,
}
impl Ticket {
pub fn tickets_dir(repo_root: &Path) -> PathBuf {
repo_root.join(".kranz").join("tickets")
}
pub fn valid_slug(slug: &str) -> bool {
!slug.is_empty()
&& slug.len() <= 128
&& !slug.starts_with('.')
&& !slug.contains("..")
&& slug
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
pub fn ensure_valid_slug(slug: &str) -> Result<()> {
if Self::valid_slug(slug) {
Ok(())
} else {
Err(EngineError::Config(format!(
"invalid ticket slug '{slug}': use letters, digits, '-', '_' \
(no path separators, no leading dot, no '..')"
)))
}
}
pub fn ticket_template(title: &str, goal: Option<&str>, context: Option<&str>) -> String {
let title = crate::scrub::scrub(title.trim());
let goal_body = goal
.map(str::trim)
.filter(|g| !g.is_empty())
.map(crate::scrub::scrub)
.unwrap_or_default();
let context_body = context
.map(str::trim)
.filter(|c| !c.is_empty())
.map(crate::scrub::scrub)
.unwrap_or_default();
format!(
"---\n\
title: {title}\n\
priority: 2\n\
schedule: once\n\
---\n\
\n\
## Goal\n\
{goal_body}\n\
\n\
## Context\n\
{context_body}\n\
\n\
## Scoping answers\n\
\n\
## Acceptance hints\n"
)
}
pub fn scaffold(
repo_root: &Path,
slug: &str,
title: &str,
goal: Option<&str>,
context: Option<&str>,
) -> Result<PathBuf> {
let body = Self::ticket_template(title, goal, context);
Self::create_markdown(repo_root, slug, &body)
}
pub fn create_markdown(repo_root: &Path, slug: &str, body: &str) -> Result<PathBuf> {
use cap_fs_ext::OpenOptionsFollowExt as _;
use cap_primitives::fs::FollowSymlinks;
use cap_std::ambient_authority;
use cap_std::fs::{Dir, OpenOptions};
use std::io::Write as _;
Self::ensure_valid_slug(slug)?;
Self::parse(slug, body)?;
let mut dir = Dir::open_ambient_dir(repo_root, ambient_authority())?;
let mut path = repo_root.to_path_buf();
for segment in [".kranz", "tickets"] {
path.push(segment);
dir = crate::paths::open_real_subdir(&dir, segment, &path, true)?;
}
let name = format!("{slug}.md");
path.push(&name);
let mut options = OpenOptions::new();
options
.write(true)
.create_new(true)
.follow(FollowSymlinks::No);
let mut file = dir.open_with(&name, &options).map_err(|error| {
if error.kind() == std::io::ErrorKind::AlreadyExists {
EngineError::InvalidState(format!(
"ticket '{slug}' already exists at {}",
path.display()
))
} else {
error.into()
}
})?;
file.write_all(body.as_bytes())?;
file.sync_data()?;
Ok(path)
}
pub fn parse(slug: &str, markdown: &str) -> Result<Ticket> {
let (front, body) = split_frontmatter(slug, markdown)?;
let mut title: Option<String> = None;
let mut priority = DEFAULT_PRIORITY;
let mut repo_refs: Vec<String> = Vec::new();
let mut schedule = Schedule::Once;
let mut max_budget_usd: Option<f64> = None;
let mut blocked_by: Vec<String> = Vec::new();
let mut task_class: Option<String> = None;
let mut review_artifact: Option<String> = None;
let mut review_output: Option<String> = None;
let mut trigger: Option<String> = None;
let mut traced_from_mission: Option<String> = None;
let mut defer_until: Option<chrono::DateTime<chrono::Utc>> = None;
let mut lifecycle: Option<TicketLifecycle> = None;
let mut state_note: Option<String> = None;
for (key, value) in front {
match key.as_str() {
"title" => title = Some(value.scalar()),
"priority" => {
if let Ok(p) = value.scalar().parse::<u8>() {
priority = p;
} else {
tracing::warn!(slug, value = %value.scalar(), "invalid ticket priority; keeping default");
}
}
"repo-refs" | "reporefs" => repo_refs = value.list(),
"blocked-by" | "blockedby" => blocked_by = value.list(),
"task-class" | "taskclass" => {
task_class = parse_task_class_value(slug, &value.scalar());
}
"review-artifact" | "reviewartifact" => {
let v = value.scalar().trim().to_string();
review_artifact = if v.is_empty() { None } else { Some(v) };
}
"review-output" | "reviewoutput" => {
let v = value.scalar().trim().to_string();
review_output = if v.is_empty() { None } else { Some(v) };
}
"trigger" => {
let v = value.scalar().trim().to_string();
trigger = if v.is_empty() { None } else { Some(v) };
}
"traced-from-mission" | "tracedfrommission" => {
let v = value.scalar().trim().to_string();
traced_from_mission = if v.is_empty() { None } else { Some(v) };
}
"defer-until" | "deferuntil" => {
let v = value.scalar().trim().to_string();
if !v.is_empty() {
defer_until = Some(parse_defer_until(slug, &v)?);
}
}
"schedule" => schedule = Schedule::parse(&value.scalar()),
"state" => {
let v = value.scalar().trim().to_string();
if !v.is_empty() {
lifecycle = Some(TicketLifecycle::parse(slug, &v)?);
}
}
"state-note" | "statenote" => {
let v = value.scalar().trim().to_string();
state_note = if v.is_empty() { None } else { Some(v) };
}
"maxbudgetusd" | "max-budget-usd" => match value.scalar().parse::<f64>() {
Ok(b) if b.is_finite() && b > 0.0 => {
if b > MAX_TICKET_BUDGET_USD {
tracing::warn!(
slug,
requested = b,
ceiling = MAX_TICKET_BUDGET_USD,
"ticket maxBudgetUsd exceeds the ceiling; clamping"
);
}
max_budget_usd = Some(b.min(MAX_TICKET_BUDGET_USD));
}
_ => {
tracing::warn!(slug, value = %value.scalar(), "invalid ticket maxBudgetUsd; ignoring");
}
},
other => {
debug_assert!(!is_known_frontmatter_key(other));
tracing::warn!(slug, key = %other, "unknown ticket frontmatter key; ignoring");
}
}
}
let sections = parse_sections(&body);
let goal = sections.goal.unwrap_or_default();
let context = sections.context.unwrap_or_default();
let title = title
.filter(|t| !t.trim().is_empty())
.or(sections.first_heading)
.unwrap_or_else(|| slug.to_string());
let review_contract = crate::review_artifact::from_ticket_fields(
slug,
task_class.as_deref(),
review_artifact.as_deref(),
review_output.as_deref(),
)?;
let (review_artifact, review_output) = review_contract
.map(|contract| (Some(contract.input_path), Some(contract.output_path)))
.unwrap_or((None, None));
Ok(Ticket {
slug: slug.to_string(),
title,
priority,
repo_refs,
schedule,
max_budget_usd,
goal,
context,
scoping_answers: sections.scoping_answers,
acceptance_hints: sections.acceptance_hints,
blocked_by,
task_class,
review_artifact,
review_output,
trigger,
traced_from_mission,
defer_until,
lifecycle,
state_note,
raw_body: body,
})
}
pub fn load(path: &Path) -> Result<Ticket> {
let slug = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| {
EngineError::Config(format!("ticket path has no file stem: {}", path.display()))
})?
.to_string();
let mut text = String::new();
crate::paths::open_read_nofollow(path)?.read_to_string(&mut text)?;
Ticket::parse(&slug, &text)
}
pub fn list(repo_root: &Path) -> Vec<Ticket> {
let dir = Self::tickets_dir(repo_root);
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(&dir) else {
return out;
};
for entry in rd.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
match Ticket::load(&path) {
Ok(t) => out.push(t),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "skipping unparseable ticket");
}
}
}
out.sort_by(|a, b| {
a.priority
.cmp(&b.priority)
.then_with(|| a.slug.cmp(&b.slug))
});
out
}
pub fn is_ready_at(&self, now: chrono::DateTime<chrono::Utc>) -> bool {
self.defer_until.is_none_or(|until| until <= now)
}
pub fn mission_goal(&self) -> String {
let mut out = String::new();
if self.goal.trim().is_empty() {
out.push_str(&self.title);
} else {
out.push_str(self.goal.trim());
}
if !self.scoping_answers.is_empty() {
out.push_str("\n\n## Scoping answers\n");
for item in &self.scoping_answers {
out.push_str("- ");
out.push_str(item);
out.push('\n');
}
}
if !self.acceptance_hints.is_empty() {
out.push_str("\n## Acceptance hints\n");
for item in &self.acceptance_hints {
out.push_str("- ");
out.push_str(item);
out.push('\n');
}
}
if !self.context.trim().is_empty() {
out.push_str("\n## Context\n");
out.push_str(self.context.trim());
out.push('\n');
}
let review_contract = crate::review_artifact::from_ticket_fields(
&self.slug,
self.task_class.as_deref(),
self.review_artifact.as_deref(),
self.review_output.as_deref(),
)
.expect("parsed ticket keeps a valid review-artifact contract");
if let Some(contract) = review_contract {
out.push_str(&crate::review_artifact::render_goal_section(&contract));
}
let task_class = self
.task_class
.as_deref()
.map(str::trim)
.filter(|class| !class.is_empty())
.unwrap_or_default();
out.push('\n');
out.push_str(TASK_CLASS_HEADING);
out.push_str(task_class);
out.push('\n');
out
}
fn status_path(repo_root: &Path, slug: &str) -> PathBuf {
Self::tickets_dir(repo_root).join(format!("{slug}.status"))
}
pub(crate) fn md_path(repo_root: &Path, slug: &str) -> PathBuf {
Self::tickets_dir(repo_root).join(format!("{slug}.md"))
}
fn frontmatter_lifecycle(repo_root: &Path, slug: &str) -> Option<TicketLifecycle> {
let text = std::fs::read_to_string(Self::md_path(repo_root, slug)).ok()?;
let (front, _) = split_frontmatter(slug, &text).ok()?;
for (key, value) in front {
if key != "state" {
continue;
}
let v = value.scalar().trim().to_string();
if v.is_empty() {
return None;
}
return match TicketLifecycle::parse(slug, &v) {
Ok(lifecycle) => Some(lifecycle),
Err(e) => {
tracing::warn!(slug, error = %e, "invalid frontmatter state; sidecar governs");
None
}
};
}
None
}
fn sidecar_state(repo_root: &Path, slug: &str) -> Option<TicketState> {
Self::read_status_file(repo_root, slug).map(|sf| sf.state)
}
pub fn resolve_state(repo_root: &Path, slug: &str) -> ResolvedTicketState {
if !Self::valid_slug(slug) {
return ResolvedTicketState {
state: TicketState::New,
divergence: None,
};
}
let sidecar = Self::sidecar_state(repo_root, slug);
let defer = |state: TicketState| ResolvedTicketState {
state,
divergence: None,
};
let Some(lifecycle) = Self::frontmatter_lifecycle(repo_root, slug) else {
return defer(sidecar.unwrap_or(TicketState::New));
};
let Some(terminal) = lifecycle.terminal_pipeline_state() else {
return defer(sidecar.unwrap_or(TicketState::New));
};
let divergence = match sidecar {
Some(sidecar) if sidecar != terminal => Some(StateDivergence {
frontmatter: terminal,
sidecar,
}),
_ => None,
};
ResolvedTicketState {
state: terminal,
divergence,
}
}
pub fn read_state(repo_root: &Path, slug: &str) -> TicketState {
let resolved = Self::resolve_state(repo_root, slug);
if let Some(divergence) = &resolved.divergence {
tracing::warn!(
slug,
frontmatter = ?divergence.frontmatter,
sidecar = ?divergence.sidecar,
"ticket frontmatter state overrides diverging .status cache"
);
}
resolved.state
}
pub fn write_state(
repo_root: &Path,
slug: &str,
state: TicketState,
note: Option<String>,
) -> Result<()> {
Self::ensure_valid_slug(slug)?;
let dir = Self::tickets_dir(repo_root);
std::fs::create_dir_all(&dir)?;
let mission_id = Self::read_status_file(repo_root, slug).and_then(|sf| sf.mission_id);
let sf = StatusFile {
state,
note,
mission_id,
};
let json = serde_json::to_string_pretty(&sf)?;
atomic_write(&Self::status_path(repo_root, slug), json.as_bytes())?;
Ok(())
}
fn read_status_file(repo_root: &Path, slug: &str) -> Option<StatusFile> {
if !Self::valid_slug(slug) {
return None;
}
let path = Self::status_path(repo_root, slug);
let text = std::fs::read_to_string(&path).ok()?;
match serde_json::from_str(&text) {
Ok(sf) => Some(sf),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "unreadable ticket status; ignoring");
None
}
}
}
pub(crate) fn sidecar_record(
repo_root: &Path,
slug: &str,
) -> Option<(TicketState, Option<String>)> {
Self::read_status_file(repo_root, slug).map(|sf| (sf.state, sf.note))
}
pub fn write_lifecycle(
repo_root: &Path,
slug: &str,
state: TicketLifecycle,
note: Option<String>,
) -> Result<()> {
Self::ensure_valid_slug(slug)?;
let terminal = state.terminal_pipeline_state().ok_or_else(|| {
EngineError::Config(format!(
"write_lifecycle takes a terminal state (done, superseded, wontfix); \
'open' is the absence of a `state:` key (ticket {slug})"
))
})?;
let note = note.map(|n| bound_state_note(&n)).filter(|n| !n.is_empty());
let md = Self::md_path(repo_root, slug);
let text = std::fs::read_to_string(&md)?;
let updated = upsert_frontmatter_state(slug, &text, state, note.as_deref())?;
atomic_write(&md, updated.as_bytes())?;
Self::write_state(repo_root, slug, terminal, note)?;
Ok(())
}
pub fn record_mission(repo_root: &Path, slug: &str, mission_id: &str) -> Result<()> {
Self::ensure_valid_slug(slug)?;
let dir = Self::tickets_dir(repo_root);
std::fs::create_dir_all(&dir)?;
let (state, note) = match Self::read_status_file(repo_root, slug) {
Some(sf) => (sf.state, sf.note),
None => (TicketState::Drafting, None),
};
let sf = StatusFile {
state,
note,
mission_id: Some(mission_id.to_string()),
};
let json = serde_json::to_string_pretty(&sf)?;
atomic_write(&Self::status_path(repo_root, slug), json.as_bytes())?;
Ok(())
}
pub fn mission_for(repo_root: &Path, slug: &str) -> Option<String> {
Self::read_status_file(repo_root, slug).and_then(|sf| sf.mission_id)
}
pub fn slug_for_mission(repo_root: &Path, mission_id: &str) -> Option<String> {
if mission_id.is_empty() {
return None;
}
let dir = Self::tickets_dir(repo_root);
let rd = std::fs::read_dir(&dir).ok()?;
let mut slugs: Vec<String> = rd
.flatten()
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("status"))
.filter_map(|e| {
e.path()
.file_stem()
.and_then(|s| s.to_str())
.filter(|s| Self::valid_slug(s))
.map(str::to_string)
})
.collect();
slugs.sort();
let mut found: Option<String> = None;
for slug in slugs {
if Self::mission_for(repo_root, &slug).as_deref() == Some(mission_id) {
match &found {
None => found = Some(slug),
Some(first) => {
tracing::warn!(
mission_id,
resolved = %first,
duplicate = %slug,
"multiple tickets link one mission; using the first by sorted slug"
);
}
}
}
}
found
}
pub fn append_needs_context(repo_root: &Path, slug: &str, questions: &[String]) -> Result<()> {
Self::ensure_valid_slug(slug)?;
let md = Self::md_path(repo_root, slug);
let mut text = std::fs::read_to_string(&md)?;
if !text.ends_with('\n') {
text.push('\n');
}
text.push_str("\n## Needs context (from orchestrator)\n");
for q in bound_questions(questions) {
text.push_str("- ");
text.push_str(&crate::scrub::scrub(&q));
text.push('\n');
}
atomic_write(&md, text.as_bytes())?;
Self::write_state(repo_root, slug, TicketState::NeedsContext, None)?;
Ok(())
}
pub fn append_wrong_plan(repo_root: &Path, slug: &str, reason: &str) -> Result<()> {
Self::ensure_valid_slug(slug)?;
let reason = bound_reason(reason);
let md = Self::md_path(repo_root, slug);
let mut text = std::fs::read_to_string(&md)?;
if !text.ends_with('\n') {
text.push('\n');
}
text.push_str("\n## Wrong plan (from orchestrator)\n");
text.push_str(&crate::scrub::scrub(&reason));
text.push('\n');
atomic_write(&md, text.as_bytes())?;
Self::write_state(
repo_root,
slug,
TicketState::WrongPlan,
Some(format!("WRONG-PLAN: {reason}")),
)?;
Ok(())
}
pub fn seed_traced_from_mission(
repo_root: &Path,
slug: &str,
mission_id: &str,
) -> Result<bool> {
Self::ensure_valid_slug(slug)?;
if !crate::paths::MissionPaths::is_safe_id(mission_id) {
return Err(EngineError::Config(format!(
"invalid mission id '{mission_id}' for traced-from-mission"
)));
}
let md = Self::md_path(repo_root, slug);
let text = std::fs::read_to_string(&md)?;
let new_line = format!("traced-from-mission: {mission_id}");
let (bom, source) = match text.strip_prefix('\u{feff}') {
Some(rest) => ("\u{feff}", rest),
None => ("", text.as_str()),
};
let lines: Vec<&str> = source.split_inclusive('\n').collect();
let has_frontmatter = lines
.first()
.map(|line| line.trim_end() == "---")
.unwrap_or(false);
let mut out = String::with_capacity(text.len() + new_line.len() + 8);
out.push_str(bom);
if !has_frontmatter {
out.push_str("---\n");
out.push_str(&new_line);
out.push('\n');
out.push_str("---\n\n");
out.push_str(source);
atomic_write(&md, out.as_bytes())?;
return Ok(true);
}
let mut closing: Option<usize> = None;
let mut existing: Option<(usize, String)> = None;
for (i, line) in lines.iter().enumerate().skip(1) {
if line.trim_end() == "---" {
closing = Some(i);
break;
}
if existing.is_none() && !line.trim_start().starts_with('#') {
if let Some((key, value)) = line.split_once(':') {
let key = normalize_key(key);
if key == "traced-from-mission" || key == "tracedfrommission" {
existing = Some((i, unquote(value.trim())));
}
}
}
}
if closing.is_none() {
return Err(EngineError::Config(format!(
"ticket {slug}: frontmatter opened with `---` but was never closed"
)));
}
if let Some((_, value)) = &existing {
if value == mission_id {
return Ok(false);
}
}
let replace_idx = existing.as_ref().map(|(i, _)| *i);
for (i, line) in lines.iter().enumerate() {
if i == 1 && replace_idx.is_none() {
out.push_str(&new_line);
out.push('\n');
}
if replace_idx == Some(i) {
out.push_str(&new_line);
out.push('\n');
} else {
out.push_str(line);
}
}
atomic_write(&md, out.as_bytes())?;
Ok(true)
}
}
const MAX_QUESTION_CHARS: usize = 500;
const MAX_QUESTION_COUNT: usize = 20;
fn truncate_one(q: &str) -> String {
if q.chars().count() > MAX_QUESTION_CHARS {
let mut truncated: String = q.chars().take(MAX_QUESTION_CHARS).collect();
truncated.push_str(" … (truncated)");
truncated
} else {
q.to_string()
}
}
fn bound_reason(reason: &str) -> String {
truncate_one(reason.trim())
}
fn bound_state_note(note: &str) -> String {
truncate_one(¬e.split_whitespace().collect::<Vec<_>>().join(" "))
}
fn bound_questions(questions: &[String]) -> Vec<String> {
if questions.len() <= MAX_QUESTION_COUNT {
return questions.iter().map(|q| truncate_one(q)).collect();
}
let mut out: Vec<String> = questions[..MAX_QUESTION_COUNT]
.iter()
.map(|q| truncate_one(q))
.collect();
let omitted = questions.len() - MAX_QUESTION_COUNT;
out.push(format!("… ({omitted} more omitted)"));
out
}
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(dir)?;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("ticket");
let tmp = dir.join(format!(".{file_name}.{}.tmp", std::process::id()));
std::fs::write(&tmp, bytes)?;
match std::fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(_) if cfg!(windows) => {
let _ = std::fs::remove_file(path);
std::fs::rename(&tmp, path)?;
Ok(())
}
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e.into())
}
}
}
enum FrontValue {
Scalar(String),
List(Vec<String>),
}
impl FrontValue {
fn scalar(&self) -> String {
match self {
FrontValue::Scalar(s) => s.clone(),
FrontValue::List(items) => items.join(", "),
}
}
fn list(&self) -> Vec<String> {
match self {
FrontValue::List(items) => items.clone(),
FrontValue::Scalar(s) if !s.is_empty() => vec![s.clone()],
FrontValue::Scalar(_) => Vec::new(),
}
}
}
fn split_frontmatter(slug: &str, markdown: &str) -> Result<(Vec<(String, FrontValue)>, String)> {
let source = markdown.trim_start_matches('\u{feff}');
let first_line_end = source.find('\n').map(|i| i + 1).unwrap_or(source.len());
let first_line = source[..first_line_end].trim_end();
if first_line != "---" {
return Ok((Vec::new(), source.to_string()));
}
let mut pairs: Vec<(String, FrontValue)> = Vec::new();
let mut offset = first_line_end;
while offset < source.len() {
let rest = &source[offset..];
let line_len = rest.find('\n').map(|i| i + 1).unwrap_or(rest.len());
let raw = &rest[..line_len];
if raw.trim_end() == "---" {
let body = source[offset + line_len..].to_string();
return Ok((pairs, body));
}
let line = raw.trim();
if !line.is_empty() && !line.starts_with('#') {
if let Some((key, value)) = line.split_once(':') {
let key = normalize_key(key);
if !key.is_empty() {
pairs.push((key, parse_front_value(value.trim())));
}
}
}
offset += line_len;
}
Err(EngineError::Config(format!(
"ticket {slug}: frontmatter opened with `---` but was never closed"
)))
}
fn normalize_key(key: &str) -> String {
key.trim().to_ascii_lowercase().replace('_', "")
}
fn parse_front_value(raw: &str) -> FrontValue {
let raw = raw.trim();
if let Some(inner) = raw.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
let items = inner
.split(',')
.map(|item| unquote(item.trim()))
.filter(|item| !item.is_empty())
.collect();
FrontValue::List(items)
} else {
FrontValue::Scalar(unquote(raw))
}
}
fn parse_defer_until(slug: &str, raw: &str) -> Result<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(raw)
.map(|ts| ts.with_timezone(&chrono::Utc))
.map_err(|e| {
EngineError::Config(format!(
"ticket {slug}: invalid defer-until '{raw}' (expected an RFC 3339 \
timestamp, e.g. 2026-08-01T09:00:00Z): {e}"
))
})
}
fn unquote(s: &str) -> String {
let bytes = s.as_bytes();
if bytes.len() >= 2 {
let (first, last) = (bytes[0], bytes[bytes.len() - 1]);
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return s[1..s.len() - 1].to_string();
}
}
s.to_string()
}
fn upsert_frontmatter_state(
slug: &str,
text: &str,
state: TicketLifecycle,
note: Option<&str>,
) -> Result<String> {
let state_line = format!("state: {}", state.as_str());
let note_line = note.map(|n| format!("state-note: {n}"));
let (bom, source) = match text.strip_prefix('\u{feff}') {
Some(rest) => ("\u{feff}", rest),
None => ("", text),
};
let lines: Vec<&str> = source.split_inclusive('\n').collect();
let has_frontmatter = lines
.first()
.map(|line| line.trim_end() == "---")
.unwrap_or(false);
let mut out = String::with_capacity(
text.len() + state_line.len() + note_line.as_deref().map_or(0, str::len) + 8,
);
out.push_str(bom);
if !has_frontmatter {
out.push_str("---\n");
out.push_str(&state_line);
out.push('\n');
if let Some(line) = ¬e_line {
out.push_str(line);
out.push('\n');
}
out.push_str("---\n\n");
out.push_str(source);
return Ok(out);
}
let mut closing: Option<usize> = None;
let mut state_idx: Option<usize> = None;
let mut note_idx: Option<usize> = None;
for (i, line) in lines.iter().enumerate().skip(1) {
if line.trim_end() == "---" {
closing = Some(i);
break;
}
if !line.trim_start().starts_with('#') {
if let Some((key, _)) = line.split_once(':') {
match normalize_key(key).as_str() {
"state" if state_idx.is_none() => state_idx = Some(i),
"state-note" | "statenote" if note_idx.is_none() => note_idx = Some(i),
_ => {}
}
}
}
}
if closing.is_none() {
return Err(EngineError::Config(format!(
"ticket {slug}: frontmatter opened with `---` but was never closed"
)));
}
for (i, line) in lines.iter().enumerate() {
if i == 1 {
if state_idx.is_none() {
out.push_str(&state_line);
out.push('\n');
}
if note_idx.is_none() {
if let Some(line) = ¬e_line {
out.push_str(line);
out.push('\n');
}
}
}
if state_idx == Some(i) {
out.push_str(&state_line);
out.push('\n');
continue;
}
if note_idx == Some(i) {
if let Some(line) = ¬e_line {
out.push_str(line);
out.push('\n');
}
continue;
}
out.push_str(line);
}
Ok(out)
}
#[derive(Default)]
struct Sections {
goal: Option<String>,
context: Option<String>,
scoping_answers: Vec<String>,
acceptance_hints: Vec<String>,
first_heading: Option<String>,
}
enum SectionKind {
Goal,
Context,
ScopingAnswers,
AcceptanceHints,
Other,
}
fn classify_heading(text: &str) -> SectionKind {
match text.trim().to_ascii_lowercase().as_str() {
"goal" => SectionKind::Goal,
"context" => SectionKind::Context,
"scoping answers" => SectionKind::ScopingAnswers,
"acceptance hints" => SectionKind::AcceptanceHints,
_ => SectionKind::Other,
}
}
fn parse_sections(body: &str) -> Sections {
let mut sections = Sections::default();
let mut current: Option<SectionKind> = None;
let mut preamble: Vec<&str> = Vec::new();
let mut text_buf: Vec<&str> = Vec::new();
let mut bullets: Vec<String> = Vec::new();
fn flush(
current: &Option<SectionKind>,
text_buf: &mut Vec<&str>,
bullets: &mut Vec<String>,
sections: &mut Sections,
) {
match current {
Some(SectionKind::Goal) => {
let joined = text_buf.join("\n").trim().to_string();
if !joined.is_empty() {
sections.goal = Some(joined);
}
}
Some(SectionKind::Context) => {
let joined = text_buf.join("\n").trim().to_string();
if !joined.is_empty() {
sections.context = Some(joined);
}
}
Some(SectionKind::ScopingAnswers) => {
sections.scoping_answers.append(bullets);
}
Some(SectionKind::AcceptanceHints) => {
sections.acceptance_hints.append(bullets);
}
Some(SectionKind::Other) | None => {}
}
text_buf.clear();
bullets.clear();
}
for raw in body.lines() {
if let Some(heading) = heading_text(raw) {
if sections.first_heading.is_none() {
sections.first_heading = Some(heading.to_string());
}
}
if let Some(heading) = section_heading_text(raw) {
flush(¤t, &mut text_buf, &mut bullets, &mut sections);
current = Some(classify_heading(heading));
continue;
}
if heading_text(raw).is_some() {
continue;
}
match current {
None => preamble.push(raw),
Some(SectionKind::ScopingAnswers) | Some(SectionKind::AcceptanceHints) => {
if let Some(item) = bullet_item(raw) {
bullets.push(item);
}
}
Some(_) => text_buf.push(raw),
}
}
flush(¤t, &mut text_buf, &mut bullets, &mut sections);
if sections.goal.is_none() {
let joined = preamble.join("\n").trim().to_string();
if !joined.is_empty() {
sections.goal = Some(joined);
}
}
sections
}
fn heading_text(line: &str) -> Option<&str> {
let t = line.trim_start();
if t.starts_with('#') {
Some(t.trim_start_matches('#').trim())
} else {
None
}
}
fn section_heading_text(line: &str) -> Option<&str> {
let t = line.trim_start();
if t.starts_with("##") {
Some(t.trim_start_matches('#').trim())
} else {
None
}
}
fn bullet_item(line: &str) -> Option<String> {
let t = line.trim_start();
for marker in ["- ", "* ", "+ "] {
if let Some(rest) = t.strip_prefix(marker) {
let item = rest.trim().to_string();
if !item.is_empty() {
return Some(item);
}
}
}
None
}