moadim 0.17.0

Loop engine for AI agents — cron jobs and routines over REST, MCP, and a built-in web UI
#![allow(clippy::missing_docs_in_private_items)]

use super::*;
use crate::cron_jobs::CronJob;

fn test_job(id: &str) -> CronJob {
    CronJob {
        id: id.to_string(),
        schedule: "@daily".to_string(),
        handler: "test-handler".to_string(),
        metadata: serde_json::json!({"key": "val"}),
        machines: vec![crate::machine::current_machine()],
        enabled: true,
        source: "managed".to_string(),
        created_at: 1000,
        updated_at: 2000,
        last_manual_trigger_at: Some(3000),
    }
}

#[test]
fn metadata_roundtrip() {
    let val = serde_json::json!({"key": "value", "num": 42});
    let table = json_to_toml_table(&val);
    let back = metadata_to_json(&table);
    assert_eq!(back["key"], "value");
    assert_eq!(back["num"], 42);
}

#[test]
fn metadata_roundtrip_empty_object() {
    let val = serde_json::json!({});
    let table = json_to_toml_table(&val);
    let back = metadata_to_json(&table);
    assert!(back.as_object().unwrap().is_empty());
}

#[test]
fn json_to_toml_table_non_object_returns_empty() {
    let val = serde_json::json!([1, 2, 3]);
    let table = json_to_toml_table(&val);
    assert!(table.is_empty());
}

#[test]
fn json_to_toml_table_null_returns_empty() {
    let table = json_to_toml_table(&serde_json::Value::Null);
    assert!(table.is_empty());
}

#[test]
fn read_job_toml_missing_returns_none() {
    let path = std::path::PathBuf::from("/nonexistent/path/job.toml");
    assert!(read_job_toml(&path).is_none());
}

#[test]
fn write_and_load_roundtrip() {
    let id = "test-write-load-roundtrip";
    let job = test_job(id);

    write_job(&job).expect("write_job failed");

    let loaded = load_job_from_dir(id).expect("load_job_from_dir failed");
    assert_eq!(loaded.id, job.id);
    assert_eq!(loaded.schedule, job.schedule);
    assert_eq!(loaded.handler, job.handler);
    assert_eq!(loaded.enabled, job.enabled);
    assert_eq!(loaded.created_at, job.created_at);
    assert_eq!(loaded.updated_at, job.updated_at);
    assert_eq!(loaded.last_manual_trigger_at, job.last_manual_trigger_at);
    assert_eq!(loaded.metadata["key"], "val");

    remove_job_dir(id).expect("cleanup failed");
}

#[test]
fn remove_job_dir_nonexistent_is_ok() {
    assert!(remove_job_dir("test-nonexistent-9999999").is_ok());
}

#[test]
fn remove_job_dir_removes_directory() {
    let id = "test-remove-dir";
    let job = test_job(id);
    write_job(&job).expect("write_job failed");

    let dir = crate::paths::job_dir(id);
    assert!(dir.exists());

    remove_job_dir(id).expect("remove failed");
    assert!(!dir.exists());
}

#[test]
fn load_store_returns_written_job() {
    let id = "test-load-store-job";
    let job = test_job(id);
    write_job(&job).expect("write_job failed");

    let store = load_store();
    let loaded = store.lock().unwrap().get(id).cloned();
    assert!(loaded.is_some(), "job not found in loaded store");
    assert_eq!(loaded.unwrap().handler, "test-handler");

    remove_job_dir(id).expect("cleanup failed");
}

#[test]
fn write_job_creates_gitignore() {
    let id = "test-gitignore-creation";
    let job = test_job(id);
    write_job(&job).expect("write_job failed");

    let gitignore = crate::paths::job_gitignore_path(id);
    assert!(gitignore.exists());
    let content = std::fs::read_to_string(&gitignore).unwrap();
    assert!(content.contains("*.local.*"));
    assert!(content.contains("run.sh"));

    remove_job_dir(id).expect("cleanup failed");
}

