fcrypt 0.3.3

Cross-platform CLI for password-based file encryption and decryption
pub fn print_help_all() {
    println!("{}", colorize_help_all(HELP_ALL));
}

pub const HELP_ALL: &str = r#"fcrypt full help

1. Purpose of fcrypt
  fcrypt encrypts and decrypts local files. Both password-based and
  asymmetric PQC encryption use the opaque container format.

2. Modes
  Symmetric mode:
    fcrypt encrypt <INPUT>
    fcrypt decrypt <INPUT>

  Asymmetric PQC opaque mode:
    fcrypt asym encrypt <INPUT>
    fcrypt asym decrypt <INPUT.bin>
    fcrypt asym sign <INPUT.bin>

3. Symmetric Mode
  encrypt and encode are aliases.
  decrypt and decode are aliases.
  Symmetric encryption writes <INPUT>.bin by default.
  There is no separate format flag.

4. Asymmetric PQC Opaque Mode
  asym and assym are aliases. Help shows asym as the primary spelling.
  The mode always creates opaque .bin files and uses:
    KEM 1: ML-KEM-1024
    KEM 2: HQC-256
    KDF: HKDF-SHA3-512
    AEAD: AES-256-GCM
    Signature: detached ML-DSA-87
    Hash: SHA3 family

5. Commands And Aliases
  fcrypt -ha
  fcrypt --help-all

  fcrypt encrypt <INPUT> [OPTIONS]
  fcrypt encode  <INPUT> [OPTIONS]
  fcrypt decrypt <INPUT> [OPTIONS]
  fcrypt decode  <INPUT> [OPTIONS]

  fcrypt keygen <N>
  fcrypt keygen phrase <N> [OPTIONS]
  fcrypt keygen pair <NAME> [LIFETIME_DAYS]

  fcrypt asym encrypt <INPUT> [OPTIONS]
  fcrypt asym encode  <INPUT> [OPTIONS]
  fcrypt asym decrypt <INPUT.bin> [OPTIONS]
  fcrypt asym decode  <INPUT.bin> [OPTIONS]
  fcrypt asym sign    <INPUT.bin> [OPTIONS]

  fcrypt assym is accepted anywhere fcrypt asym is accepted.
  -ha / --help-all is accepted from any command position and prints this page.

6. Symmetric Options
  <INPUT>, -i, --input <FILE>
      Path to the input file.
  -f, --force
      Allow overwriting the output file.

7. Keygen Options
  fcrypt keygen <N>
      Generate a random password with N characters.
  fcrypt keygen phrase <N>
      Generate a random phrase with N words from the embedded EFF large wordlist.
  -sep, -s, --sep <SEP>
      Word separator for phrase generation. Defaults to '-'.
  fcrypt keygen pair <NAME> [LIFETIME_DAYS]
      Generate four asymmetric key files in the current directory:
      <NAME>_recipient_default.pub
      <NAME>_recipient_default.sec
      <NAME>_signer_mldsa87.pub
      <NAME>_signer_mldsa87.sec
      Keys do not expire unless LIFETIME_DAYS is provided.

8. asym encrypt / encode Options
  -o, --output <FILE>
      Destination file. Defaults to <INPUT>.bin.
  -r, --recipient-public <FILE>
      Recipient public key bundle. If omitted, fcrypt generates a new
      recipient keypair automatically.
  -k, --keys-dir <DIR>
      Directory for generated or discovered keys. Defaults to
      <file_stem>_keys, for example report_keys/.
  -s, --sign
      Create a detached ML-DSA-87 signature next to the encrypted file.
  -S, --sign-key <FILE>
      Signing secret key. This automatically enables detached signing.
  -f, --force
      Allow overwriting the output file and detached signature.

9. asym decrypt / decode Options
  -o, --output <FILE>
      Destination plaintext file. Defaults to <INPUT.bin without .bin>.
  -i, --identity <FILE>
      Recipient secret key bundle used for decryption.
  -k, --keys-dir <DIR>
      Directory for auto-discovering recipient secret keys. Auto-discovery
      tries at most 32 matching recipient secret keys; use -i to select one.
  -v, --verify <FILE>
      Signing public key used to verify a detached signature.
  -R, --require-signature
      Require a valid detached signature before decrypting.
  -f, --force
      Allow overwriting the output file.

10. asym sign Options
  -o, --output <FILE>
      Detached signature output. Defaults to <INPUT.bin>.sig.
  -S, --sign-key <FILE>
      Signing secret key. If omitted, fcrypt generates a new signing keypair.
  -k, --keys-dir <DIR>
      Directory for generated signing keys. Defaults to <file_stem>_keys.
  -e, --embed
      Unsupported for opaque files.
  -f, --force
      Allow overwriting the signature file.

