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<()> {
fsmon::config::Config::init_dirs()?;
if args.install_systemd {
cmd_install_systemd()?;
}
Ok(())
}
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")?;
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(())
}
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);
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);
let sections: Vec<&str> = content.split("\n[")
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
for section in §ions {
let lines: Vec<&str> = section.lines().collect();
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"
);
}
}