use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::Path;
use super::{RefactorPlan, RiskLevel, Shim};
pub fn output_as_markdown(plan: &RefactorPlan, path: &Path) -> io::Result<()> {
let content = format_as_markdown(plan);
fs::write(path, content)
}
pub fn format_as_markdown(plan: &RefactorPlan) -> String {
let mut md = String::new();
md.push_str(&format!("# Refactor Plan: {}\n\n", plan.target));
md.push_str("## Summary\n\n");
md.push_str(&format!(
"- **Files analyzed:** {}\n",
plan.stats.total_files
));
md.push_str(&format!(
"- **Files to move:** {}\n",
plan.stats.files_to_move
));
md.push_str(&format!(
"- **Shims needed:** {}\n",
plan.stats.shims_needed
));
md.push_str("- **Risk breakdown:** ");
let risk_parts: Vec<String> = plan
.stats
.by_risk
.iter()
.map(|(k, v)| format!("{} {}", v, k))
.collect();
md.push_str(&risk_parts.join(", "));
md.push_str("\n\n");
md.push_str("## Layer Distribution\n\n");
md.push_str("### Before\n");
for (layer, count) in &plan.stats.layer_before {
let bar = "█".repeat((*count).min(20));
md.push_str(&format!("- {}: {} {}\n", layer, bar, count));
}
md.push_str("\n### After\n");
for (layer, count) in &plan.stats.layer_after {
let bar = "█".repeat((*count).min(20));
md.push_str(&format!("- {}: {} {}\n", layer, bar, count));
}
md.push('\n');
if !plan.cyclic_groups.is_empty() {
md.push_str("## ⚠️ Cyclic Dependencies\n\n");
md.push_str("The following groups of files have circular imports. Move these together or break the cycle first:\n\n");
for (i, group) in plan.cyclic_groups.iter().enumerate() {
md.push_str(&format!("**Cycle {}:**\n", i + 1));
for file in group {
md.push_str(&format!("- `{}`\n", file));
}
md.push('\n');
}
}
for phase in &plan.phases {
let risk_emoji = match phase.risk {
RiskLevel::Low => "🟢",
RiskLevel::Medium => "🟡",
RiskLevel::High => "🔴",
};
md.push_str(&format!("## {} {}\n\n", risk_emoji, phase.name));
md.push_str(&format!("{} file(s)\n\n", phase.moves.len()));
md.push_str("| File | From | To | LOC | Consumers | Reason |\n");
md.push_str("|------|------|----|----|-----------|--------|\n");
for mv in &phase.moves {
md.push_str(&format!(
"| `{}` | {} | {} | {} | {} | {} |\n",
Path::new(&mv.source)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&mv.source),
mv.current_layer.display_name(),
mv.target_layer.display_name(),
mv.loc,
mv.direct_consumers,
mv.reason
));
}
md.push('\n');
md.push_str("**Commands:**\n\n```bash\n");
md.push_str(&phase.git_script);
md.push_str("\n```\n\n");
}
if !plan.shims.is_empty() {
md.push_str("## Shimming Strategy\n\n");
md.push_str("Create re-export shims for heavily-imported files to maintain backward compatibility:\n\n");
for shim in &plan.shims {
md.push_str(&format!(
"### `{}` ({} importers)\n\n",
shim.old_path, shim.importer_count
));
md.push_str("```\n");
md.push_str(&shim.code);
md.push_str("\n```\n\n");
}
}
md.push_str("---\n\n");
md.push_str("*Generated by loctree • VibeCrafted with AI Agents (c)2026 Loctree Team*\n");
md
}
pub fn output_bundle_as_markdown(plans: &[RefactorPlan], path: &Path) -> io::Result<()> {
let content = format_bundle_as_markdown(plans);
fs::write(path, content)
}
pub fn format_bundle_as_markdown(plans: &[RefactorPlan]) -> String {
let mut md = String::new();
for (idx, plan) in plans.iter().enumerate() {
if idx > 0 {
md.push_str("\n\n---\n\n");
}
md.push_str(&format_as_markdown(plan));
}
md
}
pub fn output_as_json(plan: &RefactorPlan, path: &Path) -> io::Result<()> {
let json = serde_json::to_string_pretty(plan)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(path, json)
}
pub fn format_as_json(plan: &RefactorPlan) -> String {
serde_json::to_string_pretty(plan).unwrap_or_else(|_| "{}".to_string())
}
pub fn output_bundle_as_json(plans: &[RefactorPlan], path: &Path) -> io::Result<()> {
let json = serde_json::to_string_pretty(plans)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(path, json)
}
pub fn format_bundle_as_json(plans: &[RefactorPlan]) -> String {
serde_json::to_string_pretty(plans).unwrap_or_else(|_| "[]".to_string())
}
pub fn output_as_script(plan: &RefactorPlan, path: &Path) -> io::Result<()> {
let content = format_as_script(plan);
fs::write(path, content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)?;
}
Ok(())
}
pub fn format_as_script(plan: &RefactorPlan) -> String {
let mut script = String::new();
script.push_str("#!/bin/bash\n");
script.push_str("# Refactor Plan - Generated by loctree\n");
script.push_str(&format!("# Target: {}\n", plan.target));
script.push_str(&format!(
"# Generated: {}\n",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
));
script.push_str("#\n");
script.push_str("# VibeCrafted with AI Agents (c)2026 Loctree Team\n");
script.push_str("#\n");
script.push_str("# Usage:\n");
script.push_str("# ./refactor.sh # Execute all phases\n");
script.push_str("# ./refactor.sh --dry # Show commands without executing\n");
script.push_str("# ./refactor.sh 1 # Execute only phase 1\n");
script.push('\n');
script.push_str("set -e # Exit on error\n");
script.push_str("set -u # Error on undefined variables\n");
script.push('\n');
script.push_str("# Colors\n");
script.push_str("RED='\\033[0;31m'\n");
script.push_str("GREEN='\\033[0;32m'\n");
script.push_str("YELLOW='\\033[0;33m'\n");
script.push_str("NC='\\033[0m' # No Color\n");
script.push('\n');
script.push_str("DRY_RUN=false\n");
script.push_str("PHASE_FILTER=\"\"\n");
script.push('\n');
script.push_str("# Parse arguments\n");
script.push_str("for arg in \"$@\"; do\n");
script.push_str(" case $arg in\n");
script.push_str(" --dry)\n");
script.push_str(" DRY_RUN=true\n");
script.push_str(" ;;\n");
script.push_str(" [0-9]*)\n");
script.push_str(" PHASE_FILTER=\"$arg\"\n");
script.push_str(" ;;\n");
script.push_str(" esac\n");
script.push_str("done\n");
script.push('\n');
script.push_str("run() {\n");
script.push_str(" if [ \"$DRY_RUN\" = true ]; then\n");
script.push_str(" echo \"[DRY] $*\"\n");
script.push_str(" else\n");
script.push_str(" echo \"[RUN] $*\"\n");
script.push_str(" \"$@\"\n");
script.push_str(" fi\n");
script.push_str("}\n");
script.push('\n');
for (i, phase) in plan.phases.iter().enumerate() {
let phase_num = i + 1;
let risk_color = match phase.risk {
RiskLevel::Low => "GREEN",
RiskLevel::Medium => "YELLOW",
RiskLevel::High => "RED",
};
script.push_str(&format!("phase_{} () {{\n", phase_num));
script.push_str(&format!(
" echo -e \"${{{}}}=== {} ===${{NC}}\"\n",
risk_color, phase.name
));
script.push_str(&format!(
" echo \"Moving {} files...\"\n",
phase.moves.len()
));
script.push('\n');
for mv in &phase.moves {
if let Some(parent) = Path::new(&mv.target).parent() {
script.push_str(&format!(" run mkdir -p \"{}\"\n", parent.display()));
}
script.push_str(&format!(
" run git mv \"{}\" \"{}\"\n",
mv.source, mv.target
));
}
script.push('\n');
script.push_str(&format!(
" echo -e \"${{GREEN}}✓ Phase {} complete${{NC}}\"\n",
phase_num
));
script.push_str("}\n\n");
}
if !plan.shims.is_empty() {
script.push_str("create_shims() {\n");
script.push_str(" echo \"=== Creating Shims ===\"\n");
for shim in &plan.shims {
let escaped_code = shim.code.replace('$', "\\$").replace('`', "\\`");
script.push_str(&format!("\n cat > \"{}\" <<'SHIMEOF'\n", shim.old_path));
script.push_str(&escaped_code);
script.push_str("\nSHIMEOF\n");
}
script.push_str("\n echo -e \"${GREEN}✓ Shims created${NC}\"\n");
script.push_str("}\n\n");
}
script.push_str("# Main execution\n");
script.push_str("echo \"Refactor Plan Execution\"\n");
script.push_str(&format!("echo \"Target: {}\"\n", plan.target));
script.push_str(&format!("echo \"Phases: {}\"\n", plan.phases.len()));
script.push_str("echo \"\"\n");
script.push('\n');
for (i, _) in plan.phases.iter().enumerate() {
let phase_num = i + 1;
script.push_str(&format!(
"if [ -z \"$PHASE_FILTER\" ] || [ \"$PHASE_FILTER\" = \"{}\" ]; then\n",
phase_num
));
script.push_str(&format!(" phase_{}\n", phase_num));
script.push_str("fi\n\n");
}
if !plan.shims.is_empty() {
script.push_str("if [ -z \"$PHASE_FILTER\" ]; then\n");
script.push_str(" create_shims\n");
script.push_str("fi\n\n");
}
script.push_str("echo \"\"\n");
script.push_str("echo -e \"${GREEN}=== Refactoring Complete ===${NC}\"\n");
script.push_str("echo \"Run 'git status' to review changes\"\n");
script.push_str("echo \"Run 'loct health' to verify structure\"\n");
script
}
pub fn output_bundle_as_script(plans: &[RefactorPlan], path: &Path) -> io::Result<()> {
let content = format_bundle_as_script(plans);
fs::write(path, content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms)?;
}
Ok(())
}
pub fn format_bundle_as_script(plans: &[RefactorPlan]) -> String {
let mut script = String::new();
script.push_str("#!/bin/bash\n");
script.push_str("# Refactor Plan (multi-target) - Generated by loctree\n");
script.push_str("# Targets:\n");
for plan in plans {
script.push_str(&format!("# - {}\n", plan.target));
}
script.push_str(&format!(
"# Generated: {}\n",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
));
script.push_str("#\n");
script.push_str("# VibeCrafted with AI Agents (c)2026 Loctree Team\n");
script.push_str("#\n");
script.push_str("# Usage:\n");
script.push_str("# ./refactor.sh # Execute all phases\n");
script.push_str("# ./refactor.sh --dry # Show commands without executing\n");
script.push_str("# ./refactor.sh 1 # Execute only phase 1\n");
script.push('\n');
script.push_str("set -e # Exit on error\n");
script.push_str("set -u # Error on undefined variables\n");
script.push('\n');
script.push_str("# Colors\n");
script.push_str("RED='\\033[0;31m'\n");
script.push_str("GREEN='\\033[0;32m'\n");
script.push_str("YELLOW='\\033[0;33m'\n");
script.push_str("NC='\\033[0m' # No Color\n");
script.push('\n');
script.push_str("DRY_RUN=false\n");
script.push_str("PHASE_FILTER=\"\"\n");
script.push('\n');
script.push_str("# Parse arguments\n");
script.push_str("for arg in \"$@\"; do\n");
script.push_str(" case $arg in\n");
script.push_str(" --dry)\n");
script.push_str(" DRY_RUN=true\n");
script.push_str(" ;;\n");
script.push_str(" [0-9]*)\n");
script.push_str(" PHASE_FILTER=\"$arg\"\n");
script.push_str(" ;;\n");
script.push_str(" esac\n");
script.push_str("done\n");
script.push('\n');
script.push_str("run() {\n");
script.push_str(" if [ \"$DRY_RUN\" = true ]; then\n");
script.push_str(" echo \"[DRY] $*\"\n");
script.push_str(" else\n");
script.push_str(" echo \"[RUN] $*\"\n");
script.push_str(" \"$@\"\n");
script.push_str(" fi\n");
script.push_str("}\n");
script.push('\n');
let mut phase_num = 1usize;
for plan in plans {
for phase in &plan.phases {
let risk_color = match phase.risk {
RiskLevel::Low => "GREEN",
RiskLevel::Medium => "YELLOW",
RiskLevel::High => "RED",
};
script.push_str(&format!("phase_{} () {{\n", phase_num));
script.push_str(&format!(
" echo -e \"${{{}}}=== [{}] {} ===${{NC}}\"\n",
risk_color, plan.target, phase.name
));
script.push_str(&format!(
" echo \"Moving {} files...\"\n",
phase.moves.len()
));
script.push('\n');
for mv in &phase.moves {
if let Some(parent) = Path::new(&mv.target).parent() {
script.push_str(&format!(" run mkdir -p \"{}\"\n", parent.display()));
}
script.push_str(&format!(
" run git mv \"{}\" \"{}\"\n",
mv.source, mv.target
));
}
script.push('\n');
script.push_str(&format!(
" echo -e \"${{GREEN}}✓ Phase {} complete${{NC}}\"\n",
phase_num
));
script.push_str("}\n\n");
phase_num += 1;
}
}
let total_phases = phase_num.saturating_sub(1);
let mut shims: HashMap<String, Shim> = HashMap::new();
for plan in plans {
for shim in &plan.shims {
shims
.entry(shim.old_path.clone())
.or_insert_with(|| shim.clone());
}
}
if !shims.is_empty() {
script.push_str("create_shims() {\n");
script.push_str(" echo \"=== Creating Shims ===\"\n");
let mut keys: Vec<String> = shims.keys().cloned().collect();
keys.sort();
for key in keys {
if let Some(shim) = shims.get(&key) {
let escaped_code = shim.code.replace('$', "\\$").replace('`', "\\`");
script.push_str(&format!("\n cat > \"{}\" <<'SHIMEOF'\n", shim.old_path));
script.push_str(&escaped_code);
script.push_str("\nSHIMEOF\n");
}
}
script.push_str("\n echo -e \"${GREEN}✓ Shims created${NC}\"\n");
script.push_str("}\n\n");
}
script.push_str("# Main execution\n");
script.push_str("echo \"Refactor Plan Execution\"\n");
script.push_str(&format!("echo \"Targets: {}\"\n", plans.len()));
script.push_str(&format!("echo \"Phases: {}\"\n", total_phases));
script.push_str("echo \"\"\n");
script.push('\n');
for phase_num in 1..=total_phases {
script.push_str(&format!(
"if [ -z \"$PHASE_FILTER\" ] || [ \"$PHASE_FILTER\" = \"{}\" ]; then\n",
phase_num
));
script.push_str(&format!(" phase_{}\n", phase_num));
script.push_str("fi\n\n");
}
if !shims.is_empty() {
script.push_str("if [ -z \"$PHASE_FILTER\" ]; then\n");
script.push_str(" create_shims\n");
script.push_str("fi\n\n");
}
script.push_str("echo \"\"\n");
script.push_str("echo -e \"${GREEN}=== Refactoring Complete ===${NC}\"\n");
script.push_str("echo \"Run 'git status' to review changes\"\n");
script.push_str("echo \"Run 'loct health' to verify structure\"\n");
script
}
pub fn print_plan_summary(plan: &RefactorPlan) {
println!("Refactor Plan: {}/", plan.target);
println!();
println!(
" Files: {} total, {} to move",
plan.stats.total_files, plan.stats.files_to_move
);
if !plan.stats.by_risk.is_empty() {
let risk_str: Vec<String> = plan
.stats
.by_risk
.iter()
.map(|(k, v)| format!("{} {}", v, k))
.collect();
println!(" Risk: {}", risk_str.join(", "));
}
if plan.stats.shims_needed > 0 {
println!(" Shims: {} needed", plan.stats.shims_needed);
}
if !plan.cyclic_groups.is_empty() {
println!(
" Cycles: {} groups (move together)",
plan.cyclic_groups.len()
);
}
println!();
for phase in &plan.phases {
let emoji = match phase.risk {
RiskLevel::Low => "🟢",
RiskLevel::Medium => "🟡",
RiskLevel::High => "🔴",
};
println!(" {} {} ({} files)", emoji, phase.name, phase.moves.len());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::refactor_plan::{Layer, Move, PlanStats, RefactorPhase, RiskLevel};
use std::collections::HashMap;
fn mock_plan() -> RefactorPlan {
RefactorPlan {
target: "src/features".to_string(),
moves: vec![Move {
source: "src/features/utils.ts".to_string(),
target: "src/features/infra/utils.ts".to_string(),
current_layer: Layer::Unknown,
target_layer: Layer::Infra,
risk: RiskLevel::Low,
direct_consumers: 3,
transitive_consumers: 8,
loc: 120,
reason: "Unknown → Infra".to_string(),
verify_cmd: "loct impact src/features/infra/utils.ts".to_string(),
affected_files: vec!["src/features/main.ts".to_string()],
}],
shims: vec![],
cyclic_groups: vec![],
phases: vec![RefactorPhase {
name: "Phase 1: LOW Risk".to_string(),
risk: RiskLevel::Low,
moves: vec![Move {
source: "src/features/utils.ts".to_string(),
target: "src/features/infra/utils.ts".to_string(),
current_layer: Layer::Unknown,
target_layer: Layer::Infra,
risk: RiskLevel::Low,
direct_consumers: 3,
transitive_consumers: 8,
loc: 120,
reason: "Unknown → Infra".to_string(),
verify_cmd: "loct impact src/features/infra/utils.ts".to_string(),
affected_files: vec!["src/features/main.ts".to_string()],
}],
git_script: "git mv src/features/utils.ts src/features/infra/utils.ts".to_string(),
}],
stats: PlanStats {
total_files: 10,
files_to_move: 1,
shims_needed: 0,
layer_before: HashMap::from([("Unknown".to_string(), 1)]),
layer_after: HashMap::from([("Infra".to_string(), 1)]),
by_risk: HashMap::from([("LOW".to_string(), 1)]),
},
}
}
#[test]
fn test_format_as_markdown() {
let plan = mock_plan();
let md = format_as_markdown(&plan);
assert!(md.contains("# Refactor Plan: src/features"));
assert!(md.contains("Files analyzed:** 10"));
assert!(md.contains("Files to move:** 1"));
assert!(md.contains("Phase 1: LOW Risk"));
}
#[test]
fn test_format_as_script() {
let plan = mock_plan();
let script = format_as_script(&plan);
assert!(script.starts_with("#!/bin/bash"));
assert!(script.contains("set -e"));
assert!(script.contains("phase_1"));
assert!(script.contains("git mv"));
}
#[test]
fn test_format_bundle_as_json() {
let mut plan2 = mock_plan();
plan2.target = "src/other".to_string();
let json = format_bundle_as_json(&[mock_plan(), plan2]);
assert!(json.trim_start().starts_with('['));
assert!(json.contains("\"target\": \"src/features\""));
assert!(json.contains("\"target\": \"src/other\""));
}
}