trusty-mpm 1.3.5

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
Documentation
//! Unit tests for [`super`] (`tm hooks clean` / doctor hook-hygiene primitives).
//!
//! Why: split out of `cleanup.rs` to keep the production file focused; mirrors
//! the `hooks/{mod.rs,tests.rs}` split already used in this module tree.
//! What: exercises `contains_tm_hooks`, `tm_hook_event_names`,
//! `foreign_hook_event_names`, and `clean_settings_file` (dry-run, `--force`,
//! preservation of unrelated keys, and the missing-file no-op).
//! Test: this module IS the test suite for `super`.

use super::*;
use serde_json::json;
use tempfile::TempDir;

/// A settings JSON value with a tm-owned `PreToolUse` hook group.
fn tm_settings() -> Value {
    json!({
        "outputStyle": "trusty-mpm",
        "hooks": {
            "PreToolUse": [{
                "matcher": "*",
                "hooks": [{ "type": "command", "command": "/usr/local/bin/tm hook", "timeout": 5 }]
            }]
        }
    })
}

/// A settings JSON value with a foreign claude-mpm hook group.
fn claude_mpm_settings() -> Value {
    json!({
        "hooks": {
            "SessionStart": [{
                "matcher": "",
                "hooks": [{ "type": "command", "command": "claude-mpm hooks fire SessionStart", "timeout": 5 }]
            }]
        }
    })
}

/// A settings JSON value with a MIXED `PreToolUse` group: one tm-owned entry
/// and one foreign (claude-mpm) entry inside the SAME matcher group (issue
/// #2948) — the shape a hand-merged settings file can produce even though
/// [`super::super::mpm_hook_additions_with_exe`] never writes one itself.
fn mixed_group_settings() -> Value {
    json!({
        "outputStyle": "trusty-mpm",
        "hooks": {
            "PreToolUse": [{
                "matcher": "*",
                "hooks": [
                    { "type": "command", "command": "/usr/local/bin/tm hook", "timeout": 5 },
                    { "type": "command", "command": "claude-mpm hooks fire PreToolUse", "timeout": 5 }
                ]
            }]
        }
    })
}

#[test]
fn contains_tm_hooks_true_for_tm_entry() {
    assert!(contains_tm_hooks(&tm_settings()));
}

#[test]
fn contains_tm_hooks_false_for_foreign_or_absent() {
    assert!(!contains_tm_hooks(&claude_mpm_settings()));
    assert!(!contains_tm_hooks(&json!({})));
    assert!(!contains_tm_hooks(&json!({ "hooks": {} })));
}

#[test]
fn tm_hook_event_names_lists_only_contaminated_events() {
    let mut val = tm_settings();
    // Add a non-tm event alongside so the matcher must discriminate.
    val["hooks"]["SessionStart"] = claude_mpm_settings()["hooks"]["SessionStart"].clone();
    let names = tm_hook_event_names(&val);
    assert_eq!(names, vec!["PreToolUse".to_string()]);
}

#[test]
fn foreign_hook_event_names_lists_claude_mpm_entries() {
    let mut val = claude_mpm_settings();
    val["hooks"]["PreToolUse"] = tm_settings()["hooks"]["PreToolUse"].clone();
    let names = foreign_hook_event_names(&val);
    assert_eq!(names, vec!["SessionStart".to_string()]);
}

/// Why (issue #2948): the `.all()`-based classification made a hand-mixed
/// group invisible to `tm doctor`'s `hooks_contamination` check even though it
/// genuinely carries a tm-owned entry. `.any()` must flag it.
/// What: asserts `tm_hook_event_names` (the primitive `doctor_hooks_hygiene`
/// calls) lists `PreToolUse` for [`mixed_group_settings`].
#[test]
fn tm_hook_event_names_flags_mixed_group() {
    let names = tm_hook_event_names(&mixed_group_settings());
    assert_eq!(
        names,
        vec!["PreToolUse".to_string()],
        "a mixed group carrying a tm entry must be flagged as contaminated"
    );
}

