Skip to main content

mdx_rust_core/
refactor.rs

1//! Plan-first guardrailed refactoring.
2//!
3//! v0.5 deliberately starts with auditable plans instead of autonomous broad
4//! rewrites. Plans summarize impact and point high-confidence changes back
5//! through the existing hardening transaction path.
6
7use crate::eval::stable_hash_hex;
8use crate::hardening::{
9    run_hardening, workspace_summary, HardeningConfig, HardeningRun, WorkspaceSummary,
10};
11use crate::policy::{load_project_policy, ProjectPolicy};
12use mdx_rust_analysis::{
13    analyze_hardening, analyze_refactor, HardeningAnalyzeConfig, HardeningFinding, ModuleEdge,
14    RefactorAnalyzeConfig, RefactorFileSummary,
15};
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use std::path::{Component, Path, PathBuf};
19use std::time::Duration;
20
21#[derive(Debug, Clone)]
22pub struct RefactorPlanConfig {
23    pub target: Option<PathBuf>,
24    pub policy_path: Option<PathBuf>,
25    pub behavior_spec_path: Option<PathBuf>,
26    pub max_files: usize,
27}
28
29impl Default for RefactorPlanConfig {
30    fn default() -> Self {
31        Self {
32            target: None,
33            policy_path: None,
34            behavior_spec_path: None,
35            max_files: 100,
36        }
37    }
38}
39
40#[derive(Debug, Clone)]
41pub struct RefactorApplyConfig {
42    pub plan_path: PathBuf,
43    pub candidate_id: String,
44    pub apply: bool,
45    pub allow_public_api_impact: bool,
46    pub validation_timeout: Duration,
47}
48
49#[derive(Debug, Clone)]
50pub struct RefactorBatchApplyConfig {
51    pub plan_path: PathBuf,
52    pub apply: bool,
53    pub allow_public_api_impact: bool,
54    pub validation_timeout: Duration,
55    pub max_candidates: usize,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59pub struct RefactorPlan {
60    pub schema_version: String,
61    pub plan_id: String,
62    pub plan_hash: String,
63    pub root: String,
64    pub target: Option<String>,
65    pub workspace: WorkspaceSummary,
66    pub policy: Option<ProjectPolicy>,
67    pub behavior_spec: Option<String>,
68    pub impact: RefactorImpactSummary,
69    pub source_snapshots: Vec<SourceSnapshot>,
70    pub files: Vec<RefactorFileSummary>,
71    pub module_edges: Vec<ModuleEdge>,
72    pub candidates: Vec<RefactorCandidate>,
73    pub required_gates: Vec<String>,
74    pub non_goals: Vec<String>,
75    pub artifact_path: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
79pub struct SourceSnapshot {
80    pub file: String,
81    pub hash: String,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
85pub struct RefactorImpactSummary {
86    pub files_scanned: usize,
87    pub public_item_count: usize,
88    pub public_files: usize,
89    pub module_edge_count: usize,
90    pub patchable_hardening_changes: usize,
91    pub review_only_findings: usize,
92    pub oversized_files: usize,
93    pub oversized_functions: usize,
94    pub risk_level: RefactorRiskLevel,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
98pub enum RefactorRiskLevel {
99    Low,
100    Medium,
101    High,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
105pub struct RefactorCandidate {
106    pub id: String,
107    pub candidate_hash: String,
108    pub recipe: RefactorRecipe,
109    pub title: String,
110    pub rationale: String,
111    pub file: String,
112    pub line: usize,
113    pub risk: RefactorRiskLevel,
114    pub status: RefactorCandidateStatus,
115    pub public_api_impact: bool,
116    pub apply_command: Option<String>,
117    pub required_gates: Vec<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
121pub enum RefactorCandidateStatus {
122    ApplyViaImprove,
123    PlanOnly,
124    NeedsHumanDesign,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
128pub enum RefactorRecipe {
129    ContextualErrorHardening,
130    ExtractFunctionCandidate,
131    SplitModuleCandidate,
132    BoundaryValidationReview,
133    PublicApiReview,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
137pub struct RefactorApplyRun {
138    pub schema_version: String,
139    pub root: String,
140    pub plan_path: String,
141    pub plan_id: String,
142    pub plan_hash: String,
143    pub candidate_id: String,
144    pub candidate_hash: Option<String>,
145    pub mode: RefactorApplyMode,
146    pub status: RefactorApplyStatus,
147    pub public_api_impact_allowed: bool,
148    pub stale_files: Vec<StaleSourceFile>,
149    pub hardening_run: Option<HardeningRun>,
150    pub note: String,
151    pub artifact_path: Option<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
155pub struct RefactorBatchApplyRun {
156    pub schema_version: String,
157    pub root: String,
158    pub plan_path: String,
159    pub plan_id: String,
160    pub plan_hash: String,
161    pub mode: RefactorApplyMode,
162    pub status: RefactorBatchApplyStatus,
163    pub public_api_impact_allowed: bool,
164    pub max_candidates: usize,
165    pub requested_candidates: usize,
166    pub executed_candidates: usize,
167    pub skipped_candidates: usize,
168    pub steps: Vec<RefactorBatchCandidateRun>,
169    pub note: String,
170    pub artifact_path: Option<String>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
174pub struct RefactorBatchCandidateRun {
175    pub candidate_id: String,
176    pub candidate_hash: Option<String>,
177    pub file: String,
178    pub status: RefactorApplyStatus,
179    pub stale_file: Option<StaleSourceFile>,
180    pub hardening_run: Option<HardeningRun>,
181    pub note: String,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
185pub enum RefactorApplyMode {
186    Review,
187    Apply,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
191pub enum RefactorBatchApplyStatus {
192    Reviewed,
193    Applied,
194    PartiallyApplied,
195    Rejected,
196    StalePlan,
197    NoExecutableCandidates,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
201pub enum RefactorApplyStatus {
202    Reviewed,
203    Applied,
204    Rejected,
205    StalePlan,
206    Unsupported,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
210pub struct StaleSourceFile {
211    pub file: String,
212    pub expected_hash: String,
213    pub actual_hash: String,
214}
215
216pub fn build_refactor_plan(
217    root: &Path,
218    artifact_root: Option<&Path>,
219    config: &RefactorPlanConfig,
220) -> anyhow::Result<RefactorPlan> {
221    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
222    let refactor = analyze_refactor(
223        &root,
224        RefactorAnalyzeConfig {
225            target: config.target.as_deref(),
226            max_files: config.max_files,
227        },
228    )?;
229    let hardening = analyze_hardening(
230        &root,
231        HardeningAnalyzeConfig {
232            target: config.target.as_deref(),
233            max_files: config.max_files,
234        },
235    )?;
236    let policy = load_project_policy(&root, config.policy_path.as_deref())?;
237    let workspace = workspace_summary(&root);
238    let behavior_spec = config
239        .behavior_spec_path
240        .as_ref()
241        .map(|path| path.display().to_string());
242    let impact = summarize_impact(
243        &refactor.files,
244        refactor.module_edges.len(),
245        &hardening.findings,
246        hardening.changes.len(),
247    );
248    let mut candidates = Vec::new();
249    candidates.extend(hardening_candidates(&hardening.findings, config));
250    candidates.extend(structural_candidates(&refactor.files));
251    for candidate in &mut candidates {
252        candidate.candidate_hash = candidate_hash(candidate);
253    }
254    candidates.sort_by(|left, right| left.id.cmp(&right.id));
255    let source_snapshots = source_snapshots(&root, &refactor.files)?;
256
257    let required_gates = required_gates(config.behavior_spec_path.is_some());
258    let non_goals = vec![
259        "No autonomous broad multi-file refactors in v0.5.".to_string(),
260        "No public API changes without explicit human review.".to_string(),
261        "No plan candidate may bypass improve/apply validation gates.".to_string(),
262    ];
263
264    let plan_id = plan_id(&root, config, &impact, &candidates);
265    let mut plan = RefactorPlan {
266        schema_version: "0.5".to_string(),
267        plan_id,
268        plan_hash: String::new(),
269        root: root.display().to_string(),
270        target: config
271            .target
272            .as_ref()
273            .map(|path| path.display().to_string()),
274        workspace,
275        policy,
276        behavior_spec,
277        impact,
278        source_snapshots,
279        files: refactor.files,
280        module_edges: refactor.module_edges,
281        candidates,
282        required_gates,
283        non_goals,
284        artifact_path: None,
285    };
286    plan.plan_hash = refactor_plan_hash(&plan);
287
288    if let Some(artifact_root) = artifact_root {
289        let path = persist_refactor_plan(artifact_root, &plan)?;
290        plan.artifact_path = Some(path.display().to_string());
291        std::fs::write(&path, serde_json::to_string_pretty(&plan)?)?;
292    }
293
294    Ok(plan)
295}
296
297pub fn apply_refactor_plan_candidate(
298    root: &Path,
299    artifact_root: Option<&Path>,
300    config: &RefactorApplyConfig,
301) -> anyhow::Result<RefactorApplyRun> {
302    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
303    let plan_content = std::fs::read_to_string(&config.plan_path)?;
304    let plan: RefactorPlan = serde_json::from_str(&plan_content)?;
305    let mode = if config.apply {
306        RefactorApplyMode::Apply
307    } else {
308        RefactorApplyMode::Review
309    };
310    let mut run = RefactorApplyRun {
311        schema_version: "0.5".to_string(),
312        root: root.display().to_string(),
313        plan_path: config.plan_path.display().to_string(),
314        plan_id: plan.plan_id.clone(),
315        plan_hash: plan.plan_hash.clone(),
316        candidate_id: config.candidate_id.clone(),
317        candidate_hash: None,
318        mode,
319        status: RefactorApplyStatus::Rejected,
320        public_api_impact_allowed: config.allow_public_api_impact,
321        stale_files: Vec::new(),
322        hardening_run: None,
323        note: String::new(),
324        artifact_path: None,
325    };
326
327    let actual_plan_hash = refactor_plan_hash(&plan);
328    if actual_plan_hash != plan.plan_hash {
329        run.status = RefactorApplyStatus::Rejected;
330        run.note = format!(
331            "plan hash mismatch: expected {} but recomputed {}",
332            plan.plan_hash, actual_plan_hash
333        );
334        return persist_apply_run(artifact_root, run);
335    }
336
337    let stale_files = stale_source_files(&root, &plan.source_snapshots)?;
338    if !stale_files.is_empty() {
339        run.status = RefactorApplyStatus::StalePlan;
340        run.stale_files = stale_files;
341        run.note =
342            "plan source snapshots no longer match the workspace; re-run mdx-rust plan".to_string();
343        return persist_apply_run(artifact_root, run);
344    }
345
346    let Some(candidate) = plan
347        .candidates
348        .iter()
349        .find(|candidate| candidate.id == config.candidate_id)
350    else {
351        run.status = RefactorApplyStatus::Rejected;
352        run.note = "candidate id was not found in the refactor plan".to_string();
353        return persist_apply_run(artifact_root, run);
354    };
355    run.candidate_hash = Some(candidate.candidate_hash.clone());
356
357    let actual_candidate_hash = candidate_hash(candidate);
358    if actual_candidate_hash != candidate.candidate_hash {
359        run.status = RefactorApplyStatus::Rejected;
360        run.note = format!(
361            "candidate hash mismatch: expected {} but recomputed {}",
362            candidate.candidate_hash, actual_candidate_hash
363        );
364        return persist_apply_run(artifact_root, run);
365    }
366
367    if candidate.public_api_impact && !config.allow_public_api_impact {
368        run.status = RefactorApplyStatus::Rejected;
369        run.note = "candidate touches public API impact area; pass --allow-public-api-impact after human review".to_string();
370        return persist_apply_run(artifact_root, run);
371    }
372
373    if candidate.status != RefactorCandidateStatus::ApplyViaImprove
374        || candidate.recipe != RefactorRecipe::ContextualErrorHardening
375    {
376        run.status = RefactorApplyStatus::Unsupported;
377        run.note =
378            "candidate is plan-only in v0.5; no executable recipe is available yet".to_string();
379        return persist_apply_run(artifact_root, run);
380    }
381
382    let hardening = run_hardening(
383        &root,
384        artifact_root,
385        &HardeningConfig {
386            target: Some(PathBuf::from(&candidate.file)),
387            policy_path: plan
388                .policy
389                .as_ref()
390                .map(|policy| PathBuf::from(policy.path.clone())),
391            behavior_spec_path: plan.behavior_spec.as_ref().map(PathBuf::from),
392            apply: config.apply,
393            max_files: 1,
394            validation_timeout: config.validation_timeout,
395        },
396    )?;
397
398    run.status = if config.apply {
399        if hardening.outcome.applied {
400            RefactorApplyStatus::Applied
401        } else {
402            RefactorApplyStatus::Rejected
403        }
404    } else if hardening.changes.is_empty() {
405        RefactorApplyStatus::Rejected
406    } else {
407        RefactorApplyStatus::Reviewed
408    };
409    run.note = format!(
410        "executed candidate through hardening transaction; hardening status: {:?}",
411        hardening.outcome.status
412    );
413    run.hardening_run = Some(hardening);
414    persist_apply_run(artifact_root, run)
415}
416
417pub fn apply_refactor_plan_batch(
418    root: &Path,
419    artifact_root: Option<&Path>,
420    config: &RefactorBatchApplyConfig,
421) -> anyhow::Result<RefactorBatchApplyRun> {
422    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
423    let plan_content = std::fs::read_to_string(&config.plan_path)?;
424    let plan: RefactorPlan = serde_json::from_str(&plan_content)?;
425    let mode = if config.apply {
426        RefactorApplyMode::Apply
427    } else {
428        RefactorApplyMode::Review
429    };
430    let mut run = RefactorBatchApplyRun {
431        schema_version: "0.5".to_string(),
432        root: root.display().to_string(),
433        plan_path: config.plan_path.display().to_string(),
434        plan_id: plan.plan_id.clone(),
435        plan_hash: plan.plan_hash.clone(),
436        mode,
437        status: RefactorBatchApplyStatus::Rejected,
438        public_api_impact_allowed: config.allow_public_api_impact,
439        max_candidates: config.max_candidates,
440        requested_candidates: 0,
441        executed_candidates: 0,
442        skipped_candidates: 0,
443        steps: Vec::new(),
444        note: String::new(),
445        artifact_path: None,
446    };
447
448    let actual_plan_hash = refactor_plan_hash(&plan);
449    if actual_plan_hash != plan.plan_hash {
450        run.status = RefactorBatchApplyStatus::Rejected;
451        run.note = format!(
452            "plan hash mismatch: expected {} but recomputed {}",
453            plan.plan_hash, actual_plan_hash
454        );
455        return persist_batch_apply_run(artifact_root, run);
456    }
457
458    let initial_stale_files = stale_source_files(&root, &plan.source_snapshots)?;
459    if !initial_stale_files.is_empty() {
460        run.status = RefactorBatchApplyStatus::StalePlan;
461        run.steps = initial_stale_files
462            .into_iter()
463            .map(|stale| RefactorBatchCandidateRun {
464                candidate_id: String::new(),
465                candidate_hash: None,
466                file: stale.file.clone(),
467                status: RefactorApplyStatus::StalePlan,
468                stale_file: Some(stale),
469                hardening_run: None,
470                note: "source snapshot no longer matches the workspace".to_string(),
471            })
472            .collect();
473        run.note =
474            "plan source snapshots no longer match the workspace; re-run mdx-rust plan".to_string();
475        return persist_batch_apply_run(artifact_root, run);
476    }
477
478    let queue = executable_candidate_queue(&plan, config);
479    run.requested_candidates = queue.len();
480    if queue.is_empty() {
481        run.status = RefactorBatchApplyStatus::NoExecutableCandidates;
482        run.note = "no executable low-risk candidates were available in the plan".to_string();
483        return persist_batch_apply_run(artifact_root, run);
484    }
485
486    for candidate in queue {
487        let mut step = RefactorBatchCandidateRun {
488            candidate_id: candidate.id.clone(),
489            candidate_hash: Some(candidate.candidate_hash.clone()),
490            file: candidate.file.clone(),
491            status: RefactorApplyStatus::Rejected,
492            stale_file: None,
493            hardening_run: None,
494            note: String::new(),
495        };
496
497        let actual_candidate_hash = candidate_hash(candidate);
498        if actual_candidate_hash != candidate.candidate_hash {
499            step.note = format!(
500                "candidate hash mismatch: expected {} but recomputed {}",
501                candidate.candidate_hash, actual_candidate_hash
502            );
503            run.skipped_candidates += 1;
504            run.steps.push(step);
505            if config.apply {
506                break;
507            }
508            continue;
509        }
510
511        if let Some(stale) = stale_file_for_candidate(&root, &plan, &candidate.file)? {
512            step.status = RefactorApplyStatus::StalePlan;
513            step.stale_file = Some(stale);
514            step.note =
515                "candidate source file changed after planning; re-run mdx-rust plan".to_string();
516            run.skipped_candidates += 1;
517            run.steps.push(step);
518            if config.apply {
519                break;
520            }
521            continue;
522        }
523
524        let hardening = run_hardening(
525            &root,
526            artifact_root,
527            &HardeningConfig {
528                target: Some(PathBuf::from(&candidate.file)),
529                policy_path: plan
530                    .policy
531                    .as_ref()
532                    .map(|policy| PathBuf::from(policy.path.clone())),
533                behavior_spec_path: plan.behavior_spec.as_ref().map(PathBuf::from),
534                apply: config.apply,
535                max_files: 1,
536                validation_timeout: config.validation_timeout,
537            },
538        )?;
539
540        step.status = if config.apply {
541            if hardening.outcome.applied {
542                RefactorApplyStatus::Applied
543            } else {
544                RefactorApplyStatus::Rejected
545            }
546        } else if hardening.changes.is_empty() {
547            RefactorApplyStatus::Rejected
548        } else {
549            RefactorApplyStatus::Reviewed
550        };
551        step.note = format!(
552            "executed candidate through hardening transaction; hardening status: {:?}",
553            hardening.outcome.status
554        );
555        step.hardening_run = Some(hardening);
556
557        if matches!(
558            step.status,
559            RefactorApplyStatus::Reviewed | RefactorApplyStatus::Applied
560        ) {
561            run.executed_candidates += 1;
562        } else {
563            run.skipped_candidates += 1;
564        }
565
566        let failed_apply_step = config.apply && step.status != RefactorApplyStatus::Applied;
567        run.steps.push(step);
568        if failed_apply_step {
569            break;
570        }
571    }
572
573    run.status = batch_status(
574        config.apply,
575        run.executed_candidates,
576        run.requested_candidates,
577    );
578    run.note = format!(
579        "processed {} executable candidate(s); executed {}, skipped {}",
580        run.requested_candidates, run.executed_candidates, run.skipped_candidates
581    );
582    persist_batch_apply_run(artifact_root, run)
583}
584
585fn summarize_impact(
586    files: &[RefactorFileSummary],
587    module_edge_count: usize,
588    findings: &[HardeningFinding],
589    patchable_hardening_changes: usize,
590) -> RefactorImpactSummary {
591    let public_item_count = files.iter().map(|file| file.public_item_count).sum();
592    let public_files = files
593        .iter()
594        .filter(|file| file.public_item_count > 0)
595        .count();
596    let oversized_files = files.iter().filter(|file| file.line_count >= 300).count();
597    let oversized_functions = files
598        .iter()
599        .filter(|file| file.largest_function_lines >= 80)
600        .count();
601    let review_only_findings = findings.iter().filter(|finding| !finding.patchable).count();
602    let risk_level = if public_item_count > 10 || oversized_files > 2 {
603        RefactorRiskLevel::High
604    } else if public_item_count > 0 || oversized_files > 0 || oversized_functions > 0 {
605        RefactorRiskLevel::Medium
606    } else {
607        RefactorRiskLevel::Low
608    };
609
610    RefactorImpactSummary {
611        files_scanned: files.len(),
612        public_item_count,
613        public_files,
614        module_edge_count,
615        patchable_hardening_changes,
616        review_only_findings,
617        oversized_files,
618        oversized_functions,
619        risk_level,
620    }
621}
622
623fn hardening_candidates(
624    findings: &[HardeningFinding],
625    config: &RefactorPlanConfig,
626) -> Vec<RefactorCandidate> {
627    findings
628        .iter()
629        .filter(|finding| finding.patchable)
630        .map(|finding| {
631            let file = finding.file.display().to_string();
632            RefactorCandidate {
633                id: format!("plan-hardening-{}-{}", sanitize_id(&file), finding.line),
634                candidate_hash: String::new(),
635                recipe: RefactorRecipe::ContextualErrorHardening,
636                title: finding.title.clone(),
637                rationale: "Patchable contextual error hardening can be applied through the existing isolated validation transaction.".to_string(),
638                file: file.clone(),
639                line: finding.line,
640                risk: RefactorRiskLevel::Low,
641                status: RefactorCandidateStatus::ApplyViaImprove,
642                public_api_impact: false,
643                apply_command: Some(apply_command(&file, config)),
644                required_gates: required_gates(config.behavior_spec_path.is_some()),
645            }
646        })
647        .collect()
648}
649
650fn structural_candidates(files: &[RefactorFileSummary]) -> Vec<RefactorCandidate> {
651    let mut candidates = Vec::new();
652    for file in files {
653        let file_path = file.file.display().to_string();
654        if file.line_count >= 300 {
655            candidates.push(RefactorCandidate {
656                id: format!("plan-split-module-{}", sanitize_id(&file_path)),
657                candidate_hash: String::new(),
658                recipe: RefactorRecipe::SplitModuleCandidate,
659                title: "Split oversized module".to_string(),
660                rationale: format!(
661                    "{} has {} lines. Split only after reviewing public API and module edges.",
662                    file_path, file.line_count
663                ),
664                file: file_path.clone(),
665                line: 1,
666                risk: if file.public_item_count > 0 {
667                    RefactorRiskLevel::High
668                } else {
669                    RefactorRiskLevel::Medium
670                },
671                status: RefactorCandidateStatus::NeedsHumanDesign,
672                public_api_impact: file.public_item_count > 0,
673                apply_command: None,
674                required_gates: vec![
675                    "human design review".to_string(),
676                    "cargo check".to_string(),
677                    "cargo clippy -- -D warnings".to_string(),
678                    "behavior evals when configured".to_string(),
679                ],
680            });
681        }
682
683        if file.largest_function_lines >= 80 {
684            candidates.push(RefactorCandidate {
685                id: format!("plan-extract-function-{}", sanitize_id(&file_path)),
686                candidate_hash: String::new(),
687                recipe: RefactorRecipe::ExtractFunctionCandidate,
688                title: "Extract long function".to_string(),
689                rationale: format!(
690                    "Largest function in {} is {} lines. Extract only with behavior coverage in place.",
691                    file_path, file.largest_function_lines
692                ),
693                file: file_path.clone(),
694                line: 1,
695                risk: RefactorRiskLevel::Medium,
696                status: RefactorCandidateStatus::PlanOnly,
697                public_api_impact: file.public_item_count > 0,
698                apply_command: None,
699                required_gates: vec![
700                    "targeted tests or behavior evals".to_string(),
701                    "cargo check".to_string(),
702                    "cargo clippy -- -D warnings".to_string(),
703                ],
704            });
705        }
706
707        if file.public_item_count > 0 {
708            candidates.push(RefactorCandidate {
709                id: format!("plan-public-api-{}", sanitize_id(&file_path)),
710                candidate_hash: String::new(),
711                recipe: RefactorRecipe::PublicApiReview,
712                title: "Protect public API before refactoring".to_string(),
713                rationale: format!(
714                    "{} exposes {} public item(s). Treat signature changes as semver-impacting.",
715                    file_path, file.public_item_count
716                ),
717                file: file_path,
718                line: 1,
719                risk: RefactorRiskLevel::Medium,
720                status: RefactorCandidateStatus::PlanOnly,
721                public_api_impact: true,
722                apply_command: None,
723                required_gates: vec![
724                    "public API review".to_string(),
725                    "docs and changelog review for exported changes".to_string(),
726                ],
727            });
728        }
729    }
730
731    candidates
732}
733
734fn required_gates(has_behavior_spec: bool) -> Vec<String> {
735    let mut gates = vec![
736        "cargo check".to_string(),
737        "cargo clippy -- -D warnings".to_string(),
738        "review plan artifact before applying".to_string(),
739    ];
740    if has_behavior_spec {
741        gates.push("behavior eval spec must pass in isolation and after apply".to_string());
742    }
743    gates
744}
745
746fn apply_command(file: &str, config: &RefactorPlanConfig) -> String {
747    let mut command = format!("mdx-rust improve {} --apply", shell_word_str(file));
748    if let Some(policy) = &config.policy_path {
749        command.push_str(&format!(" --policy {}", shell_word_path(policy)));
750    }
751    if let Some(eval_spec) = &config.behavior_spec_path {
752        command.push_str(&format!(" --eval-spec {}", shell_word_path(eval_spec)));
753    }
754    command
755}
756
757fn shell_word_path(path: &Path) -> String {
758    shell_word_str(&path.display().to_string())
759}
760
761fn shell_word_str(value: &str) -> String {
762    if value
763        .chars()
764        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':'))
765    {
766        value.to_string()
767    } else {
768        format!("'{}'", value.replace('\'', "'\\''"))
769    }
770}
771
772fn plan_id(
773    root: &Path,
774    config: &RefactorPlanConfig,
775    impact: &RefactorImpactSummary,
776    candidates: &[RefactorCandidate],
777) -> String {
778    let mut bytes = Vec::new();
779    bytes.extend_from_slice(root.display().to_string().as_bytes());
780    bytes.extend_from_slice(format!("{:?}", config.target).as_bytes());
781    bytes.extend_from_slice(format!("{:?}", config.policy_path).as_bytes());
782    bytes.extend_from_slice(format!("{:?}", config.behavior_spec_path).as_bytes());
783    bytes.extend_from_slice(format!("{impact:?}").as_bytes());
784    bytes.extend_from_slice(format!("{candidates:?}").as_bytes());
785    stable_hash_hex(&bytes)
786}
787
788fn refactor_plan_hash(plan: &RefactorPlan) -> String {
789    let mut bytes = Vec::new();
790    bytes.extend_from_slice(plan.schema_version.as_bytes());
791    bytes.extend_from_slice(plan.plan_id.as_bytes());
792    bytes.extend_from_slice(plan.root.as_bytes());
793    bytes.extend_from_slice(format!("{:?}", plan.target).as_bytes());
794    bytes.extend_from_slice(format!("{:?}", plan.impact).as_bytes());
795    bytes.extend_from_slice(format!("{:?}", plan.source_snapshots).as_bytes());
796    bytes.extend_from_slice(format!("{:?}", plan.module_edges).as_bytes());
797    bytes.extend_from_slice(format!("{:?}", plan.candidates).as_bytes());
798    stable_hash_hex(&bytes)
799}
800
801fn candidate_hash(candidate: &RefactorCandidate) -> String {
802    let mut bytes = Vec::new();
803    bytes.extend_from_slice(candidate.id.as_bytes());
804    bytes.extend_from_slice(format!("{:?}", candidate.recipe).as_bytes());
805    bytes.extend_from_slice(candidate.title.as_bytes());
806    bytes.extend_from_slice(candidate.rationale.as_bytes());
807    bytes.extend_from_slice(candidate.file.as_bytes());
808    bytes.extend_from_slice(candidate.line.to_string().as_bytes());
809    bytes.extend_from_slice(format!("{:?}", candidate.risk).as_bytes());
810    bytes.extend_from_slice(format!("{:?}", candidate.status).as_bytes());
811    bytes.extend_from_slice(candidate.public_api_impact.to_string().as_bytes());
812    bytes.extend_from_slice(format!("{:?}", candidate.apply_command).as_bytes());
813    stable_hash_hex(&bytes)
814}
815
816fn source_snapshots(
817    root: &Path,
818    files: &[RefactorFileSummary],
819) -> anyhow::Result<Vec<SourceSnapshot>> {
820    let mut snapshots = Vec::new();
821    for file in files {
822        let content = std::fs::read(root.join(&file.file))?;
823        snapshots.push(SourceSnapshot {
824            file: file.file.display().to_string(),
825            hash: stable_hash_hex(&content),
826        });
827    }
828    Ok(snapshots)
829}
830
831fn stale_source_files(
832    root: &Path,
833    snapshots: &[SourceSnapshot],
834) -> anyhow::Result<Vec<StaleSourceFile>> {
835    let mut stale = Vec::new();
836    for snapshot in snapshots {
837        let rel = safe_relative_path(&snapshot.file)?;
838        let actual_hash = std::fs::read(root.join(&rel))
839            .map(|content| stable_hash_hex(&content))
840            .unwrap_or_else(|_| "<missing>".to_string());
841        if actual_hash != snapshot.hash {
842            stale.push(StaleSourceFile {
843                file: snapshot.file.clone(),
844                expected_hash: snapshot.hash.clone(),
845                actual_hash,
846            });
847        }
848    }
849    Ok(stale)
850}
851
852fn stale_file_for_candidate(
853    root: &Path,
854    plan: &RefactorPlan,
855    file: &str,
856) -> anyhow::Result<Option<StaleSourceFile>> {
857    let Some(snapshot) = plan
858        .source_snapshots
859        .iter()
860        .find(|snapshot| snapshot.file == file)
861    else {
862        return Ok(Some(StaleSourceFile {
863            file: file.to_string(),
864            expected_hash: "<missing-snapshot>".to_string(),
865            actual_hash: "<unknown>".to_string(),
866        }));
867    };
868    let rel = safe_relative_path(&snapshot.file)?;
869    let actual_hash = std::fs::read(root.join(&rel))
870        .map(|content| stable_hash_hex(&content))
871        .unwrap_or_else(|_| "<missing>".to_string());
872    if actual_hash == snapshot.hash {
873        Ok(None)
874    } else {
875        Ok(Some(StaleSourceFile {
876            file: snapshot.file.clone(),
877            expected_hash: snapshot.hash.clone(),
878            actual_hash,
879        }))
880    }
881}
882
883fn executable_candidate_queue<'a>(
884    plan: &'a RefactorPlan,
885    config: &RefactorBatchApplyConfig,
886) -> Vec<&'a RefactorCandidate> {
887    let mut queue = Vec::new();
888    let mut seen_files = std::collections::BTreeSet::new();
889    for candidate in &plan.candidates {
890        if queue.len() >= config.max_candidates {
891            break;
892        }
893        if candidate.status != RefactorCandidateStatus::ApplyViaImprove
894            || candidate.recipe != RefactorRecipe::ContextualErrorHardening
895        {
896            continue;
897        }
898        if candidate.public_api_impact && !config.allow_public_api_impact {
899            continue;
900        }
901        if seen_files.insert(candidate.file.clone()) {
902            queue.push(candidate);
903        }
904    }
905    queue
906}
907
908fn batch_status(apply: bool, executed: usize, requested: usize) -> RefactorBatchApplyStatus {
909    if requested == 0 {
910        RefactorBatchApplyStatus::NoExecutableCandidates
911    } else if executed == 0 {
912        RefactorBatchApplyStatus::Rejected
913    } else if !apply {
914        RefactorBatchApplyStatus::Reviewed
915    } else if executed == requested {
916        RefactorBatchApplyStatus::Applied
917    } else {
918        RefactorBatchApplyStatus::PartiallyApplied
919    }
920}
921
922fn safe_relative_path(value: &str) -> anyhow::Result<PathBuf> {
923    let path = PathBuf::from(value);
924    if path.is_absolute()
925        || path.components().any(|component| {
926            matches!(
927                component,
928                Component::ParentDir | Component::RootDir | Component::Prefix(_)
929            )
930        })
931    {
932        anyhow::bail!("refactor plan contains unscoped path: {value}");
933    }
934    Ok(path)
935}
936
937fn sanitize_id(value: &str) -> String {
938    value
939        .chars()
940        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
941        .collect::<String>()
942        .trim_matches('-')
943        .to_string()
944}
945
946fn persist_refactor_plan(artifact_root: &Path, plan: &RefactorPlan) -> anyhow::Result<PathBuf> {
947    let dir = artifact_root.join("plans");
948    std::fs::create_dir_all(&dir)?;
949    let millis = std::time::SystemTime::now()
950        .duration_since(std::time::UNIX_EPOCH)
951        .map(|duration| duration.as_millis())
952        .unwrap_or(0);
953    Ok(dir.join(format!("refactor-plan-{millis}-{}.json", plan.plan_id)))
954}
955
956fn persist_apply_run(
957    artifact_root: Option<&Path>,
958    mut run: RefactorApplyRun,
959) -> anyhow::Result<RefactorApplyRun> {
960    if let Some(artifact_root) = artifact_root {
961        let dir = artifact_root.join("plans");
962        std::fs::create_dir_all(&dir)?;
963        let millis = std::time::SystemTime::now()
964            .duration_since(std::time::UNIX_EPOCH)
965            .map(|duration| duration.as_millis())
966            .unwrap_or(0);
967        let path = dir.join(format!(
968            "apply-plan-{millis}-{}-{}.json",
969            sanitize_id(&run.plan_id),
970            sanitize_id(&run.candidate_id)
971        ));
972        run.artifact_path = Some(path.display().to_string());
973        std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
974    }
975    Ok(run)
976}
977
978fn persist_batch_apply_run(
979    artifact_root: Option<&Path>,
980    mut run: RefactorBatchApplyRun,
981) -> anyhow::Result<RefactorBatchApplyRun> {
982    if let Some(artifact_root) = artifact_root {
983        let dir = artifact_root.join("plans");
984        std::fs::create_dir_all(&dir)?;
985        let millis = std::time::SystemTime::now()
986            .duration_since(std::time::UNIX_EPOCH)
987            .map(|duration| duration.as_millis())
988            .unwrap_or(0);
989        let path = dir.join(format!(
990            "apply-plan-batch-{millis}-{}.json",
991            sanitize_id(&run.plan_id)
992        ));
993        run.artifact_path = Some(path.display().to_string());
994        std::fs::write(&path, serde_json::to_string_pretty(&run)?)?;
995    }
996    Ok(run)
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002    use tempfile::tempdir;
1003
1004    #[test]
1005    fn refactor_plan_points_patchable_changes_to_improve() {
1006        let dir = tempdir().unwrap();
1007        std::fs::write(
1008            dir.path().join("Cargo.toml"),
1009            r#"[package]
1010name = "plan-fixture"
1011version = "0.1.0"
1012edition = "2021"
1013
1014[dependencies]
1015anyhow = "1"
1016"#,
1017        )
1018        .unwrap();
1019        std::fs::create_dir_all(dir.path().join("src")).unwrap();
1020        std::fs::write(
1021            dir.path().join("src/lib.rs"),
1022            r#"pub fn load_config() -> anyhow::Result<String> {
1023    let content = std::fs::read_to_string("missing.toml").unwrap();
1024    Ok(content)
1025}
1026"#,
1027        )
1028        .unwrap();
1029
1030        let plan = build_refactor_plan(
1031            dir.path(),
1032            None,
1033            &RefactorPlanConfig {
1034                target: Some(PathBuf::from("src/lib.rs")),
1035                behavior_spec_path: Some(PathBuf::from(".mdx-rust/evals.json")),
1036                ..RefactorPlanConfig::default()
1037            },
1038        )
1039        .unwrap();
1040
1041        assert_eq!(plan.schema_version, "0.5");
1042        assert!(plan.candidates.iter().any(|candidate| candidate.status
1043            == RefactorCandidateStatus::ApplyViaImprove
1044            && candidate
1045                .apply_command
1046                .as_deref()
1047                .is_some_and(|command| command.contains("--eval-spec"))));
1048    }
1049}