ai-memory 0.7.1

AI-agnostic persistent memory system — MCP server, HTTP API, and CLI for any AI platform
Documentation
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
// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! v0.7.0 K11 — `ai-memory governance migrate-to-permissions` CLI.
//!
//! Backward-compatibility shim during the v0.6 → v0.7 transition.
//! Operators with mature `[governance]` rulesets in `config.toml` get an
//! automated path to the K9 `[[permissions.rules]]` schema. The
//! translator is intentionally a thin TOML-to-TOML mapper: it does NOT
//! interact with the runtime `db::enforce_governance` gate, never
//! touches the SQLite database, and never mutates the loaded
//! `AppConfig`. Operators stay in control of when (or whether) the
//! emitted rules get pasted into their live config.
//!
//! ## Field mapping (`[governance.policy]` → `[[permissions.rules]]`)
//!
//! ```text
//!   policy.scope     → rule.namespace_pattern
//!   policy.action    → rule.op
//!   policy.role      → rule.agent_pattern   (preferred)
//!   policy.agent_id  → rule.agent_pattern   (fallback when role absent)
//!   policy.decision  → rule.decision
//! ```
//!
//! Unknown fields on a policy are dropped silently — the migrator's
//! contract is "translate the documented K11 mapping, nothing more". A
//! follow-up release can extend the field set without breaking existing
//! `[governance]` files because TOML deserialization is forgiving.
//!
//! ## Modes
//!
//! - **Dry-run (default).** Render the proposed `[[permissions.rules]]`
//!   block to stdout as TOML text. Nothing on disk is modified. Safe to
//!   pipe into `diff` against an existing `[permissions]` block.
//! - **`--config-out PATH`.** Write the rendered TOML to `PATH`. When
//!   `PATH` matches the loaded config file, the migrator does an
//!   in-place merge: every non-`[governance]` section of the original
//!   file is preserved verbatim, and the new `[[permissions.rules]]`
//!   array is appended (existing `[[permissions.rules]]` entries are
//!   preserved as well — this is an additive append, NOT a replace).

use crate::models::field_names;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use clap::Args;
use serde::{Deserialize, Serialize};

use crate::cli::CliOutput;

/// Repeated `.expect` label for args validated earlier in the same fn
/// (#1558 batch 6).
const EXPECT_CHECKED_ABOVE: &str = "checked above";

// ---------------------------------------------------------------------------
// CLI arg surface
// ---------------------------------------------------------------------------

/// `ai-memory governance migrate-to-permissions` arguments.
#[derive(Args, Debug, Clone)]
pub struct MigrateToPermissionsArgs {
    /// Print the rendered `[[permissions.rules]]` block to stdout
    /// without writing anywhere. This is the default behaviour when
    /// `--config-out` is omitted; passing `--dry-run` explicitly is
    /// supported for callers who want the intent to be obvious.
    #[arg(long, default_value_t = false)]
    pub dry_run: bool,

    /// Write the rendered `[[permissions.rules]]` block to this path.
    /// When the path matches the loaded config file, the migrator
    /// performs an in-place merge that preserves every other section.
    /// When the path is different, the rendered block is written
    /// standalone (overwriting any existing file at that path).
    #[arg(long, value_name = "PATH")]
    pub config_out: Option<PathBuf>,

    /// Override the loaded config file path. Defaults to
    /// `~/.config/ai-memory/config.toml` (the path
    /// [`crate::config::AppConfig::config_path`] returns).
    #[arg(long, value_name = "PATH")]
    pub config_in: Option<PathBuf>,
}

// ---------------------------------------------------------------------------
// Wire format — `[governance]` (v0.6.x legacy)
// ---------------------------------------------------------------------------

/// Top-level `[governance]` section. The only field today is the
/// `policy` array; the wrapper exists so the deserializer can ignore
/// other unknown sub-keys an operator might have added.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LegacyGovernance {
    /// Array of `[[governance.policy]]` entries in the loaded config.
    #[serde(default)]
    pub policy: Vec<LegacyGovernancePolicy>,
}

