use std::ffi::OsString;
use std::path::Path;
use std::time::{Duration, SystemTime};
use super::*;
use crate::{
ConfigOverlay, ExecutionMode, GlobalOptions, JjCommandSpec, OutputPolicy, PagerPolicy,
SafetyClass,
};
fn strings(argv: &[OsString]) -> Vec<String> {
argv.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
}
fn source(view: SourceView, action: SourceAction) -> CommandSource {
CommandSource::new(view, action)
}
fn started_at() -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(1_000)
}
fn finish_at() -> SystemTime {
started_at() + Duration::from_millis(42)
}
fn start_from_spec(spec: &JjCommandSpec, source: CommandSource) -> CommandRecordStart {
CommandRecordStart::from_spec(spec, source).with_started_at(started_at())
}
fn first_record(history: &CommandHistory) -> &CommandRecord {
history
.records()
.next()
.unwrap_or_else(|| panic!("expected record"))
}
#[test]
fn starting_and_finishing_record_stores_completed_result() {
let spec = JjCommandSpec::render_read_only(["status"]);
let mut history = CommandHistory::new(4);
let pending = history.start(start_from_spec(
&spec,
source(SourceView::Status, SourceAction::InitialLoad),
));
let pending_id = pending.id();
assert!(history.finish(
&pending,
CommandRecordFinish::from_exit_code(0, "clean\n", "", finish_at()),
));
let record = first_record(&history);
assert_eq!(record.id, pending_id);
assert_eq!(record.result.exit_status, Some(ExitStatusSummary::code(0)));
assert_eq!(record.result.stdout.snippet, "clean\n");
assert_eq!(record.timing.duration, Some(Duration::from_millis(42)));
}
#[test]
fn record_ids_are_monotonic_across_eviction() {
let spec = JjCommandSpec::render_read_only(["log"]);
let mut history = CommandHistory::new(2);
let first = history.start(start_from_spec(
&spec,
source(SourceView::Log, SourceAction::InitialLoad),
));
let second = history.start(start_from_spec(
&spec,
source(SourceView::Log, SourceAction::Refresh),
));
let third = history.start(start_from_spec(
&spec,
source(SourceView::Log, SourceAction::Refresh),
));
assert_eq!(first.id().get(), 1);
assert_eq!(second.id().get(), 2);
assert_eq!(third.id().get(), 3);
assert_eq!(
history
.records()
.map(|record| record.id.get())
.collect::<Vec<_>>(),
vec![2, 3]
);
}
#[test]
fn history_limit_drops_oldest_records_first() {
let spec = JjCommandSpec::render_read_only(["status"]);
let mut history = CommandHistory::new(2);
history.append(
start_from_spec(&spec, source(SourceView::Status, SourceAction::InitialLoad)),
CommandRecordFinish::from_exit_code(0, "one", "", finish_at()),
);
history.append(
start_from_spec(&spec, source(SourceView::Status, SourceAction::Refresh)),
CommandRecordFinish::from_exit_code(0, "two", "", finish_at()),
);
history.append(
start_from_spec(&spec, source(SourceView::Status, SourceAction::Refresh)),
CommandRecordFinish::from_exit_code(0, "three", "", finish_at()),
);
assert_eq!(
history
.records()
.map(|record| record.result.stdout.snippet.as_str())
.collect::<Vec<_>>(),
vec!["two", "three"]
);
}
#[test]
fn identity_uses_argv_and_global_options_from_spec() {
let options = GlobalOptions::default()
.with_output(OutputPolicy {
pager: PagerPolicy::Disable,
..OutputPolicy::default()
})
.with_repository("/tmp/repo")
.with_config_overlay(ConfigOverlay::Inline {
name_value: "ui.color=always".to_owned(),
});
let spec = JjCommandSpec::render_read_only(["workspace", "update-stale"])
.with_global_options(options)
.with_title("jj -R /tmp/repo workspace update-stale");
let start = CommandRecordStart::from_spec(
&spec,
source(SourceView::Workspaces, SourceAction::WorkspaceUpdateStale),
);
assert_eq!(
strings(&start.command.argv),
vec![
"--no-pager",
"--color",
"always",
"--repository",
"/tmp/repo",
"--config",
"ui.color=always",
"workspace",
"update-stale"
]
);
assert_eq!(start.command.spec_preview, "jj workspace update-stale");
assert_eq!(
start.command.title,
"jj -R /tmp/repo workspace update-stale"
);
assert_eq!(
start.context.repository.as_deref(),
Some(Path::new("/tmp/repo"))
);
assert_eq!(
strings(&start.context.global_options.argv),
vec![
"--no-pager",
"--color",
"always",
"--repository",
"/tmp/repo",
"--config",
"ui.color=always"
]
);
assert_eq!(start.command.command_family, CommandFamily::JjWorkspace);
}
#[test]
fn command_mode_specs_use_user_command_family() {
let spec = JjCommandSpec::render_read_only(["status"]).with_mode(ExecutionMode::CommandMode);
let start = CommandRecordStart::from_spec(
&spec,
source(
SourceView::Other("command mode".to_owned()),
SourceAction::UserJjCommand,
),
);
assert_eq!(start.command.command_family, CommandFamily::UserJjCommand);
assert_eq!(start.source.action, SourceAction::UserJjCommand);
}
#[test]
fn context_global_options_are_redacted() {
let spec = JjCommandSpec::render_read_only(["log"]).with_global_options(
GlobalOptions::default().with_config_overlay(ConfigOverlay::Inline {
name_value: "auth.token=abc123".to_owned(),
}),
);
let start =
CommandRecordStart::from_spec(&spec, source(SourceView::Log, SourceAction::InitialLoad));
assert_eq!(
strings(&start.context.global_options.argv),
vec![
"--no-pager",
"--color",
"always",
"--config",
"auth.token=<redacted>"
]
);
}
#[test]
fn source_action_is_independent_from_command_family() {
let spec = JjCommandSpec::render_read_only(["status"]);
let start = CommandRecordStart::from_spec(
&spec,
source(SourceView::Workspaces, SourceAction::WorkspaceStatus),
);
assert_eq!(start.command.command_family, CommandFamily::JjStatus);
assert_eq!(start.source.view, SourceView::Workspaces);
assert_eq!(start.source.action, SourceAction::WorkspaceStatus);
}
#[test]
fn workspace_log_source_action_preserves_log_family() {
let spec = JjCommandSpec::render_read_only(["log"]);
let start = CommandRecordStart::from_spec(
&spec,
source(SourceView::Workspaces, SourceAction::WorkspaceLog),
);
assert_eq!(start.command.command_family, CommandFamily::JjLog);
assert_eq!(start.source.view, SourceView::Workspaces);
assert_eq!(start.source.action, SourceAction::WorkspaceLog);
}
#[test]
fn new_specs_use_new_family_and_typed_source_action() {
let spec = JjCommandSpec::confirm_mutation(["new", "abc123"], SafetyClass::LocalRewrite);
let start =
CommandRecordStart::from_spec(&spec, source(SourceView::Log, SourceAction::NewRevision));
assert_eq!(start.command.command_family, CommandFamily::JjNew);
assert_eq!(start.source.view, SourceView::Log);
assert_eq!(start.source.action, SourceAction::NewRevision);
}
#[test]
fn edit_specs_use_edit_family_and_typed_source_action() {
let spec = JjCommandSpec::confirm_mutation(["edit", "abc123"], SafetyClass::LocalRewrite);
let start =
CommandRecordStart::from_spec(&spec, source(SourceView::Log, SourceAction::EditRevision));
assert_eq!(start.command.command_family, CommandFamily::JjEdit);
assert_eq!(start.source.view, SourceView::Log);
assert_eq!(start.source.action, SourceAction::EditRevision);
}
#[test]
fn operation_specs_use_operation_family_and_typed_source_actions() {
let cases = [
(
JjCommandSpec::render_read_only(["op", "log"]),
SourceView::OperationLog,
SourceAction::OperationLog,
),
(
JjCommandSpec::render_read_only(["op", "show", "abc123"]),
SourceView::OperationShow,
SourceAction::OperationShow,
),
(
JjCommandSpec::render_read_only(["op", "diff", "--operation", "abc123"]),
SourceView::OperationDiff,
SourceAction::OperationDiff,
),
];
for (spec, view, action) in cases {
let start = CommandRecordStart::from_spec(&spec, source(view.clone(), action.clone()));
assert_eq!(start.command.command_family, CommandFamily::JjOperation);
assert_eq!(start.source.view, view);
assert_eq!(start.source.action, action);
}
}
#[test]
fn finish_records_duration() {
let spec = JjCommandSpec::render_read_only(["diff"]);
let mut history = CommandHistory::new(1);
let pending = history.start(start_from_spec(
&spec,
source(SourceView::Diff, SourceAction::OpenDiff),
));
assert!(history.finish(
&pending,
CommandRecordFinish::from_exit_code(0, "", "", finish_at()),
));
assert_eq!(
history
.records()
.next()
.and_then(|record| record.timing.duration),
Some(Duration::from_millis(42))
);
}
#[test]
fn spawn_error_has_no_exit_code() {
let spec = JjCommandSpec::render_read_only(["log"]);
let mut history = CommandHistory::new(1);
let pending = history.start(start_from_spec(
&spec,
source(SourceView::Log, SourceAction::InitialLoad),
));
assert!(history.finish(
&pending,
CommandRecordFinish::from_spawn_error("failed to spawn jj", "", "", finish_at()),
));
let record = first_record(&history);
assert_eq!(record.result.exit_status, None);
assert_eq!(
record.result.spawn_error.as_deref(),
Some("failed to spawn jj")
);
}
#[test]
fn finish_keeps_first_result() {
let spec = JjCommandSpec::render_read_only(["status"]);
let mut history = CommandHistory::new(1);
let pending = history.start(start_from_spec(
&spec,
source(SourceView::Status, SourceAction::InitialLoad),
));
assert!(history.finish(
&pending,
CommandRecordFinish::from_exit_code(0, "first", "", finish_at()),
));
assert!(!history.finish(
&pending,
CommandRecordFinish::from_exit_code(1, "second", "", finish_at()),
));
let record = first_record(&history);
assert_eq!(record.result.exit_status, Some(ExitStatusSummary::code(0)));
assert_eq!(record.result.stdout.snippet, "first");
}
#[test]
fn finish_applies_record_retention_limits() {
let spec = JjCommandSpec::render_read_only(["status"]);
let start = start_from_spec(&spec, source(SourceView::Status, SourceAction::InitialLoad))
.with_retention(OutputRetention::summary_only(3, 2));
let mut history = CommandHistory::new(1);
let pending = history.start(start);
assert!(history.finish(
&pending,
CommandRecordFinish::from_exit_code(1, "abcdef", "wxyz", finish_at()),
));
let record = first_record(&history);
assert_eq!(record.result.stdout.snippet, "abc");
assert!(record.result.stdout.truncated);
assert_eq!(record.result.stderr.snippet, "wx");
assert!(record.result.stderr.truncated);
}
#[test]
fn stream_summary_keeps_counts_and_truncates_on_byte_limit() {
let summary = StreamSummary::from_text("alpha\nbeta\ngamma\n", 8);
assert_eq!(summary.byte_len, 17);
assert_eq!(summary.line_count, 3);
assert_eq!(summary.snippet, "alpha\nbe");
assert!(summary.truncated);
assert!(!summary.redacted);
}
#[test]
fn stream_summary_redacts_secret_looking_values() {
let summary = StreamSummary::from_text(
"token=abc123\nnormal=value\nAuthorization: Bearer secret\n",
1024,
);
assert_eq!(summary.byte_len, 55);
assert_eq!(summary.line_count, 3);
assert_eq!(
summary.snippet,
"token=<redacted>\nnormal=value\nAuthorization:<redacted>\n"
);
assert!(summary.redacted);
assert!(!summary.truncated);
}
#[test]
fn stream_summary_does_not_redact_key_as_substring() {
let summary = StreamSummary::from_text("monkey=value\napi-key=abc123\n", 1024);
assert_eq!(summary.snippet, "monkey=value\napi-key=<redacted>\n");
assert!(summary.redacted);
}
#[test]
fn stream_summary_redaction_handles_non_ascii_graph_prefixes() {
let summary = StreamSummary::from_text("│ config.token=abc123\n", 1024);
assert_eq!(summary.snippet, "│ config.token=<redacted>\n");
assert!(summary.redacted);
}
#[test]
fn command_identity_redacts_secret_looking_args() {
let spec = JjCommandSpec::render_read_only(["log"]).with_global_options(
GlobalOptions::default().with_config_overlay(ConfigOverlay::Inline {
name_value: "auth.token=abc123".to_owned(),
}),
);
let identity = CommandIdentity::from_spec(&spec);
assert_eq!(
strings(&identity.argv),
vec![
"--no-pager",
"--color",
"always",
"--config",
"auth.token=<redacted>",
"log"
]
);
}