Skip to content

Commit e0a41cc

Browse files
committed
feat: env 소스 주석 fix 추가
1 parent 82a62af commit e0a41cc

12 files changed

Lines changed: 524 additions & 103 deletions

File tree

crates/maximus-checks/src/env.rs

Lines changed: 108 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,28 @@ use std::process::Command;
55

66
use maximus_core::{
77
is_concrete_env_file_name, is_template_env_file_name, looks_like_secret, make_finding,
8-
parse_env, plan_create_env_example, plan_sync_env_example, read_text_if_exists,
9-
render_env_template, sort_findings, unique_fixes, FileKind, FindingInput, FixPlan, ProjectFile,
10-
ProjectSnapshot, Severity,
8+
parse_env, plan_create_env_example, plan_create_env_example_with_groups, plan_sync_env_example,
9+
plan_sync_env_example_with_groups, read_text_if_exists, render_env_template,
10+
render_env_template_groups, sort_findings, unique_fixes, EnvTemplateRenderOptions,
11+
EnvTemplateSourceGroup, FileKind, FindingInput, FixPlan, ProjectFile, ProjectSnapshot,
12+
Severity,
1113
};
1214

1315
use crate::check_outcome::CheckOutcome;
1416

1517
pub fn run_env_check(project: &ProjectSnapshot) -> io::Result<CheckOutcome> {
18+
run_env_check_with_options(project, &EnvCheckOptions::default())
19+
}
20+
21+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
22+
pub struct EnvCheckOptions {
23+
pub template_render: EnvTemplateRenderOptions,
24+
}
25+
26+
pub fn run_env_check_with_options(
27+
project: &ProjectSnapshot,
28+
options: &EnvCheckOptions,
29+
) -> io::Result<CheckOutcome> {
1630
let mut findings = Vec::new();
1731
let mut fixes = Vec::new();
1832
let mut planned_fixes = Vec::new();
@@ -112,6 +126,8 @@ pub fn run_env_check(project: &ProjectSnapshot) -> io::Result<CheckOutcome> {
112126
.filter(|record| is_concrete_env_file_name(&record.file.name))
113127
.collect::<Vec<_>>();
114128
let contract_keys = collect_contract_keys(&concrete_records);
129+
let contract_groups =
130+
collect_contract_groups(&project.root_dir, &concrete_records, &contract_keys);
115131

116132
let gitignore_sources =
117133
read_ancestor_gitignore_sources(&gitignore_traversal_root, &directory.dir)?;
@@ -176,11 +192,20 @@ pub fn run_env_check(project: &ProjectSnapshot) -> io::Result<CheckOutcome> {
176192
),
177193
files: vec![output_path],
178194
});
179-
planned_fixes.push(plan_create_env_example(
180-
&project.root_dir,
181-
&directory.dir,
182-
&contract_keys,
183-
));
195+
if options.template_render.source_comments {
196+
planned_fixes.push(plan_create_env_example_with_groups(
197+
&project.root_dir,
198+
&directory.dir,
199+
contract_groups.clone(),
200+
options.template_render.clone(),
201+
));
202+
} else {
203+
planned_fixes.push(plan_create_env_example(
204+
&project.root_dir,
205+
&directory.dir,
206+
&contract_keys,
207+
));
208+
}
184209
}
185210

186211
if let Some(example_record) = example_record {
@@ -223,12 +248,27 @@ pub fn run_env_check(project: &ProjectSnapshot) -> io::Result<CheckOutcome> {
223248
),
224249
files: vec![example_record.file.path.clone()],
225250
});
226-
planned_fixes.push(plan_sync_env_example(
227-
&project.root_dir,
228-
&example_record.file.path,
229-
&example_record.parsed_source_text,
230-
&missing_keys,
231-
));
251+
if options.template_render.source_comments {
252+
let missing_groups = collect_contract_groups(
253+
&project.root_dir,
254+
&concrete_records,
255+
&missing_keys,
256+
);
257+
planned_fixes.push(plan_sync_env_example_with_groups(
258+
&project.root_dir,
259+
&example_record.file.path,
260+
&example_record.parsed_source_text,
261+
missing_groups,
262+
options.template_render.clone(),
263+
));
264+
} else {
265+
planned_fixes.push(plan_sync_env_example(
266+
&project.root_dir,
267+
&example_record.file.path,
268+
&example_record.parsed_source_text,
269+
&missing_keys,
270+
));
271+
}
232272
}
233273

234274
for contract_record in &contract_records {
@@ -367,6 +407,29 @@ pub fn render_synced_env_example(existing_text: &str, missing_keys: &[String]) -
367407
format!("{existing_text}{prefix}{addition}")
368408
}
369409

