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
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
//! §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), plus the BALL ID the op is about.
pub(super) struct Authored {
pub base: Box<dyn BaseChange>,
pub before: Option<Task>,
/// The op's ball: the verb's positional, or `create`'s freshly minted id.
/// Authored HERE because this is where identity enters the op, and carried
/// onto the §7 wire ([`crate::wire::Command::id`]) so no plugin re-derives
/// it from the change worktree (§0 obligation 4; bl-a5f3).
pub id: String,
}
/// 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 close-gate edge (§10) —
// together the §11 nesting declaration (`crate::target`).
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())),
)?;
// The § id-generation collision rule, core's half: the draw is
// re-rolled off the LIVE set (bl-1fc4) rather than written blind —
// a blind draw landing on a live id staged over that ball and died
// at finalize as a phantom "a create.pre plugin reassigned…" abort,
// with no plugin in the chain. Only a plugin's explicit
// reassignment still aborts there.
let existing = task_ids(store)?;
let id = IdScheme::default().mint(&existing)?;
let base = Create {
id: id.clone(),
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,
};
Ok(Some(Authored { base: Box::new(base), before: None, id }))
}
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: id.clone(),
claimant,
actor,
now,
message: flags.message.clone(),
current_roots: roots,
};
Ok(Some(Authored { base: Box::new(base), before: Some(before), id }))
}
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: id.clone(), actor, now, edits, message: flags.message.clone() };
Ok(Some(Authored { base: Box::new(base), before: Some(before), id }))
}
Verb::Close => {
guards::forbid_shaping(flags, verb)?;
let id = one_positional(flags, verb.token())?;
let before = read_task(store, &id)?;
let base =
Retire { id: id.clone(), title: before.title.clone(), actor, message: flags.message.clone() };
Ok(Some(Authored { base: Box::new(base), before: Some(before), id }))
}
// 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). `target` is the derived §11 delivery
/// target ([`crate::target::derive`]) — the dispatch computes it, this only
/// carries it onto the wire. `id` is the op's ball ([`Authored::id`]), riding
/// EVERY payload so identity is an op input rather than something a plugin
/// re-derives from the change worktree (§0 obligation 4; bl-a5f3).
pub(super) fn command(verb: Verb, flags: &Flags, target: Option<String>, id: String) -> Command {
Command {
op: verb.token().to_string(),
id: Some(id),
body_change: flags.body.clone(),
message: flags.message.clone(),
target,
}
}
/// 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"))),
}
}