#[test]
fn local_toml_overrides_base_handler() {
    let id = "test-local-override-handler";
    let job = test_job(id);
    write_job(&job).expect("write_job failed");

    let local_path = crate::paths::job_local_toml_path(id);
    std::fs::write(
        &local_path,
        "handler = \"overridden\"\n\n[metadata]\nlocal_key = \"local_value\"\n",
    )
    .unwrap();

    let loaded = load_job_from_dir(id).expect("load failed");
    assert_eq!(loaded.handler, "overridden");
    assert_eq!(loaded.metadata["local_key"], "local_value");

    remove_job_dir(id).expect("cleanup failed");
}

#[test]
fn write_job_twice_does_not_fail_on_existing_gitignore() {
    let id = "test-write-idempotent";
    let job = test_job(id);
    write_job(&job).expect("first write");
    write_job(&job).expect("second write (gitignore already exists)");
    remove_job_dir(id).expect("cleanup failed");
}

#[test]
fn load_store_skips_non_directory_entries() {
    let jobs_dir = crate::paths::jobs_dir();
    std::fs::create_dir_all(&jobs_dir).unwrap();
    let fake = jobs_dir.join("not-a-job.txt");
    std::fs::write(&fake, "hello").unwrap();

    let store = load_store();
    let _ = store; // must not panic

    std::fs::remove_file(&fake).unwrap();
}

#[test]
fn load_store_from_dir_missing_dir_returns_empty_store() {
    let store = load_store_from_dir(std::path::Path::new("/nonexistent-jobs-dir-99999"));
    assert!(store.lock().unwrap().is_empty());
}

#[test]
fn write_job_errors_when_job_dir_path_is_a_file() {
    // A regular file occupies the job's directory path, so `create_dir_all` fails and
    // `write_job` returns the error rather than panicking.
    let id = "test-write-blocked-by-file";
    let jobs_dir = crate::paths::jobs_dir();
    std::fs::create_dir_all(&jobs_dir).unwrap();
    let blocker = jobs_dir.join(id);
    // Make sure no leftover dir is in the way, then block the path with a file.
    let _ = std::fs::remove_dir_all(&blocker);
    std::fs::write(&blocker, "i block the job dir").unwrap();

    let err = write_job(&test_job(id)).unwrap_err();
    let _ = err; // the specific kind is OS-dependent; the point is that it errored

    assert!(blocker.is_file(), "the blocking file is left untouched");
    std::fs::remove_file(&blocker).unwrap();
}

#[test]
fn write_job_errors_when_job_toml_path_is_a_directory() {
    // The gitignore write and dir creation succeed, but a directory occupies the
    // `job.toml` path, so the final `std::fs::write(job_toml_path, ..)` fails.
    let id = "test-write-toml-blocked-by-dir";
    let dir = crate::paths::job_dir(id);
    std::fs::create_dir_all(&dir).unwrap();
    // Block the job.toml path with a directory so writing the file fails.
    std::fs::create_dir_all(crate::paths::job_toml_path(id)).unwrap();

    let err = write_job(&test_job(id)).unwrap_err();
    let _ = err;

    assert!(crate::paths::job_toml_path(id).is_dir());
    remove_job_dir(id).expect("cleanup failed");
}

#[test]
fn json_to_toml_table_skips_non_representable_values() {
    // TOML cannot represent a JSON null, so that key is dropped while the
    // representable sibling key survives — exercising the `if let Ok(..)` skip arm.
    let val = serde_json::json!({"keep": "yes", "drop": serde_json::Value::Null});
    let table = json_to_toml_table(&val);
    assert!(table.contains_key("keep"));
    assert!(
        !table.contains_key("drop"),
        "a null value is not representable in TOML and must be skipped"
    );
}

#[test]
fn load_job_from_dir_torn_toml_returns_none() {
    // A truncated/garbage job.toml must parse to None rather than panic or load a
    // half-baked job.
    let id = "test-torn-job-toml";
    let dir = crate::paths::job_dir(id);
    std::fs::create_dir_all(&dir).unwrap();
    std::fs::write(crate::paths::job_toml_path(id), "schedule = \"@dai").unwrap();
    assert!(load_job_from_dir(id).is_none());
    remove_job_dir(id).expect("cleanup failed");
}

