branchfs 0.1.1

FUSE filesystem with atomic branching for speculative execution
Documentation
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process;

use anyhow::Result;
use clap::{Parser, Subcommand};

use branchfs::daemon::{self, Request, Response};

#[derive(Parser)]
#[command(name = "branchfs")]
#[command(about = "FUSE filesystem with atomic branching")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Mount the filesystem (always starts on main branch)
    Mount {
        /// Base directory to branch from (required on first mount)
        #[arg(long)]
        base: Option<PathBuf>,

        /// Storage directory for branch data
        #[arg(long, default_value = "/var/lib/branchfs")]
        storage: PathBuf,

        /// Enable FUSE passthrough for near-native I/O performance (requires root)
        #[arg(long)]
        passthrough: bool,

        /// Maximum storage size for all branch deltas (e.g. "500M", "2G", bytes)
        #[arg(long, value_parser = parse_size)]
        max_storage: Option<u64>,

        /// Mount point
        mountpoint: PathBuf,
    },

    /// Create a new branch and switch to it
    Create {
        /// Branch name
        name: String,

        /// Mount point to switch to the new branch
        mountpoint: PathBuf,

        /// Parent branch name
        #[arg(long, short, default_value = "main")]
        parent: String,

        /// Storage directory
        #[arg(long, default_value = "/var/lib/branchfs")]
        storage: PathBuf,
    },

    /// Commit branch to base
    Commit {
        /// Mount point of the branch to commit
        mountpoint: PathBuf,

        /// Storage directory
        #[arg(long, default_value = "/var/lib/branchfs")]
        storage: PathBuf,
    },

    /// Abort branch
    Abort {
        /// Mount point of the branch to abort
        mountpoint: PathBuf,

        /// Storage directory
        #[arg(long, default_value = "/var/lib/branchfs")]
        storage: PathBuf,
    },

    /// List branches
    List {
        /// Storage directory
        #[arg(long, default_value = "/var/lib/branchfs")]
        storage: PathBuf,
    },

    /// Unmount a branch (daemon auto-exits when last mount is removed)
    Unmount {
        /// Mount point to unmount
        mountpoint: PathBuf,

        /// Storage directory
        #[arg(long, default_value = "/var/lib/branchfs")]
        storage: PathBuf,
    },

    /// Internal: run the daemon (used by `mount` to spawn the daemon process)
    #[command(hide = true)]
    RunDaemon {
        #[arg(long)]
        base: PathBuf,

        #[arg(long)]
        storage: PathBuf,

        #[arg(long)]
        max_storage: Option<u64>,
    },
}

/// Parse a human-readable size string like "500M", "2G", "1024", "1T".
fn parse_size(s: &str) -> std::result::Result<u64, String> {
    let s = s.trim();
    if s.is_empty() {
        return Err("empty size string".to_string());
    }
    let (num_str, multiplier) = match s.as_bytes().last() {
        Some(b'K' | b'k') => (&s[..s.len() - 1], 1024u64),
        Some(b'M' | b'm') => (&s[..s.len() - 1], 1024 * 1024),
        Some(b'G' | b'g') => (&s[..s.len() - 1], 1024 * 1024 * 1024),
        Some(b'T' | b't') => (&s[..s.len() - 1], 1024 * 1024 * 1024 * 1024),
        _ => (s, 1),
    };
    let num: u64 = num_str
        .parse()
        .map_err(|_| format!("invalid size: {}", s))?;
    num.checked_mul(multiplier)
        .ok_or_else(|| format!("size overflow: {}", s))
}

fn get_socket_path(storage: &Path) -> PathBuf {
    storage.join("daemon.sock")
}

fn send_request(storage: &Path, request: &Request) -> Result<Response> {
    let socket_path = get_socket_path(storage);
    daemon::send_request(&socket_path, request)
        .map_err(|e| anyhow::anyhow!("Failed to communicate with daemon: {}", e))
}

/// Get the current mount branch from the daemon.
fn get_mount_branch(storage: &Path, mountpoint: &Path) -> Result<String> {
    let resp = send_request(
        storage,
        &Request::GetMountBranch {
            mountpoint: mountpoint.to_string_lossy().to_string(),
        },
    )?;
    if !resp.ok {
        anyhow::bail!("{}", resp.error.unwrap_or_else(|| "unknown error".into()));
    }
    resp.data
        .and_then(|d| d.as_str().map(|s| s.to_string()))
        .ok_or_else(|| anyhow::anyhow!("daemon returned no branch info"))
}

