Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 58 additions & 10 deletions crates/kratos-cli/src/commands/clean.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -41,8 +44,8 @@ pub fn run(args: &[String], stdout: &mut dyn Write) -> KratosResult<i32> {
};

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);
}

Expand Down Expand Up @@ -129,15 +132,15 @@ fn parse_min_confidence(value: &ParsedFlagValue) -> KratosResult<f32> {
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() {
Expand All @@ -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));
}
}

Expand All @@ -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<String> {
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<Path>) -> String {
path.as_ref().to_string_lossy().replace('\\', "/")
}
119 changes: 119 additions & 0 deletions crates/kratos-cli/tests/clean_threshold_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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");
Expand Down Expand Up @@ -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");
}
5 changes: 4 additions & 1 deletion crates/kratos-cli/tests/cli_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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로 다시 실행하세요."));
}

Expand Down Expand Up @@ -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(
Expand Down
Loading