#[test]
fn load_job_from_dir_missing_schedule_returns_none() {
    // A job.toml that parses but lacks the required `schedule` field yields None
    // (the `?` on `base.schedule` short-circuits in `load_job_from_dir`).
    let id = "test-job-missing-schedule";
    let dir = crate::paths::job_dir(id);
    std::fs::create_dir_all(&dir).unwrap();
    std::fs::write(
        crate::paths::job_toml_path(id),
        "handler = \"h\"\nenabled = true\n",
    )
    .unwrap();
    assert!(load_job_from_dir(id).is_none());
    remove_job_dir(id).expect("cleanup failed");
}

/// RAII guard: redirect config paths to a temp dir and restore on drop.
struct HomeOverrideGuard {
    dir: std::path::PathBuf,
    previous: Option<std::ffi::OsString>,
}

impl HomeOverrideGuard {
    fn new() -> Self {
        let dir = std::env::temp_dir().join(format!("moadim-st-{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&dir).expect("create temp home");
        let previous = std::env::var_os("MOADIM_HOME_OVERRIDE");
        // SAFETY: single-threaded test execution (RUST_TEST_THREADS=1).
        unsafe { std::env::set_var("MOADIM_HOME_OVERRIDE", &dir) }
        Self { dir, previous }
    }
}

impl Drop for HomeOverrideGuard {
    fn drop(&mut self) {
        // SAFETY: single-threaded test execution.
        unsafe {
            match self.previous.take() {
                Some(val) => std::env::set_var("MOADIM_HOME_OVERRIDE", val),
                None => std::env::remove_var("MOADIM_HOME_OVERRIDE"),
            }
        }
        restore_writable_storage(&self.dir);
        let _ = std::fs::remove_dir_all(&self.dir);
    }
}

fn restore_writable_storage(dir: &std::path::Path) {
    use std::os::unix::fs::PermissionsExt as _;
    if let Ok(entries) = std::fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755));
            if path.is_dir() {
                restore_writable_storage(&path);
            }
        }
    }
    let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755));
}

#[test]
fn load_job_from_dir_missing_handler_returns_none() {
    let _home = HomeOverrideGuard::new();
    // A job.toml with schedule but no handler: `base.handler?` returns None.
    let id = "missing-handler-job";
    let dir = crate::paths::job_dir(id);
    std::fs::create_dir_all(&dir).unwrap();
    std::fs::write(
        crate::paths::job_toml_path(id),
        "schedule = \"@daily\"\nenabled = true\n",
    )
    .unwrap();
    assert!(load_job_from_dir(id).is_none());
}

#[cfg(unix)]
#[test]
fn write_job_gitignore_write_failure_returns_err() {
    use std::os::unix::fs::PermissionsExt as _;
    let home = HomeOverrideGuard::new();
    // Create the job dir and make it read-only so `.gitignore` can't be written.
    let job = test_job("gitignore-fail");
    let dir = home
        .dir
        .join(".config")
        .join("moadim")
        .join("jobs")
        .join(&job.id);
    std::fs::create_dir_all(&dir).unwrap();
    std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap();

    let result = write_job(&job);
    assert!(result.is_err());
}

#[cfg(unix)]
#[test]
fn remove_job_dir_remove_failure_returns_err() {
    use std::os::unix::fs::PermissionsExt as _;
    let home = HomeOverrideGuard::new();
    // Pre-create jobs/id/ then make jobs/ read-only so remove_dir_all fails.
    let jobs = home.dir.join(".config").join("moadim").join("jobs");
    let job_dir = jobs.join("rd-fail");
    std::fs::create_dir_all(&job_dir).unwrap();
    std::fs::set_permissions(&jobs, std::fs::Permissions::from_mode(0o555)).unwrap();

    let result = remove_job_dir("rd-fail");
    assert!(result.is_err());
}