use std::collections::BTreeMap;
use std::process::{Command, ExitStatus};
use color_eyre::eyre::Result;
use repon_core::EntityState;
use crate::config::document::{Document, LauncherConfig};
use crate::tui::Tui;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Launcher {
pub name: String,
pub source: Source,
pub shell: bool,
pub interactive: bool,
pub takes_terminal: bool,
pub env: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
Args(Vec<String>),
FromEnv(String),
EditorChain,
ShellFallback,
}
impl Source {
pub fn resolve_argv(&self, lookup: impl Fn(&str) -> Option<String>) -> Vec<String> {
match self {
Source::Args(args) => args.clone(),
Source::FromEnv(name) => env_argv(&lookup, name).unwrap_or_default(),
Source::EditorChain => chain_argv(&lookup, &["VISUAL", "EDITOR"], "vi"),
Source::ShellFallback => chain_argv(&lookup, &["SHELL"], "/bin/sh"),
}
}
}
fn env_argv(lookup: &impl Fn(&str) -> Option<String>, name: &str) -> Option<Vec<String>> {
let value = lookup(name)?;
if value.trim().is_empty() {
return None;
}
shell_words::split(&value).ok()
}
fn chain_argv(
lookup: &impl Fn(&str) -> Option<String>,
vars: &[&str],
fallback: &str,
) -> Vec<String> {
for var in vars {
if let Some(argv) = env_argv(lookup, var) {
return argv;
}
}
vec![fallback.to_string()]
}
fn shipped_defaults() -> Vec<Launcher> {
vec![
Launcher {
name: "lazygit".to_string(),
source: Source::Args(vec!["lazygit".to_string()]),
shell: false,
interactive: false,
takes_terminal: true,
env: BTreeMap::new(),
},
Launcher {
name: "tuicr".to_string(),
source: Source::Args(vec!["tuicr".to_string()]),
shell: false,
interactive: false,
takes_terminal: true,
env: BTreeMap::new(),
},
Launcher {
name: "editor".to_string(),
source: Source::EditorChain,
shell: false,
interactive: false,
takes_terminal: true,
env: BTreeMap::new(),
},
Launcher {
name: "shell".to_string(),
source: Source::ShellFallback,
shell: false,
interactive: false,
takes_terminal: true,
env: BTreeMap::new(),
},
]
}
impl Launcher {
fn from_config(config: &LauncherConfig, shipped: Option<&Launcher>) -> Self {
let source = match (&config.args, &config.from_env) {
(Some(args), _) => Source::Args(args.clone()),
(None, Some(name)) => Source::FromEnv(name.clone()),
(None, None) => shipped
.map(|launcher| launcher.source.clone())
.unwrap_or_else(|| Source::Args(Vec::new())),
};
Self {
name: config.name.get_ref().clone(),
source,
shell: config.shell,
interactive: config.interactive,
takes_terminal: config.takes_terminal,
env: config.env.clone(),
}
}
}
pub fn resolve(document: &Document) -> Vec<Launcher> {
let mut unnamed = shipped_defaults();
let mut result = Vec::with_capacity(unnamed.len() + document.launchers.len());
for declared in &document.launchers {
let name = declared.name.get_ref().as_str();
let shipped = unnamed
.iter()
.position(|launcher| launcher.name == name)
.map(|position| unnamed.remove(position));
if !declared.disabled {
result.push(Launcher::from_config(declared, shipped.as_ref()));
}
}
result.extend(unnamed);
result
}
pub fn run(tui: &mut Tui, launcher: &Launcher, entity: &EntityState) -> Result<ExitStatus> {
let mut command = build_command(launcher, entity);
if launcher.takes_terminal {
tui.suspend_for_child(&mut command)
} else {
tui.keep_screen_for_child(&mut command)
}
}
pub(crate) fn command_from_argv(argv: &[String]) -> Command {
let mut command = Command::new(argv.first().cloned().unwrap_or_default());
command.args(argv.iter().skip(1));
command
}
pub(crate) fn build_command(launcher: &Launcher, entity: &EntityState) -> Command {
let argv = launcher
.source
.resolve_argv(|name| std::env::var(name).ok());
let mut command = if launcher.shell {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let flag = if launcher.interactive { "-ic" } else { "-c" };
let mut command = Command::new(shell);
command.arg(flag).arg(argv.join(" ")).arg("repon");
command
} else {
command_from_argv(&argv)
};
command.current_dir(entity.key.path());
for (name, value) in repon_core::environment(entity, None) {
match value {
Some(value) => {
command.env(name, value);
}
None => {
command.env_remove(name);
}
}
}
for (name, value) in &launcher.env {
command.env(name, value);
}
command
}
#[cfg(test)]
mod tests {
use super::*;
fn launcher_config(
name: &str,
args: Option<Vec<&str>>,
from_env: Option<&str>,
disabled: bool,
) -> LauncherConfig {
LauncherConfig {
name: toml::Spanned::new(0..0, name.to_string()),
args: args.map(|args| args.into_iter().map(str::to_string).collect()),
from_env: from_env.map(str::to_string),
shell: false,
interactive: false,
takes_terminal: true,
env: BTreeMap::new(),
disabled,
}
}
fn spec_config_md() -> String {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(manifest_dir.join("../../docs/spec/config.md"))
.expect("read docs/spec/config.md")
}
fn spec_shipped_launcher_names(spec: &str) -> Vec<String> {
const ANCHOR: &str = "Four Launchers ship as defaults:";
let after = spec
.split(ANCHOR)
.nth(1)
.expect("the shipped-defaults sentence is present");
let sentence = after.split('.').next().expect("a sentence terminator");
sentence
.split(',')
.map(str::trim)
.filter(|phrase| !phrase.is_empty())
.map(|phrase| phrase.strip_prefix("and ").unwrap_or(phrase))
.map(|phrase| {
phrase
.strip_prefix("an ")
.or_else(|| phrase.strip_prefix("a "))
.unwrap_or(phrase)
})
.map(|phrase| phrase.split(" via ").next().unwrap_or(phrase))
.map(|phrase| phrase.trim().trim_matches('`').to_string())
.collect()
}
#[test]
fn shipped_launcher_names_match_the_spec_exactly_and_in_order() {
let expected = spec_shipped_launcher_names(&spec_config_md());
let actual: Vec<String> = shipped_defaults()
.into_iter()
.map(|launcher| launcher.name)
.collect();
assert_eq!(actual, expected);
}
#[test]
fn an_empty_document_resolves_to_exactly_the_four_shipped_defaults() {
let resolved = resolve(&Document::default());
assert_eq!(resolved, shipped_defaults());
}
#[test]
fn a_declared_entry_of_a_shipped_name_replaces_that_default_and_takes_its_file_position() {
let mut document = Document::default();
document.launchers.push(launcher_config(
"tuicr",
Some(vec!["custom-tuicr", "--flag"]),
None,
false,
));
let resolved = resolve(&document);
let names: Vec<&str> = resolved
.iter()
.map(|launcher| launcher.name.as_str())
.collect();
assert_eq!(
names,
vec!["tuicr", "lazygit", "editor", "shell"],
"the declared entry leads, and the shipped defaults it did not name follow in \
their own order"
);
assert_eq!(
resolved[0].source,
Source::Args(vec!["custom-tuicr".to_string(), "--flag".to_string()])
);
}
#[test]
fn declared_entries_lead_in_file_order_and_the_unmentioned_shipped_defaults_follow() {
let mut document = Document::default();
document
.launchers
.push(launcher_config("scratch", Some(vec!["true"]), None, false));
document
.launchers
.push(launcher_config("editor", Some(vec!["nvim"]), None, false));
let resolved = resolve(&document);
let names: Vec<&str> = resolved
.iter()
.map(|launcher| launcher.name.as_str())
.collect();
assert_eq!(
names,
vec!["scratch", "editor", "lazygit", "tuicr", "shell"],
"the two declared entries lead in file order, then lazygit, tuicr and shell keep \
their shipped relative order"
);
}
#[test]
fn declaring_a_shipped_name_with_no_argv_keys_only_moves_it_and_keeps_the_shipped_default() {
let mut document = Document::default();
document
.launchers
.push(launcher_config("shell", None, None, false));
let resolved = resolve(&document);
let names: Vec<&str> = resolved
.iter()
.map(|launcher| launcher.name.as_str())
.collect();
assert_eq!(names, vec!["shell", "lazygit", "tuicr", "editor"]);
let shipped_shell = shipped_defaults()
.into_iter()
.find(|launcher| launcher.name == "shell")
.expect("a shell ships as a default");
assert_eq!(
resolved[0], shipped_shell,
"a bare entry must resolve to the shipped default itself, argv included, rather \
than to an empty argv that fails to spawn"
);
}
#[test]
fn disabling_a_shipped_launcher_drops_it_rather_than_replacing_it() {
let mut document = Document::default();
document
.launchers
.push(launcher_config("tuicr", None, None, true));
let resolved = resolve(&document);
assert_eq!(
resolved.len(),
3,
"a disabled entry must be dropped, not kept as a fourth"
);
assert!(
!resolved.iter().any(|launcher| launcher.name == "tuicr"),
"tuicr must be gone entirely"
);
for name in ["lazygit", "editor", "shell"] {
assert!(
resolved.iter().any(|launcher| launcher.name == name),
"disabling tuicr must not touch {name}"
);
}
}
#[test]
fn a_declared_entry_with_a_new_name_joins_the_list_rather_than_replacing_anything() {
let mut document = Document::default();
document
.launchers
.push(launcher_config("scratch", Some(vec!["true"]), None, false));
let resolved = resolve(&document);
assert_eq!(
resolved.len(),
5,
"a new name must displace no shipped default"
);
assert_eq!(resolved.first().unwrap().name, "scratch");
}
#[test]
fn declaring_a_disabled_entry_under_a_new_name_does_nothing() {
let mut document = Document::default();
document
.launchers
.push(launcher_config("scratch", None, None, true));
let resolved = resolve(&document);
assert_eq!(resolved, shipped_defaults());
}
#[test]
fn every_shipped_default_takes_the_terminal() {
let kept: Vec<String> = shipped_defaults()
.into_iter()
.filter(|launcher| !launcher.takes_terminal)
.map(|launcher| launcher.name)
.collect();
assert!(
kept.is_empty(),
"a shipped default that does not take the terminal needs its own entry in \
config.md's Launchers section: {kept:?}"
);
}
#[test]
fn a_declared_terminal_declaration_reaches_the_resolved_launcher_and_only_that_one() {
let mut document = Document::default();
let mut declared = launcher_config("editor", Some(vec!["code"]), None, false);
declared.takes_terminal = false;
document.launchers.push(declared);
let resolved = resolve(&document);
for launcher in &resolved {
let expected = launcher.name != "editor";
assert_eq!(
launcher.takes_terminal, expected,
"`{}` resolved with the wrong terminal declaration",
launcher.name
);
}
}
#[test]
fn from_env_splits_a_multi_word_value_with_shell_words() {
let source = Source::FromEnv("EDITOR".to_string());
let argv = source.resolve_argv(|name| match name {
"EDITOR" => Some("code --wait".to_string()),
_ => None,
});
assert_eq!(argv, vec!["code".to_string(), "--wait".to_string()]);
}
#[test]
fn from_env_resolves_to_an_empty_argv_when_unset_or_blank() {
let source = Source::FromEnv("EDITOR".to_string());
assert_eq!(source.resolve_argv(|_| None), Vec::<String>::new());
assert_eq!(
source.resolve_argv(|_| Some(" ".to_string())),
Vec::<String>::new()
);
}
#[test]
fn editor_chain_prefers_visual_over_editor_over_the_vi_fallback() {
let visual_and_editor_set = Source::EditorChain.resolve_argv(|name| match name {
"VISUAL" => Some("code --wait".to_string()),
"EDITOR" => Some("nano".to_string()),
_ => None,
});
assert_eq!(
visual_and_editor_set,
vec!["code".to_string(), "--wait".to_string()]
);
let only_editor_set = Source::EditorChain.resolve_argv(|name| match name {
"EDITOR" => Some("nano".to_string()),
_ => None,
});
assert_eq!(only_editor_set, vec!["nano".to_string()]);
let neither_set = Source::EditorChain.resolve_argv(|_| None);
assert_eq!(neither_set, vec!["vi".to_string()]);
let visual_blank = Source::EditorChain.resolve_argv(|name| match name {
"VISUAL" => Some(String::new()),
"EDITOR" => Some("nano".to_string()),
_ => None,
});
assert_eq!(visual_blank, vec!["nano".to_string()]);
}
#[test]
fn shell_fallback_prefers_shell_over_the_bin_sh_fallback() {
let shell_set = Source::ShellFallback.resolve_argv(|name| match name {
"SHELL" => Some("/opt/homebrew/bin/zsh".to_string()),
_ => None,
});
assert_eq!(shell_set, vec!["/opt/homebrew/bin/zsh".to_string()]);
let unset = Source::ShellFallback.resolve_argv(|_| None);
assert_eq!(unset, vec!["/bin/sh".to_string()]);
}
#[test]
fn editor_chain_treats_an_empty_editor_value_as_unset_and_falls_through_to_the_vi_fallback() {
let editor_blank_visual_unset = Source::EditorChain.resolve_argv(|name| match name {
"EDITOR" => Some(String::new()),
_ => None,
});
assert_eq!(editor_blank_visual_unset, vec!["vi".to_string()]);
}
#[test]
fn git_editor_and_core_editor_appear_nowhere_in_either_crates_production_source() {
for needle in ["GIT_EDITOR", "core.editor"] {
let offending = crate::test_support::production_lines_containing(needle);
assert!(
offending.is_empty(),
"found `{needle}`; the editor chain deliberately excludes git's own editor \
variable and config key (docs/spec/config.md's \"Launchers\"), at: {offending:?}"
);
}
}
fn entity_at(path: &std::path::Path) -> EntityState {
EntityState::new(
repon_core::EntityKey::new(std::sync::Arc::from(path)),
std::sync::Arc::from(
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("entity"),
),
std::sync::Arc::from(path),
repon_core::Kind::Repo,
)
}
fn run_git(dir: &std::path::Path, args: &[&str]) {
let status = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed in {}", dir.display());
}
#[test]
fn shell_defaulting_off_never_splits_or_interprets_argv_that_looks_like_shell_syntax() {
let dir = tempfile::tempdir().expect("temp dir");
let entity = entity_at(dir.path());
let launcher = Launcher {
name: "test".to_string(),
source: Source::Args(vec!["echo".to_string(), "a && b; c | d`e`".to_string()]),
shell: false,
interactive: false,
takes_terminal: true,
env: BTreeMap::new(),
};
let command = build_command(&launcher, &entity);
assert_eq!(command.get_program(), std::ffi::OsStr::new("echo"));
assert_eq!(
command.get_args().collect::<Vec<_>>(),
vec![std::ffi::OsStr::new("a && b; c | d`e`")],
"the whole hostile string must arrive as one argument, never split or interpreted"
);
}
#[test]
fn shell_mode_wraps_the_configured_command_in_the_users_shell_with_repon_as_its_zeroth_argument()
{
let dir = tempfile::tempdir().expect("temp dir");
let entity = entity_at(dir.path());
let launcher = Launcher {
name: "log".to_string(),
source: Source::Args(vec!["git log --oneline -20 | less".to_string()]),
shell: true,
interactive: false,
takes_terminal: true,
env: BTreeMap::new(),
};
let command = build_command(&launcher, &entity);
let expected_shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
assert_eq!(command.get_program(), std::ffi::OsStr::new(&expected_shell));
assert_eq!(
command.get_args().collect::<Vec<_>>(),
vec![
std::ffi::OsStr::new("-c"),
std::ffi::OsStr::new("git log --oneline -20 | less"),
std::ffi::OsStr::new("repon"),
],
"POSIX sh -c fills $0 from the first argument after the command string; `repon` \
must be that literal trailing argument, not the launched program's own name"
);
}
#[test]
fn interactive_true_wraps_the_configured_command_with_the_ic_flag() {
let dir = tempfile::tempdir().expect("temp dir");
let entity = entity_at(dir.path());
let launcher = Launcher {
name: "log".to_string(),
source: Source::Args(vec!["git log --oneline -20 | less".to_string()]),
shell: true,
interactive: true,
takes_terminal: true,
env: BTreeMap::new(),
};
let command = build_command(&launcher, &entity);
assert_eq!(
command.get_args().collect::<Vec<_>>(),
vec![
std::ffi::OsStr::new("-ic"),
std::ffi::OsStr::new("git log --oneline -20 | less"),
std::ffi::OsStr::new("repon"),
],
"interactive = true must swap -c for -ic, everything else unchanged"
);
}
#[test]
fn a_declared_env_override_wins_over_the_guaranteed_environment_contract_pair() {
let dir = tempfile::tempdir().expect("temp dir");
let entity = entity_at(dir.path());
let mut env = BTreeMap::new();
env.insert("REPON_REPO_NAME".to_string(), "overridden".to_string());
let launcher = Launcher {
name: "test".to_string(),
source: Source::Args(vec!["true".to_string()]),
shell: false,
interactive: false,
takes_terminal: true,
env,
};
let command = build_command(&launcher, &entity);
let envs: std::collections::HashMap<_, _> = command.get_envs().collect();
assert_eq!(
envs.get(std::ffi::OsStr::new("REPON_REPO_NAME"))
.copied()
.flatten(),
Some(std::ffi::OsStr::new("overridden")),
"a declared env override must win over the guaranteed REPON_REPO_NAME pair"
);
}
#[test]
fn a_hostile_branch_and_path_reach_the_child_only_as_literal_environment_values() {
let temp = tempfile::tempdir().expect("temp dir");
let root = temp.path().canonicalize().expect("canonicalize temp dir");
let repo_name = "repo;$(touch__pwn_a)`touch__pwn_b`\n-tail";
let repo_path = root.join(repo_name);
std::fs::create_dir_all(&repo_path).expect("create a hostilely-named repo directory");
run_git(&repo_path, &["init", "-q", "."]);
run_git(
&repo_path,
&[
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test",
"commit",
"-q",
"--allow-empty",
"-m",
"first",
],
);
let hostile_branch = "feature/$(touch__pwn_c);`touch__pwn_d`";
run_git(&repo_path, &["checkout", "-q", "-b", hostile_branch]);
let core = repon_core::Core::start_discovered(repon_core::CoreSpec {
set: repon_core::SetSpec {
name: "test".to_string(),
roots: vec![root.clone()],
include: Vec::new(),
exclude: Vec::new(),
},
overrides: Vec::new(),
poll_interval: std::time::Duration::from_secs(3600),
status_stale_after: std::time::Duration::from_secs(3600),
generation_deadline: std::time::Duration::from_secs(3600),
show_submodules: false,
fetch: repon_core::FetchSpec {
enabled: false,
interval: std::time::Duration::from_secs(3600),
concurrency: 4,
},
auto_update: repon_core::AutoUpdateSpec { enabled: false },
});
let key = core.snapshot().entities[0].key.clone();
let entity = core.probe_now(&key);
let launcher = Launcher {
name: "probe".to_string(),
source: Source::Args(vec!["printenv".to_string(), "REPON_BRANCH".to_string()]),
shell: false,
interactive: false,
takes_terminal: true,
env: BTreeMap::new(),
};
let command = build_command(&launcher, &entity);
assert_eq!(command.get_program(), std::ffi::OsStr::new("printenv"));
assert_eq!(
command.get_args().collect::<Vec<_>>(),
vec![std::ffi::OsStr::new("REPON_BRANCH")]
);
assert_eq!(command.get_current_dir(), Some(repo_path.as_path()));
let envs: std::collections::HashMap<_, _> = command.get_envs().collect();
assert_eq!(
envs.get(std::ffi::OsStr::new("REPON_BRANCH"))
.copied()
.flatten(),
Some(std::ffi::OsStr::new(hostile_branch)),
"REPON_BRANCH must carry the hostile value byte-for-byte, as an environment value"
);
let mut executable = build_command(&launcher, &entity);
let output = executable.output().expect("run printenv");
assert!(output.status.success());
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim_end(),
hostile_branch,
"the child must see the hostile branch name exactly, with nothing stripped or split"
);
for marker in [
"touch__pwn_a",
"touch__pwn_b",
"touch__pwn_c",
"touch__pwn_d",
] {
assert!(
!repo_path.join(marker).exists(),
"found `{marker}`, which only exists if something executed the hostile value \
rather than treating it as an opaque string"
);
}
}
#[test]
fn no_placeholder_substitution_mechanism_exists_anywhere_in_either_crate() {
for needle in [
"replace(\"{",
"replace(\"$REPON",
"args_template",
"argv_template",
] {
let offending = crate::test_support::production_lines_containing(needle);
assert!(
offending.is_empty(),
"found `{needle}`; repo context reaches a Launcher only through the \
environment, with no template substitution into argv anywhere, at: {offending:?}"
);
}
}
}