trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! 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");
    // #7762: nor may the apply arm leave the lock sidecar of a file it never
    // writes — `tm doctor --fix` calls this for every project's absent
    // `settings.local.json`.
    assert!(
        !crate::core::settings_lock::lock_sidecar(&path).exists(),
        "no sidecar may be created for a file that does not exist"
    );
}

#[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"
    );
}

// ---------------------------------------------------------------------------
// #7262: the build-tree classifier's reporting and repair surface. Every test
// below FAILS on 1ba38636c, where `is_mpm_hook_command` judged a hook command
// purely by the binary's NAME.
// ---------------------------------------------------------------------------

use super::super::build_tree::tests::{INCIDENT_EXE, incident_settings};

#[test]
fn build_tree_hook_commands_lists_the_incident_commands() {
    let found = build_tree_hook_commands(&incident_settings());
    assert_eq!(
        found,
        vec![
            format!("{INCIDENT_EXE} hook"),
            format!("{INCIDENT_EXE} hook --pm-guard"),
        ],
        "every distinct build-tree hook command must be named, once each"
    );
}

#[test]
fn build_tree_hook_commands_is_empty_for_an_installed_binary() {
    assert!(build_tree_hook_commands(&tm_settings()).is_empty());
    assert!(build_tree_hook_commands(&claude_mpm_settings()).is_empty());
}

#[test]
fn build_tree_statusline_command_in_settings_is_reported() {
    assert_eq!(
        build_tree_statusline_command(&incident_settings()),
        Some(format!("{INCIDENT_EXE} statusline"))
    );
}

#[test]
fn build_tree_statusline_command_in_settings_ignores_an_installed_binary() {
    let val = json!({
        "statusLine": { "type": "command", "command": "/usr/local/bin/tm statusline" }
    });
    assert_eq!(build_tree_statusline_command(&val), None);
    assert_eq!(build_tree_statusline_command(&tm_settings()), None);
}

#[test]
fn strips_pm_guard_entry_is_true_only_for_a_removable_guard() {
    // The incident file's `PreToolUse` guard is build-tree-rooted, so the strip
    // takes it and the caller must be told.
    assert!(strips_pm_guard_entry(&incident_settings()));

    // An INSTALLED guard carries the same argv but survives the strip, so
    // reporting it removed would be a lie.
    let installed = json!({
        "hooks": {
            "PreToolUse": [{
                "hooks": [{ "command": "/usr/local/bin/tm hook --pm-guard" }]
            }]
        }
    });
    assert!(!strips_pm_guard_entry(&installed));

    // A contaminated file with no guard at all.
    assert!(!strips_pm_guard_entry(&tm_settings()));
    assert!(!strips_pm_guard_entry(&json!({})));
}

#[test]
fn clean_settings_file_flags_a_removed_pm_guard_entry() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("settings.json");

    std::fs::write(&path, incident_settings().to_string()).unwrap();
    let outcome = clean_settings_file(&path, false).unwrap().unwrap();
    assert!(
        outcome.removed_pm_guard,
        "the incident file's guard is removable: {outcome:?}"
    );

    // Same reader, a file whose only tm entry is the lifecycle triad.
    std::fs::write(&path, tm_settings().to_string()).unwrap();
    let outcome = clean_settings_file(&path, false).unwrap().unwrap();
    assert!(!outcome.removed_pm_guard, "{outcome:?}");
}

#[test]
fn tm_hook_event_names_flags_the_build_tree_incident() {
    // Before #7262 this returned an empty list: neither name branch recognises
    // the `test_session_lifecycle` stem, so `tm doctor` saw nothing at all.
    let mut events = tm_hook_event_names(&incident_settings());
    events.sort();
    assert_eq!(
        events,
        vec![
            "PostToolUse",
            "PreToolUse",
            "SessionEnd",
            "SessionStart",
            "Stop",
            "SubagentStop",
        ]
    );
}

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

    let outcome = clean_settings_file(&path, true)
        .unwrap()
        .expect("the incident shape must be reported as contamination");
    assert!(outcome.backup_path.is_some(), "a --force pass must back up");

    let cleaned: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
    assert!(
        build_tree_hook_commands(&cleaned).is_empty(),
        "no build-tree hook command may survive: {cleaned}"
    );
    // The project's own entry, and every unrelated key, survive untouched.
    assert_eq!(
        cleaned["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"],
        json!("trusty-memory prompt-context")
    );
    assert_eq!(cleaned["outputStyle"], json!("trusty-mpm"));
    // statusLine is detection-only: `clean_settings_file` does not own that key
    // and must not strip what it cannot put back.
    assert_eq!(
        build_tree_statusline_command(&cleaned),
        Some(format!("{INCIDENT_EXE} statusline"))
    );
}