11. Output File Format
  Both modes use .bin as the default filename extension.
  The encrypted file has no magic bytes, cleartext version, cleartext
  algorithm identifiers, key id, or media mimicry.
  Format metadata is authenticated and encrypted inside an opaque prelude.

12. Where Keys Are Stored
  If -r/--recipient-public is not specified, recipient keys are generated
  automatically. By default, keys are saved in <file_stem>_keys:
    report.pdf -> report_keys/
  Override this with -k/--keys-dir. When --keys-dir is provided, fcrypt uses
  that directory directly and does not create a nested report_keys/ directory.

13. Recipient Key vs Signing Key
  recipient_default.sec is needed to decrypt matching opaque files.
  signer_mldsa87.sec is needed to create signatures.
  signer_mldsa87.pub is used with -v/--verify.
  -i/--identity selects a recipient secret key for decryption.
  keygen pair creates both recipient and signing keypairs at once.

14. Examples
  Symmetric:
    fcrypt encrypt notes.txt
    fcrypt encode notes.txt
    fcrypt decrypt notes.txt.bin
    fcrypt decode notes.txt.bin

  Keygen:
    fcrypt keygen 32
    fcrypt keygen phrase 6
    fcrypt keygen phrase 6 -sep .
    fcrypt keygen pair alice
    fcrypt keygen pair alice 365

  Asymmetric with auto-generated keys:
    fcrypt asym encrypt report.pdf

  Creates:
    report.pdf.bin
    report_keys/
      <label8>_recipient_default.pub
      <label8>_recipient_default.sec

  Asymmetric with custom keys dir:
    fcrypt asym encrypt report.pdf -k ./keys

  Asymmetric with existing recipient public key:
    fcrypt asym encrypt report.pdf -r ./alice_recipient.pub

  Decrypt:
    fcrypt asym decrypt report.pdf.bin -i ./report_keys/a13f2c9b_recipient_default.sec

  Alias:
    fcrypt asym decode report.pdf.bin -i ./report_keys/a13f2c9b_recipient_default.sec

  Encrypt and create a detached signature:
    fcrypt asym encrypt report.pdf -s

  Encrypt and sign with existing signing key:
    fcrypt asym encrypt report.pdf -S ./sender_signer.sec

  Detached signing:
    fcrypt asym sign report.pdf.bin

  Decrypt and verify:
    fcrypt asym decrypt report.pdf.bin \
      -i ./report_keys/a13f2c9b_recipient_default.sec \
      -v ./sender_signer.pub

  Require signature:
    fcrypt asym decrypt report.pdf.bin \
      -i ./report_keys/a13f2c9b_recipient_default.sec \
      -v ./sender_signer.pub \
      -R

15. Security Notes
  .pub files are public and may be shared.
  .sec files are secret and must not be shared.

  recipient_default.sec can decrypt files encrypted for that recipient.
  signer_mldsa87.sec can create signatures but cannot decrypt files.

  Move .sec files to a safe location after encryption if needed.
  The file does not reveal which recipient key should be tried.
  Anyone with signer_mldsa87.sec can sign files as that signer.

  keygen phrase uses the embedded EFF large wordlist and cryptographically
  random word selection.

16. Troubleshooting
  If no identity key is found:
    No matching recipient secret key found.
    Use: fcrypt asym decrypt <file.bin> -i <recipient_default.sec>

  If signature verification is requested:
    The detached signature must be next to the encrypted file as <file>.sig.
"#;

const SECTION_COLOR: &str = "\x1b[38;2;9;230;189m";
const OPTION_COLOR: &str = "\x1b[38;2;247;54;99m";
const RESET: &str = "\x1b[0m";

fn colorize_help_all(help: &str) -> String {
    let mut output = String::with_capacity(help.len() + 512);
    for line in help.lines() {
        if is_section_line(line) {
            output.push_str(SECTION_COLOR);
            output.push_str(line);
            output.push_str(RESET);
        } else if is_option_line(line) {
            output.push_str(OPTION_COLOR);
            output.push_str(line);
            output.push_str(RESET);
        } else {
            output.push_str(line);
        }
        output.push('\n');
    }
    output
}

fn is_section_line(line: &str) -> bool {
    let trimmed = line.trim_start();
    let Some((number, rest)) = trimmed.split_once(". ") else {
        return false;
    };
    !rest.is_empty() && number.chars().all(|c| c.is_ascii_digit())
}

fn is_option_line(line: &str) -> bool {
    let trimmed = line.trim_start();
    trimmed.starts_with("<INPUT>") || trimmed.starts_with("-")
}