Skip to main content

toggle/
core.rs

1// Toggle algorithm implementation
2
3use anyhow::Result;
4use std::path::Path;
5
6use crate::config::ToggleConfig;
7use crate::exit_codes::UsageError;
8
9/// Returns the list of file extensions that toggle knows how to handle.
10pub fn supported_extensions() -> &'static [&'static str] {
11    &[
12        "py", "sh", "rb", "yaml", "yml", "toml", "r", "ex", "exs", "pl", "pm", "js", "jsx", "ts",
13        "tsx", "rs", "java", "c", "cpp", "go", "swift", "kt", "scala", "php", "lua", "hs", "sql",
14    ]
15}
16
17/// A discovered section marker with metadata (used by discover_sections and find_and_toggle_section).
18#[derive(Debug, Clone)]
19pub struct SectionInfo {
20    pub id: String,
21    pub desc: Option<String>,
22    pub start_line: usize, // 1-based
23    pub end_line: usize,   // 1-based
24}
25
26/// Result of toggling a section, including parsed metadata.
27pub struct SectionToggleResult {
28    pub modified: bool,
29    pub desc: Option<String>,
30}
31
32/// Information about a discovered toggle section for scan output.
33#[derive(Debug, Clone, serde::Serialize)]
34pub struct ScanSectionInfo {
35    pub id: String,
36    pub group: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub variant: Option<String>,
39    pub file: String,
40    pub start_line: usize,
41    pub end_line: Option<usize>,
42    pub description: Option<String>,
43    pub state: String,
44}
45
46/// Extract the `desc="..."` value from a section marker line.
47fn parse_section_desc(line: &str) -> Option<String> {
48    let marker = "desc=\"";
49    let start = line.find(marker)? + marker.len();
50    let rest = &line[start..];
51    let end = rest.find('"')?;
52    Some(rest[..end].to_string())
53}
54
55/// Extract the section ID as a whitespace-delimited token after `ID=`.
56fn parse_section_id(line: &str) -> Option<String> {
57    let marker = "ID=";
58    let start = line.find(marker)? + marker.len();
59    let rest = &line[start..];
60    let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
61    let id = &rest[..end];
62    if id.is_empty() {
63        None
64    } else {
65        Some(id.to_string())
66    }
67}
68
69/// Split a section ID into `(group, variant)` parts using the first `:` as separator.
70/// Solo IDs (no colon) return `(id, None)`; variant IDs return `(group, Some(variant))`.
71pub fn parse_id_parts(id: &str) -> (String, Option<String>) {
72    match id.split_once(':') {
73        Some((g, v)) => (g.to_string(), Some(v.to_string())),
74        None => (id.to_string(), None),
75    }
76}
77
78/// Check if a line contains a `toggle:start` marker with an exact section ID match.
79fn line_matches_start(line: &str, section_id: &str) -> bool {
80    if !line.contains("toggle:start") {
81        return false;
82    }
83    parse_section_id(line).as_deref() == Some(section_id)
84}
85
86/// Check if a line contains a `toggle:end` marker with an exact section ID match.
87fn line_matches_end(line: &str, section_id: &str) -> bool {
88    if !line.contains("toggle:end") {
89        return false;
90    }
91    parse_section_id(line).as_deref() == Some(section_id)
92}
93
94/// Scan file content for all section marker pairs and return their metadata.
95/// Unclosed sections are silently skipped (useful for discovery across many files).
96pub fn discover_sections(content: &str) -> Vec<SectionInfo> {
97    let lines: Vec<&str> = content.lines().collect();
98    let mut sections = Vec::new();
99    let mut i = 0;
100
101    while i < lines.len() {
102        if lines[i].contains("toggle:start") {
103            if let Some(id) = parse_section_id(lines[i]) {
104                let desc = parse_section_desc(lines[i]);
105                let start_line = i + 1; // 1-based
106
107                // Find matching end marker
108                let mut end_line = None;
109                #[allow(clippy::needless_range_loop)]
110                for j in (i + 1)..lines.len() {
111                    if line_matches_end(lines[j], &id) {
112                        end_line = Some(j + 1); // 1-based
113                        break;
114                    }
115                }
116
117                if let Some(end_line) = end_line {
118                    sections.push(SectionInfo {
119                        id,
120                        desc,
121                        start_line,
122                        end_line,
123                    });
124                    i = end_line; // skip past this section (end_line is 1-based, i is 0-based)
125                    continue;
126                }
127            }
128        }
129        i += 1;
130    }
131
132    sections
133}
134
135/// Return all `SectionInfo` whose ID parses into the given group.
136/// `discover_variants(content, "db")` matches both `db` (solo) and `db:postgres` (variant).
137pub fn discover_variants(content: &str, group: &str) -> Vec<SectionInfo> {
138    discover_sections(content)
139        .into_iter()
140        .filter(|s| parse_id_parts(&s.id).0 == group)
141        .collect()
142}
143
144/// Scan file content for toggle:start / toggle:end markers.
145/// Returns all sections found with state info. Does not modify anything.
146pub fn scan_sections(path: &Path, content: &str) -> Vec<ScanSectionInfo> {
147    let lines: Vec<&str> = content.lines().collect();
148    let mut sections = Vec::new();
149    let file_str = path.display().to_string();
150
151    // Determine comment style for state detection
152    let comment_marker = get_comment_style(path, "auto", None)
153        .map(|cs| cs.single_line)
154        .unwrap_or_else(|_| "#".to_string());
155
156    let mut i = 0;
157    while i < lines.len() {
158        if let Some(_pos) = lines[i].find("toggle:start ID=") {
159            let id = parse_section_id(lines[i]).unwrap_or_default();
160            if id.is_empty() {
161                i += 1;
162                continue;
163            }
164
165            // Extract optional description
166            let description = parse_section_desc(lines[i]);
167
168            let start_line = i + 1; // 1-based
169
170            // Find matching end marker
171            let mut end_line = None;
172            #[allow(clippy::needless_range_loop)]
173            for j in (i + 1)..lines.len() {
174                if line_matches_end(lines[j], &id) {
175                    end_line = Some(j + 1); // 1-based
176                    break;
177                }
178            }
179
180            // Determine state of content between markers
181            let state = if let Some(end) = end_line {
182                let content_start = i + 1;
183                let content_end = end - 1; // back to 0-based for the end marker line
184                detect_section_state(&lines[content_start..content_end], &comment_marker)
185            } else {
186                "unknown".to_string()
187            };
188
189            let (group, variant) = parse_id_parts(&id);
190            sections.push(ScanSectionInfo {
191                id,
192                group,
193                variant,
194                file: file_str.clone(),
195                start_line,
196                end_line,
197                description,
198                state,
199            });
200
201            if let Some(end) = end_line {
202                i = end;
203            } else {
204                i += 1;
205            }
206        } else {
207            i += 1;
208        }
209    }
210
211    sections
212}
213
214/// Detect whether section content is commented, uncommented, or mixed.
215fn detect_section_state(lines: &[&str], comment_marker: &str) -> String {
216    let non_empty: Vec<&&str> = lines.iter().filter(|l| !l.trim().is_empty()).collect();
217    if non_empty.is_empty() {
218        return "empty".to_string();
219    }
220
221    let commented_count = non_empty
222        .iter()
223        .filter(|l| l.trim_start().starts_with(comment_marker))
224        .count();
225
226    if commented_count == non_empty.len() {
227        "commented".to_string()
228    } else if commented_count == 0 {
229        "uncommented".to_string()
230    } else {
231        "mixed".to_string()
232    }
233}
234
235/// Line range representation
236#[derive(Debug, Clone)]
237pub struct LineRange {
238    pub start: usize,
239    pub end: usize,
240}
241
242impl LineRange {
243    /// Create a new line range
244    pub fn new(start: usize, end: usize) -> Self {
245        Self { start, end }
246    }
247}
248
249/// Comment style for a language
250#[derive(Debug, Clone)]
251pub struct CommentStyle {
252    pub single_line: String,
253    pub multi_line_start: Option<String>,
254    pub multi_line_end: Option<String>,
255}
256
257/// Parse a line range specification.
258/// Supports formats: "start:end", "start:+count", "single_line"
259pub fn parse_line_range(range_spec: &str) -> Result<(usize, usize)> {
260    if let Some((start, end)) = range_spec.split_once(':') {
261        let start_line = start
262            .parse::<usize>()
263            .map_err(|_| UsageError(format!("Invalid start line: {}", start)))?;
264
265        if start_line == 0 {
266            return Err(UsageError("Start line must be >= 1, got 0".into()).into());
267        }
268
269        if let Some(stripped_end) = end.strip_prefix('+') {
270            // Format: start:+count
271            let count = stripped_end
272                .parse::<usize>()
273                .map_err(|_| UsageError(format!("Invalid line count: {}", stripped_end)))?;
274            Ok((start_line, start_line + count))
275        } else {
276            // Format: start:end
277            let end_line = end
278                .parse::<usize>()
279                .map_err(|_| UsageError(format!("Invalid end line: {}", end)))?;
280            if end_line < start_line {
281                return Err(UsageError(format!(
282                    "End line {} is less than start line {}",
283                    end_line, start_line
284                ))
285                .into());
286            }
287            Ok((start_line, end_line))
288        }
289    } else {
290        // Single line
291        let line = range_spec
292            .parse::<usize>()
293            .map_err(|_| UsageError(format!("Invalid line number: {}", range_spec)))?;
294        if line == 0 {
295            return Err(UsageError("Line number must be >= 1, got 0".into()).into());
296        }
297        Ok((line, line))
298    }
299}
300
301/// Merge multiple line ranges into a minimal list of non-overlapping ranges.
302/// Sorts ascending by start, then coalesces overlapping/adjacent intervals.
303pub fn merge_ranges(ranges: &[LineRange]) -> Vec<LineRange> {
304    if ranges.is_empty() {
305        return Vec::new();
306    }
307
308    let mut sorted: Vec<LineRange> = ranges.to_vec();
309    sorted.sort_by(|a, b| a.start.cmp(&b.start).then(a.end.cmp(&b.end)));
310
311    let mut merged = vec![sorted[0].clone()];
312
313    for range in &sorted[1..] {
314        let last = merged.last_mut().unwrap();
315        if range.start <= last.end + 1 {
316            last.end = last.end.max(range.end);
317        } else {
318            merged.push(range.clone());
319        }
320    }
321
322    merged
323}
324
325/// Toggle comments in the specified line ranges.
326/// `marker`: comment prefix (e.g. `"#"`, `"//"`, `"--"`). Defaults to `"#"` if `None`.
327/// `force_mode`: `Some("on")` = always comment, `Some("off")` = always uncomment, `None` = invert.
328pub fn toggle_comments(content: &str, ranges: &[LineRange], force_mode: Option<&str>) -> String {
329    toggle_comments_with_marker(content, ranges, force_mode, "#")
330}
331
332/// Toggle comments with an explicit comment marker.
333pub fn toggle_comments_with_marker(
334    content: &str,
335    ranges: &[LineRange],
336    force_mode: Option<&str>,
337    marker: &str,
338) -> String {
339    let protected = crate::io::detect_protected_lines(content);
340    toggle_comments_inner(content, ranges, force_mode, marker, &protected)
341}
342
343/// Toggle comments with explicit protected lines (empty vec to skip protection).
344fn toggle_comments_inner(
345    content: &str,
346    ranges: &[LineRange],
347    force_mode: Option<&str>,
348    marker: &str,
349    protected: &[usize],
350) -> String {
351    let mut lines: Vec<String> = content.lines().map(String::from).collect();
352    let merged = merge_ranges(ranges);
353
354    for range in &merged {
355        // Convert 1-based inclusive range to 0-based indices
356        let start = range.start.saturating_sub(1);
357        let end = range.end.min(lines.len());
358
359        if start >= lines.len() {
360            continue;
361        }
362
363        // Determine current state for invert mode: check if majority of
364        // non-empty, non-protected lines are commented
365        let should_comment = match force_mode {
366            Some("on") => true,
367            Some("off") => false,
368            _ => {
369                // Invert: check if lines are currently commented
370                let commented_count = lines[start..end]
371                    .iter()
372                    .enumerate()
373                    .filter(|(i, line)| {
374                        let abs_idx = start + i;
375                        !protected.contains(&abs_idx) && !line.trim().is_empty()
376                    })
377                    .filter(|(_, line)| {
378                        let trimmed = line.trim_start();
379                        trimmed.starts_with(marker)
380                    })
381                    .count();
382                let total_non_empty = lines[start..end]
383                    .iter()
384                    .enumerate()
385                    .filter(|(i, line)| {
386                        let abs_idx = start + i;
387                        !protected.contains(&abs_idx) && !line.trim().is_empty()
388                    })
389                    .count();
390                // If all non-empty lines are commented, uncomment (false); otherwise comment (true)
391                !(commented_count > 0 && commented_count == total_non_empty)
392            }
393        };
394
395        #[allow(clippy::needless_range_loop)]
396        for idx in start..end {
397            if protected.contains(&idx) {
398                continue;
399            }
400
401            let line = &lines[idx];
402            if line.trim().is_empty() {
403                continue;
404            }
405
406            let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
407            let rest = &line[leading_ws.len()..];
408
409            if should_comment {
410                // Strip existing comment marker first to avoid double-commenting
411                let marker_space = format!("{} ", marker);
412                let stripped = if let Some(s) = rest.strip_prefix(&marker_space) {
413                    s
414                } else if let Some(s) = rest.strip_prefix(marker) {
415                    s
416                } else {
417                    rest
418                };
419                lines[idx] = format!("{}{} {}", leading_ws, marker, stripped);
420            } else {
421                // Uncomment: remove marker and optional following space
422                let marker_space = format!("{} ", marker);
423                if let Some(s) = rest.strip_prefix(&marker_space) {
424                    lines[idx] = format!("{}{}", leading_ws, s);
425                } else if let Some(s) = rest.strip_prefix(marker) {
426                    lines[idx] = format!("{}{}", leading_ws, s);
427                }
428            }
429        }
430    }
431
432    // Preserve trailing newline if original had one
433    let mut result = lines.join("\n");
434    if content.ends_with('\n') {
435        result.push('\n');
436    }
437    result
438}
439
440/// Toggle comments using multi-line/block comment delimiters.
441/// For each merged range, wraps the content in start/end delimiters (commenting)
442/// or strips them (uncommenting). Force mode works the same as single-line.
443pub fn toggle_comments_multi(
444    content: &str,
445    ranges: &[LineRange],
446    force_mode: Option<&str>,
447    start_delim: &str,
448    end_delim: &str,
449) -> String {
450    let mut lines: Vec<String> = content.lines().map(String::from).collect();
451    let merged = merge_ranges(ranges);
452
453    for range in &merged {
454        let start = range.start.saturating_sub(1);
455        let end = range.end.min(lines.len());
456
457        if start >= lines.len() || start >= end {
458            continue;
459        }
460
461        // Detect if the range is already block-commented:
462        // first non-blank line starts with start_delim, last non-blank line ends with end_delim
463        let first_trimmed = lines[start].trim_start();
464        let last_trimmed = lines[end - 1].trim_end();
465        let is_commented =
466            first_trimmed.starts_with(start_delim) && last_trimmed.ends_with(end_delim);
467
468        let should_comment = match force_mode {
469            Some("on") => true,
470            Some("off") => false,
471            _ => !is_commented,
472        };
473
474        if should_comment && !is_commented {
475            // Wrap: prepend start_delim to first line, append end_delim to last line
476            let first_line = &lines[start];
477            let leading_ws: String = first_line
478                .chars()
479                .take_while(|c| c.is_whitespace())
480                .collect();
481            let rest = &first_line[leading_ws.len()..];
482            lines[start] = format!("{}{} {}", leading_ws, start_delim, rest);
483
484            let last_line = &lines[end - 1];
485            lines[end - 1] = format!("{} {}", last_line, end_delim);
486        } else if !should_comment && is_commented {
487            // Unwrap: strip start_delim from first line, strip end_delim from last line
488            let first_line = &lines[start];
489            let leading_ws: String = first_line
490                .chars()
491                .take_while(|c| c.is_whitespace())
492                .collect();
493            let rest = &first_line[leading_ws.len()..];
494            let stripped_start = if let Some(s) = rest.strip_prefix(start_delim) {
495                let s = s.strip_prefix(' ').unwrap_or(s);
496                format!("{}{}", leading_ws, s)
497            } else {
498                first_line.clone()
499            };
500            lines[start] = stripped_start;
501
502            let last_line = &lines[end - 1];
503            let stripped_end = if let Some(s) = last_line.strip_suffix(end_delim) {
504                let s = s.strip_suffix(' ').unwrap_or(s);
505                s.to_string()
506            } else {
507                last_line.clone()
508            };
509            lines[end - 1] = stripped_end;
510        }
511    }
512
513    let mut result = lines.join("\n");
514    if content.ends_with('\n') {
515        result.push('\n');
516    }
517    result
518}
519
520/// Map file extension to language name for config lookup
521fn ext_to_language(ext: &str) -> &str {
522    match ext {
523        "py" => "python",
524        "js" | "jsx" => "javascript",
525        "ts" | "tsx" => "typescript",
526        "rs" => "rust",
527        "rb" => "ruby",
528        "sh" => "shell",
529        "yaml" | "yml" => "yaml",
530        "r" => "r",
531        "ex" | "exs" => "elixir",
532        "pl" | "pm" => "perl",
533        "java" => "java",
534        "c" => "c",
535        "cpp" => "cpp",
536        "go" => "go",
537        "swift" => "swift",
538        "kt" => "kotlin",
539        "scala" => "scala",
540        "php" => "php",
541        "lua" => "lua",
542        "hs" => "haskell",
543        "sql" => "sql",
544        "toml" => "toml",
545        other => other,
546    }
547}
548
549/// Get the comment style for a file based on its extension.
550/// If a config is provided, language-specific overrides take priority,
551/// then global overrides, then the hardcoded defaults.
552pub fn get_comment_style(
553    path: &Path,
554    _mode: &str,
555    config: Option<&ToggleConfig>,
556) -> Result<CommentStyle> {
557    let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
558
559    // Check config overrides first
560    if let Some(cfg) = config {
561        let lang = ext_to_language(extension);
562        // Language-specific override
563        if let Some(delimiter) = cfg.get_language_delimiter(lang) {
564            let multi = cfg.get_language_multi_line_delimiters(lang);
565            return Ok(CommentStyle {
566                single_line: delimiter.to_string(),
567                multi_line_start: multi.map(|(s, _)| s.to_string()),
568                multi_line_end: multi.map(|(_, e)| e.to_string()),
569            });
570        }
571        // Global override
572        if let Some(delimiter) = cfg
573            .global
574            .as_ref()
575            .and_then(|g| g.single_line_delimiter.as_deref())
576        {
577            let global = cfg.global.as_ref();
578            return Ok(CommentStyle {
579                single_line: delimiter.to_string(),
580                multi_line_start: global
581                    .and_then(|g| g.multi_line_delimiter_start.as_deref())
582                    .map(String::from),
583                multi_line_end: global
584                    .and_then(|g| g.multi_line_delimiter_end.as_deref())
585                    .map(String::from),
586            });
587        }
588    }
589
590    match extension {
591        // Hash-style comments (no multi-line)
592        "py" | "sh" | "rb" | "yaml" | "yml" | "toml" | "r" | "ex" | "exs" | "pl" | "pm" => {
593            Ok(CommentStyle {
594                single_line: "#".to_string(),
595                multi_line_start: None,
596                multi_line_end: None,
597            })
598        }
599        // Slash-style comments with /* */ multi-line
600        "js" | "jsx" | "ts" | "tsx" | "rs" | "java" | "c" | "cpp" | "go" | "swift" | "kt"
601        | "scala" | "php" => Ok(CommentStyle {
602            single_line: "//".to_string(),
603            multi_line_start: Some("/*".to_string()),
604            multi_line_end: Some("*/".to_string()),
605        }),
606        // Dash-style comments
607        "lua" => Ok(CommentStyle {
608            single_line: "--".to_string(),
609            multi_line_start: Some("--[[".to_string()),
610            multi_line_end: Some("]]".to_string()),
611        }),
612        "hs" => Ok(CommentStyle {
613            single_line: "--".to_string(),
614            multi_line_start: Some("{-".to_string()),
615            multi_line_end: Some("-}".to_string()),
616        }),
617        "sql" => Ok(CommentStyle {
618            single_line: "--".to_string(),
619            multi_line_start: Some("/*".to_string()),
620            multi_line_end: Some("*/".to_string()),
621        }),
622        _ => Err(UsageError(format!(
623            "Unsupported file extension: .{}; use --comment-style or --config with a [global] single_line_delimiter",
624            extension
625        ))
626        .into()),
627    }
628}
629
630/// Find section markers and toggle the content between them.
631/// Returns a `SectionToggleResult` with modification status and parsed desc.
632pub fn find_and_toggle_section(
633    lines: &mut [String],
634    section_id: &str,
635    force: &Option<String>,
636    comment_style: &CommentStyle,
637) -> Result<SectionToggleResult> {
638    let mut i = 0;
639    let mut modified = false;
640    let mut desc = None;
641
642    while i < lines.len() {
643        if line_matches_start(&lines[i], section_id) {
644            if desc.is_none() {
645                desc = parse_section_desc(&lines[i]);
646            }
647            let section_start = i + 1;
648
649            let mut section_end = None;
650
651            for (j, line) in lines.iter().enumerate().skip(i + 1) {
652                if line_matches_end(line, section_id) {
653                    section_end = Some(j);
654                    break;
655                }
656            }
657
658            let section_end = match section_end {
659                Some(end) => end,
660                None => {
661                    return Err(UsageError(format!("Unclosed section ID={}", section_id)).into());
662                }
663            };
664
665            if section_end > section_start {
666                let force_mode = force.as_deref();
667
668                // Build content string from section lines and toggle via
669                // toggle_comments_with_marker for consistent behavior (skip blanks,
670                // preserve indentation)
671                let section_content = lines[section_start..section_end].join("\n");
672                let range = LineRange::new(1, section_end - section_start);
673                // Pass empty protected set — section content is user-specified
674                // and should not have false shebang/pragma detection
675                let toggled = toggle_comments_inner(
676                    &section_content,
677                    &[range],
678                    force_mode,
679                    &comment_style.single_line,
680                    &[],
681                );
682
683                // Splice toggled lines back in.
684                // Use split('\n') instead of lines() to preserve trailing empty
685                // elements that lines() would drop (lossy roundtrip fix).
686                let mut toggled_lines: Vec<&str> = toggled.split('\n').collect();
687                // toggle_comments_inner appends '\n' when input ends with '\n',
688                // which produces a spurious trailing empty element via split.
689                if toggled_lines.last() == Some(&"") && toggled.ends_with('\n') {
690                    toggled_lines.pop();
691                }
692                let section_len = section_end - section_start;
693                assert_eq!(
694                    toggled_lines.len(),
695                    section_len,
696                    "Toggled line count ({}) must match section span ({})",
697                    toggled_lines.len(),
698                    section_len,
699                );
700                for (offset, new_line) in toggled_lines.iter().enumerate() {
701                    if offset < section_len {
702                        lines[section_start + offset] = (*new_line).to_string();
703                    }
704                }
705
706                modified = true;
707                i = section_end;
708            }
709        }
710
711        i += 1;
712    }
713
714    Ok(SectionToggleResult { modified, desc })
715}
716
717/// Toggle every variant of `group` in `content`.
718/// - `force = None` and exactly 2 variants → pair-flip (each variant inverted).
719/// - `force = None` and 1 variant → solo invert (existing per-section behavior).
720/// - `force = None` and 3+ variants → error per PRD §0.13.3.
721/// - `force = Some("on" | "off")` → apply force to every variant regardless of count.
722pub fn toggle_variant_group(
723    content: &str,
724    group: &str,
725    force: &Option<String>,
726    comment_style: &CommentStyle,
727) -> Result<String> {
728    let variants = discover_variants(content, group);
729    if variants.is_empty() {
730        return Err(UsageError(format!("no section or group '{group}' found")).into());
731    }
732    if force.is_none() && variants.len() >= 3 {
733        return Err(UsageError(format!(
734            "group '{group}' has {} variants; specify one with -S {group}:<name>",
735            variants.len()
736        ))
737        .into());
738    }
739
740    let mut lines: Vec<String> = content.lines().map(String::from).collect();
741    for v in &variants {
742        find_and_toggle_section(&mut lines, &v.id, force, comment_style)?;
743    }
744
745    let mut joined = lines.join("\n");
746    if content.ends_with('\n') {
747        joined.push('\n');
748    }
749    Ok(joined)
750}
751
752/// Inferred type of a section group (PRD §0.14.1).
753#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
754#[serde(rename_all = "lowercase")]
755pub enum SectionType {
756    Solo,
757    Pair,
758    Group,
759}
760
761/// Per-group summary across one or more files.
762#[derive(Debug, Clone, serde::Serialize)]
763pub struct GroupSummary {
764    pub group: String,
765    pub section_type: SectionType,
766    pub variant_count: usize,
767    pub file_count: usize,
768    pub state: String,
769    pub variants: Vec<String>,
770}
771
772/// Group flat scan results into per-group summaries with inferred type.
773pub fn summarize_scan(sections: &[ScanSectionInfo]) -> Vec<GroupSummary> {
774    use std::collections::{BTreeMap, BTreeSet};
775    let mut groups: BTreeMap<String, Vec<&ScanSectionInfo>> = BTreeMap::new();
776    for s in sections {
777        groups.entry(s.group.clone()).or_default().push(s);
778    }
779
780    groups
781        .into_iter()
782        .map(|(group, items)| {
783            let mut variants: Vec<String> =
784                items.iter().filter_map(|s| s.variant.clone()).collect();
785            variants.sort();
786            variants.dedup();
787
788            let section_type = match variants.len() {
789                0 | 1 => SectionType::Solo,
790                2 => SectionType::Pair,
791                _ => SectionType::Group,
792            };
793
794            let files: BTreeSet<&String> = items.iter().map(|s| &s.file).collect();
795            let states: BTreeSet<&String> = items.iter().map(|s| &s.state).collect();
796            let state = if states.len() == 1 {
797                states.into_iter().next().unwrap().clone()
798            } else {
799                "mixed".to_string()
800            };
801
802            GroupSummary {
803                group,
804                section_type,
805                variant_count: variants.len(),
806                file_count: files.len(),
807                state,
808                variants,
809            }
810        })
811        .collect()
812}
813
814/// JSON file reference for `--scan --json` output (PRD §0.14.4).
815#[derive(Debug, Clone, serde::Serialize)]
816pub struct ScanJsonFile {
817    pub path: String,
818    pub start: usize,
819    pub end: Option<usize>,
820    pub state: String,
821    #[serde(skip_serializing_if = "Option::is_none")]
822    pub desc: Option<String>,
823}
824
825/// One variant inside a pair/group entry.
826#[derive(Debug, Clone, serde::Serialize)]
827pub struct ScanJsonVariant {
828    pub id: String,
829    pub state: String,
830    pub files: Vec<ScanJsonFile>,
831}
832
833/// One top-level entry in the scan JSON tree (solo or grouped).
834#[derive(Debug, Clone, serde::Serialize)]
835#[serde(untagged)]
836pub enum ScanJsonEntry {
837    Solo {
838        id: String,
839        #[serde(rename = "type")]
840        section_type: SectionType,
841        files: Vec<ScanJsonFile>,
842    },
843    Group {
844        group: String,
845        #[serde(rename = "type")]
846        section_type: SectionType,
847        variants: Vec<ScanJsonVariant>,
848    },
849}
850
851/// Root of the nested scan JSON tree.
852#[derive(Debug, Clone, serde::Serialize)]
853pub struct ScanJsonRoot {
854    pub sections: Vec<ScanJsonEntry>,
855}
856
857/// Build the nested scan JSON tree from flat scan rows (PRD §0.14.4).
858pub fn build_scan_json(sections: &[ScanSectionInfo]) -> ScanJsonRoot {
859    use std::collections::BTreeMap;
860    let mut groups: BTreeMap<String, Vec<&ScanSectionInfo>> = BTreeMap::new();
861    for s in sections {
862        groups.entry(s.group.clone()).or_default().push(s);
863    }
864
865    let mut entries = Vec::new();
866    for (group, items) in groups {
867        let mut variant_ids: Vec<String> = items.iter().filter_map(|s| s.variant.clone()).collect();
868        variant_ids.sort();
869        variant_ids.dedup();
870
871        let section_type = match variant_ids.len() {
872            0 | 1 => SectionType::Solo,
873            2 => SectionType::Pair,
874            _ => SectionType::Group,
875        };
876
877        if matches!(section_type, SectionType::Solo) {
878            let files = items
879                .iter()
880                .map(|s| ScanJsonFile {
881                    path: s.file.clone(),
882                    start: s.start_line,
883                    end: s.end_line,
884                    state: s.state.clone(),
885                    desc: s.description.clone(),
886                })
887                .collect();
888            entries.push(ScanJsonEntry::Solo {
889                id: group,
890                section_type,
891                files,
892            });
893        } else {
894            let mut by_id: BTreeMap<String, Vec<&ScanSectionInfo>> = BTreeMap::new();
895            for s in &items {
896                by_id.entry(s.id.clone()).or_default().push(s);
897            }
898            let variants = by_id
899                .into_iter()
900                .map(|(id, recs)| {
901                    let state = recs[0].state.clone();
902                    let files = recs
903                        .iter()
904                        .map(|s| ScanJsonFile {
905                            path: s.file.clone(),
906                            start: s.start_line,
907                            end: s.end_line,
908                            state: s.state.clone(),
909                            desc: s.description.clone(),
910                        })
911                        .collect();
912                    ScanJsonVariant { id, state, files }
913                })
914                .collect();
915            entries.push(ScanJsonEntry::Group {
916                group,
917                section_type,
918                variants,
919            });
920        }
921    }
922
923    ScanJsonRoot { sections: entries }
924}
925
926/// Severity for a `--check` finding (PRD §0.14.3).
927#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
928#[serde(rename_all = "lowercase")]
929pub enum CheckLevel {
930    Ok,
931    Warn,
932    Err,
933}
934
935/// One validation finding from `validate_sections`.
936#[derive(Debug, Clone, serde::Serialize)]
937pub struct CheckIssue {
938    pub level: CheckLevel,
939    pub group: String,
940    #[serde(skip_serializing_if = "Option::is_none")]
941    pub file: Option<String>,
942    pub message: String,
943}
944
945/// Run validation on already-scanned sections grouped by file (PRD §0.14.3).
946/// `pair_only = true` skips the pair-mismatch check on solos (i.e. when invoked
947/// with `--check --pair`, a 3-variant group is still flagged).
948pub fn validate_sections(
949    per_file: &[(std::path::PathBuf, Vec<ScanSectionInfo>)],
950    pair_only: bool,
951) -> Vec<CheckIssue> {
952    use std::collections::{BTreeSet, HashMap};
953    let mut issues = Vec::new();
954
955    for (path, sections) in per_file {
956        for s in sections {
957            if s.end_line.is_none() {
958                issues.push(CheckIssue {
959                    level: CheckLevel::Err,
960                    group: s.group.clone(),
961                    file: Some(path.display().to_string()),
962                    message: format!("unclosed marker for ID={}", s.id),
963                });
964            }
965        }
966        let mut counts: HashMap<&str, usize> = HashMap::new();
967        for s in sections {
968            *counts.entry(s.id.as_str()).or_insert(0) += 1;
969        }
970        for (id, n) in counts {
971            if n > 1 {
972                issues.push(CheckIssue {
973                    level: CheckLevel::Err,
974                    group: parse_id_parts(id).0,
975                    file: Some(path.display().to_string()),
976                    message: format!("duplicate section ID '{id}' ({n} occurrences)"),
977                });
978            }
979        }
980    }
981
982    let flat: Vec<ScanSectionInfo> = per_file.iter().flat_map(|(_, v)| v.clone()).collect();
983    let summaries = summarize_scan(&flat);
984
985    for sum in &summaries {
986        let group_is_pair_like = !matches!(sum.section_type, SectionType::Solo);
987        if pair_only && group_is_pair_like && sum.variant_count != 2 {
988            issues.push(CheckIssue {
989                level: CheckLevel::Warn,
990                group: sum.group.clone(),
991                file: None,
992                message: format!("{} variants, expected 2 (pair check)", sum.variant_count),
993            });
994        }
995
996        if matches!(sum.section_type, SectionType::Pair | SectionType::Group) {
997            for (path, sections) in per_file {
998                let present: BTreeSet<String> = sections
999                    .iter()
1000                    .filter(|s| s.group == sum.group)
1001                    .filter_map(|s| s.variant.clone())
1002                    .collect();
1003                if present.is_empty() {
1004                    continue;
1005                }
1006                let expected: BTreeSet<String> = sum.variants.iter().cloned().collect();
1007                let missing: Vec<&String> = expected.difference(&present).collect();
1008                if !missing.is_empty() {
1009                    issues.push(CheckIssue {
1010                        level: CheckLevel::Warn,
1011                        group: sum.group.clone(),
1012                        file: Some(path.display().to_string()),
1013                        message: format!(
1014                            "missing variant(s): {}",
1015                            missing
1016                                .iter()
1017                                .map(|s| s.as_str())
1018                                .collect::<Vec<_>>()
1019                                .join(", ")
1020                        ),
1021                    });
1022                }
1023            }
1024        }
1025    }
1026
1027    issues
1028}
1029
1030/// Activate `group:variant`: uncomment that variant, comment every other variant of the group.
1031pub fn activate_variant(
1032    content: &str,
1033    group: &str,
1034    variant: &str,
1035    comment_style: &CommentStyle,
1036) -> Result<String> {
1037    let target_id = format!("{group}:{variant}");
1038    let variants = discover_variants(content, group);
1039    if !variants.iter().any(|s| s.id == target_id) {
1040        return Err(UsageError(format!("variant '{target_id}' not found")).into());
1041    }
1042
1043    let mut lines: Vec<String> = content.lines().map(String::from).collect();
1044    for v in &variants {
1045        let force = if v.id == target_id {
1046            Some("off".to_string())
1047        } else {
1048            Some("on".to_string())
1049        };
1050        find_and_toggle_section(&mut lines, &v.id, &force, comment_style)?;
1051    }
1052
1053    let mut joined = lines.join("\n");
1054    if content.ends_with('\n') {
1055        joined.push('\n');
1056    }
1057    Ok(joined)
1058}