diff --git a/.github/workflows/build-psu-packer.yml b/.github/workflows/build-psu-packer.yml index b02025b..416664b 100644 --- a/.github/workflows/build-psu-packer.yml +++ b/.github/workflows/build-psu-packer.yml @@ -27,13 +27,13 @@ jobs: runs-on: ${{matrix.os.version}} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - run: rustup toolchain install stable --profile minimal - uses: Swatinem/rust-cache@v2 - name: Build run: cargo build --package psu-packer --verbose --release - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: psu-packer-${{matrix.os.name}} path: target/release/${{matrix.os.executable}} \ No newline at end of file diff --git a/.github/workflows/build-suitcase.yml b/.github/workflows/build-suitcase.yml index ee55cf0..2d41116 100644 --- a/.github/workflows/build-suitcase.yml +++ b/.github/workflows/build-suitcase.yml @@ -27,15 +27,15 @@ jobs: runs-on: ${{matrix.os.version}} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - run: rustup toolchain install stable --profile minimal - uses: Swatinem/rust-cache@v2 - name: Build - run: cargo build --verbose --release + run: cargo build --package suitcase --verbose --release - name: Run tests run: cargo test --verbose - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: suitcase-${{matrix.os.name}} path: target/release/${{matrix.os.executable}} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8a04939 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Release Suitcase and PSU Packer + +on: + push: + tags: + - '*' + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build and Release ${{matrix.os.name}} + strategy: + matrix: + os: + - name: ubuntu + version: ubuntu-latest + suitcase: + executable: suitcase + asset_name: suitcase + psupacker: + executable: psu-packer + asset_name: psu-packer + - name: macos + version: macos-latest + suitcase: + executable: suitcase + asset_name: suitcase-macos + psupacker: + executable: psu-packer + asset_name: psu-packer-macos + - name: windows + version: windows-latest + suitcase: + executable: suitcase.exe + asset_name: suitcase.exe + psupacker: + executable: psu-packer.exe + asset_name: psu-packer.exe + runs-on: ${{matrix.os.version}} + + steps: + - uses: actions/checkout@v6 + - run: rustup toolchain install stable --profile minimal + - uses: Swatinem/rust-cache@v2 + - name: Build + run: cargo build --verbose --release + - name: Run tests + run: cargo test --verbose + - name: Upload Suitcase artifacts + uses: actions/upload-artifact@v6 + with: + name: suitcase-${{matrix.os.name}} + path: target/release/${{matrix.os.suitcase.executable}} + - name: Upload PSU Packer artifacts + uses: actions/upload-artifact@v6 + with: + name: psu-packer-${{matrix.os.name}} + path: target/release/${{matrix.os.psupacker.executable}} + - name: Upload Suitcase binaries to release + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + asset_name: ${{ matrix.os.suitcase.asset_name }} + file: target/release/${{ matrix.os.suitcase.executable }} + tag: ${{ github.ref }} + - name: Upload PSU Packer binaries to release + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + asset_name: ${{ matrix.os.psupacker.asset_name }} + file: target/release/${{ matrix.os.psupacker.executable }} + tag: ${{ github.ref }} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 58dbc4d..26d6d1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -176,6 +176,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + [[package]] name = "anyhow" version = "1.0.98" @@ -228,38 +278,6 @@ dependencies = [ "syn", ] -[[package]] -name = "argh" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ff18325c8a36b82f992e533ece1ec9f9a9db446bd1c14d4f936bac88fcd240" -dependencies = [ - "argh_derive", - "argh_shared", - "rust-fuzzy-search", -] - -[[package]] -name = "argh_derive" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb7b2b83a50d329d5d8ccc620f5c7064028828538bdf5646acd60dc1f767803" -dependencies = [ - "argh_shared", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "argh_shared" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a464143cc82dedcdc3928737445362466b7674b5db4e2eb8e869846d6d84f4f6" -dependencies = [ - "serde", -] - [[package]] name = "arrayref" version = "0.3.9" @@ -793,6 +811,46 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "clipboard-win" version = "5.4.0" @@ -818,6 +876,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "colored" version = "3.0.0" @@ -2006,6 +2070,12 @@ dependencies = [ "syn", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.12.1" @@ -2802,6 +2872,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "orbclient" version = "0.3.48" @@ -3033,9 +3109,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -3107,8 +3183,8 @@ dependencies = [ name = "psu-packer" version = "0.1.0" dependencies = [ - "argh", "chrono", + "clap", "colored", "ps2-filetypes", "serde", @@ -3151,9 +3227,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -3403,12 +3479,6 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f" -[[package]] -name = "rust-fuzzy-search" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a157657054ffe556d8858504af8a672a054a6e0bd9e8ee531059100c0fa11bb2" - [[package]] name = "rustc-hash" version = "1.1.0" @@ -3691,6 +3761,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.26.3" @@ -3746,9 +3822,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.101" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -4134,6 +4210,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "v_frame" version = "0.3.8" diff --git a/Cargo.toml b/Cargo.toml index cd73287..1410b91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ "crates/memcard", "crates/psu-packer", ] -default-members = ["crates/psu-packer"] +default-members = ["crates/psu-packer", "crates/suitcase"] [workspace.package] edition = "2021" diff --git a/crates/ps2-filetypes/src/common/psu.rs b/crates/ps2-filetypes/src/common/psu.rs index 812fa0e..e9c4d94 100644 --- a/crates/ps2-filetypes/src/common/psu.rs +++ b/crates/ps2-filetypes/src/common/psu.rs @@ -1,16 +1,138 @@ +use chrono::{DateTime, Local, NaiveDateTime}; +use std::fmt::{Display, Formatter, Result as FmtResult}; +use std::fs; use std::io::Cursor; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; pub const DIR_ID: u16 = 0x8427; pub const FILE_ID: u16 = 0x8497; pub const PAGE_SIZE: u32 = 0x400; - -#[derive(Default)] +#[derive(Default, Clone)] pub struct PSU { pub entries: Vec, } +impl PSU { + pub fn add_defaults(&mut self, name: &str, timestamp: NaiveDateTime) { + self.entries.push(PSUEntry { + id: DIR_ID, + size: 2, // the number of files in the psu, including . and .. + created: timestamp, + sector: 0, + modified: timestamp, + name: name.to_owned(), + kind: PSUEntryKind::Directory, + contents: None, + }); + self.entries.push(PSUEntry { + id: DIR_ID, + size: 0, + created: timestamp, + sector: 0, + modified: timestamp, + name: ".".to_string(), + kind: PSUEntryKind::Directory, + contents: None, + }); + self.entries.push(PSUEntry { + id: DIR_ID, + size: 0, + created: timestamp, + sector: 0, + modified: timestamp, + name: "..".to_string(), + kind: PSUEntryKind::Directory, + contents: None, + }); + } + + pub fn add_file(&mut self, file: &String) -> Result<(), String> { + fn convert_timestamp(time: SystemTime) -> NaiveDateTime { + let duration = time.duration_since(UNIX_EPOCH).unwrap(); + let local = + DateTime::from_timestamp(duration.as_secs() as i64, duration.subsec_nanos()) + .unwrap() + .with_timezone(&Local) + .naive_local(); + + local + } + + if !fs::exists(&file).unwrap() { + return Err("file doesn't exist".to_string()); + } + + let file_data = fs::read(file).unwrap(); + let metadata = fs::metadata(file).unwrap(); + let filename = Path::new(file) + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string(); + + self.entries.push(PSUEntry { + id: FILE_ID, + size: file_data.len() as u32, + created: convert_timestamp(metadata.created().unwrap()), + sector: 0, + modified: convert_timestamp(metadata.modified().unwrap()), + name: filename, + kind: PSUEntryKind::File, + contents: Some(file_data), + }); + + // ensure the root directory's size matches the number of entries including . and .. + self.entries[0].size += 1; + + Ok(()) + } + + pub fn remove_entry(&mut self, entry_name: &String) -> Result<(), String> { + if self + .entries + .iter() + .find(|e| e.name == *entry_name) + .is_none() + { + return Err("Entry does not exist".to_string()); + } + + self.entries + .retain(|entry| entry.name != entry_name.to_owned()); + + // ensure the root directory's size matches the number of entries including . and .. + self.entries[0].size -= 1; + + Ok(()) + } +} + +impl Display for PSU { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + let mut output = format!( + "{:12}| {:16}| {:9}| {:25}| {:25}", + "Type", "Name", "Size", "Created", "Modified" + ); + output = format!("{}\n{:-<99}", output, ""); + for entry in self.entries.clone() { + output = format!( + "{}\n{:12}| {:16}| {:9}| {:25}| {:25}", + output, + entry.kind.to_string(), + entry.name, + entry.size, + entry.created.format("%Y-%m-%d %H:%M:%S"), + entry.modified.format("%Y-%m-%d %H:%M:%S"), + ); + } + write!(f, "{}", output) + } +} + #[derive(Debug, Clone, Copy)] #[repr(u8)] pub enum PSUEntryKind { @@ -18,13 +140,22 @@ pub enum PSUEntryKind { File, } +impl Display for PSUEntryKind { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + PSUEntryKind::Directory => write!(f, "directory"), + PSUEntryKind::File => write!(f, "file"), + } + } +} + #[derive(Debug, Clone)] pub struct PSUEntry { pub id: u16, pub size: u32, - pub created: chrono::NaiveDateTime, + pub created: NaiveDateTime, pub sector: u16, - pub modified: chrono::NaiveDateTime, + pub modified: NaiveDateTime, pub name: String, pub kind: PSUEntryKind, pub contents: Option>, diff --git a/crates/psu-packer/Cargo.toml b/crates/psu-packer/Cargo.toml index c4e31af..008a4f6 100644 --- a/crates/psu-packer/Cargo.toml +++ b/crates/psu-packer/Cargo.toml @@ -7,15 +7,15 @@ version.workspace = true [dependencies] ps2-filetypes = { path = "../ps2-filetypes" } -toml = "0.9.5" -serde = { version = "1.0.219", features = ["derive"] } -argh = { version = "0.1.13" } chrono = "0.4.42" colored = "3.0.0" +clap = { version = "4.6.0", features = ["derive"] } +serde = { version = "1.0.219", features = ["derive"] } +toml = "0.9.5" [profile.release] opt-level = "z" lto = true codegen-units = 1 panic = "abort" -strip = true \ No newline at end of file +strip = true diff --git a/crates/psu-packer/src/args.rs b/crates/psu-packer/src/args.rs new file mode 100644 index 0000000..4e3e8f1 --- /dev/null +++ b/crates/psu-packer/src/args.rs @@ -0,0 +1,109 @@ +use chrono::NaiveDateTime; +use clap::{Args, Parser, Subcommand}; + +#[derive(Parser, Debug, Clone)] +#[command(version, about = "", arg_required_else_help(true))] +pub(crate) struct Cli { + #[command(subcommand)] + pub(crate) command: Commands, +} + +#[derive(Subcommand, Debug, Clone)] +pub(crate) enum Commands { + /// Create a .psu from scratch + Create(CreateArgs), + /// Read the content of a psu + Read(ReadArgs), + /// Provide a .toml file to automate the creation of multiple .psu files. + /// + /// For example you toml can contain one or many of the following block: + /// + /// [[psu]] + /// name="APP_FOOBAR" # the name of the folder the psu will unpack to + /// files=["./icon.sys", "./list.icn"] # you can use paths relative to the path of this toml + /// output="APP_FOOBARv2.psu" # optional, if omitted, the output file will be {name}.psu + /// timestamp=2024-10-10T10:30:00 # optional, if omitted, current time will be used + #[clap(verbatim_doc_comment)] + Automate(AutomateArgs), + /// Add one or many files to a .psu + Add(AddArgs), + /// Remove one or many entries from a .psu + Delete(DeleteArgs), +} + +#[derive(Args, Debug, Clone)] +pub(crate) struct CreateArgs { + #[arg(value_name = "FILES", help = "One or many files to add to the psu")] + pub(crate) files: Vec, + + #[arg( + short = 'n', + long, + value_name = "STRING", + help = "Name of the psu folder" + )] + pub(crate) name: String, + + #[arg( + short = 'o', + long, + value_name = "PATH", + help = "Output path, uses {name}.psu by default" + )] + pub(crate) output: Option, + + #[arg( + short = 't', + long, + value_name = "PATH", + help = "The timestamp to be applied to files in the psu" + )] + pub(crate) timestamp: Option, +} + +#[derive(Args, Debug, Clone)] +pub(crate) struct ReadArgs { + #[arg(value_name = "FILE", help = "Path of the psu to read")] + pub(crate) file: String, +} + +#[derive(Args, Debug, Clone)] +pub(crate) struct AutomateArgs { + #[arg(value_name = "FILE", help = "Path of the .toml to use")] + pub(crate) toml: String, + + #[arg( + short = 'o', + long, + help = "If this flag is provided, any existing .psu will be overwritten" + )] + pub(crate) overwrite: bool, +} + +#[derive(Args, Debug, Clone)] +pub(crate) struct AddArgs { + #[arg(long, value_name = "FILE", help = "Path of the psu to add entries to")] + pub(crate) psu: String, + + #[arg( + value_name = "FILES", + help = "One or many files to be added to the psu" + )] + pub(crate) files: Vec, +} + +#[derive(Args, Debug, Clone)] +pub(crate) struct DeleteArgs { + #[arg( + long, + value_name = "FILE", + help = "Path of the psu to remove entries from" + )] + pub(crate) psu: String, + + #[arg( + value_name = "ENTRIES", + help = "One or many entries to be removed from the psu" + )] + pub(crate) entries: Vec, +} diff --git a/crates/psu-packer/src/config.rs b/crates/psu-packer/src/config.rs new file mode 100644 index 0000000..bfd4944 --- /dev/null +++ b/crates/psu-packer/src/config.rs @@ -0,0 +1,20 @@ +use serde::Deserialize; +use toml::value::Datetime; + +#[derive(Debug, Deserialize)] +pub(crate) struct PsuConfig { + pub(crate) name: String, + pub(crate) files: Vec, + pub(crate) output: Option, + pub(crate) timestamp: Option, +} + +impl std::fmt::Display for PsuConfig { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "{}\n{:?}\n{:?}\n{:?}", + self.name, self.files, self.output, self.timestamp + ) + } +} diff --git a/crates/psu-packer/src/main.rs b/crates/psu-packer/src/main.rs index de14cd6..a488513 100644 --- a/crates/psu-packer/src/main.rs +++ b/crates/psu-packer/src/main.rs @@ -1,247 +1,186 @@ +mod args; +mod config; + +use crate::args::{Cli, Commands}; +use crate::config::PsuConfig; use chrono::{DateTime, Local, NaiveDateTime}; +use clap::Parser; use colored::Colorize; -use ps2_filetypes::{PSUEntry, PSUEntryKind, PSUWriter, DIR_ID, FILE_ID, PSU}; -use serde::Deserialize; -use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; -use argh::FromArgs; - -#[derive(Debug, FromArgs)] -#[argh(description = "Expects a folder with a psu.toml file that follows this format\n\t[config]\n\tname = \"Test PSU\"\t\t\t# Folder name on Memory Card\n\tinclude = [ \"BOOT.ELF\", \"icon.sys\" ]\t# using `exclude` will automatically include all files except the specified ones\n\ttimestamp = \"2024-10-10 10:30:00\"\t# Optional, but recommended\n")] -struct Args { - /// folder to package to psu - #[argh(positional)] - folder: String, - /// output path - #[argh(option, short = 'o')] - output: Option, -} - -#[derive(Debug, Deserialize)] -struct Config { - name: String, - #[serde(default, with = "date_format")] - timestamp: Option, - include: Option>, - exclude: Option>, -} +use ps2_filetypes::{PSUWriter, PSU}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use toml::value::Datetime; -mod date_format { - use chrono::NaiveDateTime; - use serde::{self, Deserialize, Deserializer}; - - pub fn deserialize<'de, D>(deserialize: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - let s: Option = Option::deserialize(deserialize)?; - if let Some(s) = s { - Ok(Some( - NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S") - .map_err(serde::de::Error::custom)?, - )) - } else { - Ok(None) +fn main() -> Result<(), Error> { + let cli = Cli::parse(); + + match &cli.command { + Commands::Create(args) => { + let output_filename = get_output_filename(&args.output, &args.name); + let psu = create_psu( + &args.name, + output_filename, + args.files.clone(), + args.timestamp, + Path::new("."), + ); + println!("{}", psu); } - } -} - -#[derive(Debug, Deserialize)] -struct ConfigFile { - config: Config, -} - -fn check_name(name: &str) -> bool { - for c in name.chars() { - if !matches!(c, 'a'..='z'|'A'..='Z'|'0'..='9'|'_'|'-'|' ') { - return false + Commands::Read(args) => { + let file = fs::read(&args.file)?; + let psu = PSU::new(file); + println!("Reading the content of {}\n", args.file); + println!("{}", psu); } - } - true -} - -fn main() -> Result<(), Error> { - let args: Args = argh::from_env(); - let folder = PathBuf::from(args.folder); - - let config_file = folder.join("psu.toml"); - - if config_file.exists() { - let str = std::fs::read_to_string(&config_file)?; - let config = toml::from_str::(&str) - .expect("Failed to parse config file") - .config; - - let output_file = args.output.unwrap_or(format!("{}.psu", config.name)); + Commands::Automate(args) => { + let toml_path = Path::new(&args.toml); + let raw_toml = fs::read_to_string(&args.toml)?; + let psu_table: HashMap> = + toml::from_str(&raw_toml).expect("Failed to parse config file"); + let psus: &[PsuConfig] = &psu_table["psu"]; + + psus.iter().for_each(|psu| { + let output_filename = get_output_filename(&psu.output, &psu.name); + if !args.overwrite && fs::exists(&output_filename).unwrap() { + println!( + "{} already exists. Use --overwrite if you want to overwrite all .psu.", + output_filename, + ); + return; + } + let psu = create_psu( + &psu.name, + output_filename, + psu.files.clone(), + convert_toml_datetime(psu.timestamp), + toml_path.parent().unwrap_or(Path::new(".")), + ); - if !check_name(&config.name) { - return Err(Error::NameError); + println!("{}\n\n{}\n", psu, "--------".dimmed()); + }); } - - if config.include.is_some() && config.exclude.is_some() { - return Err(Error::IncludeExcludeError); + Commands::Add(args) => { + let file = fs::read(&args.psu)?; + let mut psu = PSU::new(file.clone()); + + args.files.iter().for_each(|file| match psu.add_file(file) { + Ok(_) => println!("+ Adding {}", file.green()), + Err(_) => eprintln!("⚠ File {} doesn't exist. Skipping.", file.dimmed()), + }); + + fs::write( + &args.psu, + PSUWriter::new(psu.clone()) + .to_bytes() + .expect("Couldn't generate the PSU file"), + ) + .expect("Couldn't overwrite the PSU"); + + println!("\n{}", psu); } + Commands::Delete(args) => { + let file = fs::read(&args.psu)?; + let mut psu = PSU::new(file.clone()); - let mut psu = PSU::default(); - - let files = if let Some(include) = config.include { - include + args.entries .iter() - .filter_map(|file| { - if file.contains(|c| matches!(c, '\\' | '/')) { - eprintln!( - "{} {} {}", - "File".dimmed(), - file.dimmed(), - "exists in subfolder, skipping".dimmed() - ); - None - } else if !folder.join(file).exists() { - eprintln!( - "{} {} {}", - "File".dimmed(), - file.dimmed(), - "does not exist, skipping".dimmed() - ); - None - } else { - Some(folder.join(file)) - } - }) - .collect::>() - } else if let Some(exclude) = config.exclude { - std::fs::read_dir(folder)? - .into_iter() - .flatten() - .filter_map(|d| { - if !exclude.contains(&d.file_name().to_str().unwrap().to_string()) { - Some(d.path()) - } else { - None - } - }) - .collect::>() - } else { - // Include all - std::fs::read_dir(folder)? - .into_iter() - .flatten() - .map(|d| d.path()) - .collect::>() - }; - let files = filter_files(&files); - add_psu_defaults( - &mut psu, - &config.name, - files.len(), - config.timestamp.unwrap_or_default(), - ); - add_files_to_psu(&mut psu, &files)?; - std::fs::write(&output_file, PSUWriter::new(psu).to_bytes()?)?; - println!("Wrote {}! {}", output_file.green(), "".clear()); - } else { - eprintln!("{}", "Failed to find psu.toml".red()); + .for_each(|entry| match psu.remove_entry(entry) { + Ok(_) => println!("+ Removing {}", entry.green()), + Err(_) => eprintln!("⚠ Entry {} doesn't exist. Skipping.", entry.dimmed()), + }); + + fs::write( + &args.psu, + PSUWriter::new(psu.clone()) + .to_bytes() + .expect("Couldn't generate the PSU file"), + ) + .expect("Couldn't overwrite the PSU"); + + println!("\n{}", psu); + } } Ok(()) } -fn filter_files(files: &[PathBuf]) -> Vec { - files - .iter() - .filter_map(|f| { - if !f.is_file() { - println!( - "{} {}", - f.display().to_string().dimmed(), - "is not a file, skipping".dimmed() - ); - None - } else { - Some(f.to_owned()) - } - }) - .collect() +fn get_output_filename(output: &Option, name: &String) -> String { + output.to_owned().unwrap_or(format!("{}.psu", name)) } -fn add_psu_defaults(psu: &mut PSU, name: &str, file_count: usize, timestamp: NaiveDateTime) { - psu.entries.push(PSUEntry { - id: DIR_ID, - size: file_count as u32 + 2, // +2 to include . and .. - created: timestamp, - sector: 0, - modified: timestamp, - name: name.to_owned(), - kind: PSUEntryKind::Directory, - contents: None, - }); - psu.entries.push(PSUEntry { - id: DIR_ID, - size: 0, - created: timestamp, - sector: 0, - modified: timestamp, - name: ".".to_string(), - kind: PSUEntryKind::Directory, - contents: None, - }); - psu.entries.push(PSUEntry { - id: DIR_ID, - size: 0, - created: timestamp, - sector: 0, - modified: timestamp, - name: "..".to_string(), - kind: PSUEntryKind::Directory, - contents: None, - }); +fn convert_toml_datetime(time: Option) -> Option { + match time { + None => None, + Some(_) => { + let datetime_str = format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", + time.unwrap().date.unwrap().year, + time.unwrap().date.unwrap().month, + time.unwrap().date.unwrap().day, + time.unwrap().time.unwrap().hour, + time.unwrap().time.unwrap().minute, + time.unwrap().time.unwrap().second, + ); + Some( + DateTime::::from_naive_utc_and_offset( + (&datetime_str).parse().unwrap(), + *Local::now().offset(), + ) + .naive_local(), + ) + } + } } -fn add_files_to_psu(psu: &mut PSU, files: &[PathBuf]) -> Result<(), Error> { - for file in files { - let name = file.file_name().unwrap().to_str().unwrap(); - - let f = std::fs::read(file)?; - let stat = std::fs::metadata(file)?; - - println!("+ {} {}", "Adding", name.green()); +fn create_psu( + name: &String, + output: String, + files: Vec, + timestamp: Option, + path_prefix: &Path, +) -> PSU { + println!("Preparing to create {}", name); + let mut psu = PSU::default(); - psu.entries.push(PSUEntry { - id: FILE_ID, - size: f.len() as u32, - created: convert_timestamp(stat.created()?), - sector: 0, - modified: convert_timestamp(stat.modified()?), - name: name.to_owned(), - kind: PSUEntryKind::File, - contents: Some(f), + let files = files + .iter() + .filter_map(|file| { + let actual_file_path = path_prefix.join(file).to_str().unwrap().to_string(); + if fs::exists(&actual_file_path).unwrap() { + return Some(actual_file_path); + } + eprintln!("⚠ File {} doesn't exist. Skipping.", file.dimmed()); + None }) - } + .collect::>(); - Ok(()) -} + psu.add_defaults(name, timestamp.unwrap_or(Local::now().naive_local())); + + files.iter().for_each(|file| { + psu.add_file(file); + println!("+ Adding {}", file.green()); + }); -fn convert_timestamp(time: SystemTime) -> NaiveDateTime { - let duration = time.duration_since(UNIX_EPOCH).unwrap(); - let local = DateTime::from_timestamp(duration.as_secs() as i64, duration.subsec_nanos()) - .unwrap() - .with_timezone(&Local) - .naive_local(); + fs::write( + &output, + PSUWriter::new(psu.clone()) + .to_bytes() + .expect("Couldn't generate the PSU file"), + ) + .expect("Couldn't write the .psu file"); + println!("Wrote {}!\n", output.green()); - local + psu } enum Error { - NameError, IOError(std::io::Error), - IncludeExcludeError, } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { - Error::NameError => write!(f, "Name must match [a-zA-Z0-9._-\\s]+"), - Error::IncludeExcludeError => write!(f, "Exclude cannot be used in include mode"), Error::IOError(err) => write!(f, "{err:?}"), } }