1use camino::{Utf8Path, Utf8PathBuf};
13
14use super::manifest::{self, Alignment};
15use crate::cli::Commands;
16use crate::cli::branches::BranchesAction;
17use crate::cli::issue::IssueAction;
18use crate::cli::lines::LinesAction;
19use crate::cli::migrate::MigrateAction;
20use crate::cli::release::ReleaseAction;
21use crate::cli::setup::SetupAction;
22use crate::cli::stage::StageAction;
23use crate::cli::worktree::WorktreeAction;
24use crate::diagnostic::{Diagnostic, Reason};
25use crate::error::RkError;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Class {
30 Exempt,
32 PreLanding,
35 Upgrade,
37 ReadOnly,
39 Writing,
41}
42
43impl Class {
44 #[must_use]
46 pub const fn as_str(self) -> &'static str {
47 match self {
48 Self::Exempt => "exempt",
49 Self::PreLanding => "pre-landing",
50 Self::Upgrade => "upgrade",
51 Self::ReadOnly => "read-only",
52 Self::Writing => "writing",
53 }
54 }
55}
56
57pub const CLASSES: &[(&str, Class)] = &[
63 ("method", Class::Exempt),
64 ("binding", Class::Exempt),
65 ("snippet", Class::Exempt),
66 ("guide", Class::Exempt),
67 ("forge", Class::Exempt),
68 ("versions", Class::Exempt),
69 ("init", Class::PreLanding),
70 ("profile", Class::PreLanding),
71 ("status", Class::Exempt),
72 ("upgrade", Class::Upgrade),
73 ("adopt", Class::PreLanding),
74 ("assess", Class::PreLanding),
75 ("stage", Class::PreLanding),
76 ("stage clean", Class::Exempt),
77 ("stage compare", Class::PreLanding),
78 ("setup", Class::PreLanding),
79 ("setup check", Class::PreLanding),
80 ("setup checkpoint", Class::PreLanding),
81 ("setup status", Class::PreLanding),
82 ("setup step", Class::PreLanding),
83 ("setup script", Class::Exempt),
84 ("branches prune", Class::Writing),
85 ("lines list", Class::ReadOnly),
86 ("lines open", Class::Writing),
87 ("lines mint", Class::Writing),
88 ("lines rc", Class::ReadOnly),
89 ("lines retire", Class::Writing),
90 ("release status", Class::ReadOnly),
91 ("release merge", Class::Writing),
92 ("release request", Class::Writing),
93 ("release build", Class::Writing),
94 ("release eligible", Class::ReadOnly),
95 ("release publish", Class::Writing),
96 ("integrate", Class::Writing),
97 ("message", Class::ReadOnly),
98 ("migrate inventory", Class::PreLanding),
99 ("migrate archive", Class::PreLanding),
100 ("migrate mark", Class::PreLanding),
101 ("migrate status", Class::PreLanding),
102 ("migrate close", Class::PreLanding),
103 ("worktree list", Class::ReadOnly),
104 ("worktree add", Class::Writing),
105 ("worktree prune", Class::Writing),
106 ("issue start", Class::Writing),
107 ("runs", Class::Exempt),
108 ("skill", Class::Exempt),
109 ("self-depend", Class::Exempt),
110 ("doctor", Class::Exempt),
111 ("usage", Class::Exempt),
112 ("license", Class::Exempt),
113 ("completions", Class::Exempt),
114];
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum Effect {
119 ReadOnly,
121 Writing,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct Gated {
128 pub class: Class,
130 pub effect: Effect,
132 pub target: Utf8PathBuf,
134}
135
136const fn effect(apply: bool) -> Effect {
137 if apply {
138 Effect::Writing
139 } else {
140 Effect::ReadOnly
141 }
142}
143
144#[allow(
145 clippy::unnecessary_wraps,
146 reason = "every arm of classify answers an Option, and this builds the Some arm"
147)]
148fn gated(class: Class, effect: Effect, target: &Utf8Path) -> Option<Gated> {
149 Some(Gated {
150 class,
151 effect,
152 target: target.to_owned(),
153 })
154}
155
156#[must_use]
160pub fn classify(command: &Commands) -> Option<Gated> {
161 use Class::{PreLanding, ReadOnly, Writing};
162 use Effect::{ReadOnly as Reads, Writing as Writes};
163 match command {
164 Commands::Method(_)
165 | Commands::Binding(_)
166 | Commands::Snippet(_)
167 | Commands::Guide(_)
168 | Commands::Forge(_)
169 | Commands::Versions(_)
170 | Commands::Status(_)
171 | Commands::Upgrade(_)
172 | Commands::Runs(_)
173 | Commands::Skill(_)
174 | Commands::SelfDepend(_)
175 | Commands::Doctor(_)
176 | Commands::Usage
177 | Commands::License
178 | Commands::Completions(_) => None,
179 Commands::Init(args) => gated(PreLanding, effect(args.apply), &args.target),
180 Commands::Adopt(args) => gated(PreLanding, effect(args.apply), &args.target),
181 Commands::Profile(args) => gated(PreLanding, Reads, &args.target),
182 Commands::Assess(args) => gated(PreLanding, Reads, &args.target),
183 Commands::Stage(args) => match &args.action {
184 None => gated(PreLanding, Reads, &args.target),
185 Some(StageAction::Clean { .. }) => None,
186 Some(StageAction::Compare { target, .. }) => gated(PreLanding, Reads, target),
187 },
188 Commands::Setup(args) => match &args.action {
189 None if args.list => None,
190 None => args
191 .target
192 .as_deref()
193 .and_then(|target| gated(PreLanding, effect(args.apply), target)),
194 Some(SetupAction::Check { target, .. } | SetupAction::Status { target, .. }) => {
195 gated(PreLanding, Reads, target)
196 }
197 Some(SetupAction::Checkpoint { target, .. }) => gated(PreLanding, Writes, target),
198 Some(SetupAction::Step { target, apply, .. }) => {
199 gated(PreLanding, effect(*apply), target)
200 }
201 Some(SetupAction::Script { .. }) => None,
202 },
203 Commands::Migrate(args) => match &args.action {
204 MigrateAction::Inventory { target, .. } | MigrateAction::Status { target, .. } => {
205 gated(PreLanding, Reads, target)
206 }
207 MigrateAction::Archive { target, apply, .. }
208 | MigrateAction::Close { target, apply, .. } => {
209 gated(PreLanding, effect(*apply), target)
210 }
211 MigrateAction::Mark { target, .. } => gated(PreLanding, Writes, target),
212 },
213 Commands::Branches(args) => match &args.action {
214 BranchesAction::Prune { target, apply, .. } => gated(Writing, effect(*apply), target),
215 },
216 Commands::Lines(args) => match &args.action {
217 LinesAction::List { target, .. } | LinesAction::Rc { target, .. } => {
218 gated(ReadOnly, Reads, target)
219 }
220 LinesAction::Open { target, apply, .. }
221 | LinesAction::Mint { target, apply, .. }
222 | LinesAction::Retire { target, apply, .. } => gated(Writing, effect(*apply), target),
223 },
224 Commands::Release(args) => match &args.action {
225 ReleaseAction::Status { target, .. } | ReleaseAction::Eligible { target, .. } => {
226 gated(ReadOnly, Reads, target)
227 }
228 ReleaseAction::Merge { target, apply, .. } => gated(Writing, effect(*apply), target),
229 ReleaseAction::Request { target, .. } | ReleaseAction::Publish { target, .. } => {
230 gated(Writing, Writes, target)
231 }
232 ReleaseAction::Build { target, list, .. } => gated(Writing, effect(!*list), target),
233 },
234 Commands::Integrate(args) => gated(Writing, effect(args.apply), &args.target),
235 Commands::Message(args) => gated(ReadOnly, Reads, &args.target),
236 Commands::Worktree(args) => match &args.action {
237 WorktreeAction::List { target, .. } => gated(ReadOnly, Reads, target),
238 WorktreeAction::Add { target, apply, .. }
239 | WorktreeAction::Prune { target, apply, .. } => gated(Writing, effect(*apply), target),
240 },
241 Commands::Issue(args) => match &args.action {
242 IssueAction::Start { target, apply, .. } => gated(Writing, effect(*apply), target),
243 },
244 }
245}
246
247pub fn require_aligned(command: &Commands) -> Result<(), RkError> {
257 let Some(gated) = classify(command) else {
258 return Ok(());
259 };
260 if !gated.target.is_dir() {
263 return Ok(());
264 }
265 let Ok(loaded) = manifest::load(&gated.target) else {
268 return Ok(());
269 };
270 let Some(record) = loaded else {
271 if gated.class == Class::PreLanding {
272 return Ok(());
273 }
274 return Err(no_record(&gated.target));
275 };
276 let binary = env!("CARGO_PKG_VERSION");
277 let recorded = record.rk_version.as_str();
278 match manifest::alignment(recorded, binary) {
279 Alignment::Aligned => Ok(()),
280 Alignment::BinaryNewer => {
281 let pin = crate::self_depend::pinned_version(&gated.target);
282 let outside = pin
283 .as_deref()
284 .is_some_and(|pin| manifest::alignment(pin, binary) == Alignment::BinaryNewer);
285 if gated.effect == Effect::ReadOnly {
286 crate::output::Output::human().warn(binary_newer_warning(recorded, outside));
287 return Ok(());
288 }
289 Err(binary_newer(&gated.target, recorded, outside))
290 }
291 Alignment::TargetNewer => {
292 let pin = crate::self_depend::pinned_version(&gated.target)
293 .filter(|pin| manifest::alignment(recorded, pin) == Alignment::TargetNewer);
294 Err(record_newer(&gated.target, recorded, pin.as_deref()))
295 }
296 }
297}
298
299const OWN_RK: &str =
301 "run the rk of this project's environment, for example through `direnv exec . rk ...`";
302
303fn no_record(target: &Utf8Path) -> RkError {
304 RkError::refusal(
305 Diagnostic::new(
306 Reason::StateDrift,
307 format!("{target} has no release-kit landing, and this command reads one"),
308 )
309 .expected("a landing record at .release-kit/manifest.json")
310 .action(format!(
311 "run `rk init --target {target}` to land it. Read the setup skill with `rk skill show rk-setup`, and each file it names with `rk skill show rk-setup --file <path>`"
312 ))
313 .target_state("unchanged"),
314 )
315}
316
317fn binary_newer(target: &Utf8Path, recorded: &str, outside: bool) -> RkError {
318 let binary = env!("CARGO_PKG_VERSION");
319 let action = if outside {
320 format!(
321 "this rk comes from outside the project's environment, which pins rk {recorded}: {OWN_RK}. Where the project moved its pin to {binary}, run `rk upgrade --target {target} --apply` instead, then run this command again"
322 )
323 } else {
324 format!(
325 "run `rk upgrade --target {target} --apply` to move the landing, then run this command again. Where the project pins rk {recorded}, {OWN_RK}"
326 )
327 };
328 RkError::refusal(
329 Diagnostic::new(
330 Reason::StateDrift,
331 format!("the landing is at rk {recorded} and this rk is {binary}"),
332 )
333 .expected("a binary at the landing's rk_version")
334 .action(action)
335 .target_state("unchanged"),
336 )
337}
338
339fn binary_newer_warning(recorded: &str, outside: bool) -> String {
340 let binary = env!("CARGO_PKG_VERSION");
341 if outside {
342 format!(
343 "the landing is at rk {recorded} and this rk is {binary}, from outside the project's environment; {OWN_RK}, or run `rk upgrade` where the pin moved"
344 )
345 } else {
346 format!(
347 "the landing is at rk {recorded} and this rk is {binary}; run `rk upgrade` to move the landing"
348 )
349 }
350}
351
352fn record_newer(target: &Utf8Path, recorded: &str, pin: Option<&str>) -> RkError {
353 let binary = env!("CARGO_PKG_VERSION");
354 let mut action = OWN_RK.to_owned();
355 if let Some(pin) = pin {
356 action = format!("{action}. The pin of {target} names {pin}. Move it to {recorded}");
357 }
358 RkError::refusal(
359 Diagnostic::new(
360 Reason::StateDrift,
361 format!("the landing is at rk {recorded} and this rk is {binary}, which is older"),
362 )
363 .expected("a binary at the landing's rk_version")
364 .action(action)
365 .target_state("unchanged"),
366 )
367}
368
369#[cfg(test)]
370mod tests {
371 use super::CLASSES;
372 use clap::CommandFactory as _;
373
374 fn paths(command: &clap::Command, prefix: &str, out: &mut Vec<String>) {
378 for sub in command.get_subcommands() {
379 if sub.get_name() == "help" {
380 continue;
381 }
382 let path = if prefix.is_empty() {
383 sub.get_name().to_owned()
384 } else {
385 format!("{prefix} {}", sub.get_name())
386 };
387 if !sub.is_subcommand_required_set() || sub.get_subcommands().next().is_none() {
388 out.push(path.clone());
389 }
390 paths(sub, &path, out);
391 }
392 }
393
394 #[test]
399 fn every_action_has_a_gate_class() {
400 let mut all = Vec::new();
401 paths(&crate::cli::Cli::command(), "", &mut all);
402 let classed = |path: &str| {
403 let words: Vec<&str> = path.split(' ').collect();
404 (1..=words.len()).any(|end| {
405 let prefix = words[..end].join(" ");
406 CLASSES.iter().any(|(key, _)| *key == prefix)
407 })
408 };
409 for path in &all {
410 assert!(classed(path), "{path} has no gate class");
411 }
412 for (key, _) in CLASSES {
413 assert!(
414 all.iter().any(|path| path == key)
415 || all.iter().any(|path| path.starts_with(&format!("{key} "))),
416 "{key} names no command"
417 );
418 }
419 }
420}