Skip to main content

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}