use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use crate::command_history::redaction::redact_argv;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct JjCommandSpec {
argv: Vec<OsString>,
global_options: GlobalOptions,
cwd: Option<PathBuf>,
stdin: Option<String>,
title: String,
mode: ExecutionMode,
safety: SafetyClass,
refresh_plan: RefreshPlan,
}
impl JjCommandSpec {
#[must_use]
pub fn render_read_only(argv: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
let argv = argv.into_iter().map(Into::into).collect::<Vec<_>>();
let title = preview_argv(&argv);
Self {
argv,
global_options: GlobalOptions::default(),
cwd: None,
stdin: None,
title,
mode: ExecutionMode::RenderReadOnly,
safety: SafetyClass::ReadOnly,
refresh_plan: RefreshPlan::ReRunSpec,
}
}
#[must_use]
pub fn confirm_mutation(
argv: impl IntoIterator<Item = impl Into<OsString>>,
safety: SafetyClass,
) -> Self {
Self::render_read_only(argv)
.with_mode(ExecutionMode::ConfirmMutation)
.with_safety(safety)
}
#[must_use]
pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
self.cwd = Some(cwd.into());
self
}
#[must_use]
pub fn with_repository(mut self, repository: impl Into<PathBuf>) -> Self {
self.global_options.repository = Some(repository.into());
self
}
#[must_use]
pub fn with_global_options(mut self, global_options: GlobalOptions) -> Self {
self.global_options = global_options;
self
}
#[must_use]
pub fn with_stdin(mut self, stdin: impl Into<String>) -> Self {
self.stdin = Some(stdin.into());
self
}
#[must_use]
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
#[must_use]
pub const fn with_mode(mut self, mode: ExecutionMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub const fn with_safety(mut self, safety: SafetyClass) -> Self {
self.safety = safety;
self
}
#[must_use]
pub const fn with_refresh_plan(mut self, refresh_plan: RefreshPlan) -> Self {
self.refresh_plan = refresh_plan;
self
}
#[must_use]
pub fn argv(&self) -> &[OsString] {
&self.argv
}
#[must_use]
pub const fn global_options(&self) -> &GlobalOptions {
&self.global_options
}
#[must_use]
pub fn global_argv(&self) -> Vec<OsString> {
self.global_options.argv()
}
#[must_use]
pub fn process_argv(&self) -> Vec<OsString> {
let mut argv = self.global_argv();
argv.extend(self.argv.iter().cloned());
argv
}
#[must_use]
pub fn cwd(&self) -> Option<&Path> {
self.cwd.as_deref()
}
#[must_use]
pub fn repository(&self) -> Option<&Path> {
self.global_options.repository()
}
#[must_use]
pub fn stdin(&self) -> Option<&str> {
self.stdin.as_deref()
}
#[must_use]
pub fn title(&self) -> &str {
&self.title
}
#[must_use]
pub fn preview(&self) -> String {
preview_argv(&self.argv)
}
#[must_use]
pub fn process_preview(&self) -> String {
preview_argv(&self.process_argv())
}
#[must_use]
pub fn command_preview(&self) -> CommandPreview {
CommandPreview::from_spec(self)
}
#[must_use]
pub const fn mode(&self) -> ExecutionMode {
self.mode
}
#[must_use]
pub const fn safety(&self) -> SafetyClass {
self.safety
}
#[must_use]
pub const fn refresh_plan(&self) -> RefreshPlan {
self.refresh_plan
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CommandPreview {
pub spec: JjCommandSpec,
pub title: String,
pub command_line: String,
pub execution_mode: ExecutionMode,
pub safety: SafetyClass,
pub refresh_plan: RefreshPlan,
pub warnings: Vec<CommandPreviewWarning>,
}
impl CommandPreview {
#[must_use]
pub fn from_spec(spec: &JjCommandSpec) -> Self {
Self {
spec: spec.clone(),
title: spec.title().to_owned(),
command_line: preview_argv(&redact_argv(spec.process_argv())),
execution_mode: spec.mode(),
safety: spec.safety(),
refresh_plan: spec.refresh_plan(),
warnings: CommandPreviewWarning::from_spec(spec),
}
}
#[must_use]
pub const fn requires_confirmation(&self) -> bool {
matches!(
self.execution_mode,
ExecutionMode::ConfirmMutation
| ExecutionMode::ConfirmExternalTool
| ExecutionMode::DryRunThenConfirm
)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum CommandPreviewWarning {
LocalMetadata,
LocalRewrite,
DestructiveLocal,
NetworkWrite,
ExternalCommand,
IgnoresWorkingCopy,
AtOperation(String),
DoesNotIntegrateOperation,
IgnoresImmutableCommits,
}
impl CommandPreviewWarning {
fn from_spec(spec: &JjCommandSpec) -> Vec<Self> {
let mut warnings = Vec::new();
match spec.safety() {
SafetyClass::ReadOnly | SafetyClass::NetworkRead => {}
SafetyClass::LocalMetadata => warnings.push(Self::LocalMetadata),
SafetyClass::LocalRewrite => warnings.push(Self::LocalRewrite),
SafetyClass::DestructiveLocal => warnings.push(Self::DestructiveLocal),
SafetyClass::NetworkWrite => warnings.push(Self::NetworkWrite),
SafetyClass::ExternalCommand => warnings.push(Self::ExternalCommand),
}
match &spec.global_options.working_copy {
WorkingCopyPolicy::SnapshotAndUpdate => {}
WorkingCopyPolicy::Ignore => warnings.push(Self::IgnoresWorkingCopy),
}
match &spec.global_options.operation {
OperationLoadPolicy::Latest => {}
OperationLoadPolicy::AtOperation(operation) => {
warnings.push(Self::AtOperation(operation.clone()));
}
}
match spec.global_options.operation_integration {
OperationIntegrationPolicy::Integrate => {}
OperationIntegrationPolicy::DoNotIntegrate => {
warnings.push(Self::DoesNotIntegrateOperation);
}
}
match spec.global_options.immutability {
ImmutabilityPolicy::Enforce => {}
ImmutabilityPolicy::Ignore => warnings.push(Self::IgnoresImmutableCommits),
}
warnings
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GlobalOptions {
repository: Option<PathBuf>,
working_copy: WorkingCopyPolicy,
operation: OperationLoadPolicy,
operation_integration: OperationIntegrationPolicy,
immutability: ImmutabilityPolicy,
output: OutputPolicy,
debug: bool,
config_overlays: Vec<ConfigOverlay>,
}
impl GlobalOptions {
#[must_use]
pub fn with_repository(mut self, repository: impl Into<PathBuf>) -> Self {
self.repository = Some(repository.into());
self
}
#[must_use]
pub const fn with_working_copy(mut self, working_copy: WorkingCopyPolicy) -> Self {
self.working_copy = working_copy;
self
}
#[must_use]
pub fn with_operation(mut self, operation: OperationLoadPolicy) -> Self {
self.operation = operation;
self
}
#[must_use]
pub const fn with_operation_integration(
mut self,
operation_integration: OperationIntegrationPolicy,
) -> Self {
self.operation_integration = operation_integration;
self
}
#[must_use]
pub const fn with_immutability(mut self, immutability: ImmutabilityPolicy) -> Self {
self.immutability = immutability;
self
}
#[must_use]
pub const fn with_output(mut self, output: OutputPolicy) -> Self {
self.output = output;
self
}
#[must_use]
pub const fn with_debug(mut self, debug: bool) -> Self {
self.debug = debug;
self
}
#[must_use]
pub fn with_config_overlay(mut self, overlay: ConfigOverlay) -> Self {
self.config_overlays.push(overlay);
self
}
#[must_use]
pub fn repository(&self) -> Option<&Path> {
self.repository.as_deref()
}
#[must_use]
pub fn argv(&self) -> Vec<OsString> {
let mut argv = Vec::new();
self.output.push_argv(&mut argv);
if let Some(repository) = &self.repository {
argv.push("--repository".into());
argv.push(repository.as_os_str().to_owned());
}
match (&self.working_copy, &self.operation) {
(WorkingCopyPolicy::Ignore, OperationLoadPolicy::Latest) => {
argv.push("--ignore-working-copy".into());
}
(WorkingCopyPolicy::SnapshotAndUpdate, _)
| (WorkingCopyPolicy::Ignore, OperationLoadPolicy::AtOperation(_)) => {}
}
match &self.operation {
OperationLoadPolicy::Latest => {}
OperationLoadPolicy::AtOperation(operation) => {
argv.push("--at-operation".into());
argv.push(operation.as_str().into());
}
}
match self.operation_integration {
OperationIntegrationPolicy::Integrate => {}
OperationIntegrationPolicy::DoNotIntegrate => {
argv.push("--no-integrate-operation".into());
}
}
match self.immutability {
ImmutabilityPolicy::Enforce => {}
ImmutabilityPolicy::Ignore => argv.push("--ignore-immutable".into()),
}
if self.debug {
argv.push("--debug".into());
}
for overlay in &self.config_overlays {
overlay.push_argv(&mut argv);
}
argv
}
}
impl Default for GlobalOptions {
fn default() -> Self {
Self {
repository: None,
working_copy: WorkingCopyPolicy::SnapshotAndUpdate,
operation: OperationLoadPolicy::Latest,
operation_integration: OperationIntegrationPolicy::Integrate,
immutability: ImmutabilityPolicy::Enforce,
output: OutputPolicy::default(),
debug: false,
config_overlays: Vec::new(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum WorkingCopyPolicy {
SnapshotAndUpdate,
Ignore,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum OperationLoadPolicy {
Latest,
AtOperation(String),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum OperationIntegrationPolicy {
Integrate,
DoNotIntegrate,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ImmutabilityPolicy {
Enforce,
Ignore,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OutputPolicy {
pub color: ColorPolicy,
pub pager: PagerPolicy,
pub quiet: bool,
}
impl OutputPolicy {
fn push_argv(self, argv: &mut Vec<OsString>) {
match self.pager {
PagerPolicy::Disable => argv.push("--no-pager".into()),
PagerPolicy::Inherit => {}
}
argv.push("--color".into());
argv.push(self.color.as_str().into());
if self.quiet {
argv.push("--quiet".into());
}
}
}
impl Default for OutputPolicy {
fn default() -> Self {
Self {
color: ColorPolicy::Always,
pager: PagerPolicy::Disable,
quiet: false,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ColorPolicy {
Always,
Never,
Debug,
Auto,
}
impl ColorPolicy {
const fn as_str(self) -> &'static str {
match self {
Self::Always => "always",
Self::Never => "never",
Self::Debug => "debug",
Self::Auto => "auto",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum PagerPolicy {
Disable,
Inherit,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ConfigOverlay {
Inline {
name_value: String,
},
File(PathBuf),
}
impl ConfigOverlay {
fn push_argv(&self, argv: &mut Vec<OsString>) {
match self {
Self::Inline { name_value } => {
argv.push("--config".into());
argv.push(name_value.as_str().into());
}
Self::File(path) => {
argv.push("--config-file".into());
argv.push(path.as_os_str().to_owned());
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ExecutionMode {
RenderReadOnly,
ConfirmMutation,
ConfirmExternalTool,
DryRunThenConfirm,
CommandMode,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum SafetyClass {
ReadOnly,
LocalMetadata,
LocalRewrite,
DestructiveLocal,
NetworkRead,
NetworkWrite,
ExternalCommand,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum RefreshPlan {
None,
ReRunSpec,
}
pub fn preview_argv(argv: &[OsString]) -> String {
let mut preview = String::from("jj");
for arg in argv {
preview.push(' ');
preview.push_str("e_arg(arg));
}
preview
}
fn quote_arg(arg: &OsStr) -> String {
let arg = arg.to_string_lossy();
if arg.is_empty() {
return "''".to_owned();
}
if arg
.chars()
.all(|character| character.is_ascii_alphanumeric() || "-_./:@".contains(character))
{
return arg.into_owned();
}
format!("'{}'", arg.replace('\'', "'\"'\"'"))
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(argv: impl IntoIterator<Item = OsString>) -> Vec<String> {
argv.into_iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
}
#[test]
fn empty_argv_previews_as_jj() {
let spec = JjCommandSpec::render_read_only(Vec::<OsString>::new());
assert_eq!(spec.preview(), "jj");
assert_eq!(spec.title(), "jj");
}
#[test]
fn default_global_options_render_current_app_globals() {
let options = GlobalOptions::default();
assert_eq!(
strings(options.argv()),
vec!["--no-pager", "--color", "always"]
);
}
#[test]
fn repository_renders_before_command_argv() {
let spec =
JjCommandSpec::render_read_only(["diff", "-r", "@"]).with_repository("/tmp/repo");
assert_eq!(
strings(spec.process_argv()),
vec![
"--no-pager",
"--color",
"always",
"--repository",
"/tmp/repo",
"diff",
"-r",
"@"
]
);
assert_eq!(spec.repository(), Some(Path::new("/tmp/repo")));
assert_eq!(
spec.global_options().repository(),
Some(Path::new("/tmp/repo"))
);
}
#[test]
fn advanced_global_options_render_before_command_argv() {
let global_options = GlobalOptions::default()
.with_operation(OperationLoadPolicy::AtOperation("abc123".to_owned()))
.with_operation_integration(OperationIntegrationPolicy::DoNotIntegrate)
.with_immutability(ImmutabilityPolicy::Ignore)
.with_debug(true);
let spec = JjCommandSpec::render_read_only(["status"]).with_global_options(global_options);
assert_eq!(
strings(spec.process_argv()),
vec![
"--no-pager",
"--color",
"always",
"--at-operation",
"abc123",
"--no-integrate-operation",
"--ignore-immutable",
"--debug",
"status"
]
);
}
#[test]
fn ignore_working_copy_renders_before_command_argv() {
let global_options = GlobalOptions::default().with_working_copy(WorkingCopyPolicy::Ignore);
let spec = JjCommandSpec::render_read_only(["log"]).with_global_options(global_options);
assert_eq!(
strings(spec.process_argv()),
vec![
"--no-pager",
"--color",
"always",
"--ignore-working-copy",
"log"
]
);
}
#[test]
fn at_operation_does_not_duplicate_implied_working_copy_ignore() {
let global_options = GlobalOptions::default()
.with_working_copy(WorkingCopyPolicy::Ignore)
.with_operation(OperationLoadPolicy::AtOperation("abc123".to_owned()));
let spec = JjCommandSpec::render_read_only(["status"]).with_global_options(global_options);
assert_eq!(
strings(spec.process_argv()),
vec![
"--no-pager",
"--color",
"always",
"--at-operation",
"abc123",
"status"
]
);
}
#[test]
fn config_overlays_preserve_global_order() {
let options = GlobalOptions::default()
.with_config_overlay(ConfigOverlay::Inline {
name_value: "ui.color=always".to_owned(),
})
.with_config_overlay(ConfigOverlay::File("/tmp/jj.toml".into()))
.with_config_overlay(ConfigOverlay::Inline {
name_value: "aliases.l=log".to_owned(),
});
assert_eq!(
strings(options.argv()),
vec![
"--no-pager",
"--color",
"always",
"--config",
"ui.color=always",
"--config-file",
"/tmp/jj.toml",
"--config",
"aliases.l=log"
]
);
}
#[test]
fn process_argv_does_not_duplicate_repository() {
let spec =
JjCommandSpec::render_read_only(["show", "@"]).with_repository("/tmp/repository");
let argv = strings(spec.process_argv());
assert_eq!(
argv.iter()
.filter(|arg| arg.as_str() == "--repository")
.count(),
1
);
}
#[test]
fn render_read_only_sets_default_policy() {
let spec = JjCommandSpec::render_read_only(["diff", "-r", "abc123"]);
assert_eq!(spec.mode(), ExecutionMode::RenderReadOnly);
assert_eq!(spec.safety(), SafetyClass::ReadOnly);
assert_eq!(spec.refresh_plan(), RefreshPlan::ReRunSpec);
}
#[test]
fn preview_quotes_whitespace_and_shell_metacharacters() {
let spec = JjCommandSpec::render_read_only(["log", "-r", "description('a b')"]);
assert_eq!(spec.preview(), "jj log -r 'description('\"'\"'a b'\"'\"')'");
}
#[test]
fn preview_quotes_backticks() {
let spec = JjCommandSpec::render_read_only(["log", "-r", "`echo nope`"]);
assert_eq!(spec.preview(), "jj log -r '`echo nope`'");
}
#[test]
fn process_preview_includes_global_options_before_command_argv() {
let spec =
JjCommandSpec::render_read_only(["status"]).with_repository("/tmp/repo with spaces");
assert_eq!(
spec.process_preview(),
"jj --no-pager --color always --repository '/tmp/repo with spaces' status"
);
assert_eq!(spec.preview(), "jj status");
}
#[test]
fn confirm_mutation_sets_preview_policy() {
let spec = JjCommandSpec::confirm_mutation(
["workspace", "update-stale"],
SafetyClass::LocalMetadata,
);
assert_eq!(spec.mode(), ExecutionMode::ConfirmMutation);
assert_eq!(spec.safety(), SafetyClass::LocalMetadata);
assert_eq!(spec.refresh_plan(), RefreshPlan::ReRunSpec);
}
#[test]
fn command_preview_uses_process_command_line_and_short_title() {
let global_options = GlobalOptions::default()
.with_repository("/tmp/repo")
.with_working_copy(WorkingCopyPolicy::Ignore);
let spec = JjCommandSpec::confirm_mutation(["new", "@"], SafetyClass::LocalRewrite)
.with_global_options(global_options)
.with_title("new change after @");
let preview = spec.command_preview();
assert_eq!(preview.spec, spec);
assert_eq!(preview.title, "new change after @");
assert_eq!(
preview.command_line,
"jj --no-pager --color always --repository /tmp/repo --ignore-working-copy new @"
);
assert_eq!(preview.execution_mode, ExecutionMode::ConfirmMutation);
assert_eq!(preview.safety, SafetyClass::LocalRewrite);
assert_eq!(preview.refresh_plan, RefreshPlan::ReRunSpec);
assert!(preview.requires_confirmation());
assert_eq!(
preview.warnings,
vec![
CommandPreviewWarning::LocalRewrite,
CommandPreviewWarning::IgnoresWorkingCopy
]
);
}
#[test]
fn command_preview_redacts_secret_looking_global_options() {
let global_options = GlobalOptions::default().with_config_overlay(ConfigOverlay::Inline {
name_value: "auth.token=abc123".to_owned(),
});
let spec = JjCommandSpec::confirm_mutation(
["describe", "-m", "safe", "@"],
SafetyClass::LocalRewrite,
)
.with_global_options(global_options);
let preview = spec.command_preview();
assert_eq!(
preview.command_line,
"jj --no-pager --color always --config 'auth.token=<redacted>' describe -m safe @"
);
assert_eq!(preview.spec.process_argv(), spec.process_argv());
}
#[test]
fn command_preview_warns_for_advanced_global_safety_flags() {
let global_options = GlobalOptions::default()
.with_operation(OperationLoadPolicy::AtOperation("abc123".to_owned()))
.with_operation_integration(OperationIntegrationPolicy::DoNotIntegrate)
.with_immutability(ImmutabilityPolicy::Ignore);
let spec = JjCommandSpec::confirm_mutation(
["rebase", "-b", "@", "-d", "main"],
SafetyClass::LocalRewrite,
)
.with_global_options(global_options);
let preview = spec.command_preview();
assert_eq!(
preview.warnings,
vec![
CommandPreviewWarning::LocalRewrite,
CommandPreviewWarning::AtOperation("abc123".to_owned()),
CommandPreviewWarning::DoesNotIntegrateOperation,
CommandPreviewWarning::IgnoresImmutableCommits
]
);
}
#[test]
fn metadata_builders_preserve_argv() {
let spec = JjCommandSpec::render_read_only(["diff"])
.with_cwd("/tmp/work")
.with_repository("/tmp/repo")
.with_stdin("input")
.with_title("custom");
assert_eq!(spec.argv(), &[OsString::from("diff")]);
assert_eq!(spec.cwd(), Some(Path::new("/tmp/work")));
assert_eq!(spec.repository(), Some(Path::new("/tmp/repo")));
assert_eq!(spec.stdin(), Some("input"));
assert_eq!(spec.title(), "custom");
assert_eq!(spec.preview(), "jj diff");
}
}