mps-rs 1.6.2

MPS — plain-text personal productivity CLI (Rust)
Documentation
use crate::config::Config;
use anyhow::{bail, Context, Result};
use colored::Colorize;
use std::io::Write;
use std::path::PathBuf;

const SERVICE_NAME: &str = "mps-notify.service";
const TIMER_NAME: &str = "mps-notify.timer";

const SERVICE_TEMPLATE: &str = "\
[Unit]
Description=mps notification tick

[Service]
Type=oneshot
ExecStart={mps_binary} daemon run
StandardOutput=journal
StandardError=journal
Environment=DISPLAY=:0
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{uid}/bus
";

const TIMER_TEMPLATE: &str = "\
[Unit]
Description=mps notification check (minutely)

[Timer]
OnCalendar=minutely
AccuracySec=30s
Persistent=true

[Install]
WantedBy=timers.target
";

pub fn run(config: &Config, subcommand: &str) -> Result<()> {
    match subcommand.to_lowercase().as_str() {
        "install" => install(config),
        "remove" => remove(),
        "status" => status(),
        "run" => super::notify::run(config, false, None, false),
        other => bail!(
            "Unknown daemon subcommand '{}'. Use: install, remove, status, run",
            other
        ),
    }
}

fn systemd_user_dir() -> Result<PathBuf> {
    let dir = dirs::home_dir()
        .context("cannot determine home directory")?
        .join(".config/systemd/user");
    std::fs::create_dir_all(&dir).with_context(|| format!("cannot create {}", dir.display()))?;
    Ok(dir)
}

fn uid() -> String {
    // Read from /proc/self/status — no new dependency needed.
    if let Ok(s) = std::fs::read_to_string("/proc/self/status") {
        for line in s.lines() {
            if let Some(rest) = line.strip_prefix("Uid:") {
                if let Some(uid_str) = rest.split_whitespace().next() {
                    return uid_str.to_string();
                }
            }
        }
    }
    // Fall back to parsing the UID env var (set by login shells on most distros).
    std::env::var("UID").unwrap_or_else(|_| "1000".to_string())
}

fn install(config: &Config) -> Result<()> {
    let binary = std::env::current_exe().context("cannot determine current binary path")?;
    let binary_str = binary.display().to_string();

    if binary_str.contains("/target/debug/") || binary_str.contains("/target/release/") {
        println!(
            "  {} binary is at '{}' (a cargo build path).",
            "warn:".yellow(),
            binary_str
        );
        println!("         For a permanent install, use: cargo install mps-rs");
        println!("         then re-run: mps daemon install");
        println!("         Proceeding anyway...");
    }

    let uid_str = uid();
    let unit_dir = systemd_user_dir()?;

    let service_content = SERVICE_TEMPLATE
        .replace("{mps_binary}", &binary_str)
        .replace("{uid}", &uid_str);
    let timer_content = TIMER_TEMPLATE.to_string();

    let service_path = unit_dir.join(SERVICE_NAME);
    let timer_path = unit_dir.join(TIMER_NAME);

    std::fs::write(&service_path, &service_content)
        .with_context(|| format!("cannot write {}", service_path.display()))?;
    std::fs::write(&timer_path, &timer_content)
        .with_context(|| format!("cannot write {}", timer_path.display()))?;

    println!("  {} {}", "wrote".green(), service_path.display());
    println!("  {} {}", "wrote".green(), timer_path.display());

    // Ensure .mps.local is gitignored (it must never be committed).
    ensure_gitignored(&config.storage_dir, ".mps.local")?;

    sh("systemctl --user daemon-reload")?;
    sh("systemctl --user enable --now mps-notify.timer")?;

    println!("  {} mps-notify.timer enabled and started", "ok:".green());
    println!("  Run {} to check status.", "mps daemon status".bold());
    Ok(())
}

fn remove() -> Result<()> {
    // Disable + stop (ignore errors if not installed).
    let _ = sh("systemctl --user disable --now mps-notify.timer");

    let unit_dir = systemd_user_dir()?;
    for name in &[SERVICE_NAME, TIMER_NAME] {
        let p = unit_dir.join(name);
        if p.exists() {
            std::fs::remove_file(&p).with_context(|| format!("cannot remove {}", p.display()))?;
            println!("  {} {}", "removed".green(), p.display());
        }
    }

    sh("systemctl --user daemon-reload")?;
    println!("  {} mps-notify.timer removed", "ok:".green());
    Ok(())
}

fn status() -> Result<()> {
    std::process::Command::new("systemctl")
        .args(["--user", "status", "mps-notify.timer"])
        .status()
        .context("failed to run systemctl")?;
    Ok(())
}

fn sh(cmd: &str) -> Result<()> {
    let status = std::process::Command::new("sh")
        .arg("-c")
        .arg(cmd)
        .status()
        .with_context(|| format!("failed to run: {}", cmd))?;
    if !status.success() {
        bail!("command failed (exit {}): {}", status, cmd);
    }
    Ok(())
}

/// Append `entry` to `storage_dir/.gitignore` if it is not already present.
fn ensure_gitignored(storage_dir: &std::path::Path, entry: &str) -> Result<()> {
    let path = storage_dir.join(".gitignore");
    let content = std::fs::read_to_string(&path).unwrap_or_default();
    if content.lines().any(|l| l.trim() == entry) {
        return Ok(());
    }
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .with_context(|| format!("cannot open {}", path.display()))?;
    writeln!(f, "{}", entry)?;
    println!("  {} {} to {}", "added".green(), entry, path.display());
    Ok(())
}