/// A single legacy governance policy. Mirrors the documented v0.6.x
/// field set; every field is optional so partial entries round-trip.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LegacyGovernancePolicy {
    /// Namespace selector (glob-shaped string, e.g. `team/*`).
    #[serde(default)]
    pub scope: Option<String>,
    /// Operation gated by this policy: `write`, `delete`, `promote`,
    /// `recall`, etc. Translated 1:1 into `rule.op`.
    #[serde(default)]
    pub action: Option<String>,
    /// Role-based agent selector. When present, takes precedence over
    /// `agent_id` for `rule.agent_pattern`.
    #[serde(default)]
    pub role: Option<String>,
    /// Agent-id selector. Used as a fallback when `role` is absent.
    #[serde(default)]
    pub agent_id: Option<String>,
    /// Decision returned when the policy matches: `allow`, `deny`,
    /// `ask`, etc. Forwarded verbatim to `rule.decision`.
    #[serde(default)]
    pub decision: Option<String>,
}

// ---------------------------------------------------------------------------
// Wire format — `[[permissions.rules]]` (v0.7.0 K9)
// ---------------------------------------------------------------------------

/// Container for `[[permissions.rules]]`. Used only by the migrator's
/// rendering path so the K9 module can keep its richer in-memory shape
/// without forcing the migrator to depend on it.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PermissionsBlock {
    /// `[[permissions.rules]]` array.
    #[serde(default)]
    pub rules: Vec<PermissionRule>,
}

/// One rule in the K9 `[[permissions.rules]]` array.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PermissionRule {
    /// Namespace glob the rule applies to.
    pub namespace_pattern: String,
    /// Operation the rule applies to (`write`, `delete`, …).
    pub op: String,
    /// Agent-id or role glob the rule applies to. Empty string when
    /// the source policy carried neither `role` nor `agent_id`.
    pub agent_pattern: String,
    /// Decision the rule returns when matched (`allow`, `deny`, `ask`).
    pub decision: String,
}

// ---------------------------------------------------------------------------
// Translation
// ---------------------------------------------------------------------------

/// Translate one [`LegacyGovernancePolicy`] into the K9
/// [`PermissionRule`] shape. Missing fields are filled with `"*"` for
/// pattern-shaped fields (the deny-first matcher treats `"*"` as
/// "match anything") and `"ask"` for the decision (matches the K9
/// "ask-by-default for ambiguous cases" default).
#[must_use]
pub fn translate_policy(p: &LegacyGovernancePolicy) -> PermissionRule {
    let agent_pattern = p
        .role
        .clone()
        .or_else(|| p.agent_id.clone())
        .unwrap_or_else(|| "*".to_string());
    PermissionRule {
        namespace_pattern: p.scope.clone().unwrap_or_else(|| "*".to_string()),
        op: p.action.clone().unwrap_or_else(|| "*".to_string()),
        agent_pattern,
        decision: p.decision.clone().unwrap_or_else(|| "ask".to_string()),
    }
}

/// Translate a [`LegacyGovernance`] section into a [`PermissionsBlock`].
#[must_use]
pub fn translate(legacy: &LegacyGovernance) -> PermissionsBlock {
    PermissionsBlock {
        rules: legacy.policy.iter().map(translate_policy).collect(),
    }
}

// ---------------------------------------------------------------------------
// Parse + render
// ---------------------------------------------------------------------------

/// Parse the `[governance]` section out of a raw config-toml string.
/// Returns an empty [`LegacyGovernance`] when the section is missing —
/// callers can detect "nothing to migrate" by checking
/// `result.policy.is_empty()`.
pub fn parse_legacy_governance(raw: &str) -> Result<LegacyGovernance> {
    let value: toml::Value = toml::from_str(raw).context("parse config.toml")?;
    let Some(gov) = value.get(field_names::GOVERNANCE) else {
        return Ok(LegacyGovernance::default());
    };
    let parsed: LegacyGovernance = gov.clone().try_into().context("parse [governance] block")?;
    Ok(parsed)
}

