1use 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
51const EXPECT_CHECKED_ABOVE: &str = "checked above";
54
55#[derive(Args, Debug, Clone)]
61pub struct MigrateToPermissionsArgs {
62 #[arg(long, default_value_t = false)]
67 pub dry_run: bool,
68
69 #[arg(long, value_name = "PATH")]
75 pub config_out: Option<PathBuf>,
76
77 #[arg(long, value_name = "PATH")]
81 pub config_in: Option<PathBuf>,
82}
83
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
92pub struct LegacyGovernance {
93 #[serde(default)]
95 pub policy: Vec<LegacyGovernancePolicy>,
96}
97
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
101pub struct LegacyGovernancePolicy {
102 #[serde(default)]
104 pub scope: Option<String>,
105 #[serde(default)]
108 pub action: Option<String>,
109 #[serde(default)]
112 pub role: Option<String>,
113 #[serde(default)]
115 pub agent_id: Option<String>,
116 #[serde(default)]
119 pub decision: Option<String>,
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
130pub struct PermissionsBlock {
131 #[serde(default)]
133 pub rules: Vec<PermissionRule>,
134}
135
136#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138pub struct PermissionRule {
139 pub namespace_pattern: String,
141 pub op: String,
143 pub agent_pattern: String,
146 pub decision: String,
148}
149
150#[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#[must_use]
176pub fn translate(legacy: &LegacyGovernance) -> PermissionsBlock {
177 PermissionsBlock {
178 rules: legacy.policy.iter().map(translate_policy).collect(),
179 }
180}
181
182pub 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#[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
229fn 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#[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
276pub 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 let dry_run = args.dry_run || args.config_out.is_none();
300 if dry_run {
301 write!(out.stdout, "{rendered}")?;
304 return Ok(());
305 }
306
307 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 writeln!(
339 out.stderr,
340 "warning: no [governance] policies found in {} — nothing migrated",
341 in_path.display()
342 )?;
343 }
344
345 Ok(())
346}
347
348fn 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#[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#[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 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 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 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 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 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 assert!(env.stdout_str().contains("wrote 3 migrated rule(s)"));
643 }
644
645 #[test]
646 fn run_in_place_merge_when_paths_match() {
647 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 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 assert!(env.stdout_str().contains("wrote 0 migrated rule(s)"));
683 }
684
685 #[test]
686 fn run_errors_when_input_missing() {
687 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 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 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 let existing = "tier = \"semantic\""; 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 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 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 let raw = "[governance]\npolicy = 42\n";
776 let res = parse_legacy_governance(raw);
777 assert!(res.is_err());
778 }
779}