Skip to main content

ai_memory/cli/
governance_migrate.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 K11 — `ai-memory governance migrate-to-permissions` CLI.
5//!
6//! Backward-compatibility shim during the v0.6 → v0.7 transition.
7//! Operators with mature `[governance]` rulesets in `config.toml` get an
8//! automated path to the K9 `[[permissions.rules]]` schema. The
9//! translator is intentionally a thin TOML-to-TOML mapper: it does NOT
10//! interact with the runtime `db::enforce_governance` gate, never
11//! touches the SQLite database, and never mutates the loaded
12//! `AppConfig`. Operators stay in control of when (or whether) the
13//! emitted rules get pasted into their live config.
14//!
15//! ## Field mapping (`[governance.policy]` → `[[permissions.rules]]`)
16//!
17//! ```text
18//!   policy.scope     → rule.namespace_pattern
19//!   policy.action    → rule.op
20//!   policy.role      → rule.agent_pattern   (preferred)
21//!   policy.agent_id  → rule.agent_pattern   (fallback when role absent)
22//!   policy.decision  → rule.decision
23//! ```
24//!
25//! Unknown fields on a policy are dropped silently — the migrator's
26//! contract is "translate the documented K11 mapping, nothing more". A
27//! follow-up release can extend the field set without breaking existing
28//! `[governance]` files because TOML deserialization is forgiving.
29//!
30//! ## Modes
31//!
32//! - **Dry-run (default).** Render the proposed `[[permissions.rules]]`
33//!   block to stdout as TOML text. Nothing on disk is modified. Safe to
34//!   pipe into `diff` against an existing `[permissions]` block.
35//! - **`--config-out PATH`.** Write the rendered TOML to `PATH`. When
36//!   `PATH` matches the loaded config file, the migrator does an
37//!   in-place merge: every non-`[governance]` section of the original
38//!   file is preserved verbatim, and the new `[[permissions.rules]]`
39//!   array is appended (existing `[[permissions.rules]]` entries are
40//!   preserved as well — this is an additive append, NOT a replace).
41
42use crate::models::field_names;
43use std::path::{Path, PathBuf};
44
45use anyhow::{Context, Result};
46use clap::Args;
47use serde::{Deserialize, Serialize};
48
49use crate::cli::CliOutput;
50
51/// Repeated `.expect` label for args validated earlier in the same fn
52/// (#1558 batch 6).
53const EXPECT_CHECKED_ABOVE: &str = "checked above";
54
55// ---------------------------------------------------------------------------
56// CLI arg surface
57// ---------------------------------------------------------------------------
58
59/// `ai-memory governance migrate-to-permissions` arguments.
60#[derive(Args, Debug, Clone)]
61pub struct MigrateToPermissionsArgs {
62    /// Print the rendered `[[permissions.rules]]` block to stdout
63    /// without writing anywhere. This is the default behaviour when
64    /// `--config-out` is omitted; passing `--dry-run` explicitly is
65    /// supported for callers who want the intent to be obvious.
66    #[arg(long, default_value_t = false)]
67    pub dry_run: bool,
68
69    /// Write the rendered `[[permissions.rules]]` block to this path.
70    /// When the path matches the loaded config file, the migrator
71    /// performs an in-place merge that preserves every other section.
72    /// When the path is different, the rendered block is written
73    /// standalone (overwriting any existing file at that path).
74    #[arg(long, value_name = "PATH")]
75    pub config_out: Option<PathBuf>,
76
77    /// Override the loaded config file path. Defaults to
78    /// `~/.config/ai-memory/config.toml` (the path
79    /// [`crate::config::AppConfig::config_path`] returns).
80    #[arg(long, value_name = "PATH")]
81    pub config_in: Option<PathBuf>,
82}
83
84// ---------------------------------------------------------------------------
85// Wire format — `[governance]` (v0.6.x legacy)
86// ---------------------------------------------------------------------------
87
88/// Top-level `[governance]` section. The only field today is the
89/// `policy` array; the wrapper exists so the deserializer can ignore
90/// other unknown sub-keys an operator might have added.
91#[derive(Debug, Clone, Default, Serialize, Deserialize)]
92pub struct LegacyGovernance {
93    /// Array of `[[governance.policy]]` entries in the loaded config.
94    #[serde(default)]
95    pub policy: Vec<LegacyGovernancePolicy>,
96}
97
98/// A single legacy governance policy. Mirrors the documented v0.6.x
99/// field set; every field is optional so partial entries round-trip.
100#[derive(Debug, Clone, Default, Serialize, Deserialize)]
101pub struct LegacyGovernancePolicy {
102    /// Namespace selector (glob-shaped string, e.g. `team/*`).
103    #[serde(default)]
104    pub scope: Option<String>,
105    /// Operation gated by this policy: `write`, `delete`, `promote`,
106    /// `recall`, etc. Translated 1:1 into `rule.op`.
107    #[serde(default)]
108    pub action: Option<String>,
109    /// Role-based agent selector. When present, takes precedence over
110    /// `agent_id` for `rule.agent_pattern`.
111    #[serde(default)]
112    pub role: Option<String>,
113    /// Agent-id selector. Used as a fallback when `role` is absent.
114    #[serde(default)]
115    pub agent_id: Option<String>,
116    /// Decision returned when the policy matches: `allow`, `deny`,
117    /// `ask`, etc. Forwarded verbatim to `rule.decision`.
118    #[serde(default)]
119    pub decision: Option<String>,
120}
121
122// ---------------------------------------------------------------------------
123// Wire format — `[[permissions.rules]]` (v0.7.0 K9)
124// ---------------------------------------------------------------------------
125
126/// Container for `[[permissions.rules]]`. Used only by the migrator's
127/// rendering path so the K9 module can keep its richer in-memory shape
128/// without forcing the migrator to depend on it.
129#[derive(Debug, Clone, Default, Serialize, Deserialize)]
130pub struct PermissionsBlock {
131    /// `[[permissions.rules]]` array.
132    #[serde(default)]
133    pub rules: Vec<PermissionRule>,
134}
135
136/// One rule in the K9 `[[permissions.rules]]` array.
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138pub struct PermissionRule {
139    /// Namespace glob the rule applies to.
140    pub namespace_pattern: String,
141    /// Operation the rule applies to (`write`, `delete`, …).
142    pub op: String,
143    /// Agent-id or role glob the rule applies to. Empty string when
144    /// the source policy carried neither `role` nor `agent_id`.
145    pub agent_pattern: String,
146    /// Decision the rule returns when matched (`allow`, `deny`, `ask`).
147    pub decision: String,
148}
149
150// ---------------------------------------------------------------------------
151// Translation
152// ---------------------------------------------------------------------------
153
154/// Translate one [`LegacyGovernancePolicy`] into the K9
155/// [`PermissionRule`] shape. Missing fields are filled with `"*"` for
156/// pattern-shaped fields (the deny-first matcher treats `"*"` as
157/// "match anything") and `"ask"` for the decision (matches the K9
158/// "ask-by-default for ambiguous cases" default).
159#[must_use]
160pub fn translate_policy(p: &LegacyGovernancePolicy) -> PermissionRule {
161    let agent_pattern = p
162        .role
163        .clone()
164        .or_else(|| p.agent_id.clone())
165        .unwrap_or_else(|| "*".to_string());
166    PermissionRule {
167        namespace_pattern: p.scope.clone().unwrap_or_else(|| "*".to_string()),
168        op: p.action.clone().unwrap_or_else(|| "*".to_string()),
169        agent_pattern,
170        decision: p.decision.clone().unwrap_or_else(|| "ask".to_string()),
171    }
172}
173
174/// Translate a [`LegacyGovernance`] section into a [`PermissionsBlock`].
175#[must_use]
176pub fn translate(legacy: &LegacyGovernance) -> PermissionsBlock {
177    PermissionsBlock {
178        rules: legacy.policy.iter().map(translate_policy).collect(),
179    }
180}
181
182// ---------------------------------------------------------------------------
183// Parse + render
184// ---------------------------------------------------------------------------
185
186/// Parse the `[governance]` section out of a raw config-toml string.
187/// Returns an empty [`LegacyGovernance`] when the section is missing —
188/// callers can detect "nothing to migrate" by checking
189/// `result.policy.is_empty()`.
190pub fn parse_legacy_governance(raw: &str) -> Result<LegacyGovernance> {
191    let value: toml::Value = toml::from_str(raw).context("parse config.toml")?;
192    let Some(gov) = value.get(field_names::GOVERNANCE) else {
193        return Ok(LegacyGovernance::default());
194    };
195    let parsed: LegacyGovernance = gov.clone().try_into().context("parse [governance] block")?;
196    Ok(parsed)
197}
198
199/// Render a [`PermissionsBlock`] as a `[[permissions.rules]]` TOML
200/// fragment. The output is a standalone snippet — no `[permissions]`
201/// table header, just the array entries in source order. Operators can
202/// paste it into an existing `[permissions]` table or feed it into
203/// `--config-out`.
204#[must_use]
205pub fn render_permissions_block(block: &PermissionsBlock) -> String {
206    if block.rules.is_empty() {
207        return "# v0.7.0 K11: no [governance] policies found — nothing to migrate.\n".to_string();
208    }
209    let mut out = String::new();
210    out.push_str("# v0.7.0 K11: translated from legacy [[governance.policy]] entries.\n");
211    out.push_str("# Mapping: scope→namespace_pattern, action→op,\n");
212    out.push_str("#          role|agent_id→agent_pattern, decision→decision.\n");
213    for rule in &block.rules {
214        out.push_str("\n[[permissions.rules]]\n");
215        out.push_str(&format!(
216            "namespace_pattern = {}\n",
217            toml_str(&rule.namespace_pattern)
218        ));
219        out.push_str(&format!("op = {}\n", toml_str(&rule.op)));
220        out.push_str(&format!(
221            "agent_pattern = {}\n",
222            toml_str(&rule.agent_pattern)
223        ));
224        out.push_str(&format!("decision = {}\n", toml_str(&rule.decision)));
225    }
226    out
227}
228
229/// Quote a string the way TOML expects: basic-string with escaped
230/// backslashes and quotes. Avoids pulling in `toml::ser` for a
231/// four-line helper.
232fn toml_str(s: &str) -> String {
233    let escaped: String = s
234        .chars()
235        .flat_map(|c| match c {
236            '\\' => vec!['\\', '\\'],
237            '"' => vec!['\\', '"'],
238            '\n' => vec!['\\', 'n'],
239            '\r' => vec!['\\', 'r'],
240            '\t' => vec!['\\', 't'],
241            c => vec![c],
242        })
243        .collect();
244    format!("\"{escaped}\"")
245}
246
247// ---------------------------------------------------------------------------
248// In-place merge
249// ---------------------------------------------------------------------------
250
251/// Append the rendered `[[permissions.rules]]` block to an existing
252/// config file's contents. The merge strategy is intentionally
253/// conservative:
254///
255/// - Every section of the existing file is preserved verbatim
256///   (including any pre-existing `[[permissions.rules]]` entries).
257/// - The migrator block is appended at the end with a leading
258///   `# --- migrated from [governance] (K11) ---` separator so a human
259///   reader can see exactly which entries the migrator wrote.
260///
261/// This sidesteps the messy task of editing TOML in place (which would
262/// strip comments and reorder keys) while still meeting the K11
263/// "preserve other sections" contract.
264#[must_use]
265pub fn merge_in_place(existing: &str, rendered: &str) -> String {
266    let mut out = String::with_capacity(existing.len() + rendered.len() + 64);
267    out.push_str(existing);
268    if !out.ends_with('\n') {
269        out.push('\n');
270    }
271    out.push_str("\n# --- migrated from [governance] (v0.7.0 K11) ---\n");
272    out.push_str(rendered);
273    out
274}
275
276// ---------------------------------------------------------------------------
277// Driver
278// ---------------------------------------------------------------------------
279
280/// `ai-memory governance migrate-to-permissions` entry point.
281///
282/// Returns `Ok(())` after a successful dry-run / write. Errors propagate
283/// for missing input files, parse failures, and IO write failures — the
284/// caller exits non-zero in the standard way.
285pub fn run(args: MigrateToPermissionsArgs, out: &mut CliOutput<'_>) -> Result<()> {
286    let in_path = match args.config_in.clone() {
287        Some(p) => p,
288        None => crate::config::AppConfig::config_path()
289            .context("no HOME — cannot resolve default config path; pass --config-in")?,
290    };
291    let raw = std::fs::read_to_string(&in_path)
292        .with_context(|| format!("read config from {}", in_path.display()))?;
293    let legacy = parse_legacy_governance(&raw)?;
294    let block = translate(&legacy);
295    let rendered = render_permissions_block(&block);
296
297    // Dry-run is the default. We treat "no --config-out AND no
298    // --dry-run" as dry-run too, matching the K11 spec.
299    let dry_run = args.dry_run || args.config_out.is_none();
300    if dry_run {
301        // Print to stdout. The rendered block already ends in a
302        // newline, so no extra `\n` here.
303        write!(out.stdout, "{rendered}")?;
304        return Ok(());
305    }
306
307    // Write path. Either standalone (different file) or in-place merge
308    // (same file as the input). Compare canonical paths so a relative
309    // and absolute reference to the same file still take the merge
310    // branch.
311    let out_path = args.config_out.clone().expect(EXPECT_CHECKED_ABOVE);
312    let same_file = same_path(&in_path, &out_path);
313    if same_file {
314        let merged = merge_in_place(&raw, &rendered);
315        std::fs::write(&out_path, merged)
316            .with_context(|| format!("write merged config to {}", out_path.display()))?;
317        writeln!(
318            out.stdout,
319            "merged {} migrated rule(s) into {}",
320            block.rules.len(),
321            out_path.display()
322        )?;
323    } else {
324        std::fs::write(&out_path, &rendered)
325            .with_context(|| format!("write rendered block to {}", out_path.display()))?;
326        writeln!(
327            out.stdout,
328            "wrote {} migrated rule(s) to {}",
329            block.rules.len(),
330            out_path.display()
331        )?;
332    }
333
334    if block.rules.is_empty() {
335        // Surface the no-op as a non-fatal warning so operators don't
336        // mistakenly assume the migration ran successfully when their
337        // legacy config never had a `[governance]` block to begin with.
338        writeln!(
339            out.stderr,
340            "warning: no [governance] policies found in {} — nothing migrated",
341            in_path.display()
342        )?;
343    }
344
345    Ok(())
346}
347
348/// Compare two paths for equality after canonicalization, falling back
349/// to a literal-component compare when canonicalization fails (e.g. the
350/// output file does not exist yet — that's still "same path" if the
351/// strings agree).
352fn same_path(a: &Path, b: &Path) -> bool {
353    match (a.canonicalize(), b.canonicalize()) {
354        (Ok(ca), Ok(cb)) => ca == cb,
355        _ => a == b,
356    }
357}
358
359/// Internal helper exposed for the integration tests so they can drive
360/// the migrator with an explicit `--config-out` path without round-
361/// tripping through clap. Returns the rendered block as a string for
362/// post-write asserts.
363#[doc(hidden)]
364#[allow(dead_code)]
365pub fn run_with_paths(
366    in_path: &Path,
367    config_out: Option<&Path>,
368    dry_run: bool,
369    out: &mut CliOutput<'_>,
370) -> Result<String> {
371    let raw = std::fs::read_to_string(in_path)
372        .with_context(|| format!("read config from {}", in_path.display()))?;
373    let legacy = parse_legacy_governance(&raw)?;
374    let block = translate(&legacy);
375    let rendered = render_permissions_block(&block);
376
377    let dry = dry_run || config_out.is_none();
378    if dry {
379        write!(out.stdout, "{rendered}")?;
380        return Ok(rendered);
381    }
382
383    let out_path = config_out.expect(EXPECT_CHECKED_ABOVE);
384    if same_path(in_path, out_path) {
385        let merged = merge_in_place(&raw, &rendered);
386        std::fs::write(out_path, merged)
387            .with_context(|| format!("write merged to {}", out_path.display()))?;
388    } else if let Some(parent) = out_path.parent()
389        && !parent.as_os_str().is_empty()
390        && !parent.exists()
391    {
392        std::fs::create_dir_all(parent)
393            .with_context(|| format!("create parent of {}", out_path.display()))?;
394        std::fs::write(out_path, &rendered)
395            .with_context(|| format!("write rendered to {}", out_path.display()))?;
396    } else {
397        std::fs::write(out_path, &rendered)
398            .with_context(|| format!("write rendered to {}", out_path.display()))?;
399    }
400    writeln!(
401        out.stdout,
402        "wrote {} migrated rule(s) to {}",
403        block.rules.len(),
404        out_path.display()
405    )?;
406    Ok(rendered)
407}
408
409// ---------------------------------------------------------------------------
410// Tests
411// ---------------------------------------------------------------------------
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::cli::test_utils::TestEnv;
417
418    fn sample_legacy_config() -> &'static str {
419        r#"
420# user config with a mature governance ruleset
421
422[governance]
423
424[[governance.policy]]
425scope = "team/eng/*"
426action = "write"
427role = "engineer"
428decision = "allow"
429
430[[governance.policy]]
431scope = "team/finance/*"
432action = "delete"
433agent_id = "alice"
434decision = "ask"
435
436[[governance.policy]]
437scope = "*"
438action = "promote"
439decision = "deny"
440"#
441    }
442
443    #[test]
444    fn parse_three_policies() {
445        let parsed = parse_legacy_governance(sample_legacy_config()).unwrap();
446        assert_eq!(parsed.policy.len(), 3);
447        assert_eq!(parsed.policy[0].scope.as_deref(), Some("team/eng/*"));
448        assert_eq!(parsed.policy[0].role.as_deref(), Some("engineer"));
449        assert_eq!(parsed.policy[1].agent_id.as_deref(), Some("alice"));
450        assert_eq!(parsed.policy[2].decision.as_deref(), Some("deny"));
451    }
452
453    #[test]
454    fn translate_role_wins_over_agent_id() {
455        let p = LegacyGovernancePolicy {
456            scope: Some("ns".into()),
457            action: Some("write".into()),
458            role: Some("ops".into()),
459            agent_id: Some("alice".into()),
460            decision: Some("allow".into()),
461        };
462        let r = translate_policy(&p);
463        assert_eq!(r.namespace_pattern, "ns");
464        assert_eq!(r.op, "write");
465        assert_eq!(r.agent_pattern, "ops");
466        assert_eq!(r.decision, "allow");
467    }
468
469    #[test]
470    fn translate_falls_back_to_agent_id_when_role_absent() {
471        let p = LegacyGovernancePolicy {
472            scope: Some("ns".into()),
473            action: Some("write".into()),
474            role: None,
475            agent_id: Some("alice".into()),
476            decision: Some("allow".into()),
477        };
478        let r = translate_policy(&p);
479        assert_eq!(r.agent_pattern, "alice");
480    }
481
482    #[test]
483    fn translate_uses_safe_defaults_when_fields_missing() {
484        let p = LegacyGovernancePolicy::default();
485        let r = translate_policy(&p);
486        assert_eq!(r.namespace_pattern, "*");
487        assert_eq!(r.op, "*");
488        assert_eq!(r.agent_pattern, "*");
489        assert_eq!(r.decision, "ask");
490    }
491
492    #[test]
493    fn render_emits_one_block_per_rule() {
494        let parsed = parse_legacy_governance(sample_legacy_config()).unwrap();
495        let block = translate(&parsed);
496        let rendered = render_permissions_block(&block);
497        assert_eq!(rendered.matches("[[permissions.rules]]").count(), 3);
498        assert!(rendered.contains("namespace_pattern = \"team/eng/*\""));
499        assert!(rendered.contains("agent_pattern = \"engineer\""));
500        assert!(rendered.contains("agent_pattern = \"alice\""));
501        assert!(rendered.contains("decision = \"deny\""));
502    }
503
504    #[test]
505    fn render_empty_block_emits_comment() {
506        let block = PermissionsBlock::default();
507        let s = render_permissions_block(&block);
508        assert!(s.contains("nothing to migrate"));
509    }
510
511    #[test]
512    fn missing_governance_section_yields_empty() {
513        let raw = "tier = \"semantic\"\n";
514        let parsed = parse_legacy_governance(raw).unwrap();
515        assert!(parsed.policy.is_empty());
516    }
517
518    #[test]
519    fn merge_in_place_preserves_existing_then_appends() {
520        let existing = "tier = \"semantic\"\n[scoring]\nlegacy_scoring = false\n";
521        let rendered = "[[permissions.rules]]\nnamespace_pattern = \"a\"\n";
522        let merged = merge_in_place(existing, rendered);
523        assert!(merged.starts_with("tier = \"semantic\""));
524        assert!(merged.contains("[scoring]"));
525        assert!(merged.contains("[[permissions.rules]]"));
526        assert!(merged.contains("--- migrated from [governance] (v0.7.0 K11) ---"));
527    }
528
529    #[test]
530    fn run_with_paths_dry_run_writes_to_stdout() {
531        let mut env = TestEnv::fresh();
532        let cfg_path = env.db_path.parent().unwrap().join("config.toml");
533        std::fs::write(&cfg_path, sample_legacy_config()).unwrap();
534        let _ = {
535            let mut o = env.output();
536            run_with_paths(&cfg_path, None, true, &mut o).unwrap()
537        };
538        let stdout = env.stdout_str();
539        assert_eq!(stdout.matches("[[permissions.rules]]").count(), 3);
540    }
541
542    #[test]
543    fn run_with_paths_writes_to_named_file() {
544        let mut env = TestEnv::fresh();
545        let in_path = env.db_path.parent().unwrap().join("in.toml");
546        let out_path = env.db_path.parent().unwrap().join("out.toml");
547        std::fs::write(&in_path, sample_legacy_config()).unwrap();
548        let _ = {
549            let mut o = env.output();
550            run_with_paths(&in_path, Some(&out_path), false, &mut o).unwrap()
551        };
552        let written = std::fs::read_to_string(&out_path).unwrap();
553        assert_eq!(written.matches("[[permissions.rules]]").count(), 3);
554        let parsed: toml::Value = toml::from_str(&written).unwrap();
555        let rules = parsed["permissions"]["rules"].as_array().unwrap();
556        assert_eq!(rules.len(), 3);
557    }
558
559    #[test]
560    fn run_with_paths_in_place_merge_preserves_other_sections() {
561        let mut env = TestEnv::fresh();
562        let cfg_path = env.db_path.parent().unwrap().join("cfg.toml");
563        let mut original = String::from(sample_legacy_config());
564        original.push_str("\n[scoring]\nlegacy_scoring = false\n");
565        std::fs::write(&cfg_path, &original).unwrap();
566        let _ = {
567            let mut o = env.output();
568            run_with_paths(&cfg_path, Some(&cfg_path), false, &mut o).unwrap()
569        };
570        let after = std::fs::read_to_string(&cfg_path).unwrap();
571        assert!(after.contains("[scoring]"));
572        assert!(after.contains("legacy_scoring = false"));
573        assert!(after.contains("[governance]"));
574        assert_eq!(after.matches("[[permissions.rules]]").count(), 3);
575    }
576
577    // ---------- E1 coverage uplift -----------------------------------
578    // Target: `run` (production entry point, lines 280-341), `toml_str`
579    // escape characters (lines 231-235), `merge_in_place` no-trailing-
580    // newline branch (line 264), `run_with_paths` create_dir_all branch
581    // (lines 387-390).
582
583    /// Build a `MigrateToPermissionsArgs` with the given config_in /
584    /// config_out + dry_run combo.
585    fn args(in_path: &Path, out_path: Option<&Path>, dry_run: bool) -> MigrateToPermissionsArgs {
586        MigrateToPermissionsArgs {
587            dry_run,
588            config_out: out_path.map(std::path::Path::to_path_buf),
589            config_in: Some(in_path.to_path_buf()),
590        }
591    }
592
593    #[test]
594    fn run_dry_run_default_writes_stdout() {
595        // Hits run() lines 280-300: dry-run path (no config-out + no
596        // --dry-run flag).
597        let mut env = TestEnv::fresh();
598        let cfg_path = env.db_path.parent().unwrap().join("cfg.toml");
599        std::fs::write(&cfg_path, sample_legacy_config()).unwrap();
600        let a = args(&cfg_path, None, false);
601        {
602            let mut o = env.output();
603            run(a, &mut o).unwrap();
604        }
605        let s = env.stdout_str();
606        assert_eq!(s.matches("[[permissions.rules]]").count(), 3);
607    }
608
609    #[test]
610    fn run_dry_run_explicit_flag_writes_stdout() {
611        // Same path but with explicit --dry-run + a config-out that's
612        // ignored.
613        let mut env = TestEnv::fresh();
614        let cfg_path = env.db_path.parent().unwrap().join("in.toml");
615        let out_path = env.db_path.parent().unwrap().join("should-not-exist.toml");
616        std::fs::write(&cfg_path, sample_legacy_config()).unwrap();
617        let a = args(&cfg_path, Some(&out_path), true);
618        {
619            let mut o = env.output();
620            run(a, &mut o).unwrap();
621        }
622        assert!(env.stdout_str().contains("[[permissions.rules]]"));
623        // out_path must NOT have been written.
624        assert!(!out_path.exists(), "dry-run must not touch config-out");
625    }
626
627    #[test]
628    fn run_writes_standalone_file_when_paths_differ() {
629        // Hits run() lines 306-326 — write-path standalone branch.
630        let mut env = TestEnv::fresh();
631        let in_path = env.db_path.parent().unwrap().join("in.toml");
632        let out_path = env.db_path.parent().unwrap().join("out.toml");
633        std::fs::write(&in_path, sample_legacy_config()).unwrap();
634        let a = args(&in_path, Some(&out_path), false);
635        {
636            let mut o = env.output();
637            run(a, &mut o).unwrap();
638        }
639        let written = std::fs::read_to_string(&out_path).unwrap();
640        assert_eq!(written.matches("[[permissions.rules]]").count(), 3);
641        // stdout reports the write.
642        assert!(env.stdout_str().contains("wrote 3 migrated rule(s)"));
643    }
644
645    #[test]
646    fn run_in_place_merge_when_paths_match() {
647        // Hits run() lines 308-317 — in-place merge branch.
648        let mut env = TestEnv::fresh();
649        let cfg_path = env.db_path.parent().unwrap().join("cfg.toml");
650        let mut original = String::from(sample_legacy_config());
651        original.push_str("\n[scoring]\nlegacy_scoring = false\n");
652        std::fs::write(&cfg_path, &original).unwrap();
653        let a = args(&cfg_path, Some(&cfg_path), false);
654        {
655            let mut o = env.output();
656            run(a, &mut o).unwrap();
657        }
658        let after = std::fs::read_to_string(&cfg_path).unwrap();
659        assert!(after.contains("[scoring]"));
660        assert!(after.contains("[governance]"));
661        assert!(after.contains("--- migrated from [governance] (v0.7.0 K11) ---"));
662        assert!(env.stdout_str().contains("merged 3 migrated rule(s)"));
663    }
664
665    #[test]
666    fn run_writes_warning_when_no_governance_block() {
667        // Hits run() lines 329-338 — the "nothing migrated" branch
668        // when the legacy file has no [governance] section. We pair it
669        // with --config-out so the write path runs (vs dry-run, which
670        // returns before the warning branch).
671        let mut env = TestEnv::fresh();
672        let in_path = env.db_path.parent().unwrap().join("empty.toml");
673        let out_path = env.db_path.parent().unwrap().join("empty-out.toml");
674        std::fs::write(&in_path, "tier = \"semantic\"\n").unwrap();
675        let a = args(&in_path, Some(&out_path), false);
676        {
677            let mut o = env.output();
678            run(a, &mut o).unwrap();
679        }
680        assert!(env.stderr_str().contains("no [governance] policies"));
681        // stdout reports 0 rules migrated.
682        assert!(env.stdout_str().contains("wrote 0 migrated rule(s)"));
683    }
684
685    #[test]
686    fn run_errors_when_input_missing() {
687        // Hits run() lines 286-287 — read_to_string failure.
688        let mut env = TestEnv::fresh();
689        let missing = env.db_path.parent().unwrap().join("no-such-file.toml");
690        let a = args(&missing, None, false);
691        let mut o = env.output();
692        let res = run(a, &mut o);
693        assert!(res.is_err());
694        let err = res.unwrap_err().to_string();
695        assert!(err.contains("read config"));
696    }
697
698    #[test]
699    fn toml_str_escapes_special_chars() {
700        // Drives the escape-vec arms of `toml_str` (lines 231-235) —
701        // backslash, double-quote, newline, carriage-return, tab.
702        let policy = LegacyGovernancePolicy {
703            scope: Some("ns\"with\\quote".into()),
704            action: Some("op\nnewline".into()),
705            role: Some("role\ttab".into()),
706            agent_id: None,
707            decision: Some("dec\rret".into()),
708        };
709        let block = PermissionsBlock {
710            rules: vec![translate_policy(&policy)],
711        };
712        let rendered = render_permissions_block(&block);
713        // The backslash and double-quote both escape to `\\` / `\"`.
714        // The newline / CR / tab escape to the literal `\n` / `\r` /
715        // `\t` two-char sequences inside the TOML basic string.
716        assert!(
717            rendered.contains(r#"\""#),
718            "missing escaped quote: {rendered}"
719        );
720        assert!(
721            rendered.contains(r"\\"),
722            "missing escaped backslash: {rendered}"
723        );
724        assert!(
725            rendered.contains(r"\n"),
726            "missing escaped newline: {rendered}"
727        );
728        assert!(rendered.contains(r"\r"), "missing escaped CR: {rendered}");
729        assert!(rendered.contains(r"\t"), "missing escaped tab: {rendered}");
730    }
731
732    #[test]
733    fn merge_in_place_adds_newline_when_input_lacks_trailing_newline() {
734        // Hits the `if !out.ends_with('\n')` true arm of `merge_in_place`
735        // (line 264).
736        let existing = "tier = \"semantic\""; // no trailing newline
737        let rendered = "[[permissions.rules]]\n";
738        let merged = merge_in_place(existing, rendered);
739        assert!(merged.starts_with("tier = \"semantic\"\n"));
740    }
741
742    #[test]
743    fn run_with_paths_creates_missing_parent_directory() {
744        // Hits run_with_paths() lines 387-390: out_path parent doesn't
745        // exist → create_dir_all branch.
746        let mut env = TestEnv::fresh();
747        let in_path = env.db_path.parent().unwrap().join("in.toml");
748        let nested = env
749            .db_path
750            .parent()
751            .unwrap()
752            .join("nested/dir/permissions.toml");
753        std::fs::write(&in_path, sample_legacy_config()).unwrap();
754        assert!(!nested.parent().unwrap().exists());
755        let _ = {
756            let mut o = env.output();
757            run_with_paths(&in_path, Some(&nested), false, &mut o).unwrap()
758        };
759        let written = std::fs::read_to_string(&nested).unwrap();
760        assert_eq!(written.matches("[[permissions.rules]]").count(), 3);
761    }
762
763    #[test]
764    fn parse_invalid_toml_returns_err() {
765        // Drives parse_legacy_governance's context-wrapped error arm.
766        let raw = "this = not\nvalid_toml = at all = \"oops\"";
767        let res = parse_legacy_governance(raw);
768        assert!(res.is_err());
769    }
770
771    #[test]
772    fn parse_with_governance_but_bogus_inner_returns_err() {
773        // [governance] section is present but `policy` is the wrong
774        // shape — try_into fails.
775        let raw = "[governance]\npolicy = 42\n";
776        let res = parse_legacy_governance(raw);
777        assert!(res.is_err());
778    }
779}