/// Render a [`PermissionsBlock`] as a `[[permissions.rules]]` TOML
/// fragment. The output is a standalone snippet — no `[permissions]`
/// table header, just the array entries in source order. Operators can
/// paste it into an existing `[permissions]` table or feed it into
/// `--config-out`.
#[must_use]
pub fn render_permissions_block(block: &PermissionsBlock) -> String {
    if block.rules.is_empty() {
        return "# v0.7.0 K11: no [governance] policies found — nothing to migrate.\n".to_string();
    }
    let mut out = String::new();
    out.push_str("# v0.7.0 K11: translated from legacy [[governance.policy]] entries.\n");
    out.push_str("# Mapping: scope→namespace_pattern, action→op,\n");
    out.push_str("#          role|agent_id→agent_pattern, decision→decision.\n");
    for rule in &block.rules {
        out.push_str("\n[[permissions.rules]]\n");
        out.push_str(&format!(
            "namespace_pattern = {}\n",
            toml_str(&rule.namespace_pattern)
        ));
        out.push_str(&format!("op = {}\n", toml_str(&rule.op)));
        out.push_str(&format!(
            "agent_pattern = {}\n",
            toml_str(&rule.agent_pattern)
        ));
        out.push_str(&format!("decision = {}\n", toml_str(&rule.decision)));
    }
    out
}

/// Quote a string the way TOML expects: basic-string with escaped
/// backslashes and quotes. Avoids pulling in `toml::ser` for a
/// four-line helper.
fn toml_str(s: &str) -> String {
    let escaped: String = s
        .chars()
        .flat_map(|c| match c {
            '\\' => vec!['\\', '\\'],
            '"' => vec!['\\', '"'],
            '\n' => vec!['\\', 'n'],
            '\r' => vec!['\\', 'r'],
            '\t' => vec!['\\', 't'],
            c => vec![c],
        })
        .collect();
    format!("\"{escaped}\"")
}

// ---------------------------------------------------------------------------
// In-place merge
// ---------------------------------------------------------------------------

/// Append the rendered `[[permissions.rules]]` block to an existing
/// config file's contents. The merge strategy is intentionally
/// conservative:
///
/// - Every section of the existing file is preserved verbatim
///   (including any pre-existing `[[permissions.rules]]` entries).
/// - The migrator block is appended at the end with a leading
///   `# --- migrated from [governance] (K11) ---` separator so a human
///   reader can see exactly which entries the migrator wrote.
///
/// This sidesteps the messy task of editing TOML in place (which would
/// strip comments and reorder keys) while still meeting the K11
/// "preserve other sections" contract.
#[must_use]
pub fn merge_in_place(existing: &str, rendered: &str) -> String {
    let mut out = String::with_capacity(existing.len() + rendered.len() + 64);
    out.push_str(existing);
    if !out.ends_with('\n') {
        out.push('\n');
    }
    out.push_str("\n# --- migrated from [governance] (v0.7.0 K11) ---\n");
    out.push_str(rendered);
    out
}

// ---------------------------------------------------------------------------
// Driver
// ---------------------------------------------------------------------------

/// `ai-memory governance migrate-to-permissions` entry point.
///
/// Returns `Ok(())` after a successful dry-run / write. Errors propagate
/// for missing input files, parse failures, and IO write failures — the
/// caller exits non-zero in the standard way.
pub fn run(args: MigrateToPermissionsArgs, out: &mut CliOutput<'_>) -> Result<()> {
    let in_path = match args.config_in.clone() {
        Some(p) => p,
        None => crate::config::AppConfig::config_path()
            .context("no HOME — cannot resolve default config path; pass --config-in")?,
    };
    let raw = std::fs::read_to_string(&in_path)
        .with_context(|| format!("read config from {}", in_path.display()))?;
    let legacy = parse_legacy_governance(&raw)?;
    let block = translate(&legacy);
    let rendered = render_permissions_block(&block);

    // Dry-run is the default. We treat "no --config-out AND no
    // --dry-run" as dry-run too, matching the K11 spec.
    let dry_run = args.dry_run || args.config_out.is_none();
    if dry_run {
        // Print to stdout. The rendered block already ends in a
        // newline, so no extra `\n` here.
        write!(out.stdout, "{rendered}")?;
        return Ok(());
    }

    // Write path. Either standalone (different file) or in-place merge
    // (same file as the input). Compare canonical paths so a relative
    // and absolute reference to the same file still take the merge
    // branch.
    let out_path = args.config_out.clone().expect(EXPECT_CHECKED_ABOVE);
    let same_file = same_path(&in_path, &out_path);
    if same_file {
        let merged = merge_in_place(&raw, &rendered);
        std::fs::write(&out_path, merged)
            .with_context(|| format!("write merged config to {}", out_path.display()))?;
        writeln!(
            out.stdout,
            "merged {} migrated rule(s) into {}",
            block.rules.len(),
            out_path.display()
        )?;
    } else {
        std::fs::write(&out_path, &rendered)
            .with_context(|| format!("write rendered block to {}", out_path.display()))?;
        writeln!(
            out.stdout,
            "wrote {} migrated rule(s) to {}",
            block.rules.len(),
            out_path.display()
        )?;
    }

    if block.rules.is_empty() {
        // Surface the no-op as a non-fatal warning so operators don't
        // mistakenly assume the migration ran successfully when their
        // legacy config never had a `[governance]` block to begin with.
        writeln!(
            out.stderr,
            "warning: no [governance] policies found in {} — nothing migrated",
            in_path.display()
        )?;
    }

    Ok(())
}

