Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 24 additions & 102 deletions apps/cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use clap::{Parser, Subcommand, ValueEnum};
use collapse_core::paths::{inside, same_file};
use collapse_core::{
compress, compress_dir, extract, unwritable_names_with, Algorithm, CharacterFault, NameProblem,
NameReport, NameRules, Substitutions, Verify,
NameReport, NameRules, Verify,
};
use thiserror::Error;

Expand Down Expand Up @@ -105,33 +105,15 @@ pub enum Outcome {
Extracted {
output_dir: PathBuf,
/// The names as written, which is what the engine returns.
files: Vec<String>,
/// Entries whose name this machine could not hold as the archive
/// spells it, and the name they were written under instead.
///
/// Only the adjustments that need no answer land here (a trailing dot
/// or space to drop, a device name to suffix); anything needing a
/// replacement stopped the run before it started. Reported rather than
/// left to be noticed, because a rename the user did not ask for is
/// the kind of thing they should hear about from us and not from a
/// missing file later.
///
/// Always empty on Unix, where the one character no name can hold is a
/// NUL, and that is a question rather than an adjustment.
adjusted: Vec<Adjustment>,
/// "As written" is no longer a distinction: extraction refuses a name
/// it cannot write rather than adjusting it, so these are the names the
/// archive spells. The wording stays because the guarantee is worth
/// stating either way.
files: Vec<String>,
},
}

/// An entry the host could not name as the archive spells it, and what it is
/// called on disk.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Adjustment {
/// The name the archive spells.
pub entry: String,
/// The name on disk, relative to the output directory.
pub written: String,
}

impl Outcome {
/// Print a human-readable summary to stdout.
pub fn report(&self) {
Expand All @@ -149,11 +131,7 @@ impl Outcome {
Outcome::Compressed { output, .. } => {
println!("Created {}", output.display());
}
Outcome::Extracted {
output_dir,
files,
adjusted,
} => {
Outcome::Extracted { output_dir, files } => {
println!(
"Extracted {} file(s) into {}",
files.len(),
Expand All @@ -162,17 +140,6 @@ impl Outcome {
for file in files {
println!(" {file}");
}
if !adjusted.is_empty() {
println!(
"{} name(s) this system cannot write were adjusted:",
adjusted.len()
);
for change in adjusted {
// Quoted, because the difference between the two names
// can be a trailing space, which is invisible unquoted.
println!(" {:?} was written as {:?}", change.entry, change.written);
}
}
}
}
}
Expand Down Expand Up @@ -446,41 +413,8 @@ fn run_extract(archive: PathBuf, output_dir: PathBuf) -> Result<Outcome, CliErro
if !report.is_empty() {
return Err(CliError::UnwritableEntries { archive, report });
}
// Nothing is adjusted any more, so this is provably empty by the time it is
// reached: the report above was empty, and adjustments come from the report.
// Kept until `Outcome::Extracted` loses the field.
let adjusted = adjustments(&report, rules);

let files = extract(&archive, &output_dir)?;
Ok(Outcome::Extracted {
output_dir,
files,
adjusted,
})
}

/// The name each unwritable entry ends up with, for the entries whose problems
/// are settled without asking anyone.
///
/// Entries needing a replacement are absent, and that is the mechanism rather
/// than a filter: with no substitution to apply, `rewrite_entry` refuses them,
/// and those are exactly the ones [`run_extract`] has already refused to
/// proceed with. Asking the engine (instead of reimplementing "drop the
/// trailing dot, suffix the device") is what keeps this from drifting away
/// from the name actually written.
pub fn adjustments(report: &NameReport, rules: NameRules) -> Vec<Adjustment> {
let no_answers = Substitutions::new();
report
.entries
.iter()
.filter_map(|unwritable| {
let written = rules.rewrite_entry(&unwritable.entry, &no_answers).ok()?;
Some(Adjustment {
entry: unwritable.entry.clone(),
written: written.to_string_lossy().into_owned(),
})
})
.collect()
Ok(Outcome::Extracted { output_dir, files })
}

/// Why an archive was refused, which entries are at fault, what is wrong with
Expand Down Expand Up @@ -512,30 +446,18 @@ pub fn unwritable_entries_message(archive: &Path, report: &NameReport) -> String
}
}

let _ = write!(message, "\nNothing was extracted.");
let _ = write!(
message,
"\nNothing was extracted, and nothing this command could be told would change that: \
extraction writes every entry under the name the archive spells or it writes none of \
them. Extract on a system that can hold these names."
);
if !report.characters.is_empty() {
let (needed, them) = if report.characters.len() == 1 {
("a replacement for", "it")
} else {
("replacements for", "them")
};
let _ = write!(
message,
" Going ahead needs {needed} {}, and this command cannot ask for {them} mid-run \
without becoming interactive: the Collapse desktop app asks once per character, \
checks the answer is writable too, and extracts with it.",
listed(report)
);
}
if report
.entries
.iter()
.any(|e| e.problems.iter().any(|p| p.replaceable().is_none()))
{
let _ = write!(
message,
" The names above with nothing to replace are adjusted for you once it can go ahead."
);
// Named even though there is no answer to give, because it is what
// tells a user whether the archive is unusable here or merely awkward:
// one character across forty entries is a different problem from forty
// characters.
let _ = write!(message, " The characters at fault are {}.", listed(report));
}
message
}
Expand All @@ -560,12 +482,12 @@ fn explain(problem: &NameProblem) -> String {
another file as hidden data instead of becoming a file, with no error"
),
NameProblem::TrailingCharacters { removed } => format!(
"the name ends in {removed:?}, which this system does not keep, so it would be dropped"
),
NameProblem::ReservedDevice { device } => format!(
"{device:?} names a device rather than a file, in every directory, so it would be \
written under an adjusted name"
"the name ends in {removed:?}, which this system does not keep, so the file would not \
be the one the archive names"
),
NameProblem::ReservedDevice { device } => {
format!("{device:?} names a device rather than a file, in every directory")
}
}
}

