a2fuse 0.1.3

Mount and maintain Apple II ProDOS disk images
Documentation
use std::io::Write;
use std::path::Path;

use clap::Parser;
use tracing_subscriber::EnvFilter;

use a2fuse::cli::{Cli, Command, MountArgs};
use a2fuse::error::{A2FuseError, Result};
use a2fuse::prodos::{
    AccessFlags, CreateOptions, Image, MetadataMode, Node, ProdosTimestamp, PutOptions, Volume,
};

#[cfg(feature = "macfuse")]
use std::sync::mpsc;

fn main() {
    if let Err(error) = run() {
        eprintln!("a2fuse: {error}");
        std::process::exit(1);
    }
}

fn run() -> Result<()> {
    let cli = Cli::parse();
    initialise_tracing(cli.debug);

    match cli.command {
        Some(Command::Mount(args)) => mount(args),
        Some(Command::Create(args)) => {
            let image = Image::create(&CreateOptions {
                name: args.name,
                blocks: args.blocks,
            })?;
            if args.force {
                image.save(&args.image)
            } else {
                image.save_new(&args.image)
            }
        }
        Some(Command::Ls(args)) => list(&args.image, args.path.as_deref(), args.long),
        Some(Command::Catalog(args)) => catalog(&args.image, args.path.as_deref()),
        Some(Command::Cat(args)) => cat(&args.image, &args.path),
        Some(Command::Put(args)) => {
            let data = std::fs::read(&args.source).map_err(|source| A2FuseError::ReadHostFile {
                path: args.source.clone(),
                source,
            })?;
            let destination = match args.destination {
                Some(destination) => destination,
                None => args
                    .source
                    .file_name()
                    .and_then(|name| name.to_str())
                    .ok_or_else(|| A2FuseError::InvalidName {
                        name: args.source.display().to_string(),
                        reason: "the host filename is not valid UTF-8".to_owned(),
                    })?
                    .to_owned(),
            };
            let mut image = Image::open(&args.image)?;
            let mut options = PutOptions::new(destination);
            options.file_type = args.file_type;
            options.aux_type = args.aux_type;
            options.access = AccessFlags(0xe3);
            image.put_file(&data, &options)?;
            image.save(&args.image)
        }
        None => Err(A2FuseError::Fuse(
            "a subcommand is required; use `a2fuse mount IMAGE MOUNTPOINT`".to_owned(),
        )),
    }
}

fn initialise_tracing(debug: bool) {
    let default_filter = if debug { "debug" } else { "info" };
    tracing_subscriber::fmt()
        .with_env_filter(
            EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter)),
        )
        .with_target(false)
        .init();
}

fn mount(args: MountArgs) -> Result<()> {
    if args.readonly {
        tracing::debug!("read-only mode explicitly requested");
    }
    let volume = Volume::open(&args.image)?;
    tracing::info!(
        image = %args.image.display(),
        volume = %volume.header.name,
        files = volume.header.file_count,
        "opened ProDOS image"
    );
    #[cfg(feature = "macfuse")]
    {
        let session = a2fuse::fuse::spawn_mount(volume, &args.mountpoint, args.metadata)?;
        wait_for_shutdown_signal()?;
        session
            .umount_and_join()
            .map_err(|error| A2FuseError::Fuse(error.to_string()))
    }

    #[cfg(not(feature = "macfuse"))]
    {
        let _ = (volume, args);
        Err(A2FuseError::FuseDisabled)
    }
}

#[cfg(feature = "macfuse")]
fn wait_for_shutdown_signal() -> Result<()> {
    let (sender, receiver) = mpsc::channel();
    ctrlc::set_handler(move || {
        let _ = sender.send(());
    })
    .map_err(|error| A2FuseError::Fuse(error.to_string()))?;
    receiver
        .recv()
        .map_err(|error| A2FuseError::Fuse(error.to_string()))?;
    Ok(())
}

fn list(image: &Path, path: Option<&str>, long: bool) -> Result<()> {
    let volume = Volume::open(image)?;
    let nodes = nodes_at_path(&volume, path)?;
    for node in nodes {
        print_unix_node(node, long);
    }
    Ok(())
}