/// Compare two paths for equality after canonicalization, falling back
/// to a literal-component compare when canonicalization fails (e.g. the
/// output file does not exist yet — that's still "same path" if the
/// strings agree).
fn same_path(a: &Path, b: &Path) -> bool {
    match (a.canonicalize(), b.canonicalize()) {
        (Ok(ca), Ok(cb)) => ca == cb,
        _ => a == b,
    }
}

/// Internal helper exposed for the integration tests so they can drive
/// the migrator with an explicit `--config-out` path without round-
/// tripping through clap. Returns the rendered block as a string for
/// post-write asserts.
#[doc(hidden)]
#[allow(dead_code)]
pub fn run_with_paths(
    in_path: &Path,
    config_out: Option<&Path>,
    dry_run: bool,
    out: &mut CliOutput<'_>,
) -> Result<String> {
    let raw = std::fs::read_to_string(in_path)
        .with_context(|| format!("read config from {}", in_path.display()))?;
    let legacy = parse_legacy_governance(&raw)?;
    let block = translate(&legacy);
    let rendered = render_permissions_block(&block);

    let dry = dry_run || config_out.is_none();
    if dry {
        write!(out.stdout, "{rendered}")?;
        return Ok(rendered);
    }

    let out_path = config_out.expect(EXPECT_CHECKED_ABOVE);
    if same_path(in_path, out_path) {
        let merged = merge_in_place(&raw, &rendered);
        std::fs::write(out_path, merged)
            .with_context(|| format!("write merged to {}", out_path.display()))?;
    } else if let Some(parent) = out_path.parent()
        && !parent.as_os_str().is_empty()
        && !parent.exists()
    {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("create parent of {}", out_path.display()))?;
        std::fs::write(out_path, &rendered)
            .with_context(|| format!("write rendered to {}", out_path.display()))?;
    } else {
        std::fs::write(out_path, &rendered)
            .with_context(|| format!("write rendered to {}", out_path.display()))?;
    }
    writeln!(
        out.stdout,
        "wrote {} migrated rule(s) to {}",
        block.rules.len(),
        out_path.display()
    )?;
    Ok(rendered)
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::test_utils::TestEnv;

    fn sample_legacy_config() -> &'static str {
        r#"
# user config with a mature governance ruleset

[governance]

[[governance.policy]]
scope = "team/eng/*"
action = "write"
role = "engineer"
decision = "allow"

[[governance.policy]]
scope = "team/finance/*"
action = "delete"
agent_id = "alice"
decision = "ask"

