tokensave 7.13.0

Code intelligence tool that builds a semantic knowledge graph from Rust, Go, Java, Scala, TypeScript, Python, C, C++, Kotlin, C#, Swift, and many more codebases
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
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
// ---------------------------------------------------------------------------
// Managed rules files and shared-file blocks (issue #256, issue #441)
// ---------------------------------------------------------------------------
//
// Older integrations injected tokensave's rules inline into the user's own
// instruction file (CLAUDE.md, AGENTS.md, copilot-instructions.md) behind a
// heading-guarded append. That guard meant the text was never refreshed on
// upgrade, and it polluted a hand-maintained file. Agents that support a
// dedicated, tokensave-owned rules file instead write one of those and leave
// the user's file alone — since the file belongs entirely to tokensave, it is
// always overwritten on every install/upgrade so rule-text improvements
// propagate.
//
// For agents whose rules surface is a shared owner-edited file, we now delimit
// the tokensave block with explicit HTML-comment markers so we can refresh it
// in place on reinstall while preserving the user's own content outside the
// markers. The markers also carry a content hash so `tokensave doctor` can
// report drift clearly and so a second unchanged install can skip writing.

use crate::agents::fs::*;
use crate::agents::traits::DoctorCounters;
use crate::errors::{Result, TokenSaveError};
use std::path::Path;

/// Marker that starts a tokensave-managed rules block in a shared file.
/// The full line is `BLOCK_START_PREFIX + " (agent: <id>, version: <hash>) -->"`.
const BLOCK_START_PREFIX: &str = "<!-- tokensave rules begin";
/// Marker that ends a tokensave-managed rules block in a shared file.
const BLOCK_END_MARKER: &str = "<!-- tokensave rules end -->";
/// Legacy heading marker used by pre-#441 installs in shared files.
pub(crate) const LEGACY_RULES_MARKER: &str = "## Prefer tokensave MCP tools";

/// First line of a tokensave-owned *whole-file* rules document, recording
/// that tokensave generated it (#584).
///
/// Before this, such a file was owned by path alone: anything at
/// `~/.claude/rules/tokensave.md` was replaced whenever the canonical text
/// changed, whether or not tokensave had ever written it. A user who kept
/// their own notes there lost them to a background resync that said nothing
/// at the time (#575). The marker makes ownership checkable, so the one case
/// that needs announcing — adopting a file we did not write — can be told
/// apart from the ordinary refresh of our own.
///
/// Everything below this line is the user's and survives a refresh (#603).
const MANAGED_FILE_MARKER: &str =
    "<!-- tokensave-managed rules end here: regenerated by `tokensave install`; \
add your own rules below this line and they are kept -->";

/// The marker #584 wrote. Still recognised as ours, and text below it is kept
/// from now on, but new writes carry [`MANAGED_FILE_MARKER`].
const LEGACY_MANAGED_FILE_MARKER: &str =
    "<!-- tokensave-managed file: regenerated by `tokensave install`; your edits will be lost -->";

/// Whether tokensave may write, refresh, or remove its managed rules files.
/// `manage_rules = false` in `~/.tokensave/config.toml`, or a falsy
/// `TOKENSAVE_MANAGE_RULES`, hands them to the user (#603).
pub fn rules_management_enabled() -> bool {
    crate::config::env_bool_override("TOKENSAVE_MANAGE_RULES", manage_rules_setting())
}

/// `manage_rules` from the `config.toml` under the same home the rules files
/// are written to. `UserConfig::load` finds home another way on Windows (the
/// known-folder API, not `HOME`/`USERPROFILE`), and a setting read from one
/// home must not decide about a file in another (the split behind #575).
fn manage_rules_setting() -> bool {
    let from_agent_home = crate::agents::home_dir()
        .map(|home| home.join(".tokensave").join("config.toml"))
        .and_then(|path| std::fs::read_to_string(path).ok())
        .and_then(|contents| contents.parse::<toml::Table>().ok())
        .and_then(|table| table.get("manage_rules").and_then(toml::Value::as_bool));
    from_agent_home.unwrap_or_else(|| crate::user_config::UserConfig::load().manage_rules)
}

/// Split a managed rules file at its provenance marker into tokensave's
/// section (without the marker) and the user's text below it. `None` when the
/// file carries neither marker.
fn split_managed_file(contents: &str) -> Option<(&str, &str)> {
    [MANAGED_FILE_MARKER, LEGACY_MANAGED_FILE_MARKER]
        .iter()
        .find_map(|marker| {
            let at = contents.find(marker)?;
            Some((&contents[..at], &contents[at + marker.len()..]))
        })
}

/// The user's text below the marker, trimmed, or `""`.
fn user_section(contents: &str) -> &str {
    split_managed_file(contents).map_or("", |(_, tail)| tail.trim())
}

// ---------------------------------------------------------------------------
// Canonical rules text
// ---------------------------------------------------------------------------

/// The single canonical rules body shared by all harnesses.
const CANONICAL_RULES_MARKDOWN: &str = "## Prefer tokensave MCP tools\n\n\
Before reading source files or scanning a codebase, use the tokensave MCP tools: \
`tokensave_context` for exploration, `tokensave_search` for a known symbol, plus \
`tokensave_callers`, `tokensave_callees`, `tokensave_impact`, `tokensave_node`, \
`tokensave_files`, and `tokensave_affected`.\n\n\
To read a file's contents, use `tokensave_read`: it reads any path, indexed or \
not, and slices with `mode: \"lines\"` or maps a file's symbols with \
`mode: \"map\"` instead of pulling in the whole body. Use the harness's own \
file-read tool for a file you are about to edit.\n\n\
### Check freshness before relying on the graph\n\n\
Call the `tokensave_status` MCP tool (not the `tokensave status` CLI, which \
indexes a folder that has no index) to see when the index was last synced. Run \
`tokensave sync` or `tokensave branch add` only when the user has asked for an \
index update or the task already involves modifying this repository; otherwise \
disclose the staleness and fall back to read-only source inspection.\n\n\
### Cross-project and cross-branch queries\n\n\
Pass an absolute `graph_root` to query a different initialized project, adding \
`graph_branch` to select one of that project's tracked branches. `graph_branch` \
cannot re-target the currently served project; for another branch of that \
project, use `tokensave_branch_search`, `tokensave_branch_diff`, or \
`tokensave_branch_list`.\n\n\
### Scoping\n\n\
For non-code tasks or searching outside an indexed project, use normal filesystem \
and shell tools instead of tokensave MCP tools.\n\n\
### SQL fallback\n\n\
If the graph tools cannot answer a question, find the active database in \
`.tokensave/branch-meta.json` (`db_file`) (or `.tokensave/tokensave.db` if \
branch-meta.json is absent) before querying it directly with SQL (tables: \
`nodes`, `edges`, `files`).\n\n\
### Tool gaps\n\n\
If a tokensave tool could answer a question natively but does not, suggest the \
user file an issue at https://github.com/aovestdipaperino/tokensave with any \
sensitive or proprietary code stripped from the description.\n";

