fn derive_rhei_id(title: &str) -> Option<String> {
let mut out = String::new();
for ch in title.chars() {
let ch = ch.to_ascii_lowercase();
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
out.push(ch);
} else if !out.is_empty() && !out.ends_with('-') {
out.push('-');
}
}
let out: String = out
.trim_matches('-')
.chars()
.skip_while(|ch| !ch.is_ascii_alphabetic())
.collect();
(!out.is_empty()).then_some(out)
}
fn is_legal_rhei_id(id: &str) -> bool {
id.bytes().next().is_some_and(|b| b.is_ascii_alphabetic())
&& id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
fn is_legal_ticket_segment(id: &str) -> bool {
if id.is_empty() {
return false;
}
if id.bytes().all(|b| b.is_ascii_digit()) {
return id == "0" || !id.starts_with('0');
}
is_legal_rhei_id(id)
}
fn is_legal_task_id(id: &str) -> bool {
!id.is_empty() && id.split('.').all(is_legal_ticket_segment)
}
fn is_legal_export_name(name: &str) -> bool {
name.bytes().next().is_some_and(|b| b.is_ascii_alphanumeric())
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
fn reserved_rhei_id_reason(id: &str) -> Option<(&'static str, String)> {
if id == workspace::BASIN_RHEI_ID {
return Some((
"the basin is where unfiled tickets go; capture one with \
`rhei new \"<title>\" --under basin`.",
format!(
"'{id}' is a reserved rhei id: it names the project basin, the synthetic rhei \
that holds tickets with no owning rhei"
),
));
}
if id == "index" {
return Some((
"pick another id with --id, for example `--id inbox`.",
format!(
"'{id}' is a reserved rhei id: it would write `index.rhei.md` next to \
{}, and that filename is what marks a directory as a Directory Workspace \
rather than as a rhei inside one",
workspace::PANTA_INDEX_FILE
),
));
}
None
}
fn resolve_new_rhei_id(title: &str, explicit: Option<&str>) -> MietteResult<String> {
let id = match explicit {
Some(id) => id.trim().to_string(),
None => derive_rhei_id(title).ok_or_else(|| miette!(
help = "pass an explicit id, for example: rhei new \"<title>\" --id my-rhei",
"no rhei id can be derived from the title '{title}': an id must start with a \
letter and contain only letters, digits, `_`, or `-`"
))?,
};
if !is_legal_rhei_id(&id) {
let suggestion = derive_rhei_id(&id)
.map(|fixed| format!(" Try `--id {fixed}`."))
.unwrap_or_default();
return Err(miette!(
help = "a rhei id prefixes every ticket id in the project, so it has to be a single legal segment.",
"'{id}' is not a valid rhei id: it must start with a letter and contain only \
letters, digits, `_`, or `-`.{suggestion}"
));
}
if let Some((help, message)) = reserved_rhei_id_reason(&id) {
return Err(miette!(help = help, "{message}"));
}
Ok(id)
}
fn next_sibling_number(siblings: &[String]) -> u32 {
siblings
.iter()
.filter_map(|id| id.parse::<u32>().ok())
.max()
.map(|highest| highest.saturating_add(1))
.unwrap_or(1)
}
fn resolve_new_ticket_segment(
explicit: Option<&str>,
siblings: &[String],
parent_label: &str,
) -> MietteResult<String> {
let Some(explicit) = explicit else {
return Ok(next_sibling_number(siblings).to_string());
};
let id = explicit.trim();
if !is_legal_ticket_segment(id) {
return Err(miette!(
help = "a ticket id segment is a number, or a name starting with a letter (`fix-cache`).",
"'{id}' is not a valid ticket id: it must be a number, or start with a letter \
and contain only letters, digits, `_`, or `-`"
));
}
if siblings.iter().any(|sibling| sibling == id) {
return Err(miette!(
help = "pick a free id with --id, or omit --id and let the next number be chosen.",
"{parent_label} already holds a ticket with id '{id}'"
));
}
Ok(id.to_string())
}
fn complete_new_parent(current: &OsStr) -> Vec<CompletionCandidate> {
let Some(plan) = completion_plan_path() else {
return Vec::new();
};
let prefix = current.to_string_lossy();
let Ok(loaded) = load_plan_leniently(&plan) else {
return Vec::new();
};
let mut candidates: Vec<CompletionCandidate> = loaded
.rhei_ids
.iter()
.filter(|id| id.starts_with(prefix.as_ref()))
.map(|id| {
CompletionCandidate::new(id.clone()).help(Some("rhei — adds a top-level ticket".into()))
})
.collect();
candidates.extend(flatten_tasks(&loaded.rhei).into_iter().filter_map(|task| {
let id = task.id.to_string();
id.starts_with(prefix.as_ref()).then(|| {
CompletionCandidate::new(id).help(Some(format!("subtask of {}", task.title).into()))
})
}));
candidates
}
fn complete_new_node_kind(current: &OsStr) -> Vec<CompletionCandidate> {
let Some(plan) = completion_plan_path() else {
return Vec::new();
};
let prefix = current.to_string_lossy();
let Ok(loaded) = load_plan_leniently(&plan) else {
return Vec::new();
};
completion_target_node_kinds(&plan, &loaded)
.into_iter()
.filter(|kind| kind.starts_with(prefix.as_ref()))
.map(CompletionCandidate::new)
.collect()
}
fn completion_target_node_kinds(plan: &Path, loaded: &LoadedPlan) -> Vec<String> {
let merged = || loaded.rhei.structure.node_kinds.clone();
let Some(under) = completion_option_value("under") else {
return merged();
};
let rhei_id = under.split('.').next().unwrap_or(&under).to_string();
let Ok(entry) = resolve_rhei_entry(plan, loaded, &rhei_id) else {
return merged();
};
match rhei_entry_structure(&entry, plan) {
Ok(structure) => structure.structure.node_kinds,
Err(_) => merged(),
}
}
fn complete_new_states_name(current: &OsStr) -> Vec<CompletionCandidate> {
let prefix = current.to_string_lossy();
discoverable_state_machine_names(completion_plan_path().as_deref())
.into_iter()
.filter(|name| name.starts_with(prefix.as_ref()))
.map(CompletionCandidate::new)
.collect()
}