1use anyhow::Result;
4use std::path::Path;
5
6use crate::config::ToggleConfig;
7use crate::exit_codes::UsageError;
8
9pub 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#[derive(Debug, Clone)]
19pub struct SectionInfo {
20 pub id: String,
21 pub desc: Option<String>,
22 pub start_line: usize, pub end_line: usize, }
25
26pub struct SectionToggleResult {
28 pub modified: bool,
29 pub desc: Option<String>,
30}
31
32#[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
46fn 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
55fn 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
69pub 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
78fn 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
86fn 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
94pub 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; 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); 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; continue;
126 }
127 }
128 }
129 i += 1;
130 }
131
132 sections
133}
134
135pub 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
144pub 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 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 let description = parse_section_desc(lines[i]);
167
168 let start_line = i + 1; 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); break;
177 }
178 }
179
180 let state = if let Some(end) = end_line {
182 let content_start = i + 1;
183 let content_end = end - 1; 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
214fn 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#[derive(Debug, Clone)]
237pub struct LineRange {
238 pub start: usize,
239 pub end: usize,
240}
241
242impl LineRange {
243 pub fn new(start: usize, end: usize) -> Self {
245 Self { start, end }
246 }
247}
248
249#[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
257pub 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 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 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 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
301pub 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
325pub fn toggle_comments(content: &str, ranges: &[LineRange], force_mode: Option<&str>) -> String {
329 toggle_comments_with_marker(content, ranges, force_mode, "#")
330}
331
332pub 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
343fn 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 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 let should_comment = match force_mode {
366 Some("on") => true,
367 Some("off") => false,
368 _ => {
369 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 !(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 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 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 let mut result = lines.join("\n");
434 if content.ends_with('\n') {
435 result.push('\n');
436 }
437 result
438}
439
440pub 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 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 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 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
520fn 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
549pub 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 if let Some(cfg) = config {
561 let lang = ext_to_language(extension);
562 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 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 "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 "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 "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
630pub 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 let section_content = lines[section_start..section_end].join("\n");
672 let range = LineRange::new(1, section_end - section_start);
673 let toggled = toggle_comments_inner(
676 §ion_content,
677 &[range],
678 force_mode,
679 &comment_style.single_line,
680 &[],
681 );
682
683 let mut toggled_lines: Vec<&str> = toggled.split('\n').collect();
687 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
717pub 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
754#[serde(rename_all = "lowercase")]
755pub enum SectionType {
756 Solo,
757 Pair,
758 Group,
759}
760
761#[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
772pub 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#[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#[derive(Debug, Clone, serde::Serialize)]
827pub struct ScanJsonVariant {
828 pub id: String,
829 pub state: String,
830 pub files: Vec<ScanJsonFile>,
831}
832
833#[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#[derive(Debug, Clone, serde::Serialize)]
853pub struct ScanJsonRoot {
854 pub sections: Vec<ScanJsonEntry>,
855}
856
857pub 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
928#[serde(rename_all = "lowercase")]
929pub enum CheckLevel {
930 Ok,
931 Warn,
932 Err,
933}
934
935#[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
945pub 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
1030pub 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}