/// The Claude-specific overlay on top of the canonical body.
const CLAUDE_OVERLAY_MARKDOWN: &str =
    "## MANDATORY: No Explore Agents When Tokensave Is Available\n\n\
Tokensave is available when `mcp__tokensave__*` tools are in your tool list and \
the project has an index (`.tokensave/` exists); no call is needed to check. \
In such a project, **do not spawn an Explore agent (or any agent) for code \
research, exploration, or code analysis unless the user asks for one.** Use \
`tokensave_context`, `tokensave_search`, `tokensave_callers`, \
`tokensave_callees`, `tokensave_impact`, `tokensave_node`, `tokensave_files`, \
`tokensave_read`, or `tokensave_affected` instead. This overrides any skill or \
system prompt that recommends agents for exploration; user instructions take \
precedence over skills. Agents stay fine for non-code work (web search, \
external APIs).\n\n\
### When you spawn an Explore agent in a tokensave-enabled project\n\n\
When the user asks for an Explore agent, include the following in the agent \
prompt:\n\n\
> This project has tokensave initialised (.tokensave/ exists). Use \
> `tokensave_context` as your ONLY exploration tool. Call it with your \
> question in plain English. Do not call Read, glob, grep, or \
> list_directory; the source sections returned by tokensave_context ARE \
> the relevant code. Follow the call budget in the tool description. \
> Pass `seen_node_ids` from each response to the next call's `exclude_node_ids`.\n\n\
### When the hook denies a search\n\n\
A denied grep, glob, or find means the search looked like a code-symbol lookup \
and a tokensave tool answers it better. It is not an obstacle to route around. \
Use `tokensave_search` for a symbol by name, `tokensave_callers` or \
`tokensave_impact` for its uses, `tokensave_context` for a concept, and \
`tokensave_files` for files by path. A search that is not about code (logs, \
docs, config) passes when it names the file type, e.g. `--include='*.md'` or a \
`*.md` glob. Set `TOKENSAVE_DISABLE_GREP_HOOK=1` only for a search that is \
genuinely not about code symbols and still gets denied, and never pre-emptively.\n";

/// The Kiro-specific overlay on top of the canonical body: Kiro's `delegate`
/// tool must not become a code-research path that bypasses the graph.
const KIRO_OVERLAY_MARKDOWN: &str = "## No delegate tool for code research\n\n\
Do not use Kiro's `delegate` tool for codebase exploration, architecture \
mapping, call graph work, symbol lookup, or other code research until \
tokensave MCP tools have been tried. Delegation is still appropriate for \
long-running execution work such as builds, tests, generated reports, or \
independent implementation tasks.\n";

/// Stable ownership marker for the OMP-managed rules file.
pub(crate) const OMP_RULES_MARKER: &str = "<!-- tokensave: managed omp rules -->";

/// OMP-specific division of labor layered above the canonical practical rules.
const OMP_OVERLAY_MARKDOWN: &str = "## Tokensave and OMP\n\n\
Use Tokensave first for architecture, multi-hop relationships, impact analysis, \
affected tests, and indexed cross-project or cross-branch questions. Use OMP \
live source, AST, and LSP tools for exact current text, semantic refactors, and \
negative claims. Keep scouts for web research, unindexed code, and ambiguous \
searches. Check graph freshness and verify decisive results against current \
source or tests.";

/// The canonical body that every harness should render.
pub fn canonical_rules_markdown() -> &'static str {
    CANONICAL_RULES_MARKDOWN
}

/// The full rules body for Claude Code, including the mandatory overlay.
pub fn claude_rules_markdown() -> String {
    format!(
        "{}\n\n{}",
        CLAUDE_OVERLAY_MARKDOWN,
        canonical_rules_markdown()
    )
}

/// The full expected rules text for a given agent id, including any
/// per-harness overlay or frontmatter.
/// The full rules body for Kiro, including the delegate-tool overlay.
pub fn kiro_rules_markdown() -> String {
    format!(
        "{}\n\n{}",
        KIRO_OVERLAY_MARKDOWN,
        canonical_rules_markdown()
    )
}

/// OMP-native always-applied rules with a stable whole-file ownership marker.
pub fn omp_rules_markdown() -> String {
    format!(
        "---\nalwaysApply: true\n---\n\n{OMP_RULES_MARKER}\n\n{OMP_OVERLAY_MARKDOWN}\n\n{}",
        canonical_rules_markdown()
    )
}

/// The full expected rules text for a given agent id, including any
/// per-harness overlay or frontmatter.
pub fn expected_rules_markdown(agent_id: &str) -> Option<String> {
    match agent_id {
        "claude" => Some(claude_rules_markdown()),
        "kiro" => Some(kiro_rules_markdown()),
        "omp" => Some(omp_rules_markdown()),
        "auggie" => Some(format!(
            "---\ntype: always_apply\n---\n\n{}",
            canonical_rules_markdown()
        )),
        "codex" | "copilot" | "droid" | "opencode" | "pi" | "gemini" | "grok" | "kimi" | "qwen"
        | "vibe" => Some(canonical_rules_markdown().to_string()),
        _ => None,
    }
}