fn catalog(image: &Path, path: Option<&str>) -> Result<()> {
    let volume = Volume::open(image)?;
    let nodes = nodes_at_path(&volume, path)?;
    let catalogue_path = path
        .filter(|path| !path.is_empty() && *path != "/")
        .map_or_else(
            || format!("/{}", volume.header.name),
            |path| format!("/{}/{}", volume.header.name, path.trim_matches('/')),
        );

    println!("{catalogue_path}");
    println!();
    println!(" NAME            TYPE BLOCKS  MODIFIED         CREATED          ENDFILE  SUBTYPE");
    for node in nodes {
        print_catalogue_node(node);
    }
    println!();
    println!("{} FILE(S)", nodes.len());
    Ok(())
}

fn nodes_at_path<'a>(volume: &'a Volume, path: Option<&str>) -> Result<&'a [Node]> {
    match path {
        None | Some("") | Some("/") => Ok(&volume.root),
        Some(path) => {
            let node = volume.find(path, MetadataMode::Xattr)?;
            if node.is_directory() {
                Ok(&node.children)
            } else {
                Ok(std::slice::from_ref(node))
            }
        }
    }
}

fn print_unix_node(node: &Node, long: bool) {
    if long {
        let permissions = unix_permissions(node);
        let modified = format_unix_timestamp(node.entry.modification);
        let links = if node.is_directory() { 2 } else { 1 };
        println!(
            "{permissions} {links:>2} {:<6} {:<6} {:>10} {modified} {}",
            "prodos",
            "prodos",
            node.effective_eof(),
            node.entry.name
        );
    } else {
        println!("{}", node.entry.name);
    }
}

fn print_catalogue_node(node: &Node) {
    println!(
        " {:<15} {:>4} {:>6}  {:<16} {:<16} {:>8}  ${:04X}",
        node.entry.name,
        prodos_type_name(node),
        node.effective_blocks_used(),
        format_catalogue_timestamp(node.entry.modification),
        format_catalogue_timestamp(node.entry.creation),
        node.effective_eof(),
        node.entry.aux_type
    );
}

fn unix_permissions(node: &Node) -> String {
    let kind = if node.is_directory() { 'd' } else { '-' };
    let read = if node.entry.access.readable() {
        'r'
    } else {
        '-'
    };
    let write = if node.entry.access.writable() {
        'w'
    } else {
        '-'
    };
    let execute = if node.is_directory() { 'x' } else { '-' };
    format!("{kind}{read}{write}{execute}{read}-{execute}{read}-{execute}")
}

fn format_unix_timestamp(timestamp: Option<ProdosTimestamp>) -> String {
    timestamp.map_or_else(
        || "---- -- -- --:--".to_owned(),
        |timestamp| {
            format!(
                "{:04}-{:02}-{:02} {:02}:{:02}",
                timestamp.year, timestamp.month, timestamp.day, timestamp.hour, timestamp.minute
            )
        },
    )
}

fn format_catalogue_timestamp(timestamp: Option<ProdosTimestamp>) -> String {
    const MONTHS: [&str; 12] = [
        "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC",
    ];
    timestamp.map_or_else(
        || "<NO DATE>".to_owned(),
        |timestamp| {
            let month = MONTHS
                .get(usize::from(timestamp.month.saturating_sub(1)))
                .copied()
                .unwrap_or("???");
            format!(
                "{:02}-{month}-{:02} {:02}:{:02}",
                timestamp.day,
                timestamp.year % 100,
                timestamp.hour,
                timestamp.minute
            )
        },
    )
}

fn prodos_type_name(node: &Node) -> String {
    if node.is_directory() {
        return "DIR".to_owned();
    }
    match node.entry.file_type {
        0x00 => "NON".to_owned(),
        0x04 => "TXT".to_owned(),
        0x06 => "BIN".to_owned(),
        0xfc => "BAS".to_owned(),
        0xfd => "VAR".to_owned(),
        0xfe => "REL".to_owned(),
        0xff => "SYS".to_owned(),
        file_type => format!("${file_type:02X}"),
    }
}

fn cat(image: &Path, path: &str) -> Result<()> {
    let volume = Volume::open(image)?;
    let node = volume.find(path, MetadataMode::Xattr)?;
    let data = volume.read_entry(&node.entry)?;
    std::io::stdout()
        .lock()
        .write_all(&data)
        .map_err(A2FuseError::Output)
}