ignition_core/actions/edit.rs
1//! `ign edit` core — the Editor seam, the private edit tree, and the
2//! edit pipeline (13-05).
3//!
4//! Three pieces live here:
5//!
6//! - [`Editor`] / [`TokioEditor`] — the swappable editor seam. The
7//! real impl resolves `VISUAL` → `EDITOR` (trimmed; empty string =
8//! unset; no platform fallback — `open -t -W` semantics vary, so a
9//! missing editor refuses with a clear "set $EDITOR" message) and
10//! spawns it with an ARG VECTOR (`<editor> <flags...> <target>`,
11//! the target path appended LAST) — never a shell string (the
12//! lint.rs delegation precedent; injection-safe by construction).
13//! The child's exit status is ADVISORY (Pitfall E1: vscode/emacs
14//! fork or daemonize, IDE shims linger) — whether an edit happened
15//! is decided by CONTENT, never by exit code.
16//! - [`EditTempDir`] — one private (0700 on unix), unique-per-call
17//! directory per invocation with Drop-guard cleanup; [`keep`]
18//! (EditTempDir::keep) consumes it WITHOUT deleting — the
19//! fail-closed recovery path keeps the user's edit on disk.
20//! - [`edit_pipeline`] / [`StagedEdit`] — the pipeline itself
21//! (fetch → decode → edit → content-decided no-op/encode →
22//! staleness gate → staged push payload), documented at the
23//! function.
24//!
25//! Editor resolution order and the arg-vector contract are
26//! planner-locked: `VISUAL` beats `EDITOR`; IDE-style editors ride
27//! inside the variable itself (`EDITOR="code --wait"` splits to the
28//! right argv with the target last). The CLI documents this (13-08).
29
30use std::path::{Path, PathBuf};
31
32use serde::Serialize;
33
34use crate::actions::resources::export_zip_bytes;
35use crate::client::GatewayApi;
36use crate::client::resources::{
37 MemberStatus, diff_members, member_hashes, member_path, resource_members,
38};
39use crate::client::scripts_codec::{decode_export_tree, encode_export_tree};
40use crate::error::CoreError;
41
42/// The editor seam: open one file path in the user's editor.
43///
44/// Contract:
45/// - the implementation spawns the editor with an ARG VECTOR (the
46/// target path is an argument, never interpolated into a shell
47/// string);
48/// - a successful return means "the editor process finished" — it
49/// says NOTHING about whether the content changed (the exit status
50/// is advisory; the pipeline decides by content);
51/// - a spawn failure (the editor binary is missing/unrunnable) is
52/// the usage-class [`CoreError::InvalidInput`] naming the editor —
53/// a missing binary is a user-env problem, not transport.
54#[async_trait::async_trait]
55pub trait Editor: Send + Sync {
56 /// Open `path` in the editor and wait for the editor process to
57 /// exit (however it exits).
58 async fn open(&self, path: &Path) -> Result<(), CoreError>;
59}
60
61/// The real editor: resolve `VISUAL` → `EDITOR` from the process
62/// environment and spawn it via [`tokio::process::Command`] with an
63/// arg vector.
64pub struct TokioEditor;
65
66impl TokioEditor {
67 /// Resolve the editor command string from the environment:
68 /// `VISUAL` first, then `EDITOR`; trimmed; an empty value counts
69 /// as unset. Absent both → the planner-locked refusal (no
70 /// platform fallback — `open -t -W` semantics vary; the CLI
71 /// documents setting `EDITOR` instead, 13-08).
72 pub fn resolve_command() -> Result<String, CoreError> {
73 let visual = std::env::var("VISUAL").ok();
74 let editor = std::env::var("EDITOR").ok();
75 Self::resolve_from(visual.as_deref(), editor.as_deref())
76 }
77
78 /// The pure resolution rule (env-free, unit-testable): `VISUAL`
79 /// beats `EDITOR`; a trimmed-empty value counts as unset; both
80 /// unset → the "set $EDITOR" refusal.
81 pub fn resolve_from(visual: Option<&str>, editor: Option<&str>) -> Result<String, CoreError> {
82 for candidate in [visual, editor].into_iter().flatten() {
83 let trimmed = candidate.trim();
84 if !trimmed.is_empty() {
85 return Ok(trimmed.to_string());
86 }
87 }
88 Err(CoreError::InvalidInput {
89 reason: "no $EDITOR set — export VISUAL or EDITOR (IDE users: include \
90 the --wait flag, e.g. EDITOR='code --wait')"
91 .to_string(),
92 })
93 }
94
95 /// The arg vector for `command` opening `path`: the command
96 /// splits on whitespace (so `code --wait` becomes
97 /// `["code", "--wait"]`) and the target path is appended LAST.
98 /// Never a shell string.
99 pub fn arg_vector(command: &str, path: &Path) -> Vec<String> {
100 let mut argv: Vec<String> = command.split_whitespace().map(str::to_string).collect();
101 argv.push(path.to_string_lossy().into_owned());
102 argv
103 }
104}
105
106#[async_trait::async_trait]
107impl Editor for TokioEditor {
108 async fn open(&self, path: &Path) -> Result<(), CoreError> {
109 let command = Self::resolve_command()?;
110 let argv = Self::arg_vector(&command, path);
111 let (program, flags) = argv.split_first().expect("arg_vector is never empty");
112 run_editor_argv(program, flags, path).await
113 }
114}
115
116/// THE single spawn site: run `program` with `flags` plus `path`
117/// (appended last) through [`tokio::process::Command`] — ARG VECTOR
118/// only, never a shell string (lint.rs:113-121 precedent;
119/// injection-safe). A spawn failure is the usage-class refusal
120/// naming the editor. A non-zero exit is SUCCESS here — the exit
121/// status is advisory (Pitfall E1); whether anything changed is
122/// decided by content downstream.
123pub async fn run_editor_argv(
124 program: &str,
125 flags: &[String],
126 path: &Path,
127) -> Result<(), CoreError> {
128 let mut cmd = tokio::process::Command::new(program);
129 cmd.args(flags).arg(path);
130 let status = cmd.status().await.map_err(|err| CoreError::InvalidInput {
131 reason: format!("editor {program:?} could not run: {err} — is it on PATH?"),
132 })?;
133 tracing::debug!(%status, "editor exited (exit is advisory — content decides)");
134 Ok(())
135}
136
137/// One private edit tree per `ign edit` invocation: a
138/// [`tempfile::TempDir`] created with 0700 permissions on unix
139/// (unique per call), removed by its Drop guard — and
140/// [`EditTempDir::keep`] consumes it WITHOUT deleting, the
141/// fail-closed recovery path that leaves the user's edit on disk
142/// with its path printed in the error.
143pub struct EditTempDir(tempfile::TempDir);
144
145impl EditTempDir {
146 /// Create the private directory (0700 on unix).
147 pub fn new() -> Result<Self, CoreError> {
148 let mut builder = tempfile::Builder::new();
149 builder.prefix("ign-edit-");
150 #[cfg(unix)]
151 {
152 use std::os::unix::fs::PermissionsExt;
153 builder.permissions(std::fs::Permissions::from_mode(0o700));
154 }
155 let dir = builder.tempdir().map_err(|err| {
156 CoreError::Internal(format!("cannot create the private edit directory: {err}"))
157 })?;
158 Ok(Self(dir))
159 }
160
161 /// The tree root (the decoded export lives here).
162 pub fn path(&self) -> &Path {
163 self.0.path()
164 }
165
166 /// Consume the guard WITHOUT deleting — returns the tree's path.
167 /// The fail-closed recovery path: on a refused re-encode the
168 /// caller keeps the tree and prints this path in the error, so
169 /// the user's edit survives the failed run.
170 pub fn keep(self) -> PathBuf {
171 self.0.keep()
172 }
173}
174
175// ---- The edit pipeline (13-05) ----------------------------------------------
176
177/// The pipeline verdict. `NoOp` means the editor changed NOTHING —
178/// decided by CONTENT (byte-identical re-encode), never by the
179/// editor's exit code. `Ready` carries the member-level blast
180/// radius: `changed` lists every resource member the staged push
181/// would write ([`diff_members`]' B-relative non-same paths, with
182/// the ORIGINAL export as A and the re-encode as B).
183#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
184#[serde(tag = "status", rename_all = "snake_case")]
185pub enum EditStatus {
186 /// Nothing changed — the caller must NOT push and must NOT
187 /// prompt (no guard, no confirmation, a silent clean exit).
188 NoOp,
189 /// An edit is staged; `changed` = what a push would write.
190 Ready {
191 /// Resource paths the staged import zip would change.
192 changed: Vec<String>,
193 },
194}
195
196/// The pipeline's terminal product: the staged edit the CALLER (the
197/// 13-08 CLI, via the guard ladder) decides what to do with. Push is
198/// deliberately OUT of core's signature — the pipeline ENDS at the
199/// staged payload, keeping gate composition at the dispatch layer
200/// (the 10-04 preview_then_confirm lesson: one gate site).
201/// `import_zip` is `None` EXACTLY when `status` is `NoOp` — there
202/// are no bytes to push, so the caller cannot push.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
204pub struct StagedEdit {
205 /// The project the edit targets (the push destination).
206 pub project: String,
207 /// The verdict + blast radius.
208 pub status: EditStatus,
209 /// The re-encoded import zip — `Some` only on `Ready`.
210 pub import_zip: Option<Vec<u8>>,
211}
212
213/// `ign edit`'s core loop (SC-5's mechanics), in order:
214///
215/// 1. **fetch** — one project export (`export_zip_bytes`).
216/// 2. **snapshot** — `member_hashes` of the fetch (descriptor-
217/// normalized; the staleness baseline).
218/// 3. **decode** — the WHOLE tree into a fresh private
219/// [`EditTempDir`] (`decode_export_tree`; the encode-back needs
220/// full context even though `resource_path` scopes the editor).
221/// 4. **baseline** — re-encode a SECOND decode of the SAME export
222/// untouched. This — not the raw original zip container — is the
223/// no-op comparator (planner lock: "the ORIGINAL export zip's
224/// re-encode"): our zip writer sorts and re-compresses, so
225/// container bytes differ from the gateway's even with identical
226/// member content; what is byte-stable is encode-vs-encode of
227/// identical trees (13-03's member-level round-trip invariant,
228/// made whole-tree by determinism). Computing it BEFORE the
229/// editor runs means a codec failure on an unedited tree refuses
230/// before burning the user's editing session.
231/// 5. **target** — `resource_path` (a USER path) must be a resource
232/// member; its decoded file is `member_path(user)` inside the
233/// edit tree. Its sidecars (`<member>.<n>.py`, embedded scripts)
234/// decode beside it and splice back through the manifest — the
235/// editor opens the member file; untouched sidecars re-encode
236/// byte-identically. `None` refuses with the member list:
237/// editing "the whole tree" is `ign workspace checkout`'s job —
238/// edit is ONE resource.
239/// 6. **edit** — `Editor::open(target)`; the exit status is
240/// advisory (Pitfall E1).
241/// 7. **re-encode** — `encode_export_tree`, FAIL-CLOSED: a member
242/// broken in the editor refuses via the codec's own
243/// [`encode_member`] `InvalidInput` (verbatim), and the edit tree
244/// is KEPT — the error message carries its path so the user can
245/// recover the edit.
246/// 8. **no-op** — the re-encoded bytes equal the baseline ⇒
247/// [`EditStatus::NoOp`] with `import_zip: None`. Content
248/// decided; no push, no prompt, no guard.
249/// 9. **staleness** — a FRESH export + the target member's hash vs
250/// the fetch snapshot; drift refuses with
251/// `resource "<path>" changed on gateway since fetch` — NOT
252/// `--yes`-able (forcing it would clobber a concurrent Designer
253/// edit; re-run to fetch fresh).
254/// 10. **stage** — [`EditStatus::Ready`] with the diff summary and
255/// `import_zip = Some(re-encoded bytes)`. The pipeline NEVER
256/// pushes; the caller does (with the gate of its own).
257pub async fn edit_pipeline(
258 api: &dyn GatewayApi,
259 editor: &dyn Editor,
260 project: &str,
261 resource_path: Option<&str>,
262) -> Result<StagedEdit, CoreError> {
263 // 1+2. Fetch + snapshot.
264 let zip = export_zip_bytes(api, project).await?;
265 let snapshot = member_hashes(&zip)?;
266
267 // 3. Decode the whole tree into the private edit dir.
268 let temp = EditTempDir::new()?;
269 let scripts = decode_export_tree(&zip, temp.path())?;
270 tracing::debug!(scripts, "decoded the export tree for edit");
271
272 // 4. Baseline re-encode of an untouched second decode (see the
273 // doc: this is the no-op comparator, computed pre-edit).
274 let baseline = {
275 let baseline_dir = EditTempDir::new()?;
276 decode_export_tree(&zip, baseline_dir.path())?;
277 encode_export_tree(baseline_dir.path())?
278 };
279
280 // 5. Resolve the editor target.
281 let members = resource_members(&zip)?;
282 let Some(user) = resource_path else {
283 let list = if members.is_empty() {
284 "the project has no resource members".to_string()
285 } else {
286 format!("valid members: {}", members.join(", "))
287 };
288 return Err(CoreError::InvalidInput {
289 reason: format!(
290 "edit needs one resource to edit — the whole tree is \
291 `ign workspace checkout`'s job ({list})"
292 ),
293 });
294 };
295 if !members.iter().any(|member| member == user) {
296 return Err(CoreError::InvalidInput {
297 reason: format!(
298 "\"{user}\" is not a resource member of {project} — valid members: {}",
299 members.join(", ")
300 ),
301 });
302 }
303 let target = temp.path().join(member_path(user));
304 if !target.exists() {
305 // Defensive (the membership check above already gates this):
306 // a resource member missing from its own decode tree is an
307 // export-contract violation, not user error.
308 return Err(CoreError::Internal(format!(
309 "resource member \"{user}\" decoded to nothing — the export \
310 zip is inconsistent with its member list"
311 )));
312 }
313
314 // 6. Edit. Exit status is advisory; content decides.
315 editor.open(&target).await?;
316
317 // 7. Re-encode, fail-closed: the codec's own InvalidInput rides
318 // VERBATIM (the bare reason, not a re-wrapped display), and
319 // the edit tree is KEPT (its path rides the message) so the
320 // user's edit survives the failed run.
321 let reencoded = match encode_export_tree(temp.path()) {
322 Ok(bytes) => bytes,
323 Err(err) => {
324 let codec_reason = match err {
325 CoreError::InvalidInput { reason } => reason,
326 other => other.to_string(),
327 };
328 let kept = temp.keep();
329 return Err(CoreError::InvalidInput {
330 reason: format!(
331 "{codec_reason}; the edit tree is preserved at {} — fix \
332 the member by hand and re-run to retry",
333 kept.display()
334 ),
335 });
336 }
337 };
338
339 // 8. The no-op decision: CONTENT, never the exit code.
340 if reencoded == baseline {
341 return Ok(StagedEdit {
342 project: project.to_string(),
343 status: EditStatus::NoOp,
344 import_zip: None,
345 });
346 }
347
348 // 9. Staleness gate — fresh export, target-member hash vs the
349 // fetch snapshot. Reuses member_hashes unchanged (invents NO
350 // etag); NOT --yes-able (planner lock, Pitfall E2).
351 let fresh = export_zip_bytes(api, project).await?;
352 let fresh_hashes = member_hashes(&fresh)?;
353 if fresh_hashes.get(user) != snapshot.get(user) {
354 return Err(CoreError::InvalidInput {
355 reason: format!(
356 "resource \"{user}\" changed on gateway since fetch — \
357 re-run to fetch fresh"
358 ),
359 });
360 }
361
362 // 10. Stage: Ready with the member-level blast radius.
363 let diff = diff_members(&zip, &reencoded)?;
364 let changed: Vec<String> = diff
365 .entries
366 .into_iter()
367 .filter(|entry| entry.status != MemberStatus::Same)
368 .map(|entry| entry.path)
369 .collect();
370 Ok(StagedEdit {
371 project: project.to_string(),
372 status: EditStatus::Ready { changed },
373 import_zip: Some(reencoded),
374 })
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 // ---- Editor resolution matrix (env-free) -------------------
382
383 #[test]
384 fn visual_beats_editor() {
385 let resolved =
386 TokioEditor::resolve_from(Some("vim"), Some("nano")).expect("visual resolves");
387 assert_eq!(resolved, "vim");
388 }
389
390 #[test]
391 fn editor_used_when_visual_unset() {
392 let resolved = TokioEditor::resolve_from(None, Some("nano")).expect("editor resolves");
393 assert_eq!(resolved, "nano");
394 }
395
396 #[test]
397 fn all_empty_values_refuse() {
398 let err = TokioEditor::resolve_from(Some(""), Some(" ")).expect_err("refuses");
399 let CoreError::InvalidInput { reason } = err else {
400 panic!("expected InvalidInput, got {err:?}");
401 };
402 assert!(reason.contains("no $EDITOR set"), "stable prefix: {reason}");
403 }
404
405 #[test]
406 fn empty_visual_falls_through_to_editor() {
407 let resolved = TokioEditor::resolve_from(Some(" "), Some("nano"))
408 .expect("trimmed-empty VISUAL is unset → EDITOR wins");
409 assert_eq!(resolved, "nano");
410 }
411
412 #[test]
413 fn both_unset_refuses_with_the_set_editor_message() {
414 let err = TokioEditor::resolve_from(None, None).expect_err("refuses");
415 let CoreError::InvalidInput { reason } = err else {
416 panic!("expected InvalidInput, got {err:?}");
417 };
418 assert!(
419 reason.contains("no $EDITOR set"),
420 "stable prefix missing: {reason}"
421 );
422 assert!(
423 reason.contains("EDITOR='code --wait'"),
424 "IDE hint: {reason}"
425 );
426 }
427
428 // ---- Arg vector (never a shell string) ----------------------
429
430 #[test]
431 fn ide_flag_editor_splits_with_path_last() {
432 let argv = TokioEditor::arg_vector("code --wait", Path::new("/tmp/x/view.json"));
433 assert_eq!(argv, vec!["code", "--wait", "/tmp/x/view.json"]);
434 }
435
436 #[test]
437 fn plain_editor_is_program_then_path() {
438 let argv = TokioEditor::arg_vector("vim", Path::new("/tmp/x/view.json"));
439 assert_eq!(argv, vec!["vim", "/tmp/x/view.json"]);
440 assert_eq!(argv.last().expect("path last"), "/tmp/x/view.json");
441 }
442
443 // ---- EditTempDir lifecycle ----------------------------------
444
445 #[cfg(unix)]
446 #[test]
447 fn temp_dir_mode_is_private_0700() {
448 use std::os::unix::fs::PermissionsExt;
449 let dir = EditTempDir::new().expect("temp dir");
450 let mode = std::fs::metadata(dir.path())
451 .expect("metadata")
452 .permissions()
453 .mode();
454 assert_eq!(mode & 0o777, 0o700, "the edit tree is private");
455 }
456
457 #[test]
458 fn temp_dirs_are_unique_per_call() {
459 let a = EditTempDir::new().expect("a");
460 let b = EditTempDir::new().expect("b");
461 assert_ne!(a.path(), b.path(), "unique per invocation");
462 }
463
464 #[test]
465 fn keep_preserves_the_tree() {
466 let dir = EditTempDir::new().expect("temp dir");
467 let file = dir.path().join("member.json");
468 std::fs::write(&file, b"{}").expect("seed file");
469 let kept = dir.keep();
470 assert!(kept.exists(), "keep() does not delete");
471 assert!(file.exists(), "the edit survives");
472 std::fs::remove_dir_all(&kept).expect("test cleanup");
473 }
474}