Expand Down
14 changes: 4 additions & 10 deletions apps/cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,18 +478,12 @@ fn extract_lists_and_writes_files() {
out.to_str().unwrap(),
]);
match outcome {
Outcome::Extracted {
output_dir,
files,
adjusted,
} => {
Outcome::Extracted { output_dir, files } => {
assert_eq!(output_dir, out);
// A name this machine can write is written as it is spelled, which
// is now the only thing extraction does: there is no adjusted-name
// list beside the listing any more, because nothing is adjusted.
assert_eq!(listing(files), vec!["data.bin"]);
// A name this machine can write is written as it is spelled. The
// adjustment list exists for the Windows cases in tests/names.rs,
// and an ordinary archive must never populate it, or every run
// would end with a paragraph about renames that did not happen.
assert!(adjusted.is_empty());
}
_ => panic!("expected extracted"),
}
Expand Down
32 changes: 20 additions & 12 deletions apps/cli/tests/names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,29 +107,37 @@ fn the_refusal_names_every_bad_entry_and_says_what_is_wrong_with_each() {
);
}

/// Nothing is half-written, and the user is told where to go. Drop the pointer
/// and the message becomes a dead end.
/// Nothing is half-written, and the user is told the only thing that helps.
///
/// This used to promise a replacement would unblock it and point at the desktop
/// app as the front end that could ask for one. Both stopped being true when
/// extraction stopped substituting, and a message that sends someone to a
/// dialog that no longer exists is worse than one that says nothing.
#[test]
fn the_refusal_says_nothing_was_written_and_where_to_go_next() {
fn the_refusal_says_nothing_was_written_and_what_actually_helps() {
let message = windows_refusal("report.zip", &["what?.txt"]);

assert!(
message.contains("Nothing was extracted."),
message.contains("Nothing was extracted"),
"the state of the output directory is the first thing a user wonders about: {message}"
);
assert!(
message.contains("a replacement for '?' (1 entry)"),
"what would unblock it, in the singular: {message}"
message.contains("Extract on a system that can hold these names"),
"the only thing that helps, and it is not something this command can do: {message}"
);
assert!(
message.contains("desktop app"),
"the pointer at the one front end that can ask: {message}"
!message.contains("desktop app") && !message.contains("replacement for"),
"nothing may point at an answer nobody can give any more: {message}"
);
}

/// A UI puts one field per character, not one per file, and the message counts
/// the same way: the user needs to know how much of the archive one answer
/// buys.
/// The characters are still counted, though nothing can be done about them.
///
/// The count used to say how much of the archive one answer would buy. There
/// are no answers now, and it is still worth saying: one character across forty
/// entries is an archive that is awkward here, forty characters is one that
/// does not belong on this machine at all, and the user decides what to do with
/// that.
#[test]
fn the_refusal_counts_the_entries_each_character_holds_up() {
let message = windows_refusal(
Expand All @@ -138,7 +146,7 @@ fn the_refusal_counts_the_entries_each_character_holds_up() {
);

assert!(
message.contains("replacements for '?' (2 entries) and ':' (1 entry)"),
message.contains("The characters at fault are '?' (2 entries) and ':' (1 entry)"),
"both characters, both counts, and a plural that matches: {message}"
);
}
Expand Down
23 changes: 2 additions & 21 deletions apps/core/src/compression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ mod zip;
pub use self::algorithm::Algorithm;
pub use self::names::{
CharacterFault, NameError, NameProblem, NameReport, NameRules, OffendingCharacter,
Substitutions, UnwritableEntry,
UnwritableEntry,
};
pub use self::sevenz::{compress_7z, compress_7z_dir, extract_7z};
pub use self::tar::{compress_tar, compress_tar_dir, extract_tar};
pub use self::verify::{verify_archive, Verify};
pub use self::zip::{compress_zip, compress_zip_dir, extract_zip};

pub(crate) use self::names::{refuse_unwritable_names, NamePlan};
pub(crate) use self::names::refuse_unwritable_names;
pub(crate) use self::sevenz::{list_7z_entries, read_7z_entries};
pub(crate) use self::tar::{list_tar_entries, read_tar_entries};
pub(crate) use self::walk::walk_tree;
Expand Down Expand Up @@ -378,7 +378,6 @@ pub fn compress_dir(
#[non_exhaustive]
pub struct ExtractOptions {
rules: NameRules,
replacements: Substitutions,
}

impl ExtractOptions {
Expand All @@ -394,27 +393,9 @@ impl ExtractOptions {
self
}

/// **Inert.** Kept so the callers that pass answers still compile while
/// their own naming dialogs are being taken out; nothing reads it.
///
/// Extraction no longer substitutes anything: a character this filesystem
/// cannot write fails the extraction rather than being replaced, so there
/// is no answer for a caller to supply. Removing this, [`Substitutions`]
/// and the two front-end dialogs that fill it is follow-up work.
pub fn with_replacements(mut self, replacements: Substitutions) -> Self {
self.replacements = replacements;
self
}

pub fn rules(&self) -> NameRules {
self.rules
}

/// The answers this was handed. Read by nobody; see
/// [`Self::with_replacements`].
pub fn replacements(&self) -> &Substitutions {
&self.replacements
}
}

/// Which algorithm reads this archive, by file extension (not by magic bytes).
Expand Down
Loading
Loading