use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::actions::resources::export_zip_bytes;
use crate::client::GatewayApi;
use crate::client::resources::{
MemberStatus, diff_members, member_hashes, member_path, resource_members,
};
use crate::client::scripts_codec::{decode_export_tree, encode_export_tree};
use crate::error::CoreError;
#[async_trait::async_trait]
pub trait Editor: Send + Sync {
async fn open(&self, path: &Path) -> Result<(), CoreError>;
}
pub struct TokioEditor;
impl TokioEditor {
pub fn resolve_command() -> Result<String, CoreError> {
let visual = std::env::var("VISUAL").ok();
let editor = std::env::var("EDITOR").ok();
Self::resolve_from(visual.as_deref(), editor.as_deref())
}
pub fn resolve_from(visual: Option<&str>, editor: Option<&str>) -> Result<String, CoreError> {
for candidate in [visual, editor].into_iter().flatten() {
let trimmed = candidate.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
}
Err(CoreError::InvalidInput {
reason: "no $EDITOR set — export VISUAL or EDITOR (IDE users: include \
the --wait flag, e.g. EDITOR='code --wait')"
.to_string(),
})
}
pub fn arg_vector(command: &str, path: &Path) -> Vec<String> {
let mut argv: Vec<String> = command.split_whitespace().map(str::to_string).collect();
argv.push(path.to_string_lossy().into_owned());
argv
}
}
#[async_trait::async_trait]
impl Editor for TokioEditor {
async fn open(&self, path: &Path) -> Result<(), CoreError> {
let command = Self::resolve_command()?;
let argv = Self::arg_vector(&command, path);
let (program, flags) = argv.split_first().expect("arg_vector is never empty");
run_editor_argv(program, flags, path).await
}
}
pub async fn run_editor_argv(
program: &str,
flags: &[String],
path: &Path,
) -> Result<(), CoreError> {
let mut cmd = tokio::process::Command::new(program);
cmd.args(flags).arg(path);
let status = cmd.status().await.map_err(|err| CoreError::InvalidInput {
reason: format!("editor {program:?} could not run: {err} — is it on PATH?"),
})?;
tracing::debug!(%status, "editor exited (exit is advisory — content decides)");
Ok(())
}
pub struct EditTempDir(tempfile::TempDir);
impl EditTempDir {
pub fn new() -> Result<Self, CoreError> {
let mut builder = tempfile::Builder::new();
builder.prefix("ign-edit-");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
builder.permissions(std::fs::Permissions::from_mode(0o700));
}
let dir = builder.tempdir().map_err(|err| {
CoreError::Internal(format!("cannot create the private edit directory: {err}"))
})?;
Ok(Self(dir))
}
pub fn path(&self) -> &Path {
self.0.path()
}
pub fn keep(self) -> PathBuf {
self.0.keep()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum EditStatus {
NoOp,
Ready {
changed: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct StagedEdit {
pub project: String,
pub status: EditStatus,
pub import_zip: Option<Vec<u8>>,
}
pub async fn edit_pipeline(
api: &dyn GatewayApi,
editor: &dyn Editor,
project: &str,
resource_path: Option<&str>,
) -> Result<StagedEdit, CoreError> {
let zip = export_zip_bytes(api, project).await?;
let snapshot = member_hashes(&zip)?;
let temp = EditTempDir::new()?;
let scripts = decode_export_tree(&zip, temp.path())?;
tracing::debug!(scripts, "decoded the export tree for edit");
let baseline = {
let baseline_dir = EditTempDir::new()?;
decode_export_tree(&zip, baseline_dir.path())?;
encode_export_tree(baseline_dir.path())?
};
let members = resource_members(&zip)?;
let Some(user) = resource_path else {
let list = if members.is_empty() {
"the project has no resource members".to_string()
} else {
format!("valid members: {}", members.join(", "))
};
return Err(CoreError::InvalidInput {
reason: format!(
"edit needs one resource to edit — the whole tree is \
`ign workspace checkout`'s job ({list})"
),
});
};
if !members.iter().any(|member| member == user) {
return Err(CoreError::InvalidInput {
reason: format!(
"\"{user}\" is not a resource member of {project} — valid members: {}",
members.join(", ")
),
});
}
let target = temp.path().join(member_path(user));
if !target.exists() {
return Err(CoreError::Internal(format!(
"resource member \"{user}\" decoded to nothing — the export \
zip is inconsistent with its member list"
)));
}
editor.open(&target).await?;
let reencoded = match encode_export_tree(temp.path()) {
Ok(bytes) => bytes,
Err(err) => {
let codec_reason = match err {
CoreError::InvalidInput { reason } => reason,
other => other.to_string(),
};
let kept = temp.keep();
return Err(CoreError::InvalidInput {
reason: format!(
"{codec_reason}; the edit tree is preserved at {} — fix \
the member by hand and re-run to retry",
kept.display()
),
});
}
};
if reencoded == baseline {
return Ok(StagedEdit {
project: project.to_string(),
status: EditStatus::NoOp,
import_zip: None,
});
}
let fresh = export_zip_bytes(api, project).await?;
let fresh_hashes = member_hashes(&fresh)?;
if fresh_hashes.get(user) != snapshot.get(user) {
return Err(CoreError::InvalidInput {
reason: format!(
"resource \"{user}\" changed on gateway since fetch — \
re-run to fetch fresh"
),
});
}
let diff = diff_members(&zip, &reencoded)?;
let changed: Vec<String> = diff
.entries
.into_iter()
.filter(|entry| entry.status != MemberStatus::Same)
.map(|entry| entry.path)
.collect();
Ok(StagedEdit {
project: project.to_string(),
status: EditStatus::Ready { changed },
import_zip: Some(reencoded),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn visual_beats_editor() {
let resolved =
TokioEditor::resolve_from(Some("vim"), Some("nano")).expect("visual resolves");
assert_eq!(resolved, "vim");
}
#[test]
fn editor_used_when_visual_unset() {
let resolved = TokioEditor::resolve_from(None, Some("nano")).expect("editor resolves");
assert_eq!(resolved, "nano");
}
#[test]
fn all_empty_values_refuse() {
let err = TokioEditor::resolve_from(Some(""), Some(" ")).expect_err("refuses");
let CoreError::InvalidInput { reason } = err else {
panic!("expected InvalidInput, got {err:?}");
};
assert!(reason.contains("no $EDITOR set"), "stable prefix: {reason}");
}
#[test]
fn empty_visual_falls_through_to_editor() {
let resolved = TokioEditor::resolve_from(Some(" "), Some("nano"))
.expect("trimmed-empty VISUAL is unset → EDITOR wins");
assert_eq!(resolved, "nano");
}
#[test]
fn both_unset_refuses_with_the_set_editor_message() {
let err = TokioEditor::resolve_from(None, None).expect_err("refuses");
let CoreError::InvalidInput { reason } = err else {
panic!("expected InvalidInput, got {err:?}");
};
assert!(
reason.contains("no $EDITOR set"),
"stable prefix missing: {reason}"
);
assert!(
reason.contains("EDITOR='code --wait'"),
"IDE hint: {reason}"
);
}
#[test]
fn ide_flag_editor_splits_with_path_last() {
let argv = TokioEditor::arg_vector("code --wait", Path::new("/tmp/x/view.json"));
assert_eq!(argv, vec!["code", "--wait", "/tmp/x/view.json"]);
}
#[test]
fn plain_editor_is_program_then_path() {
let argv = TokioEditor::arg_vector("vim", Path::new("/tmp/x/view.json"));
assert_eq!(argv, vec!["vim", "/tmp/x/view.json"]);
assert_eq!(argv.last().expect("path last"), "/tmp/x/view.json");
}
#[cfg(unix)]
#[test]
fn temp_dir_mode_is_private_0700() {
use std::os::unix::fs::PermissionsExt;
let dir = EditTempDir::new().expect("temp dir");
let mode = std::fs::metadata(dir.path())
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o700, "the edit tree is private");
}
#[test]
fn temp_dirs_are_unique_per_call() {
let a = EditTempDir::new().expect("a");
let b = EditTempDir::new().expect("b");
assert_ne!(a.path(), b.path(), "unique per invocation");
}
#[test]
fn keep_preserves_the_tree() {
let dir = EditTempDir::new().expect("temp dir");
let file = dir.path().join("member.json");
std::fs::write(&file, b"{}").expect("seed file");
let kept = dir.keep();
assert!(kept.exists(), "keep() does not delete");
assert!(file.exists(), "the edit survives");
std::fs::remove_dir_all(&kept).expect("test cleanup");
}
}