diff --git a/crates/kratos-cli/src/commands/clean.rs b/crates/kratos-cli/src/commands/clean.rs index c87abd7..c26f5e1 100644 --- a/crates/kratos-cli/src/commands/clean.rs +++ b/crates/kratos-cli/src/commands/clean.rs @@ -1,8 +1,11 @@ use std::fs; use std::io::Write; +use std::path::Path; -use kratos_core::clean::{clean_from_report_with_min_confidence, plan_clean_candidates}; +use kratos_core::clean::clean_from_report_with_min_confidence; +use kratos_core::clean_preview::{build_clean_preview, CleanPreviewItem, CleanPreviewPlan}; use kratos_core::config::load_clean_min_confidence; +use kratos_core::model::DeletionCandidateFinding; use kratos_core::report::parse_report_json; use kratos_core::report_format::display_known_reason; use kratos_core::KratosResult; @@ -41,8 +44,8 @@ pub fn run(args: &[String], stdout: &mut dyn Write) -> KratosResult { }; if !args.apply { - let plan = plan_clean_candidates(&report, min_confidence)?; - write_output(stdout, &format_clean_plan(&plan))?; + let plan = build_clean_preview(&report, min_confidence)?; + write_output(stdout, &format_clean_preview_plan(&plan, &report.root))?; return Ok(0); } @@ -129,15 +132,15 @@ fn parse_min_confidence(value: &ParsedFlagValue) -> KratosResult { Ok(parsed) } -fn format_clean_plan(plan: &kratos_core::clean::CleanThresholdPlan) -> String { +fn format_clean_preview_plan(plan: &CleanPreviewPlan, report_root: &Path) -> String { let mut lines = vec![ "Kratos clean 미리보기입니다.".to_string(), String::new(), - format!("삭제 대상: {}", plan.deletion_targets.len()), + format!("삭제 대상: {}", plan.items.len()), ]; - for candidate in &plan.deletion_targets { - lines.push(format_candidate_line(candidate)); + for item in &plan.items { + lines.extend(format_preview_item(item)); } if !plan.threshold_skipped_targets.is_empty() { @@ -148,7 +151,19 @@ fn format_clean_plan(plan: &kratos_core::clean::CleanThresholdPlan) -> String { )); for candidate in &plan.threshold_skipped_targets { - lines.push(format_candidate_line(candidate)); + lines.push(format_candidate_line(candidate, report_root)); + } + } + + if !plan.unavailable_targets.is_empty() { + lines.push(String::new()); + lines.push(format!( + "사용할 수 없어 건너뛴 대상: {}", + plan.unavailable_targets.len() + )); + + for candidate in &plan.unavailable_targets { + lines.push(format_candidate_line(candidate, report_root)); } } @@ -157,11 +172,44 @@ fn format_clean_plan(plan: &kratos_core::clean::CleanThresholdPlan) -> String { lines.join("\n") } -fn format_candidate_line(candidate: &kratos_core::model::DeletionCandidateFinding) -> String { +fn format_preview_item(item: &CleanPreviewItem) -> Vec { + let exists_state = if item.exists { "존재함" } else { "없음" }; + let mut lines = vec![ + format!("- {}", item.relative_path), + format!(" 신뢰도: {:.2}", item.confidence), + format!(" 사유: {}", display_known_reason(&item.reason)), + format!(" 상태: {exists_state}"), + " 미리보기:".to_string(), + ]; + + if item.preview_excerpt.is_empty() { + lines.push(" [empty file]".to_string()); + } else { + lines.extend( + item.preview_excerpt + .lines() + .map(|line| format!(" {line}")), + ); + } + + lines +} + +fn format_candidate_line(candidate: &DeletionCandidateFinding, report_root: &Path) -> String { format!( "- {} (신뢰도 {:.2}, {})", - candidate.file.display(), + relative_path(&candidate.file, report_root), candidate.confidence, display_known_reason(&candidate.reason) ) } + +fn relative_path(file: &Path, report_root: &Path) -> String { + file.strip_prefix(report_root) + .map(path_to_forward_slashes) + .unwrap_or_else(|_| path_to_forward_slashes(file)) +} + +fn path_to_forward_slashes(path: impl AsRef) -> String { + path.as_ref().to_string_lossy().replace('\\', "/") +} diff --git a/crates/kratos-cli/tests/clean_threshold_cli.rs b/crates/kratos-cli/tests/clean_threshold_cli.rs index 0d6b2fe..33b529b 100644 --- a/crates/kratos-cli/tests/clean_threshold_cli.rs +++ b/crates/kratos-cli/tests/clean_threshold_cli.rs @@ -25,6 +25,11 @@ fn clean_uses_config_threshold_and_flag_override() { assert!(overridden_stdout.contains("삭제 대상: 1")); assert!(overridden_stdout.contains("신뢰도 기준 미달로 건너뛴 대상: 1")); assert!(overridden_stdout.contains("high-confidence.ts")); + assert!(overridden_stdout.contains("신뢰도: 0.96")); + assert!(overridden_stdout.contains("사유: high confidence candidate")); + assert!(overridden_stdout.contains("상태: 존재함")); + assert!(overridden_stdout.contains("미리보기:")); + assert!(overridden_stdout.contains("export const high = true;")); assert!(overridden_stdout.contains("mid-confidence.ts")); let apply = run_cli_in_dir( @@ -39,6 +44,32 @@ fn clean_uses_config_threshold_and_flag_override() { assert!(project_root.join("mid-confidence.ts").exists()); } +#[test] +fn clean_dry_run_renders_excerpts_markers_and_separate_skipped_sections() { + let project_root = temp_dir("clean-threshold-cli-preview"); + write_clean_preview_fixture(&project_root); + + let output = run_cli_in_dir(&project_root, &["clean", "--min-confidence", "0.9"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!(stdout.contains("삭제 대상: 3")); + assert!(stdout.contains("- src/live.ts")); + assert!(stdout.contains("신뢰도: 0.96")); + assert!(stdout.contains("사유: live candidate")); + assert!(stdout.contains("상태: 존재함")); + assert!(stdout.contains("export const live = true;")); + assert!(stdout.contains("- src/missing.ts")); + assert!(stdout.contains("상태: 없음")); + assert!(stdout.contains("[missing file]")); + assert!(stdout.contains("- src/binary.bin")); + assert!(stdout.contains("[binary file]")); + assert!(stdout.contains("신뢰도 기준 미달로 건너뛴 대상: 1")); + assert!(stdout.contains("src/low-confidence.ts")); + assert!(stdout.contains("사용할 수 없어 건너뛴 대상: 1")); + assert!(stdout.contains("outside-candidate.ts")); +} + #[test] fn clean_rejects_out_of_range_min_confidence_values() { let project_root = temp_dir("clean-threshold-cli-invalid"); @@ -255,3 +286,91 @@ fn write_clean_threshold_fixture( ) .expect("report should write"); } + +fn write_clean_preview_fixture(project_root: &std::path::Path) { + std::fs::create_dir_all(project_root.join(".kratos")).expect("report dir should exist"); + std::fs::create_dir_all(project_root.join("src")).expect("source dir should exist"); + + std::fs::write( + project_root.join("src/live.ts"), + "export const live = true;\n", + ) + .expect("live file should write"); + std::fs::write(project_root.join("src/binary.bin"), [0, 159, 146, 150]) + .expect("binary file should write"); + + let outside_candidate = project_root.with_file_name(format!( + "{}-outside-candidate.ts", + project_root + .file_name() + .expect("project root should have file name") + .to_string_lossy() + )); + + let report = json!({ + "schemaVersion": 3, + "generatedAt": "2026-04-21T00:00:00Z", + "project": { + "root": project_root, + "configPath": null, + }, + "summary": { + "filesScanned": 4, + "entrypoints": 0, + "brokenImports": 0, + "orphanFiles": 0, + "deadExports": 0, + "unusedImports": 0, + "routeEntrypoints": 0, + "deletionCandidates": 5, + }, + "findings": { + "brokenImports": [], + "orphanFiles": [], + "deadExports": [], + "unusedImports": [], + "routeEntrypoints": [], + "deletionCandidates": [ + { + "file": project_root.join("src/live.ts"), + "reason": "live candidate", + "confidence": 0.96, + "safe": true, + }, + { + "file": project_root.join("src/missing.ts"), + "reason": "missing candidate", + "confidence": 0.95, + "safe": true, + }, + { + "file": project_root.join("src/binary.bin"), + "reason": "binary candidate", + "confidence": 0.94, + "safe": true, + }, + { + "file": project_root.join("src/low-confidence.ts"), + "reason": "low candidate", + "confidence": 0.20, + "safe": true, + }, + { + "file": outside_candidate, + "reason": "outside candidate", + "confidence": 0.93, + "safe": true, + } + ], + }, + "graph": { + "modules": [], + }, + }); + + std::fs::write( + project_root.join(".kratos/latest-report.json"), + serde_json::to_string_pretty(&report).expect("report should serialize"), + ) + .expect("report should write"); +} diff --git a/crates/kratos-cli/tests/cli_smoke.rs b/crates/kratos-cli/tests/cli_smoke.rs index b7502f5..a8077f0 100644 --- a/crates/kratos-cli/tests/cli_smoke.rs +++ b/crates/kratos-cli/tests/cli_smoke.rs @@ -99,6 +99,9 @@ fn scan_report_and_clean_work_for_demo_fixture() { assert!(clean.status.success()); let clean_stdout = String::from_utf8_lossy(&clean.stdout); assert!(clean_stdout.contains("Kratos clean 미리보기입니다.")); + assert!(clean_stdout.contains("상태: 존재함")); + assert!(clean_stdout.contains("미리보기:")); + assert!(clean_stdout.contains("export function DeadWidget()")); assert!(clean_stdout.contains("삭제하려면 --apply로 다시 실행하세요.")); } @@ -468,7 +471,7 @@ fn clean_accepts_future_schema_reports_when_the_shape_is_compatible() { assert!(dry_run.status.success()); let dry_run_stdout = String::from_utf8_lossy(&dry_run.stdout); assert!(dry_run_stdout.contains("Kratos clean 미리보기입니다.")); - assert!(dry_run_stdout.contains(&dead_file.display().to_string())); + assert!(dry_run_stdout.contains("dead.txt")); assert!(dead_file.exists()); let apply = run_cli_in_dir(