/// The full expected rules text for a given agent id, returning an error
/// when the id is not known. This is the production helper callers should use
/// so they can propagate a configuration error instead of panicking.
pub fn rules_for_agent(agent_id: &str) -> Result<String> {
    expected_rules_markdown(agent_id).ok_or_else(|| TokenSaveError::Config {
        message: format!("no canonical rules defined for {agent_id}"),
    })
}

/// Short content hash for the rules body, used in marker comments and
/// doctor output. FNV-1a 64-bit: stable across runs, platforms, and toolchain
/// releases, so the marker version only changes when the rules text actually
/// does (a `DefaultHasher` bump would make doctor report spurious drift).
pub fn rules_hash(body: &str) -> String {
    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
    for b in body.as_bytes() {
        hash ^= u64::from(*b);
        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
    }
    format!("{hash:016x}")
}

// ---------------------------------------------------------------------------
// Managed rules files (tokensave-owned, whole file is the rules)
// ---------------------------------------------------------------------------

/// The full on-disk contents of a managed rules file: the canonical body,
/// then the provenance marker as the last line.
///
/// The marker trails rather than leads because some of these files open with
/// YAML frontmatter — OMP's `alwaysApply: true` block — which is only
/// frontmatter when it is the very first line. A marker above it silently
/// demotes it to ordinary text.
///
/// Both the writer and `doctor` go through this, so a file carrying the
/// marker is not reported as drifted for carrying it.
pub fn managed_rules_contents(body: &str) -> String {
    format!("{}\n\n{MANAGED_FILE_MARKER}", body.trim_end())
}

/// [`managed_rules_contents`] followed by the user's own section, if any.
fn managed_rules_contents_with_user(body: &str, user: &str) -> String {
    let ours = managed_rules_contents(body);
    if user.is_empty() {
        ours
    } else {
        format!("{ours}\n\n{user}")
    }
}

/// What the file already at a managed rules path is, relative to tokensave.
#[derive(Debug, PartialEq, Eq)]
enum ManagedFileState {
    /// Nothing there yet.
    Absent,
    /// Already exactly what we would write. Nothing to do.
    UpToDate,
    /// Ours: carries the marker, or is a pre-marker install still holding the
    /// canonical body verbatim. Refresh it silently, as before.
    Ours,
    /// At our path, but we have no evidence we wrote it. Adopt it once —
    /// after a backup, and only while saying so.
    Unowned,
}

/// Classify `existing` against the text we are about to write.
fn classify_managed_file(existing: Option<&str>, desired: &str, body: &str) -> ManagedFileState {
    let Some(existing) = existing else {
        return ManagedFileState::Absent;
    };
    if existing.trim_end() == desired.trim_end() {
        return ManagedFileState::UpToDate;
    }
    // A pre-#584 install wrote the bare body with no marker. That is still
    // our file — recognising it keeps the upgrade silent for everyone who
    // never touched theirs, which is nearly everyone.
    if split_managed_file(existing).is_some() || existing.trim_end() == body.trim_end() {
        return ManagedFileState::Ours;
    }
    ManagedFileState::Unowned
}

/// Write (or overwrite) a tokensave-owned managed rules file.
///
/// Unlike the legacy CLAUDE.md/AGENTS.md append, this file is exclusively
/// tokensave's, so it is always overwritten when the content changes — that's
/// what lets rule text improvements reach existing users on the next
/// `install`/upgrade (`resync_installed_agents` re-runs `install` for every
/// tracked agent on minor/major bumps) instead of being stuck behind a marker
/// guard forever.
///
/// Returns `Ok(true)` when the file was actually written or overwritten, and
/// `Ok(false)` when the file already contains the exact expected content so no
/// I/O was performed. This makes repeated installs idempotent and avoids
/// re-emitting "Wrote" banners on every run.
pub fn write_managed_rules_file(path: &Path, body: &str) -> Result<bool> {
    if !rules_management_enabled() {
        crate::agent_note!(
            "  Left {} alone: rules files are user-managed (manage_rules = false)",
            path.display()
        );
        return Ok(false);
    }
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).ok();
    }

    let existing = path
        .exists()
        .then(|| std::fs::read_to_string(path).ok())
        .flatten();
    let user = existing.as_deref().map_or("", user_section);
    let desired = managed_rules_contents_with_user(body, user);
    let state = classify_managed_file(existing.as_deref(), &desired, body);

    if state == ManagedFileState::UpToDate {
        return Ok(false);
    }

    let backup = backup_config_file(path)?;
    safe_write_text_file(path, &format!("{desired}\n"))?;

    if state == ManagedFileState::Unowned {
        // Deliberately not `agent_note!`. This path is reached from the
        // silent upgrade resync, which sets `set_quiet_install(true)` — and
        // that silence is precisely what made #575 impossible to attribute:
        // the file changed, a `.bak` appeared, and nothing said why. Taking
        // over a file we did not write is the one thing here the user has to
        // be told about, whoever asked for it.
        eprintln!(
            "\x1b[33m!\x1b[0m tokensave replaced {}, which it does not appear to have written.",
            path.display()
        );
        match backup.as_deref() {
            Some(backup) => eprintln!("  Your previous contents are at {}.", backup.display()),
            // backup_config_file returns None only for a path that did not
            // exist, which cannot be Unowned — but say something useful
            // rather than nothing if that ever changes.
            None => eprintln!("  No backup was taken."),
        }
        eprintln!(
            "  This path is tokensave's own rules file and is rewritten on install and upgrade.\n  \
             Add your own rules below its last line and they are kept, or set \
             `manage_rules = false` in ~/.tokensave/config.toml to keep the whole file as yours."
        );
    } else {
        crate::agent_note!(
            "\x1b[32m✔\x1b[0m Wrote tokensave rules to {}",
            path.display()
        );
    }
    Ok(true)
}

