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
13 changes: 12 additions & 1 deletion crates/kratos-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,13 +210,20 @@ pub fn parse_cli_options(
}

pub fn canonicalize_scan_args(raw_args: &[String]) -> KratosResult<Vec<String>> {
let parsed = parse_cli_options(raw_args, &["output"], &["json"]);
let parsed = parse_cli_options(raw_args, &["output"], &["json", "no-write"]);
let mut args = Vec::new();
let no_write = is_enabled_boolean_flag(parsed.flags.get("no-write"));

if let Some(root) = parsed.positionals.first() {
args.push(root.clone());
}

if no_write && parsed.flags.contains_key("output") {
return Err(KratosError::Config(
"--output and --no-write cannot be used together".to_string(),
));
}

if let Some(value) = parsed.flags.get("output").and_then(flag_value_as_string) {
if !value.is_empty() {
args.push("--output".to_string());
Expand All @@ -232,6 +239,10 @@ pub fn canonicalize_scan_args(raw_args: &[String]) -> KratosResult<Vec<String>>
args.push("--json".to_string());
}

if no_write {
args.push("--no-write".to_string());
}

Ok(args)
}

Expand Down
83 changes: 76 additions & 7 deletions crates/kratos-cli/src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub const NAME: &str = "scan";
pub const SPEC: CommandSpec = CommandSpec {
name: NAME,
summary: "Analyze a codebase and save the latest report.",
usage: &["kratos scan [root] [--output path] [--json]"],
usage: &["kratos scan [root] [--output path] [--no-write] [--json]"],
};

#[derive(Debug, Parser)]
Expand All @@ -28,6 +28,8 @@ struct ScanArgs {
#[arg(long, allow_hyphen_values = true)]
output: Option<String>,
#[arg(long)]
no_write: bool,
#[arg(long)]
json: bool,
}

Expand All @@ -38,24 +40,30 @@ pub fn run(args: &[String], stdout: &mut dyn Write) -> KratosResult<i32> {
Some(raw) => resolve_input_path(&cwd, raw),
None => cwd,
};
let uses_default_output_path = args.output.is_none();
let output_path = resolve_output_path(&root, args.output.as_deref());
let report = analyze_project(&root)?;
let serialized = serialize_report_pretty(&report)?;

if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
if !args.no_write {
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&output_path, format!("{serialized}\n"))?;
}
fs::write(&output_path, format!("{serialized}\n"))?;

if args.json {
write_output(stdout, &serialized)?;
return Ok(0);
}

write_output(
stdout,
&format_summary_report(&report, &output_path, "Kratos scan complete.")?,
let summary = format_scan_summary(
&report,
&output_path,
args.no_write,
uses_default_output_path,
)?;
write_output(stdout, &summary)?;
Ok(0)
}

Expand All @@ -65,6 +73,67 @@ fn parse_args(args: &[String]) -> KratosResult<ScanArgs> {
.map_err(|error| KratosError::Config(error.to_string().trim().to_string()))
}

fn format_scan_summary(
report: &kratos_core::model::ReportV2,
output_path: &Path,
no_write: bool,
uses_default_output_path: bool,
) -> KratosResult<String> {
let summary = format_summary_report(report, output_path, "Kratos scan complete.")?;
let mut lines = Vec::new();

for line in summary.lines() {
if no_write {
if line.starts_with("저장된 리포트: ") {
lines
.push("리포트 저장: --no-write 때문에 파일을 생성하지 않았습니다.".to_string());
continue;
}

if line.starts_with("- 정리 미리보기: ") || line.starts_with("- 공유용 Markdown: ")
{
continue;
}
}

lines.push(line.to_string());

if no_write && line == "다음 단계:" {
lines.push(
"- 리포트가 필요한 clean/report 작업은 기본 쓰기 모드로 다시 실행하거나 --output path를 지정하세요."
.to_string(),
);
}

if !no_write && uses_default_output_path && line.starts_with("저장된 리포트: ") {
lines.push(
"쓰기 안내: 기본 리포트 경로 .kratos/latest-report.json는 체크아웃을 dirty하게 만들 수 있습니다. .gitignore에 .kratos/를 추가하거나 --output 또는 --no-write를 사용하세요."
.to_string(),
);
}

if !no_write && line.starts_with("- 정리 미리보기: kratos clean ") {
let report_arg = line
.strip_prefix("- 정리 미리보기: kratos clean ")
.unwrap_or_default();
lines.push(format!(
"- npx로 실행 중이라면: npx @jeremyfellaz/kratos clean {report_arg}"
));
}

if !no_write && line.starts_with("- 공유용 Markdown: kratos report ") {
let report_args = line
.strip_prefix("- 공유용 Markdown: kratos report ")
.unwrap_or_default();
lines.push(format!(
"- npx로 실행 중이라면: npx @jeremyfellaz/kratos report {report_args}"
));
}
}

Ok(lines.join("\n"))
}