/// Why (issue #2948): the same mixed group also carries a genuinely foreign
/// (claude-mpm) entry, so `tm doctor`'s `hooks_foreign_conflict` check must
/// flag it too — both checks can legitimately fire on the same event now.
/// What: asserts `foreign_hook_event_names` lists `PreToolUse` for
/// [`mixed_group_settings`].
#[test]
fn foreign_hook_event_names_flags_mixed_group() {
    let names = foreign_hook_event_names(&mixed_group_settings());
    assert_eq!(
        names,
        vec!["PreToolUse".to_string()],
        "a mixed group carrying a foreign entry must be flagged as a conflict"
    );
}

/// Why (issue #2948): `tm hooks clean --force` must strip ONLY the tm-owned
/// entry from a mixed group, leaving the foreign entry AND the group itself
/// (matcher, other keys) intact — never delete a group wholesale just because
/// it also happens to carry a tm entry.
/// What: force-cleans [`mixed_group_settings`] and asserts the resulting
/// `PreToolUse` group still exists with exactly the foreign entry, while
/// `contains_tm_hooks` is now `false`.
#[test]
fn clean_settings_file_force_strips_tm_entry_from_mixed_group_preserves_foreign() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("settings.json");
    let original = serde_json::to_string_pretty(&mixed_group_settings()).unwrap();
    std::fs::write(&path, &original).unwrap();

    let outcome = clean_settings_file(&path, true).unwrap().unwrap();
    assert_eq!(outcome.removed_events, vec!["PreToolUse".to_string()]);

    let cleaned: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
    assert!(
        !contains_tm_hooks(&cleaned),
        "the tm-owned entry must be gone after cleaning"
    );
    let groups = cleaned["hooks"]["PreToolUse"]
        .as_array()
        .expect("the PreToolUse group must survive — it still carries a foreign entry");
    assert_eq!(groups.len(), 1, "the group itself must not be dropped");
    let inner = groups[0]["hooks"].as_array().unwrap();
    assert_eq!(inner.len(), 1, "only the tm entry must be removed");
    assert_eq!(
        inner[0]["command"].as_str().unwrap(),
        "claude-mpm hooks fire PreToolUse",
        "the foreign entry must survive untouched"
    );
    assert_eq!(
        groups[0]["matcher"], "*",
        "the group's other keys (matcher) must be preserved"
    );
}

#[test]
fn clean_settings_file_dry_run_reports_without_writing() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("settings.json");
    let original = serde_json::to_string_pretty(&tm_settings()).unwrap();
    std::fs::write(&path, &original).unwrap();

    let outcome = clean_settings_file(&path, false).unwrap().unwrap();
    assert_eq!(outcome.removed_events, vec!["PreToolUse".to_string()]);
    assert!(outcome.backup_path.is_none(), "dry run must not back up");

    let after = std::fs::read_to_string(&path).unwrap();
    assert_eq!(after, original, "dry run must never mutate the file");
    assert!(
        !dir.path().join("settings.json.bak").exists(),
        "dry run must never write a backup file"
    );
}

#[test]
fn clean_settings_file_force_writes_backup_and_strips() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("settings.json");
    let original = serde_json::to_string_pretty(&tm_settings()).unwrap();
    std::fs::write(&path, &original).unwrap();

    let outcome = clean_settings_file(&path, true).unwrap().unwrap();
    assert_eq!(outcome.removed_events, vec!["PreToolUse".to_string()]);
    let backup = outcome.backup_path.expect("force must write a backup");
    assert!(backup.exists());
    let backup_contents = std::fs::read_to_string(&backup).unwrap();
    assert_eq!(
        backup_contents, original,
        "backup must be byte-identical to the pre-clean file"
    );

    let cleaned: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
    assert!(
        !contains_tm_hooks(&cleaned),
        "cleaned file must no longer contain tm hooks"
    );
}

