1use crate::cli::toml_config::{TomlConfig, TomlRule};
2use crate::rules::factory;
3use crate::rules::ScanContext;
4use crate::scan::{self, BaselineResult};
5use std::fmt;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug)]
10pub enum RatchetError {
11 ConfigRead(std::io::Error),
12 ConfigParse(toml::de::Error),
13 Scan(scan::ScanError),
14 RuleNotFound(String),
15 RuleAlreadyExists(String),
16 BaselineRead(std::io::Error),
17 BaselineParse(String),
18 NoDecrease {
19 rule_id: String,
20 current: usize,
21 max_count: usize,
22 },
23}
24
25impl fmt::Display for RatchetError {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 match self {
28 RatchetError::ConfigRead(e) => write!(f, "failed to read config: {}", e),
29 RatchetError::ConfigParse(e) => write!(f, "failed to parse config: {}", e),
30 RatchetError::Scan(e) => write!(f, "scan failed: {}", e),
31 RatchetError::RuleNotFound(id) => {
32 write!(f, "no ratchet rule found with id '{}'", id)
33 }
34 RatchetError::RuleAlreadyExists(id) => {
35 write!(f, "a rule with id '{}' already exists", id)
36 }
37 RatchetError::BaselineRead(e) => write!(f, "failed to read baseline: {}", e),
38 RatchetError::BaselineParse(e) => write!(f, "failed to parse baseline JSON: {}", e),
39 RatchetError::NoDecrease {
40 rule_id,
41 current,
42 max_count,
43 } => {
44 write!(
45 f,
46 "rule '{}': current count ({}) has not decreased below max_count ({})",
47 rule_id, current, max_count
48 )
49 }
50 }
51 }
52}
53
54impl std::error::Error for RatchetError {}
55
56pub fn slugify(pattern: &str) -> String {
58 let mut result = String::with_capacity(pattern.len());
59 for ch in pattern.chars() {
60 if ch.is_ascii_alphanumeric() {
61 result.push(ch.to_ascii_lowercase());
62 } else if !result.is_empty() && !result.ends_with('-') {
63 result.push('-');
64 }
65 }
66 while result.ends_with('-') {
68 result.pop();
69 }
70 if result.is_empty() {
71 "ratchet-rule".to_string()
72 } else {
73 result
74 }
75}
76
77fn count_pattern(
79 config_path: &Path,
80 pattern: &str,
81 glob: &str,
82 regex: bool,
83 paths: &[PathBuf],
84) -> Result<usize, RatchetError> {
85 let config_text = fs::read_to_string(config_path).map_err(RatchetError::ConfigRead)?;
87 let toml_config: TomlConfig =
88 toml::from_str(&config_text).map_err(RatchetError::ConfigParse)?;
89
90 let exclude_set = scan::build_glob_set(&toml_config.baseline.exclude)
91 .map_err(RatchetError::Scan)?;
92
93 let toml_rule = TomlRule {
95 id: "__ratchet_count__".into(),
96 rule_type: "ratchet".into(),
97 pattern: Some(pattern.to_string()),
98 glob: Some(glob.to_string()),
99 regex,
100 max_count: Some(usize::MAX),
101 message: "counting".into(),
102 ..Default::default()
103 };
104
105 let rule_config = toml_rule.to_rule_config();
106 let rule = factory::build_rule("ratchet", &rule_config)
107 .map_err(|e| RatchetError::Scan(scan::ScanError::RuleFactory(e)))?;
108
109 let rule_glob = if let Some(ref pat) = rule.file_glob() {
110 Some(scan::build_glob_set_from_pattern(pat).map_err(RatchetError::Scan)?)
111 } else {
112 None
113 };
114
115 let files = scan::collect_files(paths, &exclude_set);
116
117 let mut count = 0usize;
118 for file_path in &files {
119 if let Some(ref gs) = rule_glob {
120 let file_str = file_path.to_string_lossy();
121 let file_name = file_path
122 .file_name()
123 .unwrap_or_default()
124 .to_string_lossy();
125 if !gs.is_match(&*file_str) && !gs.is_match(&*file_name) {
126 continue;
127 }
128 }
129 if let Ok(content) = fs::read_to_string(file_path) {
130 let ctx = ScanContext {
131 file_path,
132 content: &content,
133 };
134 count += rule.check_file(&ctx).len();
135 }
136 }
137
138 Ok(count)
139}
140
141struct RatchetRuleSpec {
143 id: String,
144 pattern: String,
145 glob: String,
146 regex: bool,
147 max_count: usize,
148 message: String,
149}
150
151fn append_ratchet_rule(config_text: &str, spec: &RatchetRuleSpec) -> String {
153 let mut result = config_text.to_string();
154 if !result.ends_with('\n') {
155 result.push('\n');
156 }
157 result.push('\n');
158 result.push_str("[[rule]]\n");
159 result.push_str(&format!("id = \"{}\"\n", spec.id));
160 result.push_str("type = \"ratchet\"\n");
161 result.push_str("severity = \"warning\"\n");
162 result.push_str(&format!("pattern = \"{}\"\n", escape_toml_string(&spec.pattern)));
163 if spec.regex {
164 result.push_str("regex = true\n");
165 }
166 if spec.glob != "**/*" {
167 result.push_str(&format!("glob = \"{}\"\n", escape_toml_string(&spec.glob)));
168 }
169 result.push_str(&format!("max_count = {}\n", spec.max_count));
170 result.push_str(&format!(
171 "message = \"{}\"\n",
172 escape_toml_string(&spec.message)
173 ));
174 result
175}
176
177fn escape_toml_string(s: &str) -> String {
179 s.replace('\\', "\\\\").replace('"', "\\\"")
180}
181
182fn update_max_count(config_text: &str, rule_id: &str, new_max: usize) -> Result<String, RatchetError> {
185 let lines: Vec<&str> = config_text.lines().collect();
186 let mut result_lines: Vec<String> = Vec::with_capacity(lines.len());
187
188 let mut in_target_rule = false;
189 let mut found = false;
190 let mut updated_max = false;
191
192 for line in &lines {
193 let trimmed = line.trim();
195 if trimmed == "[[rule]]" {
196 if in_target_rule && !updated_max {
198 return Err(RatchetError::RuleNotFound(rule_id.to_string()));
199 }
200 in_target_rule = false;
201 result_lines.push(line.to_string());
202 continue;
203 }
204
205 if !in_target_rule && !found {
207 if let Some(id_val) = extract_toml_string_value(trimmed, "id") {
208 if id_val == rule_id {
209 in_target_rule = true;
210 found = true;
211 }
212 }
213 }
214
215 if in_target_rule && trimmed.starts_with("max_count") {
217 result_lines.push(format!("max_count = {}", new_max));
218 updated_max = true;
219 continue;
220 }
221
222 if in_target_rule && trimmed.starts_with("message") {
224 let updated = update_remaining_in_message(line, new_max);
225 result_lines.push(updated);
226 continue;
227 }
228
229 result_lines.push(line.to_string());
230 }
231
232 if !found {
233 return Err(RatchetError::RuleNotFound(rule_id.to_string()));
234 }
235
236 let mut output = result_lines.join("\n");
237 if config_text.ends_with('\n') && !output.ends_with('\n') {
239 output.push('\n');
240 }
241 Ok(output)
242}
243
244fn extract_toml_string_value<'a>(line: &'a str, key: &str) -> Option<&'a str> {
246 let line = line.trim();
247 if !line.starts_with(key) {
248 return None;
249 }
250 let rest = line[key.len()..].trim();
251 if !rest.starts_with('=') {
252 return None;
253 }
254 let rest = rest[1..].trim();
255 if rest.starts_with('"') && rest.len() >= 2 {
256 let end = rest[1..].find('"')?;
257 Some(&rest[1..1 + end])
258 } else {
259 None
260 }
261}
262
263fn update_remaining_in_message(line: &str, new_max: usize) -> String {
265 let re = regex::Regex::new(r"\d+ remaining").unwrap();
267 if re.is_match(line) {
268 re.replace(line, &format!("{} remaining", new_max))
269 .to_string()
270 } else {
271 line.to_string()
272 }
273}
274
275pub fn run(command: crate::cli::RatchetCommands) -> Result<(), RatchetError> {
277 match command {
278 crate::cli::RatchetCommands::Add {
279 pattern,
280 id,
281 glob,
282 regex,
283 message,
284 config,
285 paths,
286 } => run_add(&config, &pattern, id.as_deref(), &glob, regex, message.as_deref(), &paths),
287
288 crate::cli::RatchetCommands::Down {
289 rule_id,
290 config,
291 paths,
292 } => run_down(&config, &rule_id, &paths),
293
294 crate::cli::RatchetCommands::From { baseline, config } => {
295 run_from(&config, &baseline)
296 }
297 }
298}
299
300fn run_add(
301 config_path: &Path,
302 pattern: &str,
303 id: Option<&str>,
304 glob: &str,
305 regex: bool,
306 message: Option<&str>,
307 paths: &[PathBuf],
308) -> Result<(), RatchetError> {
309 let config_text = fs::read_to_string(config_path).map_err(RatchetError::ConfigRead)?;
310 let toml_config: TomlConfig =
311 toml::from_str(&config_text).map_err(RatchetError::ConfigParse)?;
312
313 let rule_id = id
314 .map(|s| s.to_string())
315 .unwrap_or_else(|| slugify(pattern));
316
317 if toml_config.rule.iter().any(|r| r.id == rule_id) {
319 return Err(RatchetError::RuleAlreadyExists(rule_id));
320 }
321
322 let count = count_pattern(config_path, pattern, glob, regex, paths)?;
323
324 let msg = message
325 .map(|s| s.to_string())
326 .unwrap_or_else(|| format!("{} remaining", count));
327
328 let spec = RatchetRuleSpec {
329 id: rule_id.clone(),
330 pattern: pattern.to_string(),
331 glob: glob.to_string(),
332 regex,
333 max_count: count,
334 message: msg,
335 };
336
337 let updated = append_ratchet_rule(&config_text, &spec);
338 fs::write(config_path, &updated).map_err(RatchetError::ConfigRead)?;
339
340 eprintln!(
341 "\x1b[32m✓\x1b[0m Added ratchet rule '{}' (max_count = {}, {} current occurrence{})",
342 rule_id,
343 count,
344 count,
345 if count == 1 { "" } else { "s" }
346 );
347
348 Ok(())
349}
350
351fn run_down(
352 config_path: &Path,
353 rule_id: &str,
354 paths: &[PathBuf],
355) -> Result<(), RatchetError> {
356 let config_text = fs::read_to_string(config_path).map_err(RatchetError::ConfigRead)?;
357 let toml_config: TomlConfig =
358 toml::from_str(&config_text).map_err(RatchetError::ConfigParse)?;
359
360 let toml_rule = toml_config
362 .rule
363 .iter()
364 .find(|r| r.id == rule_id && r.rule_type == "ratchet")
365 .ok_or_else(|| RatchetError::RuleNotFound(rule_id.to_string()))?;
366
367 let old_max = toml_rule.max_count.unwrap_or(0);
368 let pattern = toml_rule
369 .pattern
370 .as_deref()
371 .unwrap_or("");
372 let glob = toml_rule.glob.as_deref().unwrap_or("**/*");
373 let regex = toml_rule.regex;
374
375 let current = count_pattern(config_path, pattern, glob, regex, paths)?;
376
377 if current >= old_max {
378 return Err(RatchetError::NoDecrease {
379 rule_id: rule_id.to_string(),
380 current,
381 max_count: old_max,
382 });
383 }
384
385 let updated = update_max_count(&config_text, rule_id, current)?;
386 fs::write(config_path, &updated).map_err(RatchetError::ConfigRead)?;
387
388 eprintln!(
389 "\x1b[32m✓\x1b[0m Ratcheted down '{}': {} → {}",
390 rule_id, old_max, current
391 );
392
393 Ok(())
394}
395
396fn run_from(config_path: &Path, baseline_path: &Path) -> Result<(), RatchetError> {
397 let baseline_text =
398 fs::read_to_string(baseline_path).map_err(RatchetError::BaselineRead)?;
399 let baseline: BaselineResult = serde_json::from_str(&baseline_text)
400 .map_err(|e| RatchetError::BaselineParse(e.to_string()))?;
401
402 let config_text = fs::read_to_string(config_path).map_err(RatchetError::ConfigRead)?;
403 let toml_config: TomlConfig =
404 toml::from_str(&config_text).map_err(RatchetError::ConfigParse)?;
405
406 let existing_ids: std::collections::HashSet<&str> =
408 toml_config.rule.iter().map(|r| r.id.as_str()).collect();
409
410 let mut updated = config_text.clone();
411 let mut added = 0usize;
412
413 for entry in &baseline.entries {
414 if existing_ids.contains(entry.rule_id.as_str()) {
415 eprintln!(
416 "\x1b[33m⚠\x1b[0m Skipping '{}': rule already exists",
417 entry.rule_id
418 );
419 continue;
420 }
421
422 let spec = RatchetRuleSpec {
423 id: entry.rule_id.clone(),
424 pattern: entry.pattern.clone(),
425 glob: "**/*".to_string(),
426 regex: false,
427 max_count: entry.count,
428 message: format!("{} remaining", entry.count),
429 };
430
431 updated = append_ratchet_rule(&updated, &spec);
432 added += 1;
433
434 eprintln!(
435 " {} (max_count = {})",
436 entry.rule_id, entry.count
437 );
438 }
439
440 fs::write(config_path, &updated).map_err(RatchetError::ConfigRead)?;
441
442 eprintln!(
443 "\x1b[32m✓\x1b[0m Added {} ratchet rule{} from baseline",
444 added,
445 if added == 1 { "" } else { "s" }
446 );
447
448 Ok(())
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
458 fn slugify_simple_pattern() {
459 assert_eq!(slugify("console.log"), "console-log");
460 }
461
462 #[test]
463 fn slugify_regex_pattern() {
464 assert_eq!(slugify(r"console\.log"), "console-log");
465 }
466
467 #[test]
468 fn slugify_complex_pattern() {
469 assert_eq!(slugify("TODO|FIXME|HACK"), "todo-fixme-hack");
470 }
471
472 #[test]
473 fn slugify_leading_special_chars() {
474 assert_eq!(slugify("...hello"), "hello");
475 }
476
477 #[test]
478 fn slugify_empty() {
479 assert_eq!(slugify(""), "ratchet-rule");
480 }
481
482 #[test]
483 fn slugify_all_special() {
484 assert_eq!(slugify("..."), "ratchet-rule");
485 }
486
487 #[test]
488 fn slugify_preserves_numbers() {
489 assert_eq!(slugify("v2_api"), "v2-api");
490 }
491
492 #[test]
495 fn append_generates_valid_toml() {
496 let config = "[baseline]\n";
497 let spec = RatchetRuleSpec {
498 id: "no-console".into(),
499 pattern: r"console\.log".into(),
500 glob: "**/*.ts".into(),
501 regex: true,
502 max_count: 42,
503 message: "42 remaining".into(),
504 };
505
506 let result = append_ratchet_rule(config, &spec);
507
508 let parsed: TomlConfig = toml::from_str(&result).unwrap();
510 assert_eq!(parsed.rule.len(), 1);
511 assert_eq!(parsed.rule[0].id, "no-console");
512 assert_eq!(parsed.rule[0].rule_type, "ratchet");
513 assert_eq!(parsed.rule[0].pattern.as_deref(), Some(r"console\.log"));
514 assert_eq!(parsed.rule[0].max_count, Some(42));
515 assert_eq!(parsed.rule[0].glob.as_deref(), Some("**/*.ts"));
516 assert!(parsed.rule[0].regex);
517 }
518
519 #[test]
520 fn append_default_glob_omitted() {
521 let config = "[baseline]\n";
522 let spec = RatchetRuleSpec {
523 id: "test".into(),
524 pattern: "foo".into(),
525 glob: "**/*".into(),
526 regex: false,
527 max_count: 5,
528 message: "5 remaining".into(),
529 };
530
531 let result = append_ratchet_rule(config, &spec);
532 assert!(!result.contains("glob = "));
533 }
534
535 #[test]
536 fn append_escapes_quotes() {
537 let config = "[baseline]\n";
538 let spec = RatchetRuleSpec {
539 id: "test".into(),
540 pattern: r#"say "hello""#.into(),
541 glob: "**/*".into(),
542 regex: false,
543 max_count: 1,
544 message: r#"found "hello""#.into(),
545 };
546
547 let result = append_ratchet_rule(config, &spec);
548 let parsed: TomlConfig = toml::from_str(&result).unwrap();
550 assert_eq!(parsed.rule[0].pattern.as_deref(), Some(r#"say "hello""#));
551 }
552
553 #[test]
556 fn update_max_count_basic() {
557 let config = r#"[baseline]
558
559[[rule]]
560id = "legacy-api"
561type = "ratchet"
562pattern = "legacyCall"
563max_count = 42
564message = "42 remaining"
565"#;
566
567 let result = update_max_count(config, "legacy-api", 10).unwrap();
568 assert!(result.contains("max_count = 10"));
569 assert!(result.contains("10 remaining"));
570 assert!(!result.contains("max_count = 42"));
571 }
572
573 #[test]
574 fn update_max_count_nonexistent_id() {
575 let config = r#"[baseline]
576
577[[rule]]
578id = "legacy-api"
579type = "ratchet"
580max_count = 42
581message = "test"
582"#;
583
584 let result = update_max_count(config, "nonexistent", 10);
585 assert!(result.is_err());
586 assert!(matches!(result.unwrap_err(), RatchetError::RuleNotFound(_)));
587 }
588
589 #[test]
590 fn update_max_count_multiple_rules() {
591 let config = r#"[baseline]
592
593[[rule]]
594id = "rule-a"
595type = "ratchet"
596pattern = "a"
597max_count = 100
598message = "100 remaining"
599
600[[rule]]
601id = "rule-b"
602type = "ratchet"
603pattern = "b"
604max_count = 200
605message = "200 remaining"
606"#;
607
608 let result = update_max_count(config, "rule-b", 50).unwrap();
609 assert!(result.contains("max_count = 100"));
611 assert!(result.contains("100 remaining"));
612 assert!(result.contains("max_count = 50"));
614 assert!(result.contains("50 remaining"));
615 assert!(!result.contains("max_count = 200"));
616 }
617
618 #[test]
619 fn update_max_count_preserves_trailing_newline() {
620 let config = "[baseline]\n\n[[rule]]\nid = \"test\"\ntype = \"ratchet\"\nmax_count = 5\nmessage = \"test\"\n";
621 let result = update_max_count(config, "test", 3).unwrap();
622 assert!(result.ends_with('\n'));
623 }
624
625 #[test]
628 fn extract_value_basic() {
629 assert_eq!(
630 extract_toml_string_value(r#"id = "my-rule""#, "id"),
631 Some("my-rule")
632 );
633 }
634
635 #[test]
636 fn extract_value_with_spaces() {
637 assert_eq!(
638 extract_toml_string_value(r#"id = "my-rule""#, "id"),
639 Some("my-rule")
640 );
641 }
642
643 #[test]
644 fn extract_value_wrong_key() {
645 assert_eq!(
646 extract_toml_string_value(r#"type = "ratchet""#, "id"),
647 None
648 );
649 }
650
651 #[test]
654 fn escape_backslash() {
655 assert_eq!(escape_toml_string(r"console\.log"), r"console\\.log");
656 }
657
658 #[test]
659 fn escape_quotes() {
660 assert_eq!(escape_toml_string(r#"say "hi""#), r#"say \"hi\""#);
661 }
662
663 #[test]
666 fn update_remaining_replaces_count() {
667 let line = r#"message = "42 remaining""#;
668 assert_eq!(
669 update_remaining_in_message(line, 10),
670 r#"message = "10 remaining""#
671 );
672 }
673
674 #[test]
675 fn update_remaining_no_match_passthrough() {
676 let line = r#"message = "legacy API usage""#;
677 assert_eq!(
678 update_remaining_in_message(line, 10),
679 r#"message = "legacy API usage""#
680 );
681 }
682
683 #[test]
686 fn error_display_config_read() {
687 let err = RatchetError::ConfigRead(std::io::Error::new(
688 std::io::ErrorKind::NotFound,
689 "not found",
690 ));
691 assert!(err.to_string().contains("failed to read config"));
692 }
693
694 #[test]
695 fn error_display_rule_not_found() {
696 let err = RatchetError::RuleNotFound("my-rule".into());
697 assert!(err.to_string().contains("my-rule"));
698 }
699
700 #[test]
701 fn error_display_rule_already_exists() {
702 let err = RatchetError::RuleAlreadyExists("my-rule".into());
703 assert!(err.to_string().contains("already exists"));
704 }
705
706 #[test]
707 fn error_display_no_decrease() {
708 let err = RatchetError::NoDecrease {
709 rule_id: "test".into(),
710 current: 10,
711 max_count: 10,
712 };
713 let msg = err.to_string();
714 assert!(msg.contains("10"));
715 assert!(msg.contains("not decreased"));
716 }
717
718 #[test]
719 fn error_display_baseline_parse() {
720 let err = RatchetError::BaselineParse("bad json".into());
721 assert!(err.to_string().contains("bad json"));
722 }
723
724 #[test]
727 fn run_add_creates_rule_and_counts() {
728 let dir = tempfile::tempdir().unwrap();
729
730 let config = dir.path().join("baseline.toml");
731 fs::write(&config, "[baseline]\n").unwrap();
732
733 let src_dir = dir.path().join("src");
734 fs::create_dir(&src_dir).unwrap();
735 fs::write(src_dir.join("app.ts"), "TODO: fix\nTODO: cleanup\nok\n").unwrap();
736
737 run_add(&config, "TODO", None, "**/*", false, None, &[src_dir]).unwrap();
738
739 let result = fs::read_to_string(&config).unwrap();
740 let parsed: TomlConfig = toml::from_str(&result).unwrap();
741 assert_eq!(parsed.rule.len(), 1);
742 assert_eq!(parsed.rule[0].id, "todo");
743 assert_eq!(parsed.rule[0].max_count, Some(2));
744 }
745
746 #[test]
747 fn run_add_custom_id_and_message() {
748 let dir = tempfile::tempdir().unwrap();
749
750 let config = dir.path().join("baseline.toml");
751 fs::write(&config, "[baseline]\n").unwrap();
752
753 let src_dir = dir.path().join("src");
754 fs::create_dir(&src_dir).unwrap();
755 fs::write(src_dir.join("app.ts"), "legacy()\n").unwrap();
756
757 run_add(
758 &config,
759 "legacy",
760 Some("my-legacy"),
761 "**/*",
762 false,
763 Some("stop using legacy"),
764 &[src_dir],
765 )
766 .unwrap();
767
768 let result = fs::read_to_string(&config).unwrap();
769 let parsed: TomlConfig = toml::from_str(&result).unwrap();
770 assert_eq!(parsed.rule[0].id, "my-legacy");
771 assert_eq!(parsed.rule[0].message, "stop using legacy");
772 }
773
774 #[test]
775 fn run_add_duplicate_id_errors() {
776 let dir = tempfile::tempdir().unwrap();
777
778 let config = dir.path().join("baseline.toml");
779 fs::write(
780 &config,
781 r#"[baseline]
782
783[[rule]]
784id = "existing"
785type = "banned-pattern"
786pattern = "x"
787message = "m"
788"#,
789 )
790 .unwrap();
791
792 let result = run_add(
793 &config,
794 "x",
795 Some("existing"),
796 "**/*",
797 false,
798 None,
799 &[dir.path().to_path_buf()],
800 );
801 assert!(result.is_err());
802 assert!(matches!(
803 result.unwrap_err(),
804 RatchetError::RuleAlreadyExists(_)
805 ));
806 }
807
808 #[test]
809 fn run_add_with_regex() {
810 let dir = tempfile::tempdir().unwrap();
811
812 let config = dir.path().join("baseline.toml");
813 fs::write(&config, "[baseline]\n").unwrap();
814
815 let src_dir = dir.path().join("src");
816 fs::create_dir(&src_dir).unwrap();
817 fs::write(src_dir.join("app.ts"), "console.log('a')\nconsole.warn('b')\n").unwrap();
818
819 run_add(
820 &config,
821 r"console\.(log|warn)",
822 None,
823 "**/*",
824 true,
825 None,
826 &[src_dir],
827 )
828 .unwrap();
829
830 let result = fs::read_to_string(&config).unwrap();
831 let parsed: TomlConfig = toml::from_str(&result).unwrap();
832 assert_eq!(parsed.rule[0].max_count, Some(2));
833 assert!(parsed.rule[0].regex);
834 }
835
836 #[test]
837 fn run_down_lowers_max_count() {
838 let dir = tempfile::tempdir().unwrap();
839
840 let config = dir.path().join("baseline.toml");
841 fs::write(
842 &config,
843 r#"[baseline]
844
845[[rule]]
846id = "legacy-api"
847type = "ratchet"
848pattern = "legacyCall"
849max_count = 10
850message = "10 remaining"
851"#,
852 )
853 .unwrap();
854
855 let src_dir = dir.path().join("src");
857 fs::create_dir(&src_dir).unwrap();
858 fs::write(src_dir.join("app.ts"), "legacyCall()\nlegacyCall()\nok\n").unwrap();
859
860 run_down(&config, "legacy-api", &[src_dir]).unwrap();
861
862 let result = fs::read_to_string(&config).unwrap();
863 assert!(result.contains("max_count = 2"));
864 assert!(result.contains("2 remaining"));
865 }
866
867 #[test]
868 fn run_down_no_decrease_errors() {
869 let dir = tempfile::tempdir().unwrap();
870
871 let config = dir.path().join("baseline.toml");
872 fs::write(
873 &config,
874 r#"[baseline]
875
876[[rule]]
877id = "legacy-api"
878type = "ratchet"
879pattern = "legacyCall"
880max_count = 2
881message = "test"
882"#,
883 )
884 .unwrap();
885
886 let src_dir = dir.path().join("src");
887 fs::create_dir(&src_dir).unwrap();
888 fs::write(src_dir.join("app.ts"), "legacyCall()\nlegacyCall()\nlegacyCall()\n").unwrap();
889
890 let result = run_down(&config, "legacy-api", &[src_dir]);
891 assert!(result.is_err());
892 assert!(matches!(result.unwrap_err(), RatchetError::NoDecrease { .. }));
893 }
894
895 #[test]
896 fn run_down_rule_not_found_errors() {
897 let dir = tempfile::tempdir().unwrap();
898
899 let config = dir.path().join("baseline.toml");
900 fs::write(&config, "[baseline]\n").unwrap();
901
902 let result = run_down(&config, "nonexistent", &[dir.path().to_path_buf()]);
903 assert!(result.is_err());
904 assert!(matches!(result.unwrap_err(), RatchetError::RuleNotFound(_)));
905 }
906
907 #[test]
908 fn run_from_creates_rules_from_baseline() {
909 let dir = tempfile::tempdir().unwrap();
910
911 let config = dir.path().join("baseline.toml");
912 fs::write(&config, "[baseline]\n").unwrap();
913
914 let baseline = dir.path().join("baseline.json");
915 fs::write(
916 &baseline,
917 r#"{"entries":[{"rule_id":"todo","pattern":"TODO","count":5},{"rule_id":"fixme","pattern":"FIXME","count":3}],"files_scanned":10}"#,
918 )
919 .unwrap();
920
921 run_from(&config, &baseline).unwrap();
922
923 let result = fs::read_to_string(&config).unwrap();
924 let parsed: TomlConfig = toml::from_str(&result).unwrap();
925 assert_eq!(parsed.rule.len(), 2);
926 assert_eq!(parsed.rule[0].id, "todo");
927 assert_eq!(parsed.rule[0].max_count, Some(5));
928 assert_eq!(parsed.rule[1].id, "fixme");
929 assert_eq!(parsed.rule[1].max_count, Some(3));
930 }
931
932 #[test]
933 fn run_from_skips_existing_rules() {
934 let dir = tempfile::tempdir().unwrap();
935
936 let config = dir.path().join("baseline.toml");
937 fs::write(
938 &config,
939 r#"[baseline]
940
941[[rule]]
942id = "todo"
943type = "ratchet"
944pattern = "TODO"
945max_count = 99
946message = "existing"
947"#,
948 )
949 .unwrap();
950
951 let baseline = dir.path().join("baseline.json");
952 fs::write(
953 &baseline,
954 r#"{"entries":[{"rule_id":"todo","pattern":"TODO","count":5},{"rule_id":"fixme","pattern":"FIXME","count":3}],"files_scanned":10}"#,
955 )
956 .unwrap();
957
958 run_from(&config, &baseline).unwrap();
959
960 let result = fs::read_to_string(&config).unwrap();
961 let parsed: TomlConfig = toml::from_str(&result).unwrap();
962 assert_eq!(parsed.rule.len(), 2);
964 assert_eq!(parsed.rule[0].id, "todo");
965 assert_eq!(parsed.rule[0].max_count, Some(99)); assert_eq!(parsed.rule[1].id, "fixme");
967 assert_eq!(parsed.rule[1].max_count, Some(3));
968 }
969
970 #[test]
971 fn run_from_invalid_baseline_errors() {
972 let dir = tempfile::tempdir().unwrap();
973
974 let config = dir.path().join("baseline.toml");
975 fs::write(&config, "[baseline]\n").unwrap();
976
977 let baseline = dir.path().join("baseline.json");
978 fs::write(&baseline, "not valid json").unwrap();
979
980 let result = run_from(&config, &baseline);
981 assert!(result.is_err());
982 assert!(matches!(result.unwrap_err(), RatchetError::BaselineParse(_)));
983 }
984
985 #[test]
986 fn run_from_missing_baseline_errors() {
987 let dir = tempfile::tempdir().unwrap();
988
989 let config = dir.path().join("baseline.toml");
990 fs::write(&config, "[baseline]\n").unwrap();
991
992 let result = run_from(&config, &dir.path().join("nonexistent.json"));
993 assert!(result.is_err());
994 assert!(matches!(result.unwrap_err(), RatchetError::BaselineRead(_)));
995 }
996
997 #[test]
998 fn count_pattern_counts_matches() {
999 let dir = tempfile::tempdir().unwrap();
1000
1001 let config = dir.path().join("baseline.toml");
1002 fs::write(&config, "[baseline]\n").unwrap();
1003
1004 let src_dir = dir.path().join("src");
1005 fs::create_dir(&src_dir).unwrap();
1006 fs::write(src_dir.join("a.ts"), "TODO\nTODO\nok\n").unwrap();
1007 fs::write(src_dir.join("b.ts"), "TODO\n").unwrap();
1008
1009 let count = count_pattern(&config, "TODO", "**/*", false, &[src_dir]).unwrap();
1010 assert_eq!(count, 3);
1011 }
1012
1013 #[test]
1014 fn count_pattern_respects_glob() {
1015 let dir = tempfile::tempdir().unwrap();
1016
1017 let config = dir.path().join("baseline.toml");
1018 fs::write(&config, "[baseline]\n").unwrap();
1019
1020 let src_dir = dir.path().join("src");
1021 fs::create_dir(&src_dir).unwrap();
1022 fs::write(src_dir.join("a.ts"), "TODO\n").unwrap();
1023 fs::write(src_dir.join("b.rs"), "TODO\n").unwrap();
1024
1025 let count = count_pattern(&config, "TODO", "**/*.ts", false, &[src_dir]).unwrap();
1026 assert_eq!(count, 1);
1027 }
1028}