use std::path::Path;
use std::process::{ExitStatus, Output};
use std::sync::Arc;
use crate::command::{CommandRunner, DryRunCommandRunner};
use crate::filesystem::Filesystem;
use crate::git::Git;
use crate::github::client::CodeForgeClient;
#[derive(Debug, Clone)]
pub struct Env {
editor: Option<String>,
runner: Arc<dyn CommandRunner>,
filesystem: Arc<dyn Filesystem>,
git: Arc<dyn Git>,
code_forge_client: Result<Arc<dyn CodeForgeClient>, String>,
oidc_environment: bool,
node_auth_token_present: bool,
cargo_registry_token_present: bool,
locale: String,
}
impl Env {
pub fn new(
runner: Arc<dyn CommandRunner>,
filesystem: Arc<dyn Filesystem>,
git: Arc<dyn Git>,
) -> Self {
Self {
runner,
filesystem,
git,
editor: None,
code_forge_client: Err("No code forge client configured".into()),
oidc_environment: false,
node_auth_token_present: false,
cargo_registry_token_present: false,
locale: crate::locale::DEFAULT_LOCALE.to_string(),
}
}
pub fn with_oidc_environment(mut self, oidc_environment: bool) -> Self {
self.oidc_environment = oidc_environment;
self
}
pub fn with_node_auth_token_present(mut self, present: bool) -> Self {
self.node_auth_token_present = present;
self
}
pub fn with_cargo_registry_token_present(mut self, present: bool) -> Self {
self.cargo_registry_token_present = present;
self
}
pub fn with_editor(mut self, editor: String) -> Self {
self.editor = Some(editor);
self
}
pub fn with_code_forge_client(mut self, client: Arc<dyn CodeForgeClient>) -> Self {
self.code_forge_client = Ok(client);
self
}
pub fn with_editor_opt(mut self, editor: Option<String>) -> Self {
self.editor = editor;
self
}
pub fn with_code_forge_client_result(
mut self,
client: Result<Arc<dyn CodeForgeClient>, String>,
) -> Self {
self.code_forge_client = client;
self
}
pub fn with_locale(mut self, locale: String) -> Self {
self.locale = locale;
self
}
pub fn with_dry_run_runner(self) -> Self {
let dry_runner: Arc<dyn CommandRunner> =
Arc::new(DryRunCommandRunner::new(Arc::clone(&self.runner)));
Self {
runner: dry_runner,
filesystem: self.filesystem,
editor: self.editor,
git: self.git,
code_forge_client: self.code_forge_client,
oidc_environment: self.oidc_environment,
node_auth_token_present: self.node_auth_token_present,
cargo_registry_token_present: self.cargo_registry_token_present,
locale: self.locale,
}
}
pub fn apply_global(self, global: &crate::cli::GlobalArgs) -> Self {
if global.dry_run {
self.with_dry_run_runner()
} else {
self
}
}
pub(crate) fn editor(&self) -> Option<&str> {
self.editor.as_deref()
}
pub fn fs(&self) -> &dyn Filesystem {
&*self.filesystem
}
pub fn runner(&self) -> Arc<dyn CommandRunner> {
Arc::clone(&self.runner)
}
pub fn git(&self) -> &dyn Git {
&*self.git
}
pub(crate) fn code_forge_client(&self) -> Result<&dyn CodeForgeClient, &str> {
self.code_forge_client
.as_ref()
.map(|c| &**c as &dyn CodeForgeClient)
.map_err(|e| e.as_str())
}
pub(crate) fn oidc_environment(&self) -> bool {
self.oidc_environment
}
pub(crate) fn node_auth_token_present(&self) -> bool {
self.node_auth_token_present
}
pub(crate) fn cargo_registry_token_present(&self) -> bool {
self.cargo_registry_token_present
}
pub(crate) fn locale(&self) -> &str {
&self.locale
}
async fn find_default_editor(&self, cwd: &Path) -> Option<String> {
let (probe_cmd, candidates): (&str, &[&str]) = if cfg!(windows) {
("where.exe", &["notepad"])
} else {
("which", &["nano", "vim", "vi", "emacs"])
};
for cmd in candidates {
if self
.run(probe_cmd, &[cmd], cwd)
.await
.is_ok_and(|o| o.status.success())
{
return Some((*cmd).to_string());
}
}
None
}
pub async fn run_editor_on(&self, path: &Path, cwd: &Path) -> anyhow::Result<()> {
use anyhow::Context as _;
let editor = match self.editor().filter(|v| !v.is_empty()).map(String::from) {
Some(e) => e,
None => self
.find_default_editor(cwd)
.await
.context("No editor found. Set the VISUAL or EDITOR environment variable.")?,
};
let path_str = path.to_string_lossy();
let shell_cmd = format!("{editor} {}", crate::shell::shell_quote(&path_str));
let status = self
.run_shell_interactive(&shell_cmd, cwd)
.await
.with_context(|| format!("Failed to open editor: {editor}"))?;
if !status.success() {
anyhow::bail!("Editor exited with status: {status}");
}
Ok(())
}
pub async fn run(&self, program: &str, args: &[&str], cwd: &Path) -> anyhow::Result<Output> {
self.runner.run(program, args, cwd).await
}
pub async fn run_mut(
&self,
program: &str,
args: &[&str],
cwd: &Path,
) -> anyhow::Result<Output> {
self.runner.run_mut(program, args, cwd).await
}
pub async fn run_interactive(
&self,
program: &str,
args: &[&str],
cwd: &Path,
) -> anyhow::Result<ExitStatus> {
self.runner.run_interactive(program, args, cwd).await
}
pub async fn run_shell_interactive(
&self,
command: &str,
cwd: &Path,
) -> anyhow::Result<ExitStatus> {
self.runner.run_shell_interactive(command, cwd).await
}
pub async fn run_streaming(&self, command: &str, cwd: &Path) -> anyhow::Result<ExitStatus> {
self.runner.run_streaming(command, cwd).await
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::sync::Arc;
use crate::command::test_support::RecordingCommandRunner;
use crate::command::{CommandRunner, shell_program};
use crate::filesystem::LocalFilesystem;
use crate::github::client::CodeForgeClient;
use crate::github::client::test_support::RecordingCodeForgeClient;
use super::*;
fn recording_env(exit_code: i32) -> (Arc<RecordingCommandRunner>, Env, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
let runner = Arc::new(RecordingCommandRunner::new(exit_code));
let git = Arc::new(crate::git::GitWorkdir::new(
Arc::clone(&runner) as Arc<dyn CommandRunner>,
crate::path::AbsolutePath::new(dir.path()).unwrap(),
));
let env = Env::new(
Arc::clone(&runner) as Arc<dyn CommandRunner>,
Arc::new(LocalFilesystem),
git,
);
(runner, env, dir)
}
#[test]
fn new_has_no_editor_or_code_forge_client() {
let (_, env, _dir) = recording_env(0);
assert!(env.editor().is_none());
assert!(env.code_forge_client().is_err());
}
#[test]
fn new_has_false_auth_flags() {
let (_, env, _dir) = recording_env(0);
assert!(!env.oidc_environment());
assert!(!env.node_auth_token_present());
assert!(!env.cargo_registry_token_present());
}
#[test]
fn with_oidc_environment_sets_flag() {
let (_, env, _dir) = recording_env(0);
let env = env.with_oidc_environment(true);
assert!(env.oidc_environment());
}
#[test]
fn with_node_auth_token_present_sets_flag() {
let (_, env, _dir) = recording_env(0);
let env = env.with_node_auth_token_present(true);
assert!(env.node_auth_token_present());
}
#[test]
fn with_cargo_registry_token_present_sets_flag() {
let (_, env, _dir) = recording_env(0);
let env = env.with_cargo_registry_token_present(true);
assert!(env.cargo_registry_token_present());
}
#[test]
fn new_has_default_locale() {
let (_, env, _dir) = recording_env(0);
assert_eq!(env.locale(), crate::locale::DEFAULT_LOCALE);
}
#[test]
fn with_locale_sets_locale() {
let (_, env, _dir) = recording_env(0);
let env = env.with_locale("pt-BR".to_string());
assert_eq!(env.locale(), "pt-BR");
}
#[test]
fn with_dry_run_runner_preserves_auth_flags() {
let (_, env, _dir) = recording_env(0);
let env = env
.with_oidc_environment(true)
.with_node_auth_token_present(true)
.with_cargo_registry_token_present(true);
let dry_env = env.with_dry_run_runner();
assert!(dry_env.oidc_environment());
assert!(dry_env.node_auth_token_present());
assert!(dry_env.cargo_registry_token_present());
}
#[test]
fn with_dry_run_runner_preserves_locale() {
let (_, env, _dir) = recording_env(0);
let env = env.with_locale("fr".to_string());
let dry_env = env.with_dry_run_runner();
assert_eq!(dry_env.locale(), "fr");
}
#[test]
fn with_dry_run_runner_preserves_git() {
let (_, env, _dir) = recording_env(0);
let path = env.git().path().clone();
let dry_env = env.with_dry_run_runner();
assert_eq!(dry_env.git().path(), &path);
}
#[test]
fn with_editor_sets_editor() {
let (_, env, _dir) = recording_env(0);
let env = env.with_editor("vim".to_string());
assert_eq!(env.editor(), Some("vim"));
}
#[test]
fn with_code_forge_client_sets_client() {
let (_, env, _dir) = recording_env(0);
let client = Arc::new(RecordingCodeForgeClient::new()) as Arc<dyn CodeForgeClient>;
let env = env.with_code_forge_client(Arc::clone(&client));
assert!(env.code_forge_client().is_ok());
}
#[test]
fn with_editor_opt_some_sets_editor() {
let (_, env, _dir) = recording_env(0);
let env = env.with_editor_opt(Some("nano".to_string()));
assert_eq!(env.editor(), Some("nano"));
}
#[test]
fn with_editor_opt_none_clears_editor() {
let (_, env, _dir) = recording_env(0);
let env = env.with_editor("vim".to_string()).with_editor_opt(None);
assert!(env.editor().is_none());
}
#[test]
fn with_code_forge_client_result_ok_sets_client() {
let (_, env, _dir) = recording_env(0);
let client = Arc::new(RecordingCodeForgeClient::new()) as Arc<dyn CodeForgeClient>;
let env = env.with_code_forge_client_result(Ok(client));
assert!(env.code_forge_client().is_ok());
}
#[test]
fn with_code_forge_client_result_err_clears_client() {
let (_, env, _dir) = recording_env(0);
let client = Arc::new(RecordingCodeForgeClient::new()) as Arc<dyn CodeForgeClient>;
let env = env
.with_code_forge_client(client)
.with_code_forge_client_result(Err("no token".into()));
assert!(env.code_forge_client().is_err());
}
#[tokio::test]
async fn run_delegates_to_runner() {
let (runner, env, _dir) = recording_env(0);
env.run("echo", &["hello"], Path::new(".")).await.unwrap();
let invocations = runner.invocations();
assert_eq!(invocations[0].program, "echo");
assert_eq!(invocations[0].args, ["hello"]);
}
#[tokio::test]
async fn run_mut_delegates_to_runner() {
let (runner, env, _dir) = recording_env(0);
env.run_mut("git", &["commit", "-m", "msg"], Path::new("."))
.await
.unwrap();
let invocations = runner.invocations();
assert_eq!(invocations[0].program, "git");
assert_eq!(invocations[0].args, ["commit", "-m", "msg"]);
}
#[tokio::test]
async fn run_streaming_delegates_to_runner() {
let (runner, env, _dir) = recording_env(0);
env.run_streaming("npm install", Path::new("."))
.await
.unwrap();
let invocations = runner.invocations();
assert_eq!(invocations[0].program, shell_program());
assert!(invocations[0].is_shell);
assert!(invocations[0].is_streaming);
}
#[tokio::test]
async fn run_interactive_delegates_to_runner() {
let (runner, env, _dir) = recording_env(0);
env.run_interactive("vim", &[], Path::new("."))
.await
.unwrap();
let invocations = runner.invocations();
assert_eq!(invocations[0].program, "vim");
assert!(invocations[0].is_interactive);
}
#[tokio::test]
async fn with_dry_run_runner_suppresses_run_mut() {
let (runner, env, _dir) = recording_env(0);
let dry_env = env.with_dry_run_runner();
dry_env
.run_mut("git", &["push", "origin", "HEAD"], Path::new("."))
.await
.unwrap();
assert!(runner.invocations().is_empty());
}
#[tokio::test]
async fn with_dry_run_runner_still_forwards_run() {
let (runner, env, _dir) = recording_env(0);
let dry_env = env.with_dry_run_runner();
dry_env
.run("git", &["status"], Path::new("."))
.await
.unwrap();
assert_eq!(runner.invocations().len(), 1);
assert_eq!(runner.invocations()[0].program, "git");
}
#[tokio::test]
async fn run_editor_on_uses_editor_when_set() {
let workdir = tempfile::tempdir().unwrap();
let path = workdir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
let (runner, env, _dir) = recording_env(0);
let env = env.with_editor("vim".to_string());
env.run_editor_on(&path, workdir.path()).await.unwrap();
let invocations = runner.invocations();
let editor_call = invocations
.iter()
.find(|i| i.is_interactive && i.is_shell)
.expect("Expected a shell interactive invocation");
let expected = format!("vim {}", crate::shell::shell_quote(&path.to_string_lossy()));
assert_eq!(editor_call.args[1], expected);
}
#[tokio::test]
async fn run_editor_on_ignores_empty_editor_string() {
let workdir = tempfile::tempdir().unwrap();
let path = workdir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
let (runner, env, _dir) = recording_env(0);
let env = env.with_editor(String::new());
env.run_editor_on(&path, workdir.path()).await.unwrap();
let invocations = runner.invocations();
let editor_call = invocations
.iter()
.find(|i| i.is_interactive && i.is_shell)
.expect("Expected a shell interactive invocation");
let expected = format!(
"nano {}",
crate::shell::shell_quote(&path.to_string_lossy())
);
assert_eq!(editor_call.args[1], expected, "Should fall back to nano");
}
#[tokio::test]
async fn run_editor_on_nonzero_exit_returns_error() {
let workdir = tempfile::tempdir().unwrap();
let path = workdir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
let (_, env, _dir) = recording_env(1);
let env = env.with_editor("vim".to_string());
let result = env.run_editor_on(&path, workdir.path()).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Editor exited with status")
);
}
#[tokio::test]
async fn run_editor_on_falls_back_to_default_editor() {
let workdir = tempfile::tempdir().unwrap();
let path = workdir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
let (runner, env, _dir) = recording_env(0);
env.run_editor_on(&path, workdir.path()).await.unwrap();
let invocations = runner.invocations();
let editor_call = invocations
.iter()
.find(|i| i.is_interactive && i.is_shell)
.expect("Expected a shell interactive invocation");
let expected = format!(
"nano {}",
crate::shell::shell_quote(&path.to_string_lossy())
);
assert_eq!(editor_call.args[1], expected);
}
#[tokio::test]
async fn run_editor_on_no_editor_found_returns_error() {
let workdir = tempfile::tempdir().unwrap();
let path = workdir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
let (_, env, _dir) = recording_env(1);
let result = env.run_editor_on(&path, workdir.path()).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No editor found"));
}
#[tokio::test]
async fn run_editor_on_uses_provided_cwd() {
let workdir = tempfile::tempdir().unwrap();
let cursus_dir = workdir.path().join(".cursus");
std::fs::create_dir_all(&cursus_dir).unwrap();
let path = cursus_dir.join("config.toml");
std::fs::write(&path, "").unwrap();
let (runner, env, _dir) = recording_env(0);
let env = env.with_editor("vim".to_string());
env.run_editor_on(&path, workdir.path()).await.unwrap();
let invocations = runner.invocations();
let editor_call = invocations
.iter()
.find(|i| i.is_interactive && i.is_shell)
.expect("Expected a shell interactive editor invocation");
assert_eq!(
editor_call.cwd,
workdir.path(),
"Editor should be invoked with the provided cwd, not the file's parent"
);
}
#[tokio::test]
async fn run_editor_on_handles_multi_word_editor() {
let workdir = tempfile::tempdir().unwrap();
let path = workdir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
let (runner, env, _dir) = recording_env(0);
let env = env.with_editor("code --wait".to_string());
env.run_editor_on(&path, workdir.path()).await.unwrap();
let invocations = runner.invocations();
let editor_call = invocations
.iter()
.find(|i| i.is_interactive && i.is_shell)
.expect("Expected a shell interactive invocation");
let expected = format!(
"code --wait {}",
crate::shell::shell_quote(&path.to_string_lossy())
);
assert_eq!(editor_call.args[1], expected);
}
#[tokio::test]
async fn run_editor_on_handles_path_with_single_quote() {
let workdir = tempfile::tempdir().unwrap();
let path = workdir.path().join("it's a file.toml");
std::fs::write(&path, "").unwrap();
let (runner, env, _dir) = recording_env(0);
let env = env.with_editor("vim".to_string());
env.run_editor_on(&path, workdir.path()).await.unwrap();
let invocations = runner.invocations();
let editor_call = invocations
.iter()
.find(|i| i.is_interactive && i.is_shell)
.expect("Expected a shell interactive invocation");
let expected = format!("vim {}", crate::shell::shell_quote(&path.to_string_lossy()));
assert_eq!(editor_call.args[1], expected);
}
#[tokio::test]
async fn run_shell_interactive_delegates_to_runner() {
let (runner, env, _dir) = recording_env(0);
let cwd = tempfile::tempdir().unwrap();
env.run_shell_interactive("echo hello", cwd.path())
.await
.unwrap();
let invocations = runner.invocations();
assert_eq!(invocations.len(), 1);
assert!(invocations[0].is_shell);
assert!(invocations[0].is_interactive);
}
}