Skip to main content

code_baseline/
ratchet.rs

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
56/// Convert a pattern string into a valid rule ID slug.
57pub 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    // Trim trailing dash
67    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
77/// Count occurrences of a pattern across files using the scan infrastructure.
78fn count_pattern(
79    config_path: &Path,
80    pattern: &str,
81    glob: &str,
82    regex: bool,
83    paths: &[PathBuf],
84) -> Result<usize, RatchetError> {
85    // Read config to get exclude patterns
86    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    // Build a temporary ratchet rule (max_count is required by RatchetRule)
94    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
141/// Specification for a ratchet rule to be appended to config.
142struct RatchetRuleSpec {
143    id: String,
144    pattern: String,
145    glob: String,
146    regex: bool,
147    max_count: usize,
148    message: String,
149}
150
151/// Append a `[[rule]]` block for a ratchet rule to the end of config text.
152fn 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
177/// Escape a string for use in a TOML double-quoted value.
178fn escape_toml_string(s: &str) -> String {
179    s.replace('\\', "\\\\").replace('"', "\\\"")
180}
181
182/// Update the `max_count` value for a specific rule ID in config text.
183/// Also updates the message if it contains an "N remaining" pattern.
184fn 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        // Detect `[[rule]]` boundaries
194        let trimmed = line.trim();
195        if trimmed == "[[rule]]" {
196            // If we were in the target rule but never updated max_count, error
197            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        // Check if this is the target rule by its id
206        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        // Update max_count line if in target rule
216        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        // Update message if it contains "N remaining"
223        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    // Preserve trailing newline if original had one
238    if config_text.ends_with('\n') && !output.ends_with('\n') {
239        output.push('\n');
240    }
241    Ok(output)
242}
243
244/// Extract a TOML string value from a line like `key = "value"`.
245fn 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
263/// If a message line contains "N remaining", update N to new_max.
264fn update_remaining_in_message(line: &str, new_max: usize) -> String {
265    // Match pattern like: message = "... 42 remaining ..."
266    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
275/// Entry point dispatching to subcommands.
276pub 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    // Check for duplicate ID
318    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    // Find the existing ratchet rule
361    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    // Collect existing rule IDs to skip duplicates
407    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    // ── slugify tests ──
456
457    #[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    // ── append_ratchet_rule tests ──
493
494    #[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        // Verify the result is valid TOML
509        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        // Should parse without error
549        let parsed: TomlConfig = toml::from_str(&result).unwrap();
550        assert_eq!(parsed.rule[0].pattern.as_deref(), Some(r#"say "hello""#));
551    }
552
553    // ── update_max_count tests ──
554
555    #[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        // rule-a should be unchanged
610        assert!(result.contains("max_count = 100"));
611        assert!(result.contains("100 remaining"));
612        // rule-b should be updated
613        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    // ── extract_toml_string_value tests ──
626
627    #[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    // ── escape_toml_string tests ──
652
653    #[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    // ── update_remaining_in_message tests ──
664
665    #[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    // ── RatchetError Display tests ──
684
685    #[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    // ── Integration tests (filesystem) ──
725
726    #[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        // Create source file with fewer matches than max_count
856        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        // Should have 2 rules: existing "todo" (unchanged) + new "fixme"
963        assert_eq!(parsed.rule.len(), 2);
964        assert_eq!(parsed.rule[0].id, "todo");
965        assert_eq!(parsed.rule[0].max_count, Some(99)); // unchanged
966        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}