struct TicketParent {
rhei_id: String,
parent_local: Option<String>,
siblings: Vec<String>,
label: String,
}
fn new_ticket_write(
target: &Path,
options: &NewOptions,
parent: &str,
description: Option<&str>,
) -> MietteResult<NewWrite> {
reject_malformed_export_flags(options)?;
let loaded = load_plan_leniently(target)?;
reject_unloadable_target_rhei(&loaded, parent.trim())?;
reject_unloadable_reference_rheis(&loaded, options)?;
let placement = resolve_ticket_parent(&loaded, parent.trim())?;
let entry = resolve_rhei_entry(target, &loaded, &placement.rhei_id)?;
let structure = rhei_entry_structure(&entry, target)?;
let segment =
resolve_new_ticket_segment(options.id.as_deref(), &placement.siblings, &placement.label)?;
let local_id = match &placement.parent_local {
Some(parent_local) => format!("{parent_local}.{segment}"),
None => segment,
};
let depth = local_id.split('.').count() as u8;
reject_excess_depth(&placement, depth, &structure.structure, structure.declared)?;
let kind =
resolve_ticket_kind(options.kind.as_deref(), &structure.structure, &placement.rhei_id)?;
let qualified = format!("{}.{}", placement.rhei_id, local_id);
let machines = resolve_state_machines_for_create(target, &loaded, &placement.rhei_id)?;
let machine = machines.machine_for_task_str(&qualified);
let state = resolve_ticket_state(options.state.as_deref(), machine, &kind, depth)?;
let block = render_ticket(&TicketFields {
kind: &kind,
local_id: &local_id,
title: &options.title,
state: &state,
prior: &options.prior,
provides: &options.provides,
consumes: &options.consumes,
assignee: options.assignee.as_deref(),
model: options.model.as_deref(),
target: options.target.as_deref(),
description,
});
let placed = place_ticket(&entry, &placement, &local_id, &loaded, target, &options.title, &block)?;
Ok(NewWrite {
kind: "ticket",
id: qualified,
title: options.title.trim().to_string(),
path: placed.path,
state: Some(state),
contents: placed.contents,
preview: block,
dirs: placed.dirs,
next_hint: Some("`rhei list` shows the rhei; `rhei next` picks up the work".to_string()),
notes: ticket_create_notes(options),
})
}
fn ticket_create_notes(options: &NewOptions) -> Vec<String> {
let Some(assignee) = options.assignee.as_deref() else {
return Vec::new();
};
vec![format!(
"`--assignee {}` marks the ticket claimed and in progress: `rhei next` and `rhei run` \
skip it until `rhei release <id>`.",
assignee.trim()
)]
}
fn reject_unloadable_target_rhei(loaded: &LoadedPlan, parent: &str) -> MietteResult<()> {
let rhei_id = parent.split('.').next().unwrap_or(parent);
let marker = format!("rhei '{rhei_id}' could not be loaded");
let Some(skipped) = loaded.unloadable.iter().find(|message| message.starts_with(&marker))
else {
return Ok(());
};
Err(miette!(
help = "fix that rhei and re-run; `rhei validate` reports it with a code frame. Creates into every other rhei in this project are unaffected.",
"{skipped}\n\n`rhei new` cannot place a ticket in a rhei it cannot read: the ids already \
in it decide the new ticket's number, and its `## Tasks` section decides where the \
block goes."
))
}
fn resolve_state_machines_for_create(
input: &Path,
loaded: &LoadedPlan,
target_rhei: &str,
) -> MietteResult<ResolvedMachineSet> {
let default = resolve_state_machine_for_loaded_plan(input, loaded, None)?;
let mut per_rhei = BTreeMap::new();
let mut declared: Vec<(&String, &String)> = loaded.rhei_machines.iter().collect();
declared.sort();
for (rhei_id, machine_name) in declared {
if *machine_name == default.machine.name {
continue;
}
match resolve_declared_rhei_machine(input, loaded, rhei_id, machine_name) {
Ok(resolved) => {
per_rhei.insert(rhei_id.clone(), resolved);
}
Err(report) if rhei_id == target_rhei => return Err(report),
Err(_) => {}
}
}
Ok(ResolvedMachineSet { default, per_rhei })
}
fn reject_unloadable_reference_rheis(
loaded: &LoadedPlan,
options: &NewOptions,
) -> MietteResult<()> {
let priors = options.prior.iter().map(|value| ("--prior", value.as_str()));
let consumed = options
.consumes
.iter()
.filter_map(|value| value.split_once(':').map(|(task, _)| ("--consumes", task)));
for (flag, reference) in priors.chain(consumed) {
let Some(rhei_id) = referenced_rhei_id(reference) else {
continue;
};
let marker = format!("rhei '{rhei_id}' could not be loaded");
let Some(skipped) = loaded.unloadable.iter().find(|message| message.starts_with(&marker))
else {
continue;
};
return Err(miette!(
help = "fix that rhei and re-run; `rhei validate` reports it with a code frame. Drop the reference to create the ticket now and add it once the rhei reads.",
"{skipped}\n\n{flag} '{}' points into rhei '{rhei_id}', so nothing can check that \
the reference resolves — and a reference written unchecked comes back as an error \
the moment that rhei is repaired.",
reference.trim()
));
}
Ok(())
}
fn referenced_rhei_id(reference: &str) -> Option<&str> {
let id = reference.trim().rsplit(char::is_whitespace).next()?;
let (leading, _) = id.split_once('.')?;
(!leading.is_empty()).then_some(leading)
}
fn reject_malformed_export_flags(options: &NewOptions) -> MietteResult<()> {
for name in &options.provides {
let name = name.trim();
if name.is_empty() || !is_legal_export_name(name) {
return Err(miette!(
help = "an export name is one word: letters, digits, '.', '_', or '-'. Repeat --provides, or separate several with commas.",
"--provides '{name}' is not a valid export name: it starts with a letter or a \
digit and continues with letters, digits, '.', '_', or '-' \
(`--provides api-contract`)"
));
}
}
for reference in &options.consumes {
let reference = reference.trim();
let shape_ok = reference
.split_once(':')
.is_some_and(|(task, name)| is_legal_task_id(task) && is_legal_export_name(name));
if !shape_ok {
return Err(miette!(
help = "name the ticket and the export it publishes, separated by a colon. Repeat --consumes, or separate several with commas.",
"--consumes '{reference}' is not a valid reference: it is \
'<task-id>:<export-name>' (`--consumes auth.1:api-contract`)"
));
}
}
Ok(())
}
fn resolve_ticket_parent(loaded: &LoadedPlan, parent: &str) -> MietteResult<TicketParent> {
if parent.is_empty() {
return Err(miette!(
help = "name a rhei (`--under auth`) or a ticket (`--under auth.3`).",
"--under needs a rhei id or a ticket id"
));
}
if !parent.contains('.') {
let known = loaded.rhei_ids.iter().any(|id| id == parent);
if !known && parent != workspace::BASIN_RHEI_ID {
return Err(unknown_parent_error(loaded, parent));
}
let prefix = format!("{parent}.");
let siblings = loaded
.rhei
.tasks
.iter()
.filter_map(|task| task.id.to_string().strip_prefix(&prefix).map(ToOwned::to_owned))
.filter(|local| !local.contains('.'))
.collect();
return Ok(TicketParent {
rhei_id: parent.to_string(),
parent_local: None,
siblings,
label: format!("rhei '{parent}'"),
});
}
let Some(task) = find_task_by_id_str(&loaded.rhei.tasks, parent) else {
return Err(unknown_parent_error(loaded, parent));
};
let (rhei_id, parent_local) = parent
.split_once('.')
.map(|(rhei, local)| (rhei.to_string(), local.to_string()))
.expect("a dotted id splits");
let siblings = task
.children
.iter()
.filter_map(|child| {
child.id.to_string().rsplit_once('.').map(|(_, last)| last.to_string())
})
.collect();
Ok(TicketParent {
rhei_id,
parent_local: Some(parent_local),
siblings,
label: format!("ticket {parent}"),
})
}
fn unknown_parent_error(loaded: &LoadedPlan, parent: &str) -> miette::Report {
let mut known: Vec<String> = loaded.rhei_ids.clone();
if !known.iter().any(|id| id == workspace::BASIN_RHEI_ID) {
known.push(workspace::BASIN_RHEI_ID.to_string());
}
miette!(
help = did_you_mean(parent, &known)
.unwrap_or_else(|| "pass a rhei id, or a ticket id to nest under.".to_string()),
"'{parent}' names no rhei or ticket in this project. --under takes a rhei id \
({}) for a top-level ticket, or a ticket id like `{}.1` for a subtask",
known.join(", "),
known.first().map(String::as_str).unwrap_or("auth")
)
}
fn reject_excess_depth(
placement: &TicketParent,
depth: u8,
structure: &rhei_core::ast::Structure,
declared_max_levels: bool,
) -> MietteResult<()> {
let max_levels = structure.max_levels;
if depth <= max_levels {
return Ok(());
}
let help = if declared_max_levels {
format!(
"raise `structure.maxLevels` to {depth} in the rhei's frontmatter, or add the \
ticket higher up."
)
} else {
format!(
"this rhei declares no frontmatter, so {max_levels} is the default. Add a block \
right under the `# Rhei:` heading — a line `---`, then `structure:`, then \
` maxLevels: {depth}`, then `---` — or add the ticket higher up."
)
};
Err(miette!(
help = help,
"a ticket under {} would sit at depth {depth}, but rhei '{}' allows {max_levels} \
(`structure.maxLevels`)",
placement.label,
placement.rhei_id
))
}
fn resolve_ticket_kind(
requested: Option<&str>,
structure: &rhei_core::ast::Structure,
rhei_id: &str,
) -> MietteResult<String> {
let kind = requested.unwrap_or("task").trim().to_ascii_lowercase();
if structure.node_kinds.iter().any(|declared| declared.eq_ignore_ascii_case(&kind)) {
return Ok(kind);
}
let declared = structure.node_kinds.join(", ");
let Some(requested) = requested else {
return Err(miette!(
help = format!(
"add the flag, for example: --kind {}",
structure.node_kinds.first().map(String::as_str).unwrap_or("task")
),
"rhei '{rhei_id}' requires --kind: it declares {declared}, and no default among \
them"
));
};
Err(miette!(
help = did_you_mean(&kind, &structure.node_kinds)
.unwrap_or_else(|| "declare the kind in `structure.nodeKinds` first.".to_string()),
"rhei '{rhei_id}' does not declare the node kind '{}'. It declares: {declared}",
requested.trim()
))
}
fn resolve_ticket_state(
requested: Option<&str>,
machine: &rhei_validator::StateMachine,
kind: &str,
depth: u8,
) -> MietteResult<String> {
let Some(requested) = requested else {
return initial_state_for_node(machine, kind, depth);
};
let normalized = normalized_state_name(requested.trim(), machine);
if machine.is_valid_state(&normalized) {
return Ok(normalized);
}
let known: Vec<String> = machine.allowed_states().map(ToOwned::to_owned).collect();
Err(miette!(
help = did_you_mean(requested.trim(), &known)
.unwrap_or_else(|| "omit --state to start in the machine's initial state.".to_string()),
"state machine '{}' has no state '{}'. It declares: {}",
machine.name,
requested.trim(),
known.join(", ")
))
}