fn resolve_output_path(root: &Path, output_flag: Option<&str>) -> PathBuf {
match output_flag {
Some(raw) => resolve_input_path(root, raw),
Expand Down
86 changes: 84 additions & 2 deletions crates/kratos-cli/tests/cli_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ fn root_help_matches_expected_shape() {
assert!(output.status.success());
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"Kratos\n죽은 코드를 가차 없이 제거합니다.\n\n사용법:\n kratos scan [root] [--output path] [--json]\n kratos report [report-path-or-root] [--format summary|json|md]\n kratos diff [before-report-path-or-root] [after-report-path-or-root] [--format summary|json|md]\n kratos clean [report-path-or-root] [--apply] [--min-confidence value]\n\n명령:\n scan 코드베이스를 분석하고 최신 리포트를 저장합니다.\n report 저장된 리포트를 summary, json, markdown 형식으로 출력합니다.\n diff 저장된 두 리포트를 비교합니다.\n clean 삭제 후보를 표시하거나 --apply로 삭제합니다.\n"
"Kratos\n죽은 코드를 가차 없이 제거합니다.\n\n사용법:\n kratos scan [root] [--output path] [--no-write] [--json]\n kratos report [report-path-or-root] [--format summary|json|md]\n kratos diff [before-report-path-or-root] [after-report-path-or-root] [--format summary|json|md]\n kratos clean [report-path-or-root] [--apply] [--min-confidence value]\n\n명령:\n scan 코드베이스를 분석하고 최신 리포트를 저장합니다.\n report 저장된 리포트를 summary, json, markdown 형식으로 출력합니다.\n diff 저장된 두 리포트를 비교합니다.\n clean 삭제 후보를 표시하거나 --apply로 삭제합니다.\n"
);
}

Expand All @@ -32,7 +32,7 @@ fn unknown_command_returns_help_and_exit_code_one() {
assert_eq!(output.status.code(), Some(1));
assert_eq!(
String::from_utf8_lossy(&output.stderr),
"알 수 없는 명령: nope\n\nKratos\n죽은 코드를 가차 없이 제거합니다.\n\n사용법:\n kratos scan [root] [--output path] [--json]\n kratos report [report-path-or-root] [--format summary|json|md]\n kratos diff [before-report-path-or-root] [after-report-path-or-root] [--format summary|json|md]\n kratos clean [report-path-or-root] [--apply] [--min-confidence value]\n\n명령:\n scan 코드베이스를 분석하고 최신 리포트를 저장합니다.\n report 저장된 리포트를 summary, json, markdown 형식으로 출력합니다.\n diff 저장된 두 리포트를 비교합니다.\n clean 삭제 후보를 표시하거나 --apply로 삭제합니다.\n"
"알 수 없는 명령: nope\n\nKratos\n죽은 코드를 가차 없이 제거합니다.\n\n사용법:\n kratos scan [root] [--output path] [--no-write] [--json]\n kratos report [report-path-or-root] [--format summary|json|md]\n kratos diff [before-report-path-or-root] [after-report-path-or-root] [--format summary|json|md]\n kratos clean [report-path-or-root] [--apply] [--min-confidence value]\n\n명령:\n scan 코드베이스를 분석하고 최신 리포트를 저장합니다.\n report 저장된 리포트를 summary, json, markdown 형식으로 출력합니다.\n diff 저장된 두 리포트를 비교합니다.\n clean 삭제 후보를 표시하거나 --apply로 삭제합니다.\n"
);
}

Expand All @@ -56,6 +56,9 @@ fn scan_report_and_clean_work_for_demo_fixture() {
assert!(scan_stdout.contains("라우트 진입점: 1"));
assert!(scan_stdout.contains("삭제 후보: 2"));
assert!(scan_stdout.contains("다음 단계:"));
assert!(scan_stdout.contains("쓰기 안내: 기본 리포트 경로 .kratos/latest-report.json는 체크아웃을 dirty하게 만들 수 있습니다. .gitignore에 .kratos/를 추가하거나 --output 또는 --no-write를 사용하세요."));
assert!(scan_stdout.contains("npx로 실행 중이라면: npx @jeremyfellaz/kratos clean"));
assert!(scan_stdout.contains("npx로 실행 중이라면: npx @jeremyfellaz/kratos report"));
assert!(scan_stdout.contains("상위 정리 후보:"));
assert!(scan_stdout.contains("- src/components/DeadWidget.tsx"));
assert!(scan_stdout.contains(
Expand Down Expand Up @@ -105,6 +108,85 @@ fn scan_report_and_clean_work_for_demo_fixture() {
assert!(clean_stdout.contains("삭제하려면 --apply로 다시 실행하세요."));
}

#[test]
fn scan_no_write_prints_human_summary_without_creating_default_report() {
let project_root = copy_demo_app("cli-no-write");
let report_path = project_root.join(".kratos/latest-report.json");

let scan = run_cli_in_dir(&project_root, &["scan", "--no-write"]);

assert!(scan.status.success());
let scan_stdout = String::from_utf8_lossy(&scan.stdout);
assert!(scan_stdout.contains("Kratos 스캔 완료."));
assert!(scan_stdout.contains(
"영향: 즉시 수정 대상 1개: 깨진 import 1개. 자동 정리 후보 2개: 삭제 후보 2개. 수동 검토 대상 3개: 사용되지 않는 export 3개."
));
assert!(scan_stdout.contains("리포트 저장: --no-write 때문에 파일을 생성하지 않았습니다."));
assert!(scan_stdout.contains(
"리포트가 필요한 clean/report 작업은 기본 쓰기 모드로 다시 실행하거나 --output path를 지정하세요."
));
assert!(!scan_stdout.contains("정리 미리보기: kratos clean"));
assert!(!scan_stdout.contains("공유용 Markdown: kratos report"));
assert!(!report_path.exists());
assert!(!project_root.join(".kratos").exists());
}

#[test]
fn scan_no_write_rejects_output_flag() {
let project_root = copy_demo_app("cli-no-write-output-conflict");

let scan = run_cli_in_dir(
&project_root,
&["scan", "--no-write", "--output", "report.json"],
);

assert_eq!(scan.status.code(), Some(1));
assert!(String::from_utf8_lossy(&scan.stderr).contains(
"Kratos 실행 실패: Config error: --output and --no-write cannot be used together"
));
assert!(!project_root.join("report.json").exists());
assert!(!project_root.join(".kratos").exists());

let empty_output = run_cli_in_dir(&project_root, &["scan", "--no-write", "--output="]);
assert_eq!(empty_output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&empty_output.stderr).contains(
"Kratos 실행 실패: Config error: --output and --no-write cannot be used together"
));
}

#[test]
fn scan_json_stdout_stays_json_only() {
let project_root = copy_demo_app("cli-json-only");

let scan = run_cli_in_dir(&project_root, &["scan", "--json"]);

assert!(scan.status.success());
let report: serde_json::Value =
serde_json::from_slice(&scan.stdout).expect("scan json should parse");
assert_eq!(report["summary"]["filesScanned"], 5);
let scan_stdout = String::from_utf8_lossy(&scan.stdout);
assert!(!scan_stdout.contains("쓰기 안내"));
assert!(!scan_stdout.contains("npx로 실행 중이라면"));
}

#[test]
fn scan_json_no_write_stays_json_only_without_creating_default_report() {
let project_root = copy_demo_app("cli-json-no-write");
let report_path = project_root.join(".kratos/latest-report.json");

let scan = run_cli_in_dir(&project_root, &["scan", "--json", "--no-write"]);

assert!(scan.status.success());
let report: serde_json::Value =
serde_json::from_slice(&scan.stdout).expect("scan json should parse");
assert_eq!(report["summary"]["filesScanned"], 5);
let scan_stdout = String::from_utf8_lossy(&scan.stdout);
assert!(!scan_stdout.contains("쓰기 안내"));
assert!(!scan_stdout.contains("npx로 실행 중이라면"));
assert!(!report_path.exists());
assert!(!project_root.join(".kratos").exists());
}

#[test]
fn scan_does_not_reopen_node_modules_for_broad_negated_ignore_patterns() {
let project_root = copy_demo_app("cli-node-modules-ignore");
Expand Down
Loading