[[governance.policy]]
scope = "*"
action = "promote"
decision = "deny"
"#
    }

    #[test]
    fn parse_three_policies() {
        let parsed = parse_legacy_governance(sample_legacy_config()).unwrap();
        assert_eq!(parsed.policy.len(), 3);
        assert_eq!(parsed.policy[0].scope.as_deref(), Some("team/eng/*"));
        assert_eq!(parsed.policy[0].role.as_deref(), Some("engineer"));
        assert_eq!(parsed.policy[1].agent_id.as_deref(), Some("alice"));
        assert_eq!(parsed.policy[2].decision.as_deref(), Some("deny"));
    }

    #[test]
    fn translate_role_wins_over_agent_id() {
        let p = LegacyGovernancePolicy {
            scope: Some("ns".into()),
            action: Some("write".into()),
            role: Some("ops".into()),
            agent_id: Some("alice".into()),
            decision: Some("allow".into()),
        };
        let r = translate_policy(&p);
        assert_eq!(r.namespace_pattern, "ns");
        assert_eq!(r.op, "write");
        assert_eq!(r.agent_pattern, "ops");
        assert_eq!(r.decision, "allow");
    }

    #[test]
    fn translate_falls_back_to_agent_id_when_role_absent() {
        let p = LegacyGovernancePolicy {
            scope: Some("ns".into()),
            action: Some("write".into()),
            role: None,
            agent_id: Some("alice".into()),
            decision: Some("allow".into()),
        };
        let r = translate_policy(&p);
        assert_eq!(r.agent_pattern, "alice");
    }

    #[test]
    fn translate_uses_safe_defaults_when_fields_missing() {
        let p = LegacyGovernancePolicy::default();
        let r = translate_policy(&p);
        assert_eq!(r.namespace_pattern, "*");
        assert_eq!(r.op, "*");
        assert_eq!(r.agent_pattern, "*");
        assert_eq!(r.decision, "ask");
    }

    #[test]
    fn render_emits_one_block_per_rule() {
        let parsed = parse_legacy_governance(sample_legacy_config()).unwrap();
        let block = translate(&parsed);
        let rendered = render_permissions_block(&block);
        assert_eq!(rendered.matches("[[permissions.rules]]").count(), 3);
        assert!(rendered.contains("namespace_pattern = \"team/eng/*\""));
        assert!(rendered.contains("agent_pattern = \"engineer\""));
        assert!(rendered.contains("agent_pattern = \"alice\""));
        assert!(rendered.contains("decision = \"deny\""));
    }

    #[test]
    fn render_empty_block_emits_comment() {
        let block = PermissionsBlock::default();
        let s = render_permissions_block(&block);
        assert!(s.contains("nothing to migrate"));
    }

    #[test]
    fn missing_governance_section_yields_empty() {
        let raw = "tier = \"semantic\"\n";
        let parsed = parse_legacy_governance(raw).unwrap();
        assert!(parsed.policy.is_empty());
    }

    #[test]
    fn merge_in_place_preserves_existing_then_appends() {
        let existing = "tier = \"semantic\"\n[scoring]\nlegacy_scoring = false\n";
        let rendered = "[[permissions.rules]]\nnamespace_pattern = \"a\"\n";
        let merged = merge_in_place(existing, rendered);
        assert!(merged.starts_with("tier = \"semantic\""));
        assert!(merged.contains("[scoring]"));
        assert!(merged.contains("[[permissions.rules]]"));
        assert!(merged.contains("--- migrated from [governance] (v0.7.0 K11) ---"));
    }

    #[test]
    fn run_with_paths_dry_run_writes_to_stdout() {
        let mut env = TestEnv::fresh();
        let cfg_path = env.db_path.parent().unwrap().join("config.toml");
        std::fs::write(&cfg_path, sample_legacy_config()).unwrap();
        let _ = {
            let mut o = env.output();
            run_with_paths(&cfg_path, None, true, &mut o).unwrap()
        };
        let stdout = env.stdout_str();
        assert_eq!(stdout.matches("[[permissions.rules]]").count(), 3);
    }

    #[test]
    fn run_with_paths_writes_to_named_file() {
        let mut env = TestEnv::fresh();
        let in_path = env.db_path.parent().unwrap().join("in.toml");
        let out_path = env.db_path.parent().unwrap().join("out.toml");
        std::fs::write(&in_path, sample_legacy_config()).unwrap();
        let _ = {
            let mut o = env.output();
            run_with_paths(&in_path, Some(&out_path), false, &mut o).unwrap()
        };
        let written = std::fs::read_to_string(&out_path).unwrap();
        assert_eq!(written.matches("[[permissions.rules]]").count(), 3);
        let parsed: toml::Value = toml::from_str(&written).unwrap();
        let rules = parsed["permissions"]["rules"].as_array().unwrap();
        assert_eq!(rules.len(), 3);
    }

    #[test]
    fn run_with_paths_in_place_merge_preserves_other_sections() {
        let mut env = TestEnv::fresh();
        let cfg_path = env.db_path.parent().unwrap().join("cfg.toml");
        let mut original = String::from(sample_legacy_config());
        original.push_str("\n[scoring]\nlegacy_scoring = false\n");
        std::fs::write(&cfg_path, &original).unwrap();
        let _ = {
            let mut o = env.output();
            run_with_paths(&cfg_path, Some(&cfg_path), false, &mut o).unwrap()
        };
        let after = std::fs::read_to_string(&cfg_path).unwrap();
        assert!(after.contains("[scoring]"));
        assert!(after.contains("legacy_scoring = false"));
        assert!(after.contains("[governance]"));
        assert_eq!(after.matches("[[permissions.rules]]").count(), 3);
    }

    // ---------- E1 coverage uplift -----------------------------------
    // Target: `run` (production entry point, lines 280-341), `toml_str`
    // escape characters (lines 231-235), `merge_in_place` no-trailing-
    // newline branch (line 264), `run_with_paths` create_dir_all branch
    // (lines 387-390).

    /// Build a `MigrateToPermissionsArgs` with the given config_in /
    /// config_out + dry_run combo.
    fn args(in_path: &Path, out_path: Option<&Path>, dry_run: bool) -> MigrateToPermissionsArgs {
        MigrateToPermissionsArgs {
            dry_run,
            config_out: out_path.map(std::path::Path::to_path_buf),
            config_in: Some(in_path.to_path_buf()),
        }
    }

    #[test]
    fn run_dry_run_default_writes_stdout() {
        // Hits run() lines 280-300: dry-run path (no config-out + no
        // --dry-run flag).
        let mut env = TestEnv::fresh();
        let cfg_path = env.db_path.parent().unwrap().join("cfg.toml");
        std::fs::write(&cfg_path, sample_legacy_config()).unwrap();
        let a = args(&cfg_path, None, false);
        {
            let mut o = env.output();
            run(a, &mut o).unwrap();
        }
        let s = env.stdout_str();
        assert_eq!(s.matches("[[permissions.rules]]").count(), 3);
    }

    #[test]
    fn run_dry_run_explicit_flag_writes_stdout() {
        // Same path but with explicit --dry-run + a config-out that's
        // ignored.
        let mut env = TestEnv::fresh();
        let cfg_path = env.db_path.parent().unwrap().join("in.toml");
        let out_path = env.db_path.parent().unwrap().join("should-not-exist.toml");
        std::fs::write(&cfg_path, sample_legacy_config()).unwrap();
        let a = args(&cfg_path, Some(&out_path), true);
        {
            let mut o = env.output();
            run(a, &mut o).unwrap();
        }
        assert!(env.stdout_str().contains("[[permissions.rules]]"));
        // out_path must NOT have been written.
        assert!(!out_path.exists(), "dry-run must not touch config-out");
    }

    #[test]
    fn run_writes_standalone_file_when_paths_differ() {
        // Hits run() lines 306-326 — write-path standalone branch.
        let mut env = TestEnv::fresh();
        let in_path = env.db_path.parent().unwrap().join("in.toml");
        let out_path = env.db_path.parent().unwrap().join("out.toml");
        std::fs::write(&in_path, sample_legacy_config()).unwrap();
        let a = args(&in_path, Some(&out_path), false);
        {
            let mut o = env.output();
            run(a, &mut o).unwrap();
        }
        let written = std::fs::read_to_string(&out_path).unwrap();
        assert_eq!(written.matches("[[permissions.rules]]").count(), 3);
        // stdout reports the write.
        assert!(env.stdout_str().contains("wrote 3 migrated rule(s)"));
    }

    #[test]
    fn run_in_place_merge_when_paths_match() {
        // Hits run() lines 308-317 — in-place merge branch.
        let mut env = TestEnv::fresh();
        let cfg_path = env.db_path.parent().unwrap().join("cfg.toml");
        let mut original = String::from(sample_legacy_config());
        original.push_str("\n[scoring]\nlegacy_scoring = false\n");
        std::fs::write(&cfg_path, &original).unwrap();
        let a = args(&cfg_path, Some(&cfg_path), false);
        {
            let mut o = env.output();
            run(a, &mut o).unwrap();
        }
        let after = std::fs::read_to_string(&cfg_path).unwrap();
        assert!(after.contains("[scoring]"));
        assert!(after.contains("[governance]"));
        assert!(after.contains("--- migrated from [governance] (v0.7.0 K11) ---"));
        assert!(env.stdout_str().contains("merged 3 migrated rule(s)"));
    }

    #[test]
    fn run_writes_warning_when_no_governance_block() {
        // Hits run() lines 329-338 — the "nothing migrated" branch
        // when the legacy file has no [governance] section. We pair it
        // with --config-out so the write path runs (vs dry-run, which
        // returns before the warning branch).
        let mut env = TestEnv::fresh();
        let in_path = env.db_path.parent().unwrap().join("empty.toml");
        let out_path = env.db_path.parent().unwrap().join("empty-out.toml");
        std::fs::write(&in_path, "tier = \"semantic\"\n").unwrap();
        let a = args(&in_path, Some(&out_path), false);
        {
            let mut o = env.output();
            run(a, &mut o).unwrap();
        }
        assert!(env.stderr_str().contains("no [governance] policies"));
        // stdout reports 0 rules migrated.
        assert!(env.stdout_str().contains("wrote 0 migrated rule(s)"));
    }

    #[test]
    fn run_errors_when_input_missing() {
        // Hits run() lines 286-287 — read_to_string failure.
        let mut env = TestEnv::fresh();
        let missing = env.db_path.parent().unwrap().join("no-such-file.toml");
        let a = args(&missing, None, false);
        let mut o = env.output();
        let res = run(a, &mut o);
        assert!(res.is_err());
        let err = res.unwrap_err().to_string();
        assert!(err.contains("read config"));
    }

    #[test]
    fn toml_str_escapes_special_chars() {
        // Drives the escape-vec arms of `toml_str` (lines 231-235) —
        // backslash, double-quote, newline, carriage-return, tab.
        let policy = LegacyGovernancePolicy {
            scope: Some("ns\"with\\quote".into()),
            action: Some("op\nnewline".into()),
            role: Some("role\ttab".into()),
            agent_id: None,
            decision: Some("dec\rret".into()),
        };
        let block = PermissionsBlock {
            rules: vec![translate_policy(&policy)],
        };
        let rendered = render_permissions_block(&block);
        // The backslash and double-quote both escape to `\\` / `\"`.
        // The newline / CR / tab escape to the literal `\n` / `\r` /
        // `\t` two-char sequences inside the TOML basic string.
        assert!(
            rendered.contains(r#"\""#),
            "missing escaped quote: {rendered}"
        );
        assert!(
            rendered.contains(r"\\"),
            "missing escaped backslash: {rendered}"
        );
        assert!(
            rendered.contains(r"\n"),
            "missing escaped newline: {rendered}"
        );
        assert!(rendered.contains(r"\r"), "missing escaped CR: {rendered}");
        assert!(rendered.contains(r"\t"), "missing escaped tab: {rendered}");
    }

    #[test]
    fn merge_in_place_adds_newline_when_input_lacks_trailing_newline() {
        // Hits the `if !out.ends_with('\n')` true arm of `merge_in_place`
        // (line 264).
        let existing = "tier = \"semantic\""; // no trailing newline
        let rendered = "[[permissions.rules]]\n";
        let merged = merge_in_place(existing, rendered);
        assert!(merged.starts_with("tier = \"semantic\"\n"));
    }

    #[test]
    fn run_with_paths_creates_missing_parent_directory() {
        // Hits run_with_paths() lines 387-390: out_path parent doesn't
        // exist → create_dir_all branch.
        let mut env = TestEnv::fresh();
        let in_path = env.db_path.parent().unwrap().join("in.toml");
        let nested = env
            .db_path
            .parent()
            .unwrap()
            .join("nested/dir/permissions.toml");
        std::fs::write(&in_path, sample_legacy_config()).unwrap();
        assert!(!nested.parent().unwrap().exists());
        let _ = {
            let mut o = env.output();
            run_with_paths(&in_path, Some(&nested), false, &mut o).unwrap()
        };
        let written = std::fs::read_to_string(&nested).unwrap();
        assert_eq!(written.matches("[[permissions.rules]]").count(), 3);
    }

    #[test]
    fn parse_invalid_toml_returns_err() {
        // Drives parse_legacy_governance's context-wrapped error arm.
        let raw = "this = not\nvalid_toml = at all = \"oops\"";
        let res = parse_legacy_governance(raw);
        assert!(res.is_err());
    }

    #[test]
    fn parse_with_governance_but_bogus_inner_returns_err() {
        // [governance] section is present but `policy` is the wrong
        // shape — try_into fails.
        let raw = "[governance]\npolicy = 42\n";
        let res = parse_legacy_governance(raw);
        assert!(res.is_err());
    }
}