struct NewWrite {
kind: &'static str,
id: String,
title: String,
path: PathBuf,
state: Option<String>,
contents: String,
preview: String,
dirs: Vec<PathBuf>,
next_hint: Option<String>,
notes: Vec<String>,
}
fn new_command(options: &NewOptions) -> MietteResult<()> {
reject_unusable_title(&options.title)?;
reject_mode_confusion(options)?;
let description = resolve_new_description(options)?;
let resolved = resolve_plan_target(options.project.clone())?;
if !options.json {
report_new_widened(&resolved);
}
let target = resolved.path().to_path_buf();
let scope_lock = lock_new_create(&target)?;
let (write, _destination_lock) =
decide_under_destination_lock(&target, options, description.as_deref(), &scope_lock)?;
apply_new_write(&target, &write, options)
}
fn decide_under_destination_lock(
target: &Path,
options: &NewOptions,
description: Option<&str>,
scope_lock: &NewCreateLock,
) -> MietteResult<(NewWrite, Option<NewCreateLock>)> {
const ATTEMPTS: usize = 3;
for _ in 0..ATTEMPTS {
let write = decide_new_write(target, options, description)?;
let witnessed = fs::read(&write.path).ok();
let destination_lock = lock_new_destination(scope_lock, &write.path)?;
if fs::read(&write.path).ok() == witnessed {
return Ok((write, destination_lock));
}
drop(destination_lock);
}
Err(miette!(
help = "another command is rewriting that plan. Let it finish, then re-run.",
"gave up deciding what to create: the plan kept changing while `rhei new` prepared \
the write, {ATTEMPTS} times running"
))
}
fn decide_new_write(
target: &Path,
options: &NewOptions,
description: Option<&str>,
) -> MietteResult<NewWrite> {
match options.under.as_deref() {
Some(parent) => new_ticket_write(target, options, parent, description),
None => new_rhei_write(target, options, description),
}
}
fn reject_unusable_title(title: &str) -> MietteResult<()> {
if title.trim().is_empty() {
return Err(miette!(
help = "give the thing a name: rhei new \"Rotate signing keys\" --under auth.",
"TITLE is empty: `rhei new` writes it into the heading it creates, and a heading \
with nothing after the colon is not a node the plan language can read"
));
}
if title.contains('\n') || title.contains('\r') {
return Err(miette!(
help = "keep the title to one line and put the rest in --description, which is written under the heading as prose.",
"TITLE runs over more than one line: a node's title is the rest of its heading \
line, so the second line would be read as plan content rather than as part of \
the title"
));
}
Ok(())
}
fn reject_mode_confusion(options: &NewOptions) -> MietteResult<()> {
let rhei_flags = [
("--dir", options.dir),
("--states", options.states.is_some()),
("--max-levels", options.max_levels.is_some()),
("--node-kinds", !options.node_kinds.is_empty()),
];
let ticket_flags = [
("--kind", options.kind.is_some()),
("--state", options.state.is_some()),
("--prior", !options.prior.is_empty()),
("--provides", !options.provides.is_empty()),
("--consumes", !options.consumes.is_empty()),
("--assignee", options.assignee.is_some()),
("--model", options.model.is_some()),
("--target", options.target.is_some()),
];
if options.under.is_some() {
if let Some((flag, _)) = rhei_flags.into_iter().find(|(_, given)| *given) {
return Err(miette!(
help = "`--under` creates a ticket; drop it to create a rhei, or drop the rhei-only flag.",
"{flag} configures a new rhei, but --under creates a ticket"
));
}
return Ok(());
}
if let Some((flag, _)) = ticket_flags.into_iter().find(|(_, given)| *given) {
return Err(miette!(
help = "name where the ticket goes, for example: --under <rhei-id>.",
"{flag} configures a new ticket, but without --under a rhei is created"
));
}
Ok(())
}
fn report_new_widened(target: &PlanTarget) {
let Some(id) = target.implied_scope.first() else {
return;
};
println!(
"Scope: rhei '{id}' belongs to the project at {}, and its state machine, settings, and \
cross-rhei **Prior:** resolve only there — creating into that project.",
display_path(target.path())
);
}
struct AppliedWrite {
previous: Option<String>,
created_dirs: Vec<PathBuf>,
inherited: Vec<String>,
failure: Option<CreateFailure>,
}
fn perform_new_write(target: &Path, write: &NewWrite) -> MietteResult<AppliedWrite> {
let inherited = create_validation_errors(target);
let before = create_plan_ids(target);
let previous = fs::read_to_string(&write.path).ok();
let created_dirs: Vec<PathBuf> =
write.dirs.iter().filter(|dir| !dir.exists()).cloned().collect();
for dir in &write.dirs {
fs::create_dir_all(dir).map_err(|err| file_io_report(dir, "failed to create", err))?;
}
write_plan_file_atomically(&write.path, &write.contents)?;
let failure = new_write_failure(target, write, &inherited, before.as_ref());
Ok(AppliedWrite { previous, created_dirs, inherited, failure })
}
fn apply_new_write(target: &Path, write: &NewWrite, options: &NewOptions) -> MietteResult<()> {
let applied = perform_new_write(target, write)?;
if options.dry_run {
return report_new_dry_run(write, applied, options.json);
}
let Some(failure) = applied.failure else {
report_inherited_validation_failure(&applied.inherited);
report_new_write(write, options.json);
return Ok(());
};
if options.keep_on_error {
eprintln!(
"warning: kept {} — the project is left failing validation",
display_path(&write.path)
);
return Err(failure.report);
}
roll_back_new_write(&write.path, applied.previous.as_deref(), &applied.created_dirs);
eprintln!(
"note: nothing was written — the create was rolled back because {}. Re-run with \
`--keep-on-error` to inspect it.",
failure.reason
);
Err(failure.report)
}
fn report_inherited_validation_failure(inherited: &[String]) {
if inherited.is_empty() {
return;
}
let count = inherited.len();
let noun = if count == 1 { "error" } else { "errors" };
eprintln!(
"warning: the project was already failing validation before this create \
({count} {noun}), and it fails the same way after it — the write is kept, and \
those errors are not this create's. Run `rhei validate` to see them."
);
}
fn roll_back_new_write(path: &Path, previous: Option<&str>, created_dirs: &[PathBuf]) {
match previous {
Some(previous) => {
let _ = fs::write(path, previous);
}
None => {
let _ = fs::remove_file(path);
}
}
for dir in created_dirs.iter().rev() {
let _ = fs::remove_dir(dir);
}
}
fn report_new_dry_run(write: &NewWrite, applied: AppliedWrite, json: bool) -> MietteResult<()> {
roll_back_new_write(&write.path, applied.previous.as_deref(), &applied.created_dirs);
if let Some(failure) = applied.failure {
eprintln!(
"note: nothing was written — this was a dry run, and the real create would have \
been rolled back because {}.",
failure.reason
);
return Err(failure.report);
}
report_inherited_validation_failure(&applied.inherited);
if json {
let mut value = new_write_json(write);
value["dry_run"] = serde_json::Value::Bool(true);
value["markdown"] = serde_json::Value::String(write.preview.clone());
println!("{value}");
return Ok(());
}
println!("Would create {} {} at {}", write.kind, write.id, display_path(&write.path));
println!();
print!("{}", write.preview);
Ok(())
}
fn new_write_json(write: &NewWrite) -> serde_json::Value {
let mut value = serde_json::json!({
"kind": write.kind,
"id": write.id,
"title": write.title,
"path": display_path(&write.path),
});
if let Some(state) = &write.state {
value["state"] = serde_json::Value::String(state.clone());
}
value
}
fn report_new_write(write: &NewWrite, json: bool) {
if json {
println!("{}", new_write_json(write));
return;
}
match &write.state {
Some(state) => println!(
"Created ticket {} \"{}\" [{}] in {}",
write.id,
write.title,
state,
display_path(&write.path)
),
None => println!(
"Created rhei \"{}\" as `{}` at {}",
write.title,
write.id,
display_path(&write.path)
),
}
for note in &write.notes {
println!("Note: {note}");
}
if let Some(hint) = &write.next_hint {
println!("Next: {hint}");
}
}