Skip to main content

jk_core/
command.rs

1//! Command descriptions shared across `jk` crates.
2//!
3//! [`JjCommandSpec`] stores argv as data first, then renders a display-only preview string for
4//! titles, help, and future command previews. Callers must execute the argv directly instead of
5//! sending the preview string through a shell.
6
7use std::ffi::{OsStr, OsString};
8use std::path::{Path, PathBuf};
9
10use crate::command_history::redaction::redact_argv;
11
12/// A typed description of one `jj` command.
13#[derive(Clone, Debug, Eq, PartialEq)]
14pub struct JjCommandSpec {
15    argv: Vec<OsString>,
16    global_options: GlobalOptions,
17    cwd: Option<PathBuf>,
18    stdin: Option<String>,
19    title: String,
20    mode: ExecutionMode,
21    safety: SafetyClass,
22    refresh_plan: RefreshPlan,
23}
24
25impl JjCommandSpec {
26    /// Creates a read-only `jj` command spec from command arguments after the `jj` binary.
27    #[must_use]
28    pub fn render_read_only(argv: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
29        let argv = argv.into_iter().map(Into::into).collect::<Vec<_>>();
30        let title = preview_argv(&argv);
31        Self {
32            argv,
33            global_options: GlobalOptions::default(),
34            cwd: None,
35            stdin: None,
36            title,
37            mode: ExecutionMode::RenderReadOnly,
38            safety: SafetyClass::ReadOnly,
39            refresh_plan: RefreshPlan::ReRunSpec,
40        }
41    }
42
43    /// Creates a `jj` command spec that must be previewed before mutation execution.
44    #[must_use]
45    pub fn confirm_mutation(
46        argv: impl IntoIterator<Item = impl Into<OsString>>,
47        safety: SafetyClass,
48    ) -> Self {
49        Self::render_read_only(argv)
50            .with_mode(ExecutionMode::ConfirmMutation)
51            .with_safety(safety)
52    }
53
54    /// Sets the process working directory metadata.
55    #[must_use]
56    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
57        self.cwd = Some(cwd.into());
58        self
59    }
60
61    /// Sets the repository path metadata.
62    #[must_use]
63    pub fn with_repository(mut self, repository: impl Into<PathBuf>) -> Self {
64        self.global_options.repository = Some(repository.into());
65        self
66    }
67
68    /// Sets the global `jj` options.
69    #[must_use]
70    pub fn with_global_options(mut self, global_options: GlobalOptions) -> Self {
71        self.global_options = global_options;
72        self
73    }
74
75    /// Sets stdin text for a future command runner.
76    #[must_use]
77    pub fn with_stdin(mut self, stdin: impl Into<String>) -> Self {
78        self.stdin = Some(stdin.into());
79        self
80    }
81
82    /// Sets the display title independently from the executable argv.
83    #[must_use]
84    pub fn with_title(mut self, title: impl Into<String>) -> Self {
85        self.title = title.into();
86        self
87    }
88
89    /// Sets the execution mode.
90    #[must_use]
91    pub const fn with_mode(mut self, mode: ExecutionMode) -> Self {
92        self.mode = mode;
93        self
94    }
95
96    /// Sets the safety class.
97    #[must_use]
98    pub const fn with_safety(mut self, safety: SafetyClass) -> Self {
99        self.safety = safety;
100        self
101    }
102
103    /// Sets the refresh plan.
104    #[must_use]
105    pub const fn with_refresh_plan(mut self, refresh_plan: RefreshPlan) -> Self {
106        self.refresh_plan = refresh_plan;
107        self
108    }
109
110    /// Returns command arguments after the `jj` binary.
111    #[must_use]
112    pub fn argv(&self) -> &[OsString] {
113        &self.argv
114    }
115
116    /// Returns global `jj` options owned by the spec.
117    #[must_use]
118    pub const fn global_options(&self) -> &GlobalOptions {
119        &self.global_options
120    }
121
122    /// Returns global `jj` arguments rendered before the command family.
123    #[must_use]
124    pub fn global_argv(&self) -> Vec<OsString> {
125        self.global_options.argv()
126    }
127
128    /// Returns process arguments after the `jj` binary.
129    #[must_use]
130    pub fn process_argv(&self) -> Vec<OsString> {
131        let mut argv = self.global_argv();
132        argv.extend(self.argv.iter().cloned());
133        argv
134    }
135
136    /// Returns the process working directory metadata.
137    #[must_use]
138    pub fn cwd(&self) -> Option<&Path> {
139        self.cwd.as_deref()
140    }
141
142    /// Returns the repository path metadata.
143    #[must_use]
144    pub fn repository(&self) -> Option<&Path> {
145        self.global_options.repository()
146    }
147
148    /// Returns stdin text for a future command runner.
149    #[must_use]
150    pub fn stdin(&self) -> Option<&str> {
151        self.stdin.as_deref()
152    }
153
154    /// Returns the display title.
155    #[must_use]
156    pub fn title(&self) -> &str {
157        &self.title
158    }
159
160    /// Returns a display-only command preview.
161    #[must_use]
162    pub fn preview(&self) -> String {
163        preview_argv(&self.argv)
164    }
165
166    /// Returns a display-only process preview with global options before command arguments.
167    #[must_use]
168    pub fn process_preview(&self) -> String {
169        preview_argv(&self.process_argv())
170    }
171
172    /// Returns a command preview suitable for confirmation UI.
173    #[must_use]
174    pub fn command_preview(&self) -> CommandPreview {
175        CommandPreview::from_spec(self)
176    }
177
178    /// Returns the execution mode.
179    #[must_use]
180    pub const fn mode(&self) -> ExecutionMode {
181        self.mode
182    }
183
184    /// Returns the safety class.
185    #[must_use]
186    pub const fn safety(&self) -> SafetyClass {
187        self.safety
188    }
189
190    /// Returns the refresh plan.
191    #[must_use]
192    pub const fn refresh_plan(&self) -> RefreshPlan {
193        self.refresh_plan
194    }
195}
196
197/// Command data shown before a command is allowed to run.
198#[derive(Clone, Debug, Eq, PartialEq)]
199pub struct CommandPreview {
200    /// The executable spec that confirmation should run.
201    pub spec: JjCommandSpec,
202    /// Short command title used by the source view.
203    pub title: String,
204    /// Full `jj` command line, including global options in process order.
205    pub command_line: String,
206    /// Execution mode that determines the confirmation behavior.
207    pub execution_mode: ExecutionMode,
208    /// Safety classification shown to the user.
209    pub safety: SafetyClass,
210    /// Refresh policy requested after a successful command.
211    pub refresh_plan: RefreshPlan,
212    /// User-visible warnings inferred from the command spec.
213    pub warnings: Vec<CommandPreviewWarning>,
214}
215
216impl CommandPreview {
217    /// Builds a preview from the same command spec that execution will receive.
218    #[must_use]
219    pub fn from_spec(spec: &JjCommandSpec) -> Self {
220        Self {
221            spec: spec.clone(),
222            title: spec.title().to_owned(),
223            command_line: preview_argv(&redact_argv(spec.process_argv())),
224            execution_mode: spec.mode(),
225            safety: spec.safety(),
226            refresh_plan: spec.refresh_plan(),
227            warnings: CommandPreviewWarning::from_spec(spec),
228        }
229    }
230
231    /// Returns whether the preview represents a command that needs confirmation.
232    #[must_use]
233    pub const fn requires_confirmation(&self) -> bool {
234        matches!(
235            self.execution_mode,
236            ExecutionMode::ConfirmMutation
237                | ExecutionMode::ConfirmExternalTool
238                | ExecutionMode::DryRunThenConfirm
239        )
240    }
241}
242
243/// Warning shown beside a command preview when flags or safety class deserve attention.
244#[derive(Clone, Debug, Eq, PartialEq)]
245#[non_exhaustive]
246pub enum CommandPreviewWarning {
247    /// The command changes repository-local metadata.
248    LocalMetadata,
249    /// The command rewrites local history.
250    LocalRewrite,
251    /// The command is destructive in the local repository.
252    DestructiveLocal,
253    /// The command writes to a network service or remote.
254    NetworkWrite,
255    /// The command launches an external tool.
256    ExternalCommand,
257    /// The command ignores the current working-copy snapshot.
258    IgnoresWorkingCopy,
259    /// The command inspects or operates at a specific operation.
260    AtOperation(String),
261    /// The command creates an operation but does not integrate it.
262    DoesNotIntegrateOperation,
263    /// The command may rewrite immutable commits.
264    IgnoresImmutableCommits,
265}
266
267impl CommandPreviewWarning {
268    fn from_spec(spec: &JjCommandSpec) -> Vec<Self> {
269        let mut warnings = Vec::new();
270        match spec.safety() {
271            SafetyClass::ReadOnly | SafetyClass::NetworkRead => {}
272            SafetyClass::LocalMetadata => warnings.push(Self::LocalMetadata),
273            SafetyClass::LocalRewrite => warnings.push(Self::LocalRewrite),
274            SafetyClass::DestructiveLocal => warnings.push(Self::DestructiveLocal),
275            SafetyClass::NetworkWrite => warnings.push(Self::NetworkWrite),
276            SafetyClass::ExternalCommand => warnings.push(Self::ExternalCommand),
277        }
278
279        match &spec.global_options.working_copy {
280            WorkingCopyPolicy::SnapshotAndUpdate => {}
281            WorkingCopyPolicy::Ignore => warnings.push(Self::IgnoresWorkingCopy),
282        }
283
284        match &spec.global_options.operation {
285            OperationLoadPolicy::Latest => {}
286            OperationLoadPolicy::AtOperation(operation) => {
287                warnings.push(Self::AtOperation(operation.clone()));
288            }
289        }
290
291        match spec.global_options.operation_integration {
292            OperationIntegrationPolicy::Integrate => {}
293            OperationIntegrationPolicy::DoNotIntegrate => {
294                warnings.push(Self::DoesNotIntegrateOperation);
295            }
296        }
297
298        match spec.global_options.immutability {
299            ImmutabilityPolicy::Enforce => {}
300            ImmutabilityPolicy::Ignore => warnings.push(Self::IgnoresImmutableCommits),
301        }
302
303        warnings
304    }
305}
306
307/// Global `jj` options rendered before the command family.
308#[derive(Clone, Debug, Eq, PartialEq)]
309pub struct GlobalOptions {
310    repository: Option<PathBuf>,
311    working_copy: WorkingCopyPolicy,
312    operation: OperationLoadPolicy,
313    operation_integration: OperationIntegrationPolicy,
314    immutability: ImmutabilityPolicy,
315    output: OutputPolicy,
316    debug: bool,
317    config_overlays: Vec<ConfigOverlay>,
318}
319
320impl GlobalOptions {
321    /// Sets the repository passed with `jj --repository`.
322    #[must_use]
323    pub fn with_repository(mut self, repository: impl Into<PathBuf>) -> Self {
324        self.repository = Some(repository.into());
325        self
326    }
327
328    /// Sets whether `jj` should snapshot and update the working copy.
329    #[must_use]
330    pub const fn with_working_copy(mut self, working_copy: WorkingCopyPolicy) -> Self {
331        self.working_copy = working_copy;
332        self
333    }
334
335    /// Sets which operation `jj` should load.
336    #[must_use]
337    pub fn with_operation(mut self, operation: OperationLoadPolicy) -> Self {
338        self.operation = operation;
339        self
340    }
341
342    /// Sets whether `jj` should integrate the loaded operation.
343    #[must_use]
344    pub const fn with_operation_integration(
345        mut self,
346        operation_integration: OperationIntegrationPolicy,
347    ) -> Self {
348        self.operation_integration = operation_integration;
349        self
350    }
351
352    /// Sets whether immutable commits may be rewritten.
353    #[must_use]
354    pub const fn with_immutability(mut self, immutability: ImmutabilityPolicy) -> Self {
355        self.immutability = immutability;
356        self
357    }
358
359    /// Sets output-related `jj` options.
360    #[must_use]
361    pub const fn with_output(mut self, output: OutputPolicy) -> Self {
362        self.output = output;
363        self
364    }
365
366    /// Sets whether `jj --debug` should be rendered.
367    #[must_use]
368    pub const fn with_debug(mut self, debug: bool) -> Self {
369        self.debug = debug;
370        self
371    }
372
373    /// Adds a config overlay in render order.
374    #[must_use]
375    pub fn with_config_overlay(mut self, overlay: ConfigOverlay) -> Self {
376        self.config_overlays.push(overlay);
377        self
378    }
379
380    /// Returns the configured repository path.
381    #[must_use]
382    pub fn repository(&self) -> Option<&Path> {
383        self.repository.as_deref()
384    }
385
386    /// Returns global `jj` arguments in canonical render order.
387    #[must_use]
388    pub fn argv(&self) -> Vec<OsString> {
389        let mut argv = Vec::new();
390
391        self.output.push_argv(&mut argv);
392
393        if let Some(repository) = &self.repository {
394            argv.push("--repository".into());
395            argv.push(repository.as_os_str().to_owned());
396        }
397
398        match (&self.working_copy, &self.operation) {
399            (WorkingCopyPolicy::Ignore, OperationLoadPolicy::Latest) => {
400                argv.push("--ignore-working-copy".into());
401            }
402            (WorkingCopyPolicy::SnapshotAndUpdate, _)
403            | (WorkingCopyPolicy::Ignore, OperationLoadPolicy::AtOperation(_)) => {}
404        }
405
406        match &self.operation {
407            OperationLoadPolicy::Latest => {}
408            OperationLoadPolicy::AtOperation(operation) => {
409                argv.push("--at-operation".into());
410                argv.push(operation.as_str().into());
411            }
412        }
413
414        match self.operation_integration {
415            OperationIntegrationPolicy::Integrate => {}
416            OperationIntegrationPolicy::DoNotIntegrate => {
417                argv.push("--no-integrate-operation".into());
418            }
419        }
420
421        match self.immutability {
422            ImmutabilityPolicy::Enforce => {}
423            ImmutabilityPolicy::Ignore => argv.push("--ignore-immutable".into()),
424        }
425
426        if self.debug {
427            argv.push("--debug".into());
428        }
429
430        for overlay in &self.config_overlays {
431            overlay.push_argv(&mut argv);
432        }
433
434        argv
435    }
436}
437
438impl Default for GlobalOptions {
439    fn default() -> Self {
440        Self {
441            repository: None,
442            working_copy: WorkingCopyPolicy::SnapshotAndUpdate,
443            operation: OperationLoadPolicy::Latest,
444            operation_integration: OperationIntegrationPolicy::Integrate,
445            immutability: ImmutabilityPolicy::Enforce,
446            output: OutputPolicy::default(),
447            debug: false,
448            config_overlays: Vec::new(),
449        }
450    }
451}
452
453/// How `jj` should handle working-copy state before running a command.
454#[derive(Clone, Copy, Debug, Eq, PartialEq)]
455#[non_exhaustive]
456pub enum WorkingCopyPolicy {
457    /// Snapshot and update the working copy.
458    SnapshotAndUpdate,
459    /// Ignore the working copy.
460    Ignore,
461}
462
463/// Which operation `jj` should load before running a command.
464#[derive(Clone, Debug, Eq, PartialEq)]
465#[non_exhaustive]
466pub enum OperationLoadPolicy {
467    /// Load the latest operation.
468    Latest,
469    /// Load the repository at a specific operation.
470    AtOperation(String),
471}
472
473/// Whether `jj` should integrate the loaded operation.
474#[derive(Clone, Copy, Debug, Eq, PartialEq)]
475#[non_exhaustive]
476pub enum OperationIntegrationPolicy {
477    /// Integrate the loaded operation.
478    Integrate,
479    /// Do not integrate the loaded operation.
480    DoNotIntegrate,
481}
482
483/// Whether `jj` should enforce immutable commits.
484#[derive(Clone, Copy, Debug, Eq, PartialEq)]
485#[non_exhaustive]
486pub enum ImmutabilityPolicy {
487    /// Enforce immutable commits.
488    Enforce,
489    /// Allow commands to rewrite immutable commits.
490    Ignore,
491}
492
493/// Output-related global `jj` options.
494#[derive(Clone, Copy, Debug, Eq, PartialEq)]
495pub struct OutputPolicy {
496    /// The color policy passed to `jj --color`.
497    pub color: ColorPolicy,
498    /// The pager policy for `jj` output.
499    pub pager: PagerPolicy,
500    /// Whether to pass `jj --quiet`.
501    pub quiet: bool,
502}
503
504impl OutputPolicy {
505    fn push_argv(self, argv: &mut Vec<OsString>) {
506        match self.pager {
507            PagerPolicy::Disable => argv.push("--no-pager".into()),
508            PagerPolicy::Inherit => {}
509        }
510
511        argv.push("--color".into());
512        argv.push(self.color.as_str().into());
513
514        if self.quiet {
515            argv.push("--quiet".into());
516        }
517    }
518}
519
520impl Default for OutputPolicy {
521    fn default() -> Self {
522        Self {
523            color: ColorPolicy::Always,
524            pager: PagerPolicy::Disable,
525            quiet: false,
526        }
527    }
528}
529
530/// Color rendering policy passed to `jj --color`.
531#[derive(Clone, Copy, Debug, Eq, PartialEq)]
532#[non_exhaustive]
533pub enum ColorPolicy {
534    /// Always render color.
535    Always,
536    /// Never render color.
537    Never,
538    /// Render color for debugging.
539    Debug,
540    /// Let `jj` choose automatically.
541    Auto,
542}
543
544impl ColorPolicy {
545    const fn as_str(self) -> &'static str {
546        match self {
547            Self::Always => "always",
548            Self::Never => "never",
549            Self::Debug => "debug",
550            Self::Auto => "auto",
551        }
552    }
553}
554
555/// Pager rendering policy passed to `jj`.
556#[derive(Clone, Copy, Debug, Eq, PartialEq)]
557#[non_exhaustive]
558pub enum PagerPolicy {
559    /// Disable paging with `jj --no-pager`.
560    Disable,
561    /// Inherit `jj`'s normal pager behavior.
562    Inherit,
563}
564
565/// Config overlays passed through global `jj` options.
566#[derive(Clone, Debug, Eq, PartialEq)]
567#[non_exhaustive]
568pub enum ConfigOverlay {
569    /// Inline `NAME=VALUE` config passed with `jj --config`.
570    Inline {
571        /// The raw `NAME=VALUE` value.
572        name_value: String,
573    },
574    /// A config file passed with `jj --config-file`.
575    File(PathBuf),
576}
577
578impl ConfigOverlay {
579    fn push_argv(&self, argv: &mut Vec<OsString>) {
580        match self {
581            Self::Inline { name_value } => {
582                argv.push("--config".into());
583                argv.push(name_value.as_str().into());
584            }
585            Self::File(path) => {
586                argv.push("--config-file".into());
587                argv.push(path.as_os_str().to_owned());
588            }
589        }
590    }
591}
592
593/// How a command should be executed.
594#[derive(Clone, Copy, Debug, Eq, PartialEq)]
595#[non_exhaustive]
596pub enum ExecutionMode {
597    /// Run immediately and render output without confirmation.
598    RenderReadOnly,
599    /// Show a mutation confirmation before execution.
600    ConfirmMutation,
601    /// Restore the terminal and run a foreground external tool.
602    ConfirmExternalTool,
603    /// Run a dry-run first, then ask before the real command.
604    DryRunThenConfirm,
605    /// User-entered command mode.
606    CommandMode,
607}
608
609/// The safety class for command preview and confirmation policy.
610#[derive(Clone, Copy, Debug, Eq, PartialEq)]
611#[non_exhaustive]
612pub enum SafetyClass {
613    /// Read-only command.
614    ReadOnly,
615    /// Local metadata update.
616    LocalMetadata,
617    /// Local history rewrite.
618    LocalRewrite,
619    /// Destructive local operation.
620    DestructiveLocal,
621    /// Network read.
622    NetworkRead,
623    /// Network write.
624    NetworkWrite,
625    /// External command.
626    ExternalCommand,
627}
628
629/// What should refresh after a command succeeds.
630#[derive(Clone, Copy, Debug, Eq, PartialEq)]
631#[non_exhaustive]
632pub enum RefreshPlan {
633    /// Do not refresh automatically.
634    None,
635    /// Re-run the command spec that produced the active view.
636    ReRunSpec,
637}
638
639pub fn preview_argv(argv: &[OsString]) -> String {
640    let mut preview = String::from("jj");
641    for arg in argv {
642        preview.push(' ');
643        preview.push_str(&quote_arg(arg));
644    }
645    preview
646}
647
648fn quote_arg(arg: &OsStr) -> String {
649    let arg = arg.to_string_lossy();
650    if arg.is_empty() {
651        return "''".to_owned();
652    }
653
654    if arg
655        .chars()
656        .all(|character| character.is_ascii_alphanumeric() || "-_./:@".contains(character))
657    {
658        return arg.into_owned();
659    }
660
661    format!("'{}'", arg.replace('\'', "'\"'\"'"))
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    fn strings(argv: impl IntoIterator<Item = OsString>) -> Vec<String> {
669        argv.into_iter()
670            .map(|arg| arg.to_string_lossy().into_owned())
671            .collect()
672    }
673
674    #[test]
675    fn empty_argv_previews_as_jj() {
676        let spec = JjCommandSpec::render_read_only(Vec::<OsString>::new());
677
678        assert_eq!(spec.preview(), "jj");
679        assert_eq!(spec.title(), "jj");
680    }
681
682    #[test]
683    fn default_global_options_render_current_app_globals() {
684        let options = GlobalOptions::default();
685
686        assert_eq!(
687            strings(options.argv()),
688            vec!["--no-pager", "--color", "always"]
689        );
690    }
691
692    #[test]
693    fn repository_renders_before_command_argv() {
694        let spec =
695            JjCommandSpec::render_read_only(["diff", "-r", "@"]).with_repository("/tmp/repo");
696
697        assert_eq!(
698            strings(spec.process_argv()),
699            vec![
700                "--no-pager",
701                "--color",
702                "always",
703                "--repository",
704                "/tmp/repo",
705                "diff",
706                "-r",
707                "@"
708            ]
709        );
710        assert_eq!(spec.repository(), Some(Path::new("/tmp/repo")));
711        assert_eq!(
712            spec.global_options().repository(),
713            Some(Path::new("/tmp/repo"))
714        );
715    }
716
717    #[test]
718    fn advanced_global_options_render_before_command_argv() {
719        let global_options = GlobalOptions::default()
720            .with_operation(OperationLoadPolicy::AtOperation("abc123".to_owned()))
721            .with_operation_integration(OperationIntegrationPolicy::DoNotIntegrate)
722            .with_immutability(ImmutabilityPolicy::Ignore)
723            .with_debug(true);
724        let spec = JjCommandSpec::render_read_only(["status"]).with_global_options(global_options);
725
726        assert_eq!(
727            strings(spec.process_argv()),
728            vec![
729                "--no-pager",
730                "--color",
731                "always",
732                "--at-operation",
733                "abc123",
734                "--no-integrate-operation",
735                "--ignore-immutable",
736                "--debug",
737                "status"
738            ]
739        );
740    }
741
742    #[test]
743    fn ignore_working_copy_renders_before_command_argv() {
744        let global_options = GlobalOptions::default().with_working_copy(WorkingCopyPolicy::Ignore);
745        let spec = JjCommandSpec::render_read_only(["log"]).with_global_options(global_options);
746
747        assert_eq!(
748            strings(spec.process_argv()),
749            vec![
750                "--no-pager",
751                "--color",
752                "always",
753                "--ignore-working-copy",
754                "log"
755            ]
756        );
757    }
758
759    #[test]
760    fn at_operation_does_not_duplicate_implied_working_copy_ignore() {
761        let global_options = GlobalOptions::default()
762            .with_working_copy(WorkingCopyPolicy::Ignore)
763            .with_operation(OperationLoadPolicy::AtOperation("abc123".to_owned()));
764        let spec = JjCommandSpec::render_read_only(["status"]).with_global_options(global_options);
765
766        assert_eq!(
767            strings(spec.process_argv()),
768            vec![
769                "--no-pager",
770                "--color",
771                "always",
772                "--at-operation",
773                "abc123",
774                "status"
775            ]
776        );
777    }
778
779    #[test]
780    fn config_overlays_preserve_global_order() {
781        let options = GlobalOptions::default()
782            .with_config_overlay(ConfigOverlay::Inline {
783                name_value: "ui.color=always".to_owned(),
784            })
785            .with_config_overlay(ConfigOverlay::File("/tmp/jj.toml".into()))
786            .with_config_overlay(ConfigOverlay::Inline {
787                name_value: "aliases.l=log".to_owned(),
788            });
789
790        assert_eq!(
791            strings(options.argv()),
792            vec![
793                "--no-pager",
794                "--color",
795                "always",
796                "--config",
797                "ui.color=always",
798                "--config-file",
799                "/tmp/jj.toml",
800                "--config",
801                "aliases.l=log"
802            ]
803        );
804    }
805
806    #[test]
807    fn process_argv_does_not_duplicate_repository() {
808        let spec =
809            JjCommandSpec::render_read_only(["show", "@"]).with_repository("/tmp/repository");
810        let argv = strings(spec.process_argv());
811
812        assert_eq!(
813            argv.iter()
814                .filter(|arg| arg.as_str() == "--repository")
815                .count(),
816            1
817        );
818    }
819
820    #[test]
821    fn render_read_only_sets_default_policy() {
822        let spec = JjCommandSpec::render_read_only(["diff", "-r", "abc123"]);
823
824        assert_eq!(spec.mode(), ExecutionMode::RenderReadOnly);
825        assert_eq!(spec.safety(), SafetyClass::ReadOnly);
826        assert_eq!(spec.refresh_plan(), RefreshPlan::ReRunSpec);
827    }
828
829    #[test]
830    fn preview_quotes_whitespace_and_shell_metacharacters() {
831        let spec = JjCommandSpec::render_read_only(["log", "-r", "description('a b')"]);
832
833        assert_eq!(spec.preview(), "jj log -r 'description('\"'\"'a b'\"'\"')'");
834    }
835
836    #[test]
837    fn preview_quotes_backticks() {
838        let spec = JjCommandSpec::render_read_only(["log", "-r", "`echo nope`"]);
839
840        assert_eq!(spec.preview(), "jj log -r '`echo nope`'");
841    }
842
843    #[test]
844    fn process_preview_includes_global_options_before_command_argv() {
845        let spec =
846            JjCommandSpec::render_read_only(["status"]).with_repository("/tmp/repo with spaces");
847
848        assert_eq!(
849            spec.process_preview(),
850            "jj --no-pager --color always --repository '/tmp/repo with spaces' status"
851        );
852        assert_eq!(spec.preview(), "jj status");
853    }
854
855    #[test]
856    fn confirm_mutation_sets_preview_policy() {
857        let spec = JjCommandSpec::confirm_mutation(
858            ["workspace", "update-stale"],
859            SafetyClass::LocalMetadata,
860        );
861
862        assert_eq!(spec.mode(), ExecutionMode::ConfirmMutation);
863        assert_eq!(spec.safety(), SafetyClass::LocalMetadata);
864        assert_eq!(spec.refresh_plan(), RefreshPlan::ReRunSpec);
865    }
866
867    #[test]
868    fn command_preview_uses_process_command_line_and_short_title() {
869        let global_options = GlobalOptions::default()
870            .with_repository("/tmp/repo")
871            .with_working_copy(WorkingCopyPolicy::Ignore);
872        let spec = JjCommandSpec::confirm_mutation(["new", "@"], SafetyClass::LocalRewrite)
873            .with_global_options(global_options)
874            .with_title("new change after @");
875
876        let preview = spec.command_preview();
877
878        assert_eq!(preview.spec, spec);
879        assert_eq!(preview.title, "new change after @");
880        assert_eq!(
881            preview.command_line,
882            "jj --no-pager --color always --repository /tmp/repo --ignore-working-copy new @"
883        );
884        assert_eq!(preview.execution_mode, ExecutionMode::ConfirmMutation);
885        assert_eq!(preview.safety, SafetyClass::LocalRewrite);
886        assert_eq!(preview.refresh_plan, RefreshPlan::ReRunSpec);
887        assert!(preview.requires_confirmation());
888        assert_eq!(
889            preview.warnings,
890            vec![
891                CommandPreviewWarning::LocalRewrite,
892                CommandPreviewWarning::IgnoresWorkingCopy
893            ]
894        );
895    }
896
897    #[test]
898    fn command_preview_redacts_secret_looking_global_options() {
899        let global_options = GlobalOptions::default().with_config_overlay(ConfigOverlay::Inline {
900            name_value: "auth.token=abc123".to_owned(),
901        });
902        let spec = JjCommandSpec::confirm_mutation(
903            ["describe", "-m", "safe", "@"],
904            SafetyClass::LocalRewrite,
905        )
906        .with_global_options(global_options);
907
908        let preview = spec.command_preview();
909
910        assert_eq!(
911            preview.command_line,
912            "jj --no-pager --color always --config 'auth.token=<redacted>' describe -m safe @"
913        );
914        assert_eq!(preview.spec.process_argv(), spec.process_argv());
915    }
916
917    #[test]
918    fn command_preview_warns_for_advanced_global_safety_flags() {
919        let global_options = GlobalOptions::default()
920            .with_operation(OperationLoadPolicy::AtOperation("abc123".to_owned()))
921            .with_operation_integration(OperationIntegrationPolicy::DoNotIntegrate)
922            .with_immutability(ImmutabilityPolicy::Ignore);
923        let spec = JjCommandSpec::confirm_mutation(
924            ["rebase", "-b", "@", "-d", "main"],
925            SafetyClass::LocalRewrite,
926        )
927        .with_global_options(global_options);
928
929        let preview = spec.command_preview();
930
931        assert_eq!(
932            preview.warnings,
933            vec![
934                CommandPreviewWarning::LocalRewrite,
935                CommandPreviewWarning::AtOperation("abc123".to_owned()),
936                CommandPreviewWarning::DoesNotIntegrateOperation,
937                CommandPreviewWarning::IgnoresImmutableCommits
938            ]
939        );
940    }
941
942    #[test]
943    fn metadata_builders_preserve_argv() {
944        let spec = JjCommandSpec::render_read_only(["diff"])
945            .with_cwd("/tmp/work")
946            .with_repository("/tmp/repo")
947            .with_stdin("input")
948            .with_title("custom");
949
950        assert_eq!(spec.argv(), &[OsString::from("diff")]);
951        assert_eq!(spec.cwd(), Some(Path::new("/tmp/work")));
952        assert_eq!(spec.repository(), Some(Path::new("/tmp/repo")));
953        assert_eq!(spec.stdin(), Some("input"));
954        assert_eq!(spec.title(), "custom");
955        assert_eq!(spec.preview(), "jj diff");
956    }
957}