#[test]
fn clean_settings_file_preserves_non_tm_keys() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("settings.json");
    let mut settings = tm_settings();
    settings["hooks"]["SessionStart"] = claude_mpm_settings()["hooks"]["SessionStart"].clone();
    std::fs::write(&path, serde_json::to_string_pretty(&settings).unwrap()).unwrap();

    clean_settings_file(&path, true).unwrap();

    let cleaned: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
    assert_eq!(
        cleaned["outputStyle"], "trusty-mpm",
        "unrelated top-level keys must survive the clean"
    );
    assert!(
        cleaned["hooks"]["SessionStart"].is_array(),
        "the foreign claude-mpm SessionStart group must be preserved untouched"
    );
    assert!(
        cleaned["hooks"].get("PreToolUse").is_none(),
        "the tm-owned PreToolUse group must be removed"
    );
}

#[test]
fn clean_settings_file_missing_file_is_noop() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("does-not-exist.json");
    let result = clean_settings_file(&path, true).unwrap();
    assert!(result.is_none());
    assert!(!path.exists(), "a missing file must never be created");
}

#[test]
fn clean_settings_file_no_tm_hooks_is_noop() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("settings.json");
    let original = serde_json::to_string_pretty(&claude_mpm_settings()).unwrap();
    std::fs::write(&path, &original).unwrap();

    let result = clean_settings_file(&path, true).unwrap();
    assert!(
        result.is_none(),
        "a file with only foreign hooks must never be reported or touched"
    );
    let after = std::fs::read_to_string(&path).unwrap();
    assert_eq!(after, original);
}

/// Why (#2940 review round 1, MEDIUM): a settings file that fails to parse as
/// JSON at all must be treated as "nothing to clean" — never as an error, and
/// NEVER touched or backed up, even with `force: true`. This pins the
/// early-return at `cleanup.rs`'s `serde_json::from_str` failure branch.
/// What: writes deliberately invalid JSON, calls `clean_settings_file` with
/// `force: true`, and asserts `Ok(None)`, the file is byte-for-byte
/// unchanged, and no `.bak-*` file was created alongside it.
#[test]
fn clean_settings_file_malformed_json_is_noop() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("settings.json");
    let original = "{ not valid json !!";
    std::fs::write(&path, original).unwrap();

    let result = clean_settings_file(&path, true).unwrap();
    assert!(
        result.is_none(),
        "malformed JSON must never be reported or touched"
    );
    let after = std::fs::read_to_string(&path).unwrap();
    assert_eq!(
        after, original,
        "malformed JSON file must be byte-for-byte unchanged"
    );
    let has_backup = std::fs::read_dir(dir.path())
        .unwrap()
        .filter_map(Result::ok)
        .any(|e| e.file_name().to_string_lossy().contains(".bak-"));
    assert!(
        !has_backup,
        "a malformed file must never get a backup written"
    );
}

/// Why (#2940 review round 1, MEDIUM): a settings file that parses as valid
/// JSON but is NOT a top-level object (e.g. a bare array or string) must also
/// be treated as "nothing to clean" — the same early-return contract as a
/// missing or malformed file, never an error and never a write.
/// What: writes a valid JSON array (not an object) as the settings file,
/// calls `clean_settings_file` with `force: true`, and asserts `Ok(None)`,
/// the file is byte-for-byte unchanged, and no backup was created.
#[test]
fn clean_settings_file_non_object_json_is_noop() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("settings.json");
    let original = serde_json::to_string_pretty(&json!(["not", "an", "object"])).unwrap();
    std::fs::write(&path, &original).unwrap();

    let result = clean_settings_file(&path, true).unwrap();
    assert!(
        result.is_none(),
        "non-object top-level JSON must never be reported or touched"
    );
    let after = std::fs::read_to_string(&path).unwrap();
    assert_eq!(
        after, original,
        "non-object JSON file must be byte-for-byte unchanged"
    );
    let has_backup = std::fs::read_dir(dir.path())
        .unwrap()
        .filter_map(Result::ok)
        .any(|e| e.file_name().to_string_lossy().contains(".bak-"));
    assert!(
        !has_backup,
        "a non-object JSON file must never get a backup written"
    );
}