410+
pub fn render_created_env_example_with_sources(groups: Vec<EnvTemplateSourceGroup>) -> String {
411+
render_env_template_groups(
412+
groups,
413+
&EnvTemplateRenderOptions {
414+
source_comments: true,
415+
},
416+
)
417+
}
418+
419+
pub fn render_synced_env_example_with_sources(
420+
existing_text: &str,
421+
groups: Vec<EnvTemplateSourceGroup>,
422+
) -> String {
423+
let prefix = if existing_text.ends_with('\n') || existing_text.is_empty() {
424+
""
425+
} else {
426+
"\n"
427+
};
428+
let addition = render_created_env_example_with_sources(groups);
429+
430+
format!("{existing_text}{prefix}{addition}")
431+
}
432+
370433
#[derive(Debug, Clone)]
371434
struct ParsedEnvRecord {
372435
file: ProjectFile,
@@ -389,6 +452,37 @@ fn collect_contract_keys(records: &[&ParsedEnvRecord]) -> Vec<String> {
389452
keys
390453
}
391454

455+
fn collect_contract_groups(
456+
root_dir: &Path,
457+
records: &[&ParsedEnvRecord],
458+
selected_keys: &[String],
459+
) -> Vec<EnvTemplateSourceGroup> {
460+
let selected = selected_keys.iter().cloned().collect::<BTreeSet<_>>();
461+
let mut seen = BTreeSet::new();
462+
let mut groups = Vec::new();
463+
464+
for record in records {
465+
let keys = record
466+
.parsed
467+
.order
468+
.iter()
469+
.filter(|key| selected.contains(*key) && seen.insert((*key).clone()))
470+
.cloned()
471+
.collect::<Vec<_>>();
472+
473+
if keys.is_empty() {
474+
continue;
475+
}
476+
477+
groups.push(EnvTemplateSourceGroup {
478+
source: Some(relative_display_path(root_dir, &record.file.path)),
479+
keys,
480+
});
481+
}
482+
483+
groups
484+
}
485+
392486
fn relative_display_path(root_dir: &Path, target: &Path) -> String {
393487
root_dir
394488
.strip_prefix(root_dir)

crates/maximus-checks/src/lib.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ mod workspace_config;
1717

1818
pub use check_outcome::CheckOutcome;
1919
pub use config_duplicates::run_config_duplicate_check;
20-
pub use env::{render_created_env_example, render_synced_env_example, run_env_check};
20+
pub use env::{
21+
render_created_env_example, render_created_env_example_with_sources, render_synced_env_example,
22+
render_synced_env_example_with_sources, run_env_check, run_env_check_with_options,
23+
EnvCheckOptions,
24+
};
2125
pub use eslint_prettier::run_eslint_prettier_check;
2226
pub use jsx_config::run_jsx_config_check;
2327
pub use module_system::run_module_system_check;

crates/maximus-checks/tests/env_checks.rs

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,15 @@ mod check_outcome;
77
#[path = "../src/env.rs"]
88
mod env;
99

10-
use env::{render_created_env_example, render_synced_env_example, run_env_check};
11-
use maximus_core::{apply_fix, discover_project, FixOperation, FixPlan, Severity};
10+
use env::{
11+
render_created_env_example, render_created_env_example_with_sources, render_synced_env_example,
12+
render_synced_env_example_with_sources, run_env_check, run_env_check_with_options,
13+
EnvCheckOptions,
14+
};
15+
use maximus_core::{
16+
apply_fix, discover_project, EnvTemplateRenderOptions, EnvTemplateSourceGroup, FixOperation,
17+
FixPlan, Severity,
18+
};
1219
use tempfile::TempDir;
1320

1421
#[test]
@@ -515,11 +522,12 @@ fn env_sync_planned_fix_uses_audited_snapshot_text() {
515522
match &planned.operation {
516523
FixOperation::SyncEnvExample {
517524
existing_text,
518-
missing_keys,
525+
groups,
519526
..
520527
} => {
521528
assert_eq!(existing_text, "PRIMARY=\n");
522-
assert_eq!(missing_keys, &vec!["SECONDARY".to_string()]);
529+
assert_eq!(groups.len(), 1);
530+
assert_eq!(groups[0].keys, vec!["SECONDARY".to_string()]);
523531
}
524532
_ => panic!("expected sync env example operation"),
525533
}
@@ -560,6 +568,84 @@ fn env_example_render_helpers_match_js_create_and_sync_semantics() {
560568
);
561569
}
562570

571+
#[test]
572+
fn env_example_source_comment_helpers_group_and_sort_opt_in_output() {
573+
let groups = vec![
574+
EnvTemplateSourceGroup {
575+
source: Some(".env.local".to_string()),
576+
keys: vec!["LOCAL_Z".to_string(), "LOCAL_A".to_string()],
577+
},
578+
EnvTemplateSourceGroup {
579+
source: Some(".env".to_string()),
580+
keys: vec![
581+
"BASE_Z".to_string(),
582+
"BASE_A".to_string(),
583+
"BASE_A".to_string(),
584+
],
585+
},
586+
];
587+
588+
assert_eq!(
589+
render_created_env_example_with_sources(groups.clone()),
590+
"# Source: .env\nBASE_A=\nBASE_Z=\n\n# Source: .env.local\nLOCAL_A=\nLOCAL_Z=\n"
591+
);
592+
assert_eq!(
593+
render_synced_env_example_with_sources("EXISTING=", groups),
594+
"EXISTING=\n# Source: .env\nBASE_A=\nBASE_Z=\n\n# Source: .env.local\nLOCAL_A=\nLOCAL_Z=\n"
595+
);
596+
}
597+
598+
#[test]
599+
fn env_source_comment_option_changes_planned_fix_output_without_default_regression() {
600+
let fixture = TempDir::new().expect("temp dir should exist");
601+
write(
602+
fixture.path().join(".env.local"),
603+
"LOCAL_Z=1\nLOCAL_A=2\nSHARED=local\n",
604+
);
605+
write(
606+
fixture.path().join(".env"),
607+
"BASE_Z=1\nBASE_A=2\nSHARED=base\n",
608+
);
609+
610+
let project = discover_project(fixture.path()).expect("project should discover");
611+
let default_outcome = run_env_check(&project).expect("default check should run");
612+
let default_fix = default_outcome
613+
.planned_fixes
614+
.first()
615+
.expect("default planned fix should exist")
616+
.clone();
617+
apply_fix(&default_fix).expect("default fix should apply");
618+
let default_output = fs::read_to_string(fixture.path().join(".env.example"))
619+
.expect("default example should exist");
620+
assert_eq!(
621+
default_output,
622+
"BASE_A=\nBASE_Z=\nLOCAL_A=\nLOCAL_Z=\nSHARED=\n"
623+
);
624+
625+
fs::remove_file(fixture.path().join(".env.example")).expect("example should remove");
626+
let opt_in_outcome = run_env_check_with_options(
627+
&project,
628+
&EnvCheckOptions {
629+
template_render: EnvTemplateRenderOptions {
630+
source_comments: true,
631+
},
632+
},
633+
)
634+
.expect("opt-in check should run");
635+
let opt_in_fix = opt_in_outcome
636+
.planned_fixes
637+
.first()
638+
.expect("opt-in planned fix should exist")
639+
.clone();
640+
apply_fix(&opt_in_fix).expect("opt-in fix should apply");
641+
let opt_in_output = fs::read_to_string(fixture.path().join(".env.example"))
642+
.expect("opt-in example should exist");
643+
assert_eq!(
644+
opt_in_output,
645+
"# Source: .env\nBASE_A=\nBASE_Z=\nSHARED=\n\n# Source: .env.local\nLOCAL_A=\nLOCAL_Z=\n"
646+
);
647+
}
648+
563649
#[test]
564650
fn env_contract_matrix_fixtures_cover_template_variants_and_duplicate_chains() {
565651
let matrix_root = fixture_root("env-contract-matrix");

crates/maximus-cli/src/args.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::fmt::{Display, Formatter};
55
pub struct Flags {
66
pub diff: bool,
77
pub dry_run: bool,
8+
pub env_source_comments: bool,
89
pub fail_on: Option<String>,
910
pub fix_ids: Vec<String>,
1011
pub fix_prefixes: Vec<String>,
@@ -74,6 +75,7 @@ where
7475
match token.to_str() {
7576
Some("--diff") => flags.diff = true,
7677
Some("--dry-run") => flags.dry_run = true,
78+
Some("--env-source-comments") => flags.env_source_comments = true,
7779
Some("--fail-on") => {
7880
let value = next_option_value(tokens.next(), "--fail-on")?;
7981
flags.fail_on = Some(value.to_string_lossy().into_owned());
@@ -218,6 +220,7 @@ mod tests {
218220
flags: Flags {
219221
diff: false,
220222
dry_run: true,
223+
env_source_comments: false,
221224
fail_on: None,
222225
fix_ids: Vec::new(),
223226
fix_prefixes: Vec::new(),
@@ -250,6 +253,7 @@ mod tests {
250253
"env,tsconfig",
251254
"--skip",
252255
"duplicates",
256+
"--env-source-comments",
253257
"--fail-on",
254258
"error",
255259
"--fix-id",
@@ -271,6 +275,7 @@ mod tests {
271275
Some(vec!["duplicates".to_string()])
272276
);
273277
assert_eq!(parsed.flags.fail_on.as_deref(), Some("error"));
278+
assert!(parsed.flags.env_source_comments);
274279
assert_eq!(
275280
parsed.flags.fix_ids,
276281
vec!["env-example:create:.".to_string()]

0 commit comments

Comments
 (0)