fsmon 0.3.3

Lightweight High-Performance File System Change Tracking Tool
Documentation
use anyhow::{Context, Result, bail};
use std::fs;
use std::path::{Path, PathBuf};
use std::process;

use crate::InitArgs;

pub fn cmd_init(args: InitArgs) -> Result<()> {
    // 1. Always initialize directories
    fsmon::config::Config::init_dirs()?;

    // 2. If --install-systemd, generate and install the service unit
    if args.install_systemd {
        cmd_install_systemd()?;
    }

    Ok(())
}

/// Generate and install systemd service unit file at /etc/systemd/system/fsmon.service.
pub fn cmd_install_systemd() -> Result<()> {
    let exe = std::env::current_exe()
        .context("Failed to get current executable path")?
        .canonicalize()
        .context("Failed to resolve canonical executable path")?;

    // Check that systemd is available on this system
    let unit_dir = PathBuf::from("/etc/systemd/system");
    if !unit_dir.exists() {
        bail!(
            "systemd does not appear to be available on this system.\n\
             Directory '{}' not found.\n\
             Tip: this command is for systems using systemd as the init system.\n\
             For other environments, use external supervisors like s6 or runit.",
            unit_dir.display()
        );
    }

    let service_content = generate_service_content(&exe);
    let unit_path = unit_dir.join("fsmon.service");

    fs::write(&unit_path, &service_content)
        .with_context(|| {
            format!(
                "Failed to write {}. Make sure you have root permissions (run with sudo).",
                unit_path.display()
            )
        })?;

    println!("Created systemd service file:");
    println!("  {}", unit_path.display());
    println!();
    println!("Run the following commands to enable and start the service:");
    println!("  sudo systemctl daemon-reload");
    println!("  sudo systemctl enable --now fsmon");
    println!();
    println!("View logs with:");
    println!("  journalctl -u fsmon -f");

    Ok(())
}

/// Generate the content of the systemd service unit file.
///
/// Uses `Type=simple` (not `Type=notify`) because fsmon starts in milliseconds
/// and does not need to signal readiness via sd_notify. This avoids pulling in
/// the libsystemd C library dependency.
fn generate_service_content(exe: &Path) -> String {
    format!(
        "[Unit]\n\
         Description=fsmon filesystem monitor\n\
         Documentation=https://github.com/lenitain/fsmon\n\
         After=local-fs.target\n\
         \n\
         [Service]\n\
         Type=simple\n\
         ExecStart={} daemon\n\
         ExecReload=/bin/kill -HUP $MAINPID\n\
         Restart=always\n\
         RestartSec=2\n\
         StartLimitBurst=3\n\
         StartLimitIntervalSec=60\n\
         OOMScoreAdjust=-1000\n\
         LimitNOFILE=65536\n\
         TasksMax=100\n\
         StandardOutput=journal\n\
         StandardError=journal\n\
         \n\
         [Install]\n\
         WantedBy=multi-user.target\n",
        exe.display()
    )
}

pub fn cmd_cd() -> Result<()> {
    let mut cfg = fsmon::config::Config::load()?;
    cfg.resolve_paths()?;
    let dir = cfg.logging.path.clone();

    if !dir.exists() {
        eprintln!("Log directory does not exist yet. Run 'fsmon init' first.");
        process::exit(1);
    }

    let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());

    eprintln!("Entering fsmon log directory (type 'exit' to return)...");
    eprintln!("  {}", dir.display());
    eprintln!();

    let status = std::process::Command::new(&shell)
        .current_dir(&dir)
        .status()
        .with_context(|| format!("Failed to start shell: {}", shell))?;

    if !status.success() {
        let code = status.code().unwrap_or(-1);
        process::exit(code);
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    #[test]
    fn test_generate_service_content_contains_key_directives() {
        let exe = PathBuf::from("/usr/local/bin/fsmon");
        let content = generate_service_content(&exe);

        assert!(content.contains("Description=fsmon filesystem monitor"));
        assert!(content.contains("/usr/local/bin/fsmon daemon"));
        assert!(content.contains("Type=simple"));
        assert!(content.contains("Restart=always"));
        assert!(content.contains("RestartSec=2"));
        assert!(content.contains("OOMScoreAdjust=-1000"));
        assert!(content.contains("LimitNOFILE=65536"));
        assert!(content.contains("TasksMax=100"));
        assert!(content.contains("ExecReload=/bin/kill -HUP $MAINPID"));
        assert!(content.contains("StandardOutput=journal"));
        assert!(content.contains("StandardError=journal"));
        assert!(content.contains("WantedBy=multi-user.target"));
    }

    #[test]
    fn test_generate_service_content_exe_path_included() {
        let exe = PathBuf::from("/home/user/.cargo/bin/fsmon");
        let content = generate_service_content(&exe);

        assert!(
            content.contains("/home/user/.cargo/bin/fsmon daemon"),
            "ExecStart should contain the full canonical path to the binary"
        );
    }

    #[test]
    fn test_generate_service_content_sections() {
        let exe = PathBuf::from("/usr/bin/fsmon");
        let content = generate_service_content(&exe);

        // Must have all three systemd sections
        assert!(content.starts_with("[Unit]"), "Must start with [Unit] section");
        assert!(
            content.contains("\n[Service]"),
            "Must contain [Service] section"
        );
        assert!(
            content.contains("\n[Install]"),
            "Must contain [Install] section"
        );
    }

    #[test]
    fn test_generate_service_content_no_blank_sections() {
        let exe = PathBuf::from("/usr/local/bin/fsmon");
        let content = generate_service_content(&exe);

        // Each section must have content between it and the next section
        let sections: Vec<&str> = content.split("\n[")
            .map(|s| s.trim())
            .filter(|s| !s.is_empty())
            .collect();

        for section in &sections {
            let lines: Vec<&str> = section.lines().collect();
            // First line is the section name without the bracket
            // There should be at least one key=value line
            assert!(
                lines.len() > 1 || section.contains("WantedBy"),
                "Section {:?} should have content",
                section.lines().next().unwrap_or("")
            );
        }
    }

    #[test]
    fn test_generate_service_content_no_type_notify() {
        let exe = PathBuf::from("/usr/local/bin/fsmon");
        let content = generate_service_content(&exe);

        assert!(
            !content.contains("Type=notify"),
            "fsmon should use Type=simple, not Type=notify"
        );
    }

    #[test]
    fn test_generate_service_content_no_infinity_timeout() {
        let exe = PathBuf::from("/usr/local/bin/fsmon");
        let content = generate_service_content(&exe);

        assert!(
            !content.contains("TimeoutStartSec=infinity"),
            "fsmon has instant startup, no need for infinity timeout"
        );
        assert!(
            !content.contains("TimeoutStopSec=infinity"),
            "fsmon has instant shutdown, no need for infinity timeout"
        );
    }
}