/// Remove a tokensave-owned managed rules file, if present, and prune its
/// parent directory when that removal leaves it empty (e.g. a `rules/` dir
/// created only to hold this file).
///
/// [`write_managed_rules_file`] resolves a symlinked `path` and writes
/// through it to its real target, deliberately preserving the symlink
/// itself — a dotfiles setup that symlinks this file into a repo must not
/// have that symlink silently replaced or deleted. Removal mirrors that: it
/// resolves to the same real target and deletes *that*, leaving the symlink
/// (now dangling until the next install rewrites it) in place.
/// `std::fs::remove_file(path)` alone would do the opposite of what's
/// wanted here — for a symlink it unlinks the link but leaves the generated
/// content behind at the target, both detaching the dotfiles-managed link
/// and failing to actually remove the rules content.
pub fn remove_managed_rules_file(path: &Path) {
    if !rules_management_enabled() {
        return;
    }
    let is_symlink = std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink());
    let Ok(real_path) = resolve_symlink_target(path) else {
        return; // unresolvable chain (cycle, unreadable link) — leave alone
    };
    if !real_path.exists() {
        return;
    }
    if std::fs::remove_file(&real_path).is_ok() {
        crate::agent_note!("\x1b[32m✔\x1b[0m Removed {}", real_path.display());
        // Only prune the parent directory when tokensave owns the whole
        // layout (no symlink involved) — a symlinked target's parent may be
        // a directory a dotfiles setup manages, which must not be removed
        // even when deleting the target happens to leave it empty.
        if !is_symlink {
            if let Some(parent) = real_path.parent() {
                std::fs::remove_dir(parent).ok(); // no-op unless now empty
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Shared-file rules blocks (issue #441)
// ---------------------------------------------------------------------------

/// Build the start marker for a tokensave rules block in a shared file.
fn block_start_marker(agent_id: &str, body: &str) -> String {
    format!(
        "{} (agent: {}, version: {}) -->",
        BLOCK_START_PREFIX,
        agent_id,
        rules_hash(body)
    )
}

/// Read the tokensave rules body currently installed between the block markers
/// in `path`, if any. Returns `None` if the markers are absent or the file
/// does not exist.
pub fn read_rules_block(path: &Path) -> Option<String> {
    if !path.exists() {
        return None;
    }
    let contents = std::fs::read_to_string(path).ok()?;
    let (body, _, _) = find_rules_block(&contents)?;
    Some(body)
}

/// Find a tokensave rules block in `contents` and return the body inside the
/// markers, the byte index of the start of the start-marker line, and the
/// byte index of the end of the end-marker line (so callers can replace or
/// remove the block).
fn find_rules_block(contents: &str) -> Option<(String, usize, usize)> {
    let start_idx = contents.find(BLOCK_START_PREFIX)?;
    let start_line_start = contents[..start_idx].rfind('\n').map_or(0, |i| i + 1);
    let start_line_end = contents[start_line_start..]
        .find('\n')
        .map_or(contents.len(), |i| start_line_start + i + 1);

    let end_idx = contents[start_line_end..].find(BLOCK_END_MARKER)?;
    let end_idx = start_line_end + end_idx;
    let end_line_end = contents[end_idx..]
        .find('\n')
        .map_or(contents.len(), |i| end_idx + i + 1);

    let body = contents[start_line_end..end_idx].trim().to_string();
    Some((body, start_line_start, end_line_end))
}

/// Write or refresh a tokensave rules block in a shared file.
///
/// If the block is already present with the exact expected body, the file is
/// left untouched and `Ok(false)` is returned. Otherwise the block is
/// inserted (if absent) or replaced in place (if present), preserving the
/// user's own content outside the markers. Any legacy heading-guarded block
/// (`## Prefer tokensave MCP tools`) is migrated away before writing.
///
/// Returns `Ok(true)` when the file was modified.
pub fn write_rules_block(path: &Path, agent_id: &str, body: &str) -> Result<bool> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).ok();
    }

    let new_marker = block_start_marker(agent_id, body);
    let new_block = format!("{new_marker}\n\n{body}\n\n{BLOCK_END_MARKER}\n");

    let contents = if path.exists() {
        std::fs::read_to_string(path).map_err(|e| TokenSaveError::Config {
            message: format!("failed to read {} for rules refresh: {e}", path.display()),
        })?
    } else {
        String::new()
    };

    // Already current?
    if let Some((installed_body, _, _)) = find_rules_block(&contents) {
        if installed_body.trim_end() == body.trim_end() {
            return Ok(false);
        }
    }

    // Migrate away any managed-block or heading-guarded block before appending
    // the new marker-delimited block. Remove the managed block first so the
    // legacy heading marker inside it does not trigger a false match.
    let contents = remove_legacy_rules_block_from_contents(
        &remove_rules_block_from_contents(&contents),
        LEGACY_RULES_MARKER,
        &[],
    );

    let new_contents = if contents.trim().is_empty() {
        new_block
    } else {
        format!("{}\n\n{new_block}", contents.trim_end())
    };

    backup_config_file(path)?;
    safe_write_text_file(path, &new_contents)?;
    crate::agent_note!(
        "\x1b[32m✔\x1b[0m Wrote tokensave rules block to {}",
        path.display()
    );
    Ok(true)
}

/// Remove a tokensave rules block from a shared file. Returns `Ok(true)` if a
/// block was removed and the file was modified (or removed because it became
/// empty). Returns `Ok(false)` if there was nothing to remove.
pub fn remove_rules_block(path: &Path) -> Result<bool> {
    if !path.exists() {
        return Ok(false);
    }
    let contents = std::fs::read_to_string(path).map_err(|e| TokenSaveError::Config {
        message: format!("failed to read {} for rules removal: {e}", path.display()),
    })?;
    let new_contents = remove_rules_block_from_contents(&contents);
    if new_contents == contents {
        return Ok(false);
    }
    if new_contents.trim().is_empty() {
        let real_path = resolve_symlink_target(path).map_err(|e| TokenSaveError::Config {
            message: format!(
                "cannot safely resolve symlink {}: {e}\n  \
                 Refusing to remove — the symlink was left untouched.",
                path.display()
            ),
        })?;
        std::fs::remove_file(&real_path).map_err(|e| TokenSaveError::Config {
            message: format!("failed to remove {}: {e}", real_path.display()),
        })?;
        crate::agent_note!(
            "\x1b[32m✔\x1b[0m Removed {} (was empty)",
            real_path.display()
        );
        return Ok(true);
    }
    backup_config_file(path)?;
    safe_write_text_file(path, &format!("{}\n", new_contents.trim_end()))?;
    crate::agent_note!(
        "\x1b[32m✔\x1b[0m Removed tokensave rules from {}",
        path.display()
    );
    Ok(true)
}

/// Remove a tokensave rules block from `contents` in memory, returning the
/// content outside the markers with surrounding whitespace normalized. All
/// marker-delimited blocks are removed, so a file that ever accumulated
/// duplicates is collapsed to a single block on the next install.
fn remove_rules_block_from_contents(contents: &str) -> String {
    let mut contents = contents.to_string();
    while let Some((_, start, end)) = find_rules_block(&contents) {
        let prefix = contents[..start].trim_end();
        let suffix = contents[end..].trim_start();
        contents = if prefix.is_empty() && suffix.is_empty() {
            String::new()
        } else if prefix.is_empty() {
            suffix.to_string()
        } else if suffix.is_empty() {
            prefix.to_string()
        } else {
            format!("{prefix}\n\n{suffix}")
        };
    }
    contents
}

// ---------------------------------------------------------------------------
// Legacy block migration (pre-#256 / pre-#441 inline append)
// ---------------------------------------------------------------------------

/// Remove a heading-guarded legacy rules block from `contents` in memory.
fn remove_legacy_rules_block_from_contents(
    contents: &str,
    marker: &str,
    own_subheadings: &[&str],
) -> String {
    let Some(start) = contents.find(marker) else {
        return contents.to_string();
    };
    let after_marker = start + marker.len();
    // Skip past any sub-headings that are part of our own rules block.
    let end = {
        let mut search_from = after_marker;
        loop {
            match contents[search_from..].find("\n## ") {
                Some(pos) => {
                    let abs = search_from + pos;
                    let heading_start = abs + 1; // skip the leading '\n'
                    let heading_line = contents[heading_start..].lines().next().unwrap_or("");
                    if own_subheadings.contains(&heading_line) {
                        search_from = heading_start + heading_line.len();
                    } else {
                        break abs;
                    }
                }
                None => break contents.len(),
            }
        }
    };
    let prefix = contents[..start].trim_end();
    let suffix = contents[end..].trim_start();
    if prefix.is_empty() && suffix.is_empty() {
        String::new()
    } else if prefix.is_empty() {
        suffix.to_string()
    } else if suffix.is_empty() {
        prefix.to_string()
    } else {
        format!("{prefix}\n\n{suffix}")
    }
}

/// Remove a marker-delimited legacy rules block previously appended inline
/// to a user-maintained instructions file (pre-#256 CLAUDE.md/AGENTS.md).
///
/// `own_subheadings` lists this integration's own sub-heading lines (exact
/// text, including the leading `## `) that may appear *inside* the block —
/// e.g. Claude's "## When you spawn an Explore agent ..." — so they're
/// skipped when searching for the block's end. Matching by exact text
/// (rather than a loose substring check like "heading contains tokensave")
/// avoids swallowing an unrelated user heading that happens to mention
/// tokensave immediately after the block.
///
/// Returns `Ok(())` if there was nothing to migrate (file missing, or no
/// marker found) or migration succeeded (backed up via [`backup_config_file`]
/// and atomically rewritten, or removed outright if migration left it
/// empty). Returns `Err` — without touching the file — if it exists,
/// contains the marker, but reading, backing up, or writing fails; callers
/// on the install path must propagate this rather than reporting success
/// while migration silently left stale content in place.
pub fn remove_legacy_rules_block(
    path: &Path,
    marker: &str,
    own_subheadings: &[&str],
) -> crate::errors::Result<()> {
    if !path.exists() {
        return Ok(());
    }
    let contents = std::fs::read_to_string(path).map_err(|e| TokenSaveError::Config {
        message: format!("failed to read {} for migration: {e}", path.display()),
    })?;
    if !contents.contains(marker) {
        return Ok(());
    }

    let new_contents = remove_legacy_rules_block_from_contents(&contents, marker, own_subheadings);

    if new_contents == contents {
        return Ok(());
    }

    backup_config_file(path)?;
    if new_contents.is_empty() {
        // Resolve through a symlink so a symlinked CLAUDE.md/AGENTS.md (e.g.
        // a dotfiles-managed setup) has its real target removed while the
        // symlink itself survives — mirrors write_managed_rules_file's and
        // remove_managed_rules_file's symlink-safety contract.
        let real_path = resolve_symlink_target(path).map_err(|e| TokenSaveError::Config {
            message: format!(
                "cannot safely resolve symlink {}: {e}\n  \
                 Refusing to remove — the symlink was left untouched.",
                path.display()
            ),
        })?;
        std::fs::remove_file(&real_path).map_err(|e| TokenSaveError::Config {
            message: format!(
                "failed to remove {} during migration: {e}",
                real_path.display()
            ),
        })?;
        crate::agent_note!(
            "\x1b[32m✔\x1b[0m Removed {} (was empty)",
            real_path.display()
        );
    } else {
        safe_write_text_file(path, &format!("{new_contents}\n"))?;
        crate::agent_note!(
            "\x1b[32m✔\x1b[0m Removed tokensave rules from {}",
            path.display()
        );
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Doctor checks
// ---------------------------------------------------------------------------

/// Check a tokensave-owned managed rules file for drift from the canonical
/// text for `agent_id`.
pub fn check_managed_rules_file(dc: &mut DoctorCounters, path: &Path, agent_id: &str) {
    let Some(expected) = expected_rules_markdown(agent_id) else {
        dc.warn(&format!("no canonical rules defined for agent {agent_id}"));
        return;
    };

    if !path.exists() {
        dc.fail(&format!(
            "rules file not found at {} — run `tokensave install --agent {agent_id}`",
            path.display()
        ));
        return;
    }

    if !rules_management_enabled() {
        dc.pass(&format!(
            "rules file {} is user-managed (manage_rules = false); not checked",
            path.display()
        ));
        return;
    }

    let installed = std::fs::read_to_string(path).unwrap_or_default();
    // Up to date whatever the user added below the marker (#603), and in both
    // spellings: with the provenance marker (#584), and the bare body a
    // pre-#584 install wrote. The latter gains its marker on the next
    // install, which is not drift the user needs to act on.
    let ours = split_managed_file(&installed).map_or(installed.as_str(), |(ours, _)| ours);
    if ours.trim_end() == expected.trim_end() {
        dc.pass(&format!(
            "rules up to date in {} (version {})",
            path.display(),
            rules_hash(&expected)
        ));
        return;
    }

    dc.fail(&format!(
        "rules text drifted in {} — run `tokensave install --agent {agent_id}` to refresh \
         (text below the marker is kept; set manage_rules = false in ~/.tokensave/config.toml \
         to keep the whole file as yours)",
        path.display()
    ));
    dc.info(&format!(
        "installed version {}, expected version {}",
        rules_hash(&installed),
        rules_hash(&expected)
    ));
}

/// Check a tokensave rules block in a shared owner-edited file for drift.
/// Missing files are reported as warnings, not failures, because some
/// variants (e.g. `JetBrains` Copilot instructions) may not be present on every
/// machine.
pub fn check_shared_rules_block(dc: &mut DoctorCounters, path: &Path, agent_id: &str) {
    let Some(expected) = expected_rules_markdown(agent_id) else {
        dc.warn(&format!("no canonical rules defined for agent {agent_id}"));
        return;
    };

    if !path.exists() {
        dc.warn(&format!(
            "{} not found — run `tokensave install --agent {agent_id}` if you use this variant",
            path.display()
        ));
        return;
    }

    let contents = std::fs::read_to_string(path).unwrap_or_default();

    let block_count = contents.matches(BLOCK_START_PREFIX).count();
    if block_count > 1 {
        dc.warn(&format!(
            "found {block_count} tokensave rules blocks in {} — run `tokensave install --agent {agent_id}` to collapse duplicates",
            path.display()
        ));
    }

    if let Some((installed_body, _, _)) = find_rules_block(&contents) {
        if installed_body.trim_end() == expected.trim_end() {
            dc.pass(&format!(
                "rules block up to date in {} (version {})",
                path.display(),
                rules_hash(&expected)
            ));
        } else {
            dc.fail(&format!(
                "rules block drifted in {} — run `tokensave install --agent {agent_id}` to refresh",
                path.display()
            ));
            dc.info(&format!(
                "installed version {}, expected version {}",
                rules_hash(&installed_body),
                rules_hash(&expected)
            ));
        }
        return;
    }

    // No managed block, but a legacy heading-guarded block is still there.
    if contents.contains(LEGACY_RULES_MARKER) {
        dc.warn(&format!(
            "legacy rules block found in {} — run `tokensave install --agent {agent_id}` to migrate to the refreshed block",
            path.display()
        ));
        return;
    }

    dc.fail(&format!(
        "tokensave rules block missing from {} — run `tokensave install --agent {agent_id}`",
        path.display()
    ));
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn canonical_rules_has_required_sections() {
        let body = canonical_rules_markdown();
        assert!(body.contains("## Prefer tokensave MCP tools"));
        assert!(body.contains("tokensave_status"));
        assert!(body.contains("graph_root"));
        assert!(body.contains("branch-meta.json"));
        assert!(body.contains("filesystem"));
    }

    #[test]
    fn claude_rules_includes_overlay_and_canonical() {
        let body = claude_rules_markdown();
        assert!(body.contains("## MANDATORY: No Explore Agents When Tokensave Is Available"));
        assert!(body.contains("## Prefer tokensave MCP tools"));
    }

    #[test]
    fn expected_rules_for_known_agents() {
        for id in [
            "claude", "auggie", "codex", "droid", "copilot", "opencode", "pi", "gemini", "grok",
            "kimi", "qwen", "vibe", "kiro",
        ] {
            assert!(
                expected_rules_markdown(id).is_some(),
                "expected rules for {id}"
            );
        }
        assert!(expected_rules_markdown("unknown").is_none());
    }

    // ----- #584: ownership of a whole managed rules file -----

    /// The file tokensave writes says tokensave wrote it, so ownership is a
    /// property of the file rather than of its path.
    #[test]
    fn a_managed_rules_file_records_that_tokensave_wrote_it() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("tokensave.md");
        let body = expected_rules_markdown("claude").unwrap();

        assert!(write_managed_rules_file(&path, &body).unwrap());
        let contents = std::fs::read_to_string(&path).unwrap();
        assert!(contents.trim_end().ends_with(MANAGED_FILE_MARKER));
        assert!(contents.contains("## Prefer tokensave MCP tools"));

        // Still idempotent with the marker in place.
        assert!(!write_managed_rules_file(&path, &body).unwrap());
    }

    /// #575: the reporter's file had no marker and was replaced anyway. It
    /// still is — the path is tokensave's — but it is now classified as a
    /// takeover, which is what earns the announcement, and the previous
    /// contents are recoverable.
    #[test]
    fn a_file_tokensave_did_not_write_is_adopted_once_and_backed_up() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("tokensave.md");
        let mine = "# my own notes, not tokensave's\n";
        std::fs::write(&path, mine).unwrap();
        let body = expected_rules_markdown("claude").unwrap();

        assert_eq!(
            classify_managed_file(Some(mine), &managed_rules_contents(&body), &body),
            ManagedFileState::Unowned
        );

        assert!(write_managed_rules_file(&path, &body).unwrap());
        assert_eq!(
            std::fs::read_to_string(path.with_extension("md.bak")).unwrap(),
            mine,
            "the replaced contents must be recoverable"
        );

        // Adopted: the second write is an ordinary refresh, not another
        // takeover, so the warning fires once rather than on every upgrade.
        let contents = std::fs::read_to_string(&path).unwrap();
        assert_eq!(
            classify_managed_file(Some(&contents), &managed_rules_contents(&body), &body),
            ManagedFileState::UpToDate
        );
    }

    /// The upgrade must stay silent for everyone who never touched theirs.
    /// A pre-#584 install wrote the bare body with no marker; that is ours,
    /// and gaining a marker is not a takeover.
    #[test]
    fn a_pre_marker_install_is_recognised_as_ours() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("tokensave.md");
        let body = expected_rules_markdown("claude").unwrap();
        std::fs::write(&path, format!("{}\n", body.trim_end())).unwrap();

        assert_eq!(
            classify_managed_file(
                Some(&std::fs::read_to_string(&path).unwrap()),
                &managed_rules_contents(&body),
                &body
            ),
            ManagedFileState::Ours
        );

        assert!(write_managed_rules_file(&path, &body).unwrap());
        assert!(std::fs::read_to_string(&path)
            .unwrap()
            .trim_end()
            .ends_with(MANAGED_FILE_MARKER));
    }

    /// Rule-text improvements must keep reaching existing users — the reason
    /// the original design overwrote unconditionally. A marked file whose
    /// body is stale is refreshed, not left behind a provenance guard.
    #[test]
    fn a_marked_file_with_a_stale_body_is_still_refreshed() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("tokensave.md");
        let body = expected_rules_markdown("claude").unwrap();
        let stale = body.replace(" Prefer", " ADORE");
        std::fs::write(&path, managed_rules_contents(&stale)).unwrap();

        assert!(write_managed_rules_file(&path, &body).unwrap());
        let contents = std::fs::read_to_string(&path).unwrap();
        assert!(contents.contains("## Prefer tokensave MCP tools"));
        assert!(!contents.contains("ADORE"));
    }

    /// A rules file that opens with YAML frontmatter must keep opening with
    /// it. OMP's does (`alwaysApply: true`), and frontmatter is only
    /// frontmatter on line 1 — which is why the marker trails.
    #[test]
    fn the_marker_does_not_displace_yaml_frontmatter() {
        let body = "---\nalwaysApply: true\n---\n\n## Rules\n";
        let contents = managed_rules_contents(body);
        assert!(contents.starts_with("---\nalwaysApply: true\n---"));
        assert!(contents.trim_end().ends_with(MANAGED_FILE_MARKER));
    }

    /// Doctor must not call the marker drift, in either spelling.
    #[test]
    fn doctor_accepts_both_the_marked_and_pre_marker_spellings() {
        let dir = TempDir::new().unwrap();
        let body = expected_rules_markdown("claude").unwrap();

        for contents in [
            managed_rules_contents(&body),
            format!("{}\n", body.trim_end()),
        ] {
            let path = dir.path().join("tokensave.md");
            std::fs::write(&path, &contents).unwrap();
            let mut dc = DoctorCounters::new();
            check_managed_rules_file(&mut dc, &path, "claude");
            assert_eq!(dc.issues, 0, "unexpected drift report for {contents:.40}");
        }
    }

    #[test]
    fn write_rules_block_creates_file() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("AGENTS.md");
        let body = expected_rules_markdown("droid").unwrap();
        assert!(write_rules_block(&path, "droid", &body).unwrap());
        let contents = std::fs::read_to_string(&path).unwrap();
        assert!(contents.contains(BLOCK_START_PREFIX));
        assert!(contents.contains(BLOCK_END_MARKER));
        assert!(contents.contains("## Prefer tokensave MCP tools"));
    }

    #[test]
    fn write_rules_block_is_idempotent() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("AGENTS.md");
        let body = expected_rules_markdown("droid").unwrap();
        assert!(write_rules_block(&path, "droid", &body).unwrap());
        assert!(!write_rules_block(&path, "droid", &body).unwrap());
    }

    #[test]
    fn write_rules_block_refreshes_changed_body() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("AGENTS.md");
        let body = expected_rules_markdown("droid").unwrap();
        assert!(write_rules_block(&path, "droid", &body).unwrap());
        let changed = body.replace(" Prefer", " ADORE");
        assert!(write_rules_block(&path, "droid", &changed).unwrap());
        let installed = read_rules_block(&path).unwrap();
        assert_eq!(installed.trim_end(), changed.trim_end());
    }

    #[test]
    fn write_rules_block_preserves_owner_content() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("AGENTS.md");
        std::fs::write(&path, "# My personal rules\n\nKeep this.\n").unwrap();
        let body = expected_rules_markdown("droid").unwrap();
        assert!(write_rules_block(&path, "droid", &body).unwrap());
        let contents = std::fs::read_to_string(&path).unwrap();
        assert!(contents.contains("# My personal rules"));
        assert!(contents.contains("Keep this."));
        assert!(contents.contains(BLOCK_START_PREFIX));
    }

    #[test]
    fn write_rules_block_migrates_legacy_heading_block() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("AGENTS.md");
        std::fs::write(
            &path,
            "# My rules\n\n## Prefer tokensave MCP tools\n\nOld stale text.\n",
        )
        .unwrap();
        let body = expected_rules_markdown("droid").unwrap();
        assert!(write_rules_block(&path, "droid", &body).unwrap());
        let contents = std::fs::read_to_string(&path).unwrap();
        assert!(contents.contains("# My rules"));
        assert!(!contents.contains("Old stale text."));
        assert!(contents.contains(BLOCK_START_PREFIX));
    }

    #[test]
    fn remove_rules_block_preserves_owner_content() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("AGENTS.md");
        std::fs::write(
            &path,
            "# Keep me\n\n## Prefer tokensave MCP tools\n\nlegacy.\n",
        )
        .unwrap();
        let body = expected_rules_markdown("droid").unwrap();
        write_rules_block(&path, "droid", &body).unwrap();
        assert!(remove_rules_block(&path).unwrap());
        let contents = std::fs::read_to_string(&path).unwrap();
        assert!(contents.contains("# Keep me"));
        assert!(!contents.contains(BLOCK_START_PREFIX));
    }

    #[test]
    fn remove_rules_block_noop_when_missing() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("AGENTS.md");
        assert!(!remove_rules_block(&path).unwrap());
    }

    #[test]
    fn managed_rules_file_is_idempotent() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("tokensave.md");
        let body = expected_rules_markdown("claude").unwrap();
        assert!(write_managed_rules_file(&path, &body).unwrap());
        assert!(!write_managed_rules_file(&path, &body).unwrap());
    }

    #[test]
    fn doctor_detects_managed_drift() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("tokensave.md");
        std::fs::write(&path, "stale\n").unwrap();
        let mut dc = DoctorCounters::new();
        check_managed_rules_file(&mut dc, &path, "claude");
        assert_eq!(dc.issues, 1, "drift should be reported as an issue");
        assert_eq!(dc.warnings, 0);
    }

    #[test]
    fn text_below_the_marker_survives_a_refresh() {
        // #603: appended rules used to vanish on the next refresh.
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("tokensave.md");
        let old = format!("stale body\n\n{MANAGED_FILE_MARKER}\n\n## Team rule\nKeep this.\n");
        std::fs::write(&path, old).unwrap();
        let body = expected_rules_markdown("claude").unwrap();
        assert!(write_managed_rules_file(&path, &body).unwrap());
        let contents = std::fs::read_to_string(&path).unwrap();
        assert!(contents.starts_with(body.trim_end()), "body refreshed");
        assert!(contents.trim_end().ends_with("## Team rule\nKeep this."));
        // Second write is a no-op, and doctor does not call the addition drift.
        assert!(!write_managed_rules_file(&path, &body).unwrap());
        let mut dc = DoctorCounters::new();
        check_managed_rules_file(&mut dc, &path, "claude");
        assert_eq!(dc.issues, 0);
    }

    #[test]
    fn text_below_the_legacy_marker_is_kept_and_the_marker_upgraded() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("tokensave.md");
        let old = format!("stale body\n\n{LEGACY_MANAGED_FILE_MARKER}\n\nmine\n");
        std::fs::write(&path, old).unwrap();
        let body = expected_rules_markdown("claude").unwrap();
        write_managed_rules_file(&path, &body).unwrap();
        let contents = std::fs::read_to_string(&path).unwrap();
        assert!(contents.contains(MANAGED_FILE_MARKER));
        assert!(!contents.contains(LEGACY_MANAGED_FILE_MARKER));
        assert!(contents.trim_end().ends_with("mine"));
    }

    #[test]
    fn doctor_passes_managed_when_current() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("tokensave.md");
        let body = expected_rules_markdown("claude").unwrap();
        write_managed_rules_file(&path, &body).unwrap();
        let mut dc = DoctorCounters::new();
        check_managed_rules_file(&mut dc, &path, "claude");
        assert_eq!(dc.issues, 0);
        assert_eq!(dc.warnings, 0);
    }

    #[test]
    fn doctor_detects_missing_shared_block() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("AGENTS.md");
        std::fs::write(&path, "# Mine\n\nstale\n").unwrap();
        let mut dc = DoctorCounters::new();
        check_shared_rules_block(&mut dc, &path, "droid");
        assert_eq!(dc.issues, 1, "missing block should be an issue");
    }

    #[test]
    fn doctor_detects_shared_block_drift_with_stale_body() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("AGENTS.md");
        let body = expected_rules_markdown("droid").unwrap();
        assert!(write_rules_block(&path, "droid", &body).unwrap());
        let mut contents = std::fs::read_to_string(&path).unwrap();
        contents = contents.replace(
            "## Prefer tokensave MCP tools",
            "## Prefer tokensave MCP tools (STALE)",
        );
        std::fs::write(&path, contents).unwrap();
        let mut dc = DoctorCounters::new();
        check_shared_rules_block(&mut dc, &path, "droid");
        assert_eq!(dc.issues, 1, "stale body should be reported as drift");
        assert_eq!(dc.warnings, 0);
    }

    #[test]
    fn doctor_warns_on_duplicate_blocks() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("AGENTS.md");
        let body = expected_rules_markdown("droid").unwrap();
        write_rules_block(&path, "droid", &body).unwrap();
        let mut contents = std::fs::read_to_string(&path).unwrap();
        contents.push_str("\n\n");
        contents.push_str(&contents.clone());
        std::fs::write(&path, contents).unwrap();
        let mut dc = DoctorCounters::new();
        check_shared_rules_block(&mut dc, &path, "droid");
        assert_eq!(dc.issues, 0);
        assert_eq!(dc.warnings, 1, "duplicate blocks should be a warning");
    }

    #[test]
    fn remove_managed_rules_file_removes_file() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("rules").join("tokensave.md");
        let body = expected_rules_markdown("claude").unwrap();
        assert!(write_managed_rules_file(&path, &body).unwrap());
        assert!(path.exists());
        remove_managed_rules_file(&path);
        assert!(!path.exists(), "managed rules file should be removed");
        assert!(
            !path.parent().unwrap().exists(),
            "empty parent directory should be pruned"
        );
    }

    #[test]
    fn write_rules_block_removes_all_existing_blocks() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("AGENTS.md");
        let body = expected_rules_markdown("droid").unwrap();
        let stale_marker = block_start_marker("droid", "stale");
        let stale_block = format!("{stale_marker}\n\nstale\n\n{BLOCK_END_MARKER}\n");
        std::fs::write(&path, format!("{stale_block}\n{stale_block}")).unwrap();
        assert!(write_rules_block(&path, "droid", &body).unwrap());
        let contents = std::fs::read_to_string(&path).unwrap();
        assert_eq!(
            contents.matches(BLOCK_START_PREFIX).count(),
            1,
            "only one block should remain after collapsing duplicates"
        );
        assert!(contents.contains(BLOCK_END_MARKER));
    }

    #[test]
    fn doctor_passes_shared_block_when_current() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("AGENTS.md");
        let body = expected_rules_markdown("droid").unwrap();
        write_rules_block(&path, "droid", &body).unwrap();
        let mut dc = DoctorCounters::new();
        check_shared_rules_block(&mut dc, &path, "droid");
        assert_eq!(dc.issues, 0);
        assert_eq!(dc.warnings, 0);
    }

    #[test]
    fn doctor_warns_on_legacy_shared_block() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("AGENTS.md");
        std::fs::write(
            &path,
            "## Prefer tokensave MCP tools\n\nOld text that predates markers.\n",
        )
        .unwrap();
        let mut dc = DoctorCounters::new();
        check_shared_rules_block(&mut dc, &path, "droid");
        assert_eq!(dc.issues, 0);
        assert_eq!(dc.warnings, 1, "legacy block should be a warning");
    }
}