1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
//! §9 base-change authoring — parse the verb's [`Flags`] into the [`BaseChange`]
//! it seals, with the per-verb [`guards`] run first and the flag→edge translation
//! delegated to [`build`]. Lifted from [`crate::mutate`] so the dispatch there
//! stays engine wiring; this is the verb→diff half.
use std::io;
use std::path::Path;
use crate::change::{Create, FieldEdit, Occupancy, Retire, Update};
use crate::id::IdScheme;
use crate::lifecycle::BaseChange;
use crate::task::Task;
use crate::taskfile::{read_task, task_ids};
use crate::verb::Verb;
use crate::wire::Command;
use super::{build, edit, guards, other, Flags};
/// A verb's authored change plus the ball's op-start state (the §7
/// `current_state` a `pre` plugin sees — `None` on `create`, which has no prior
/// ball).
pub(super) type Authored = (Box<dyn BaseChange>, Option<Task>);
/// Author the verb's [`BaseChange`] from the parsed `flags` (see [`Authored`]).
/// `now` and `roots` (this checkout's [`crate::delivery_repo::Project::root_commits`])
/// are injected, so the change stays pure (it reads no clock and shells no git):
/// `create` STAMPS the first (canonical) root on the ball, `claim` ADMITS a ball
/// recorded against ANY of them (bl-0161), the other verbs ignore it. The
/// `editor` seam serves only `update --edit`. `Ok(None)` is `--edit`'s
/// unchanged-buffer no-op — there is nothing to author. Only the five mutating
/// verbs reach here.
pub(super) fn base_change(
verb: Verb,
store: &Path,
flags: &Flags,
now: i64,
roots: Vec<String>,
editor: &mut edit::Editor,
) -> io::Result<Option<Authored>> {
let actor = flags.actor.clone();
match verb {
Verb::Create => {
guards::forbid_removals_on_create(flags)?;
let title = one_positional(flags, "create")?;
// `--subtask-of` folds into the parent + a claim-gate edge (§10).
let parent = build::effective_parent(flags)?;
let blockers = build::needs_blockers(flags)?;
let blocks = build::blocks_edges(flags, parent.as_deref())?;
build::require_live(
store,
verb,
blockers.iter().map(|b| b.id.as_str()).chain(blocks.iter().map(|(id, _)| id.as_str())),
)?;
let base = Create {
id: IdScheme::default().generate(),
actor,
now,
title,
parent: parent.clone(),
priority: flags.priority,
tags: flags.tags.clone(),
blockers,
blocks,
body: flags.body.clone(),
message: flags.message.clone(),
root_commit: roots.into_iter().next(),
existing: task_ids(store)?,
};
Ok(Some((Box::new(base), None)))
}
Verb::Claim | Verb::Unclaim => {
guards::forbid_shaping(flags, verb)?;
let id = one_positional(flags, verb.token())?;
let before = read_task(store, &id)?;
let claimant = (verb == Verb::Claim).then(|| actor.clone());
let base =
Occupancy { verb, id, claimant, actor, now, message: flags.message.clone(), current_roots: roots };
Ok(Some((Box::new(base), Some(before))))
}
Verb::Update => {
guards::forbid_foreign_blocks(flags, verb)?;
guards::forbid_contradictions(flags)?;
let mut positionals = flags.positionals.iter();
let id = positionals.next().ok_or_else(|| crate::usage("update: needs a task id"))?.clone();
let before = read_task(store, &id)?;
let edits = if flags.edit {
// `--edit`: the buffer IS the payload — field flags and key=value
// extras would race over it, so they are mutually exclusive (§9).
guards::forbid_fields_with_edit(flags)?;
if positionals.next().is_some() {
return Err(crate::usage("update: --edit and key=value extras are mutually exclusive — the buffer is the payload"));
}
let Some(after) = editor.edited(&before, &id)? else { return Ok(None) };
vec![FieldEdit::Replace(Box::new(after))]
} else {
build::edits(positionals, flags)?
};
// Only the flag-minted edges are validated (§10, bl-6b8c): `--edit`'s
// whole-buffer Replace is the blessed hand-stitch escape hatch, and a
// RemoveBlocker unlink is the dangling-edge remedy — never refused.
build::require_live(
store,
verb,
edits.iter().filter_map(|e| match e {
FieldEdit::AddBlocker(b) => Some(b.id.as_str()),
_ => None,
}),
)?;
let base = Update { id, actor, now, edits, message: flags.message.clone() };
Ok(Some((Box::new(base), Some(before))))
}
Verb::Close => {
guards::forbid_shaping(flags, verb)?;
let id = one_positional(flags, verb.token())?;
let before = read_task(store, &id)?;
let base = Retire { id, title: before.title.clone(), actor, message: flags.message.clone() };
Ok(Some((Box::new(base), Some(before))))
}
// The diffless verbs never reach run()'s mutating branch; reject defensively.
_ => Err(other(format!("{}: not a mutating verb", verb.token()))),
}
}
/// The §7 `command` — the op plus its body intent. `body_change` is the new
/// markdown ball body (`--body`) when the op rewrites it (§7); `message` is the
/// `-m` note, threaded for a close's delivery-message override (bl-b9a6).
/// Field-level changes are NOT carried here (single source of truth, bl-3bfd
/// §15): a plugin reads them from the change worktree / the `before`/`after`
/// states, not a second diff description. Its presence (vs the diffless `None`)
/// marks this a ball-mutating op (§7).
pub(super) fn command(verb: Verb, flags: &Flags) -> Command {
Command { op: verb.token().to_string(), body_change: flags.body.clone(), message: flags.message.clone() }
}
/// The single positional `verb` expects (a `create` title, else a task id).
fn one_positional(flags: &Flags, verb: &str) -> io::Result<String> {
match flags.positionals.as_slice() {
[only] => Ok(only.clone()),
_ => Err(crate::usage(format!("{verb}: expects exactly one positional argument"))),
}
}