use crate::{
ExpectedError, Result, cargo_cli::CargoCli, dispatch::EarlyArgs, output::OutputContext,
};
use camino::{Utf8Path, Utf8PathBuf};
use chrono::Utc;
use clap::{Args, Subcommand};
use nextest_runner::{
helpers::ThemeCharacters,
pager::PagedOutput,
record::{
DisplayRunList, PortableRecording, PortableRecordingWriter, PruneKind,
RecordRetentionPolicy, RecordedRunStatus, RunIdIndex, RunIdOrRecordingSelector,
RunIdSelector, RunStore, SnapshotWithReplayability, Styles as RecordStyles,
has_zip_extension, records_state_dir,
},
redact::Redactor,
user_config::{UserConfig, elements::RecordConfig},
write_str::WriteStr,
};
use owo_colors::OwoColorize;
use tracing::{info, warn};
#[derive(Debug, Subcommand)]
pub(crate) enum StoreCommand {
List {},
Info(InfoOpts),
Prune(PruneOpts),
Export(ExportOpts),
}
#[derive(Debug, Args)]
pub(crate) struct InfoOpts {
#[arg(
value_name = "RUN_ID_OR_RECORDING",
required_unless_present = "run_id_opt"
)]
run_id: Option<RunIdOrRecordingSelector>,
#[arg(
short = 'R',
long = "run-id",
hide = true,
value_name = "RUN_ID_OR_RECORDING",
conflicts_with = "run_id"
)]
run_id_opt: Option<RunIdOrRecordingSelector>,
}
impl InfoOpts {
fn resolved_selector(&self) -> &RunIdOrRecordingSelector {
self.run_id
.as_ref()
.or(self.run_id_opt.as_ref())
.expect("run_id or run_id_opt is present due to clap validation")
}
fn exec_from_store(
&self,
run_id_selector: &RunIdSelector,
state_dir: &Utf8Path,
styles: &RecordStyles,
theme_characters: &ThemeCharacters,
paged_output: &mut PagedOutput,
redactor: &Redactor,
) -> Result<i32> {
let store =
RunStore::new(state_dir).map_err(|err| ExpectedError::RecordSetupError { err })?;
let snapshot = store
.lock_shared()
.map_err(|err| ExpectedError::RecordSetupError { err })?
.into_snapshot();
let resolved = snapshot
.resolve_run_id(run_id_selector)
.map_err(|err| ExpectedError::RunIdResolutionError { err })?;
let run_id = resolved.run_id;
let run = snapshot
.get_run(run_id)
.expect("run ID was just resolved, so the run should exist");
let replayability = run.check_replayability(&snapshot.runs_dir().run_files(run_id));
let display = run.display_detailed(
snapshot.run_id_index(),
&replayability,
Utc::now(),
styles,
theme_characters,
redactor,
);
write!(paged_output, "{}", display).map_err(|err| ExpectedError::WriteError { err })?;
Ok(0)
}
fn exec_from_archive(
&self,
archive_path: &Utf8Path,
styles: &RecordStyles,
theme_characters: &ThemeCharacters,
paged_output: &mut PagedOutput,
redactor: &Redactor,
) -> Result<i32> {
let archive = PortableRecording::open(archive_path)
.map_err(|err| ExpectedError::PortableRecordingReadError { err })?;
let run_info = archive.run_info();
let run_id_index = RunIdIndex::new(std::slice::from_ref(&run_info));
let replayability = run_info.check_replayability(&archive);
let display = run_info.display_detailed(
&run_id_index,
&replayability,
Utc::now(),
styles,
theme_characters,
redactor,
);
write!(paged_output, "{}", display).map_err(|err| ExpectedError::WriteError { err })?;
Ok(0)
}
}
#[derive(Debug, Args)]
pub(crate) struct PruneOpts {
#[arg(long)]
dry_run: bool,
}
#[derive(Debug, Args)]
pub(crate) struct ExportOpts {
#[arg(value_name = "RUN_ID", required_unless_present = "run_id_opt")]
run_id: Option<RunIdSelector>,
#[arg(
short = 'R',
long = "run-id",
hide = true,
value_name = "RUN_ID",
conflicts_with = "run_id"
)]
run_id_opt: Option<RunIdSelector>,
#[arg(long, value_name = "PATH", value_parser = zip_extension_path)]
archive_file: Option<Utf8PathBuf>,
}
fn zip_extension_path(input: &str) -> Result<Utf8PathBuf, &'static str> {
let path = Utf8PathBuf::from(input);
if has_zip_extension(&path) {
Ok(path)
} else {
Err("must end in .zip")
}
}
impl ExportOpts {
fn resolved_run_id(&self) -> &RunIdSelector {
self.run_id
.as_ref()
.or(self.run_id_opt.as_ref())
.expect("run_id or run_id_opt is present due to clap validation")
}
fn exec(&self, state_dir: &Utf8Path, styles: &RecordStyles) -> Result<i32> {
let store =
RunStore::new(state_dir).map_err(|err| ExpectedError::RecordSetupError { err })?;
let snapshot = store
.lock_shared()
.map_err(|err| ExpectedError::RecordSetupError { err })?
.into_snapshot();
let resolved = snapshot
.resolve_run_id(self.resolved_run_id())
.map_err(|err| ExpectedError::RunIdResolutionError { err })?;
let run_id = resolved.run_id;
let run = snapshot
.get_run(run_id)
.expect("run ID was just resolved, so the run should exist");
if matches!(
run.status,
RecordedRunStatus::Incomplete | RecordedRunStatus::Unknown
) {
warn!(
"run {} is {}: the exported archive may be incomplete or corrupted",
run_id.style(styles.label),
run.status.short_status_str(),
);
}
let writer = PortableRecordingWriter::new(run, snapshot.runs_dir())
.map_err(|err| ExpectedError::PortableRecordingError { err })?;
let output_path = self
.archive_file
.clone()
.unwrap_or_else(|| Utf8PathBuf::from(writer.default_filename()));
let result = writer
.write_to_path(&output_path)
.map_err(|err| ExpectedError::PortableRecordingError { err })?;
info!(
"exported run {} to {} ({} bytes)",
run_id.style(styles.label),
result.path.style(styles.label),
result.size,
);
Ok(0)
}
}
impl PruneOpts {
fn exec(
&self,
state_dir: &Utf8Path,
record_config: &RecordConfig,
styles: &RecordStyles,
paged_output: &mut PagedOutput,
redactor: &Redactor,
) -> Result<i32> {
let store =
RunStore::new(state_dir).map_err(|err| ExpectedError::RecordSetupError { err })?;
let policy = RecordRetentionPolicy::from(record_config);
if self.dry_run {
let snapshot = store
.lock_shared()
.map_err(|err| ExpectedError::RecordSetupError { err })?
.into_snapshot();
let plan = snapshot.compute_prune_plan(&policy);
write!(
paged_output,
"{}",
plan.display(snapshot.run_id_index(), styles, redactor)
)
.map_err(|err| ExpectedError::WriteError { err })?;
Ok(0)
} else {
let mut locked = store
.lock_exclusive()
.map_err(|err| ExpectedError::RecordSetupError { err })?;
let result = locked
.prune(&policy, PruneKind::Explicit)
.map_err(|err| ExpectedError::RecordSetupError { err })?;
info!("{}", result.display(styles));
Ok(0)
}
}
}
impl StoreCommand {
pub(crate) fn exec(
self,
early_args: &EarlyArgs,
manifest_path: Option<Utf8PathBuf>,
user_config: &UserConfig,
output: OutputContext,
) -> Result<i32> {
let mut styles = RecordStyles::default();
let mut theme_characters = ThemeCharacters::default();
if output.color.should_colorize(supports_color::Stream::Stdout) {
styles.colorize();
}
if supports_unicode::on(supports_unicode::Stream::Stdout) {
theme_characters.use_unicode();
}
let (pager_setting, paginate) = early_args.resolve_pager(&user_config.ui);
let mut paged_output =
PagedOutput::request_pager(&pager_setting, paginate, &user_config.ui.streampager);
let redactor = if crate::output::should_redact() {
Redactor::for_snapshot_testing()
} else {
Redactor::noop()
};
if let Self::Info(ref opts) = self
&& let RunIdOrRecordingSelector::RecordingPath(path) = opts.resolved_selector()
{
return opts.exec_from_archive(
path,
&styles,
&theme_characters,
&mut paged_output,
&redactor,
);
}
let mut cargo_cli = CargoCli::new("locate-project", manifest_path.as_deref(), output);
cargo_cli.add_args(["--workspace", "--message-format=plain"]);
let locate_project_output = cargo_cli
.to_expression()
.stdout_capture()
.unchecked()
.run()
.map_err(|error| {
ExpectedError::cargo_locate_project_exec_failed(cargo_cli.all_args(), error)
})?;
if !locate_project_output.status.success() {
return Err(ExpectedError::cargo_locate_project_failed(
cargo_cli.all_args(),
locate_project_output.status,
));
}
let workspace_root = String::from_utf8(locate_project_output.stdout)
.map_err(|err| ExpectedError::WorkspaceRootInvalidUtf8 { err })?;
let workspace_root = Utf8Path::new(workspace_root.trim_end());
let workspace_root =
workspace_root
.parent()
.ok_or_else(|| ExpectedError::WorkspaceRootInvalid {
workspace_root: workspace_root.to_owned(),
})?;
let state_dir = records_state_dir(workspace_root)
.map_err(|err| ExpectedError::RecordStateDirNotFound { err })?;
match self {
Self::List {} => {
let store = RunStore::new(&state_dir)
.map_err(|err| ExpectedError::RecordSetupError { err })?;
let snapshot = store
.lock_shared()
.map_err(|err| ExpectedError::RecordSetupError { err })?
.into_snapshot();
let store_path = if output.verbose {
Some(state_dir.as_path())
} else {
None
};
let snapshot_with_replayability = SnapshotWithReplayability::new(&snapshot);
let display = DisplayRunList::new(
&snapshot_with_replayability,
store_path,
&styles,
&theme_characters,
&redactor,
);
write!(paged_output, "{}", display)
.map_err(|err| ExpectedError::WriteError { err })?;
if snapshot.run_count() == 0 {
info!("no recorded runs");
}
Ok(0)
}
Self::Info(opts) => {
match opts.resolved_selector() {
RunIdOrRecordingSelector::RunId(run_id_selector) => opts.exec_from_store(
run_id_selector,
&state_dir,
&styles,
&theme_characters,
&mut paged_output,
&redactor,
),
RunIdOrRecordingSelector::RecordingPath(_) => {
unreachable!("recording path was handled above")
}
}
}
Self::Prune(opts) => opts.exec(
&state_dir,
&user_config.record,
&styles,
&mut paged_output,
&redactor,
),
Self::Export(opts) => opts.exec(&state_dir, &styles),
}
}
}