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;
const EXPECT_CHECKED_ABOVE: &str = "checked above";
#[derive(Args, Debug, Clone)]
pub struct MigrateToPermissionsArgs {
#[arg(long, default_value_t = false)]
pub dry_run: bool,
#[arg(long, value_name = "PATH")]
pub config_out: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub config_in: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LegacyGovernance {
#[serde(default)]
pub policy: Vec<LegacyGovernancePolicy>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LegacyGovernancePolicy {
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub action: Option<String>,
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub agent_id: Option<String>,
#[serde(default)]
pub decision: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PermissionsBlock {
#[serde(default)]
pub rules: Vec<PermissionRule>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PermissionRule {
pub namespace_pattern: String,
pub op: String,
pub agent_pattern: String,
pub decision: String,
}
#[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()),
}
}
#[must_use]
pub fn translate(legacy: &LegacyGovernance) -> PermissionsBlock {
PermissionsBlock {
rules: legacy.policy.iter().map(translate_policy).collect(),
}
}
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)
}
#[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
}
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}\"")
}
#[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
}
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);
let dry_run = args.dry_run || args.config_out.is_none();
if dry_run {
write!(out.stdout, "{rendered}")?;
return Ok(());
}
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() {
writeln!(
out.stderr,
"warning: no [governance] policies found in {} — nothing migrated",
in_path.display()
)?;
}
Ok(())
}
fn same_path(a: &Path, b: &Path) -> bool {
match (a.canonicalize(), b.canonicalize()) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => a == b,
}
}
#[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)
}
#[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);
}
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() {
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() {
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]]"));
assert!(!out_path.exists(), "dry-run must not touch config-out");
}
#[test]
fn run_writes_standalone_file_when_paths_differ() {
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);
assert!(env.stdout_str().contains("wrote 3 migrated rule(s)"));
}
#[test]
fn run_in_place_merge_when_paths_match() {
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() {
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"));
assert!(env.stdout_str().contains("wrote 0 migrated rule(s)"));
}
#[test]
fn run_errors_when_input_missing() {
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() {
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);
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() {
let existing = "tier = \"semantic\""; 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() {
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() {
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() {
let raw = "[governance]\npolicy = 42\n";
let res = parse_legacy_governance(raw);
assert!(res.is_err());
}
}