fn main() -> Result<()> {
    env_logger::init();
    let cli = Cli::parse();

    match cli.command {
        Commands::Mount {
            base,
            storage,
            passthrough,
            max_storage,
            mountpoint,
        } => {
            if passthrough && nix::unistd::geteuid().as_raw() != 0 {
                eprintln!("Error: --passthrough requires root (CAP_SYS_ADMIN)");
                process::exit(1);
            }

            std::fs::create_dir_all(&storage)?;
            let storage = storage.canonicalize()?;

            // Canonicalize base if provided
            let base = base.map(|b| b.canonicalize()).transpose()?;

            // Ensure daemon is running (auto-start if needed)
            daemon::ensure_daemon(base.as_deref(), &storage, max_storage)
                .map_err(|e| anyhow::anyhow!("{}", e))?;

            // Create mountpoint
            std::fs::create_dir_all(&mountpoint)?;
            let mountpoint = mountpoint.canonicalize()?;

            // Send mount request (always mounts main branch)
            let response = send_request(
                &storage,
                &Request::Mount {
                    branch: "main".to_string(),
                    mountpoint: mountpoint.to_string_lossy().to_string(),
                    passthrough,
                },
            )?;

            if response.ok {
                println!("Mounted at {:?}", mountpoint);
            } else {
                eprintln!("Error: {}", response.error.unwrap_or_default());
                process::exit(1);
            }
        }

        Commands::Create {
            name,
            mountpoint,
            parent,
            storage,
        } => {
            let storage = storage.canonicalize()?;
            let mountpoint = mountpoint.canonicalize()?;

            let response = send_request(
                &storage,
                &Request::Create {
                    name: name.clone(),
                    parent: parent.clone(),
                },
            )?;

            if response.ok {
                // Switch to the new branch via ctl file
                // (FUSE handler updates manager.mount_branches internally)
                let ctl_path = mountpoint.join(".branchfs_ctl");

                let mut file = std::fs::OpenOptions::new()
                    .write(true)
                    .open(&ctl_path)
                    .map_err(|e| {
                        anyhow::anyhow!(
                            "Failed to open control file (is {} mounted?): {}",
                            mountpoint.display(),
                            e
                        )
                    })?;

                file.write_all(format!("switch:{}", name).as_bytes())
                    .map_err(|e| anyhow::anyhow!("Failed to switch to branch: {}", e))?;

                println!(
                    "Created and switched to branch '{}' (parent: '{}')",
                    name, parent
                );
            } else {
                eprintln!("Error: {}", response.error.unwrap_or_default());
                process::exit(1);
            }
        }

        Commands::Commit {
            mountpoint,
            storage,
        } => {
            let mountpoint = mountpoint.canonicalize()?;
            let storage = storage.canonicalize()?;

            let branch = get_mount_branch(&storage, &mountpoint)?;
            if branch == "main" {
                anyhow::bail!("Cannot commit main branch");
            }

            // Write to the per-branch ctl file
            // (FUSE handler does commit + switch_mount_branch internally)
            let ctl_path = mountpoint
                .join(format!("@{}", branch))
                .join(".branchfs_ctl");
            let mut file = std::fs::OpenOptions::new()
                .write(true)
                .open(&ctl_path)
                .map_err(|e| anyhow::anyhow!("Failed to open branch control file: {}", e))?;

            file.write_all(b"commit")
                .map_err(|e| anyhow::anyhow!("Commit failed: {}", e))?;

            println!("Committed branch at {:?}", mountpoint);
        }

        Commands::Abort {
            mountpoint,
            storage,
        } => {
            let mountpoint = mountpoint.canonicalize()?;
            let storage = storage.canonicalize()?;

            let branch = get_mount_branch(&storage, &mountpoint)?;
            if branch == "main" {
                anyhow::bail!("Cannot abort main branch");
            }

            // Write to the per-branch ctl file
            // (FUSE handler does abort + switch_mount_branch internally)
            let ctl_path = mountpoint
                .join(format!("@{}", branch))
                .join(".branchfs_ctl");
            let mut file = std::fs::OpenOptions::new()
                .write(true)
                .open(&ctl_path)
                .map_err(|e| anyhow::anyhow!("Failed to open branch control file: {}", e))?;

            file.write_all(b"abort")
                .map_err(|e| anyhow::anyhow!("Abort failed: {}", e))?;

            println!("Aborted branch at {:?}", mountpoint);
        }

        Commands::List { storage } => {
            let storage = storage.canonicalize()?;

            let response = send_request(&storage, &Request::List)?;

            if response.ok {
                println!("{:<20} {:<20}", "BRANCH", "PARENT");
                println!("{:<20} {:<20}", "------", "------");

                if let Some(data) = response.data {
                    if let Some(branches) = data.as_array() {
                        for branch in branches {
                            let name = branch["name"].as_str().unwrap_or("-");
                            let parent = branch["parent"].as_str().unwrap_or("-");
                            println!("{:<20} {:<20}", name, parent);
                        }
                    }
                }
            } else {
                eprintln!("Error: {}", response.error.unwrap_or_default());
                process::exit(1);
            }
        }

        Commands::Unmount {
            mountpoint,
            storage,
        } => {
            let storage = storage.canonicalize()?;
            let mountpoint = mountpoint.canonicalize()?;

            let response = send_request(
                &storage,
                &Request::Unmount {
                    mountpoint: mountpoint.to_string_lossy().to_string(),
                },
            )?;

            if response.ok {
                println!("Unmounted {:?}", mountpoint);
            } else {
                eprintln!("Error: {}", response.error.unwrap_or_default());
                process::exit(1);
            }
        }
        Commands::RunDaemon {
            base,
            storage,
            max_storage,
        } => {
            std::fs::create_dir_all(&storage)?;
            let storage = storage.canonicalize()?;
            let base = base.canonicalize()?;

            let d = daemon::Daemon::new(base.clone(), storage, base, max_storage)
                .map_err(|e| anyhow::anyhow!("Failed to create daemon: {}", e))?;
            d.run()
                .map_err(|e| anyhow::anyhow!("Daemon error: {}", e))?;
        }
    }

    Ok(())
}