moadim 3.8.0

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
//! Read/write support for the tracked `disabled.json` routine marker.

use chrono::{SecondsFormat, Utc};

use crate::utils::atomic::atomic_write;

/// Resolve enabled state from `disabled.json` first, then legacy `routine.toml`.
pub(crate) fn read_enabled_state(
    base: &std::path::Path,
    dir_name: &str,
    legacy_enabled: Option<bool>,
) -> bool {
    if disabled_marker_path(base, dir_name).exists() {
        false
    } else {
        legacy_enabled.unwrap_or(true)
    }
}

/// Read the optional human-provided disable reason from `disabled.json` metadata.
pub(crate) fn read_disabled_reason(base: &std::path::Path, dir_name: &str) -> Option<String> {
    let text = std::fs::read_to_string(disabled_marker_path(base, dir_name)).ok()?;
    let value = serde_json::from_str::<serde_json::Value>(&text).ok()?;
    value
        .get("reason")
        .and_then(serde_json::Value::as_str)
        .and_then(normalize_reason)
}

/// Persist the source-of-truth disabled marker for a routine.
pub(crate) fn write_disabled_state(
    rel_dir: &str,
    enabled: bool,
    reason: Option<&str>,
) -> std::io::Result<()> {
    let path = crate::paths::routine_disabled_json_path(rel_dir);
    if enabled {
        if path.exists() {
            std::fs::remove_file(path)?;
        }
        return Ok(());
    }
    let reason = reason.and_then(normalize_reason);
    let existing = path
        .exists()
        .then(|| std::fs::read_to_string(&path))
        .transpose()?
        .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok());
    if existing
        .as_ref()
        .and_then(|marker| marker.get("reason"))
        .and_then(serde_json::Value::as_str)
        .and_then(normalize_reason)
        == reason
        && existing
            .as_ref()
            .and_then(|marker| marker.get("disabled_at"))
            .and_then(serde_json::Value::as_str)
            .is_some()
    {
        return Ok(());
    }
    let disabled_at = existing
        .as_ref()
        .and_then(|marker| marker.get("disabled_at"))
        .and_then(serde_json::Value::as_str)
        .map_or_else(
            || Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
            str::to_owned,
        );
    let mut marker = serde_json::json!({
        "version": 1,
        "disabled_at": disabled_at,
        "disabled_by_machine": crate::machine::current_machine(),
        "disabled_by_user": current_user(),
        "source": "daemon",
    });
    if let Some(reason) = reason {
        marker["reason"] = serde_json::Value::String(reason);
    }
    let text = marker.to_string();
    atomic_write(&path, text.as_bytes())?;
    Ok(())
}

/// Resolve the disabled marker path under any scan base.
fn disabled_marker_path(base: &std::path::Path, dir_name: &str) -> std::path::PathBuf {
    base.join(dir_name).join("disabled.json")
}

/// Trim a reason and collapse blank values to absent.
fn normalize_reason(reason: &str) -> Option<String> {
    let reason = reason.trim();
    (!reason.is_empty()).then(|| reason.to_string())
}

/// Best-effort current OS user for audit metadata.
fn current_user() -> Option<String> {
    ["USER", "USERNAME"]
        .into_iter()
        .find_map(|key| std::env::var(key).ok())
        .filter(|value| !value.trim().is_empty())
}

#[cfg(test)]
#[path = "routine_disabled_state_tests.rs"]
mod routine_disabled_state_tests;