use anyhow::{Context, Result};
use std::collections::BTreeMap;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use toggle::cli::Cli;
use toggle::config::ToggleConfig;
use toggle::core;
use toggle::exit_codes::{ExitCode, UsageError};
use toggle::io;
use toggle::journal;
use toggle::walk;
struct ToggleOptions<'a> {
force: &'a Option<String>,
mode: &'a str,
temp_suffix: Option<&'a str>,
dry_run: bool,
backup: Option<&'a str>,
config: Option<&'a ToggleConfig>,
verbose: bool,
eol: &'a str,
no_dereference: bool,
encoding: &'a str,
json: bool,
to_end: bool,
comment_style_override: &'a [String],
interactive: bool,
}
struct ProcessResult {
action: String,
lines_changed: usize,
section_id: Option<String>,
desc: Option<String>,
}
#[derive(serde::Serialize)]
struct ToggleResult {
file: String,
action: String,
lines_changed: usize,
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
dry_run: bool,
#[serde(skip_serializing_if = "Option::is_none")]
section_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
desc: Option<String>,
}
#[derive(serde::Serialize)]
struct SectionListEntry {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
desc: Option<String>,
files: Vec<SectionFileEntry>,
}
#[derive(serde::Serialize)]
struct SectionFileEntry {
file: String,
start_line: usize,
end_line: usize,
}
fn build_command() -> clap::Command {
let bin_name = env!("CARGO_BIN_NAME");
<Cli as clap::CommandFactory>::command()
.name(bin_name)
.bin_name(bin_name)
}
fn main() {
let mut cmd = build_command();
let matches = match cmd.try_get_matches_from_mut(std::env::args_os()) {
Ok(m) => m,
Err(e) => {
let kind = e.kind();
let _ = e.print();
let display_only = matches!(
kind,
clap::error::ErrorKind::DisplayHelp
| clap::error::ErrorKind::DisplayVersion
| clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
);
std::process::exit(if display_only {
0
} else {
ExitCode::Usage.code()
});
}
};
let cli = match <Cli as clap::FromArgMatches>::from_arg_matches(&matches) {
Ok(c) => c,
Err(e) => {
let _ = e.print();
std::process::exit(ExitCode::Usage.code());
}
};
let result = run(&cli);
let code = match &result {
Ok(_) => ExitCode::Success,
Err(e) => classify_error(e),
};
if let Err(e) = &result {
if !cli.json {
eprintln!("Error: {:#}", e);
}
}
let exit_val = if cli.posix_exit {
code.posix()
} else {
code.code()
};
std::process::exit(exit_val);
}
fn classify_error(err: &anyhow::Error) -> ExitCode {
for cause in err.chain() {
if cause.downcast_ref::<std::io::Error>().is_some() {
return ExitCode::IoError;
}
if cause.downcast_ref::<UsageError>().is_some() {
return ExitCode::Usage;
}
}
ExitCode::ToggleError
}
fn run(cli: &Cli) -> Result<()> {
if let Some(shell) = cli.completions {
let mut command = build_command();
let bin = command.get_name().to_string();
clap_complete::generate(shell, &mut command, bin, &mut std::io::stdout());
return Ok(());
}
if cli.man {
let command = build_command();
clap_mangen::Man::new(command)
.render(&mut std::io::stdout())
.context("failed to render man page")?;
return Ok(());
}
if cli.paths.is_empty() {
return Err(UsageError("at least one file or directory path is required".into()).into());
}
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let journal_path = cwd.join(journal::JOURNAL_FILENAME);
if cli.recover {
journal::perform_recovery(&journal_path, cli.recover_forward)
.map_err(|e| anyhow::anyhow!("{}", e))?;
return Ok(());
}
if journal_path.exists() && !cli.recover {
return Err(UsageError(
"A previous atomic operation was interrupted. \
Run with --recover to clean up, or --recover --recover-forward to complete it."
.into(),
)
.into());
}
if cli.atomic && cli.dry_run {
return Err(UsageError("--atomic cannot be combined with --dry-run.".into()).into());
}
if cli.no_backup && !cli.atomic {
return Err(UsageError("--no-backup is only valid with --atomic.".into()).into());
}
if cli.recover_forward && !cli.recover {
return Err(UsageError("--recover-forward requires --recover.".into()).into());
}
let config = if let Some(config_path) = &cli.config {
Some(ToggleConfig::load(config_path)?)
} else {
None
};
let effective_mode = if cli.mode == "auto" {
config
.as_ref()
.and_then(|c| c.global.as_ref())
.and_then(|g| g.default_mode.as_deref())
.unwrap_or("auto")
.to_string()
} else {
cli.mode.clone()
};
let effective_force = if let Some(ref val) = cli.force {
match val.as_str() {
"on" | "off" => cli.force.clone(),
"invert" => None,
other => {
return Err(UsageError(format!(
"Invalid --force value '{}': expected on, off, or invert",
other
))
.into());
}
}
} else {
config
.as_ref()
.and_then(|c| c.global.as_ref())
.and_then(|g| g.force_state.as_deref())
.filter(|&s| s != "none")
.map(String::from)
};
if !io::is_valid_encoding(&cli.encoding) {
return Err(UsageError(format!("Unsupported encoding: '{}'", cli.encoding)).into());
}
if cli.scan {
if !cli.lines.is_empty() {
return Err(UsageError("--scan cannot be combined with --line".into()).into());
}
if cli.force.is_some() {
return Err(UsageError("--scan cannot be combined with --force".into()).into());
}
return run_scan(cli);
}
if cli.check && !cli.scan {
return Err(UsageError("--check requires --scan".into()).into());
}
if cli.comment_style.len() == 2 {
return Err(UsageError(
"--comment-style requires 1 value (single-line) or 3 values (single-line, multi-start, multi-end)".into(),
)
.into());
}
if cli.to_end && cli.lines.is_empty() {
return Err(UsageError("--to-end requires at least one --line range".into()).into());
}
if cli.pair {
if cli.sections.is_empty() {
return Err(UsageError("--pair requires at least one -S <group>".into()).into());
}
validate_pair_groups(cli)?;
}
if cli.list_sections && !cli.lines.is_empty() {
return Err(UsageError("--list-sections cannot be combined with --line".into()).into());
}
if cli.list_sections && cli.force.is_some() {
return Err(UsageError("--list-sections cannot be combined with --force".into()).into());
}
match cli.eol.as_str() {
"preserve" | "lf" | "crlf" => {}
other => {
return Err(UsageError(format!(
"Invalid --eol value '{}': must be preserve, lf, or crlf",
other
))
.into());
}
}
let opts = ToggleOptions {
force: &effective_force,
mode: &effective_mode,
temp_suffix: cli.temp_suffix.as_deref(),
dry_run: cli.dry_run,
backup: cli.backup.as_deref(),
config: config.as_ref(),
verbose: cli.verbose && !cli.json, eol: &cli.eol,
no_dereference: cli.no_dereference,
encoding: &cli.encoding,
json: cli.json,
to_end: cli.to_end,
comment_style_override: &cli.comment_style,
interactive: cli.interactive,
};
if cli.list_sections {
run_list_sections(cli, &opts)
} else if cli.atomic {
run_atomic(cli, &opts)
} else if cli.json {
run_json(cli, &opts)
} else {
run_normal(cli, &opts)
}
}
fn validate_pair_groups(cli: &Cli) -> Result<()> {
let walk_opts = walk::WalkOptions {
verbose: cli.verbose,
..walk::WalkOptions::default()
};
let files = walk::collect_files(&cli.paths, cli.recursive, &walk_opts)?;
for section in &cli.sections {
let (group, _variant) = core::parse_id_parts(section);
for file in &files {
let content = match io::read_file_encoded(file, &cli.encoding) {
Ok(c) => c,
Err(_) => continue,
};
let count = core::discover_variants(&content, &group).len();
if count != 2 {
return Err(UsageError(format!(
"--pair: group '{group}' has {count} variants in {}, expected exactly 2",
file.display()
))
.into());
}
}
}
Ok(())
}
fn file_has_matching_sections(path: &Path, section_ids: &[String], encoding: &str) -> bool {
if section_ids.is_empty() {
return true;
}
let content = match io::read_file_encoded(path, encoding) {
Ok(c) => c,
Err(_) => return false,
};
let found = core::discover_sections(&content);
section_ids.iter().any(|id| {
let (group, variant) = core::parse_id_parts(id);
found.iter().any(|s| match &variant {
Some(v) => s.id == format!("{group}:{v}"),
None => {
s.id == *id || core::parse_id_parts(&s.id).0 == group
}
})
})
}
fn collect_and_filter_files(cli: &Cli, opts: &ToggleOptions) -> Result<Vec<PathBuf>> {
let walk_opts = walk::WalkOptions {
verbose: opts.verbose,
skip_unsupported_extensions: false,
..walk::WalkOptions::default()
};
let files = walk::collect_files(&cli.paths, cli.recursive, &walk_opts)?;
Ok(files
.into_iter()
.filter(|path| {
if cli.recursive
&& !cli.sections.is_empty()
&& !file_has_matching_sections(path, &cli.sections, opts.encoding)
{
return false;
}
if cli.recursive
&& opts.comment_style_override.is_empty()
&& core::get_comment_style(path, opts.mode, opts.config).is_err()
{
return false;
}
true
})
.collect())
}
fn run_normal(cli: &Cli, opts: &ToggleOptions) -> Result<()> {
let files = collect_and_filter_files(cli, opts)?;
for path in &files {
process_file(path, cli, opts)
.with_context(|| format!("Failed to process {}", path.display()))?;
}
Ok(())
}
fn run_atomic(cli: &Cli, opts: &ToggleOptions) -> Result<()> {
let files = collect_and_filter_files(cli, opts)?;
if files.is_empty() {
return Ok(());
}
let interrupted = Arc::new(AtomicBool::new(false));
let _ = signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(&interrupted));
let _ = signal_hook::flag::register(signal_hook::consts::SIGINT, Arc::clone(&interrupted));
let backup_enabled = !cli.no_backup;
let mut changes: Vec<(PathBuf, String, String)> = Vec::new();
for path in &files {
if interrupted.load(Ordering::Relaxed) {
anyhow::bail!("Interrupted before staging. No files were modified.");
}
let original = io::read_file_encoded(path, opts.encoding)
.with_context(|| format!("Failed to read {}", path.display()))?;
let modified = compute_file_changes(path, cli, opts, &original)
.with_context(|| format!("Failed to compute changes for {}", path.display()))?;
if original != modified {
changes.push((path.clone(), original, modified));
}
}
if changes.is_empty() {
if opts.verbose {
eprintln!("No changes to apply.");
}
return Ok(());
}
if opts.verbose {
eprintln!("Staging {} file(s) in atomic mode...", changes.len());
}
let target_paths: Vec<PathBuf> = changes.iter().map(|(p, _, _)| p.clone()).collect();
let mut batch = io::AtomicBatch::new(&target_paths, backup_enabled, Arc::clone(&interrupted))
.map_err(|e| anyhow::anyhow!("Failed to initialize atomic batch: {}", e))?;
for (path, _original, modified) in &changes {
let encoded = io::encode_for_atomic(modified, opts.encoding)
.with_context(|| format!("Failed to encode content for {}", path.display()))?;
batch
.stage(path, &encoded, opts.encoding)
.map_err(|e| anyhow::anyhow!("Failed to stage '{}': {}", path.display(), e))?;
}
if opts.verbose {
eprintln!("All files staged. Committing...");
}
batch
.commit()
.map_err(|e| anyhow::anyhow!("Atomic commit failed: {}", e))?;
if opts.verbose {
eprintln!(
"Atomic commit successful. {} file(s) modified.",
changes.len()
);
}
if !opts.json {
for (path, original, modified) in &changes {
let lines_changed = count_changed_lines(original, modified);
eprintln!(
"Modified {} ({} line(s) changed)",
path.display(),
lines_changed
);
}
}
Ok(())
}
fn compute_file_changes(
path: &Path,
cli: &Cli,
opts: &ToggleOptions,
original: &str,
) -> Result<String> {
if cli.strict_ext {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "py" {
return Err(UsageError(format!(
"File '{}' is not a .py file (rejected by --strict-ext)",
path.display()
))
.into());
}
}
let mut content = original.to_string();
if !cli.lines.is_empty() {
content = compute_line_range_changes(path, &cli.lines, opts, &content)?;
}
for section in &cli.sections {
content = compute_section_changes(path, section, opts, &content)?;
}
Ok(content)
}
fn compute_line_range_changes(
path: &Path,
line_range_specs: &[String],
opts: &ToggleOptions,
content: &str,
) -> Result<String> {
let comment_style = resolve_comment_style(path, opts)?;
let line_count = content.lines().count();
let mut ranges = Vec::new();
for spec in line_range_specs {
let (start_line, end_line) = core::parse_line_range(spec)?;
if start_line > line_count {
return Err(UsageError(format!(
"Start line {} is out of range (file has {} lines)",
start_line, line_count
))
.into());
}
ranges.push(core::LineRange::new(start_line, end_line));
}
if opts.to_end {
if let Some(last) = ranges.last_mut() {
last.end = line_count;
}
}
for range in &ranges {
if range.end > line_count {
return Err(UsageError(format!(
"End line {} is out of range (file has {} lines)",
range.end, line_count
))
.into());
}
}
let merged = core::merge_ranges(&ranges);
let force_mode = opts.force.as_deref();
let toggled = if opts.mode == "multi" {
let (ms, me) = match (
&comment_style.multi_line_start,
&comment_style.multi_line_end,
) {
(Some(s), Some(e)) => (s.as_str(), e.as_str()),
_ => {
return Err(UsageError(format!(
"Multi-line comments not supported for {}",
path.display()
))
.into());
}
};
core::toggle_comments_multi(content, &merged, force_mode, ms, me)
} else {
core::toggle_comments_with_marker(content, &merged, force_mode, &comment_style.single_line)
};
Ok(io::normalize_eol(&toggled, opts.eol))
}
fn compute_section_changes(
path: &Path,
section_id: &str,
opts: &ToggleOptions,
content: &str,
) -> Result<String> {
let comment_style = resolve_comment_style(path, opts)?;
let (group, variant) = core::parse_id_parts(section_id);
let toggled = match variant {
Some(v) => core::activate_variant(content, &group, &v, &comment_style)?,
None => {
let variants = core::discover_variants(content, &group);
if variants.len() <= 1 && opts.force.is_none() {
let mut lines: Vec<String> = content.lines().map(String::from).collect();
let result = core::find_and_toggle_section(
&mut lines,
section_id,
opts.force,
&comment_style,
)?;
if !result.modified {
return Ok(content.to_string());
}
let mut joined = lines.join("\n");
if content.ends_with('\n') {
joined.push('\n');
}
joined
} else {
core::toggle_variant_group(content, &group, opts.force, &comment_style)?
}
}
};
Ok(io::normalize_eol(&toggled, opts.eol))
}
fn run_json(cli: &Cli, opts: &ToggleOptions) -> Result<()> {
let files = collect_and_filter_files(cli, opts)?;
let mut results: Vec<ToggleResult> = Vec::new();
let mut had_error = false;
for path in &files {
match process_file(path, cli, opts) {
Ok(proc_results) => {
for pr in proc_results {
results.push(ToggleResult {
file: path.display().to_string(),
action: pr.action,
lines_changed: pr.lines_changed,
success: true,
error: None,
dry_run: opts.dry_run,
section_id: pr.section_id,
desc: pr.desc,
});
}
}
Err(e) => {
had_error = true;
results.push(ToggleResult {
file: path.display().to_string(),
action: String::new(),
lines_changed: 0,
success: false,
error: Some(format!("{:#}", e)),
dry_run: opts.dry_run,
section_id: None,
desc: None,
});
}
}
}
println!(
"{}",
serde_json::to_string(&results).expect("Failed to serialize JSON")
);
if had_error {
anyhow::bail!("One or more files failed to process");
}
Ok(())
}
type SectionAggregation = (Option<String>, Vec<(String, usize, usize)>);
fn run_list_sections(cli: &Cli, opts: &ToggleOptions) -> Result<()> {
let walk_opts = walk::WalkOptions {
verbose: opts.verbose,
skip_unsupported_extensions: false,
..walk::WalkOptions::default()
};
let files = walk::collect_files(&cli.paths, cli.recursive, &walk_opts)?;
let mut sections_by_id: BTreeMap<String, SectionAggregation> = BTreeMap::new();
for path in &files {
let content = match io::read_file_encoded(path, opts.encoding) {
Ok(c) => c,
Err(_) => continue,
};
let found = core::discover_sections(&content);
for section in found {
let entry = sections_by_id
.entry(section.id.clone())
.or_insert_with(|| (section.desc.clone(), Vec::new()));
if entry.0.is_none() && section.desc.is_some() {
entry.0 = section.desc.clone();
}
entry.1.push((
path.display().to_string(),
section.start_line,
section.end_line,
));
}
}
if cli.json {
let entries: Vec<SectionListEntry> = sections_by_id
.into_iter()
.map(|(id, (desc, files))| SectionListEntry {
id,
desc,
files: files
.into_iter()
.map(|(file, start, end)| SectionFileEntry {
file,
start_line: start,
end_line: end,
})
.collect(),
})
.collect();
println!(
"{}",
serde_json::to_string(&entries).expect("Failed to serialize JSON")
);
} else {
for (id, (desc, locations)) in §ions_by_id {
if let Some(d) = desc {
println!("{} desc=\"{}\"", id, d);
} else {
println!("{}", id);
}
for (file, start, end) in locations {
println!(" {}:{}-{}", file, start, end);
}
}
}
Ok(())
}
fn process_file(path: &Path, cli: &Cli, opts: &ToggleOptions) -> Result<Vec<ProcessResult>> {
if cli.strict_ext {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "py" {
return Err(UsageError(format!(
"File '{}' is not a .py file (rejected by --strict-ext)",
path.display()
))
.into());
}
}
if opts.verbose {
eprintln!("Processing {}:", path.display());
}
let mut results = Vec::new();
if !cli.lines.is_empty() {
if opts.verbose {
for lr in &cli.lines {
eprintln!(" Line range: {}", lr);
}
}
let pr = toggle_line_ranges(path, &cli.lines, opts)?;
results.push(pr);
}
for section in &cli.sections {
if opts.verbose {
eprintln!(" Section: {}", section);
}
let pr = toggle_section(path, section, opts)?;
results.push(pr);
}
Ok(results)
}
fn count_changed_lines(original: &str, modified: &str) -> usize {
let orig_lines: Vec<&str> = original.lines().collect();
let mod_lines: Vec<&str> = modified.lines().collect();
let max_len = orig_lines.len().max(mod_lines.len());
let mut changed = 0;
for i in 0..max_len {
let a = orig_lines.get(i).copied().unwrap_or("");
let b = mod_lines.get(i).copied().unwrap_or("");
if a != b {
changed += 1;
}
}
changed
}
fn apply_changes(
path: &Path,
original: &str,
modified: &str,
opts: &ToggleOptions,
) -> Result<usize> {
let lines_changed = count_changed_lines(original, modified);
if opts.dry_run {
if !opts.json {
io::print_diff(path, original, modified);
}
if opts.interactive && std::io::stdin().is_terminal() {
eprintln!("(dry-run mode, no changes will be written)");
}
return Ok(lines_changed);
}
if opts.interactive {
if std::io::stdin().is_terminal() && !opts.json {
io::print_diff(path, original, modified);
}
eprint!("Modify {}? [y/N] ", path.display());
use std::io::Write;
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.map_err(|e| anyhow::anyhow!("Failed to read interactive input: {}", e))?;
if !answer.trim().eq_ignore_ascii_case("y") {
if opts.verbose {
eprintln!(" Skipped {}", path.display());
}
return Ok(0);
}
}
if let Some(ext) = opts.backup {
io::create_backup(path, ext)?;
}
io::write_file_encoded(
path,
modified,
opts.temp_suffix,
opts.no_dereference,
opts.encoding,
)?;
Ok(lines_changed)
}
fn resolve_comment_style(path: &Path, opts: &ToggleOptions) -> Result<core::CommentStyle> {
if !opts.comment_style_override.is_empty() {
let single = opts.comment_style_override[0].clone();
let (ms, me) = if opts.comment_style_override.len() == 3 {
(
Some(opts.comment_style_override[1].clone()),
Some(opts.comment_style_override[2].clone()),
)
} else {
(None, None)
};
return Ok(core::CommentStyle {
single_line: single,
multi_line_start: ms,
multi_line_end: me,
});
}
core::get_comment_style(path, opts.mode, opts.config)
}
fn toggle_line_ranges(
path: &Path,
line_range_specs: &[String],
opts: &ToggleOptions,
) -> Result<ProcessResult> {
let comment_style = resolve_comment_style(path, opts)?;
let content = io::read_file_encoded(path, opts.encoding)?;
let line_count = content.lines().count();
let mut ranges = Vec::new();
for spec in line_range_specs {
let (start_line, end_line) = core::parse_line_range(spec)?;
if start_line > line_count {
return Err(UsageError(format!(
"Start line {} is out of range (file has {} lines)",
start_line, line_count
))
.into());
}
ranges.push(core::LineRange::new(start_line, end_line));
}
if opts.to_end {
if let Some(last) = ranges.last_mut() {
last.end = line_count;
}
}
for range in &ranges {
if range.end > line_count {
return Err(UsageError(format!(
"End line {} is out of range (file has {} lines)",
range.end, line_count
))
.into());
}
}
let merged = core::merge_ranges(&ranges);
let force_mode = opts.force.as_deref();
let toggled = if opts.mode == "multi" {
let (ms, me) = match (
&comment_style.multi_line_start,
&comment_style.multi_line_end,
) {
(Some(s), Some(e)) => (s.as_str(), e.as_str()),
_ => {
return Err(UsageError(format!(
"Multi-line comments not supported for {}",
path.display()
))
.into());
}
};
core::toggle_comments_multi(&content, &merged, force_mode, ms, me)
} else {
core::toggle_comments_with_marker(&content, &merged, force_mode, &comment_style.single_line)
};
let result = io::normalize_eol(&toggled, opts.eol);
let lines_changed = apply_changes(path, &content, &result, opts)?;
Ok(ProcessResult {
action: "toggle_line_range".to_string(),
lines_changed,
section_id: None,
desc: None,
})
}
fn toggle_section(path: &Path, section_id: &str, opts: &ToggleOptions) -> Result<ProcessResult> {
if opts.verbose {
eprintln!(" Looking for section with ID={}", section_id);
}
let original_content = io::read_file_encoded(path, opts.encoding)?;
let modified = compute_section_changes(path, section_id, opts, &original_content)?;
let lines_changed = if modified == original_content {
if opts.verbose {
eprintln!(" No changes made to file");
}
0
} else {
if opts.verbose {
eprintln!(" File modified, writing changes back");
}
apply_changes(path, &original_content, &modified, opts)?
};
let (group, variant) = core::parse_id_parts(section_id);
let desc = core::discover_variants(&original_content, &group)
.into_iter()
.find(|s| match &variant {
Some(v) => s.id == format!("{group}:{v}"),
None => s.id == section_id || core::parse_id_parts(&s.id).1.is_some(),
})
.and_then(|s| s.desc);
if opts.verbose {
if let Some(ref d) = desc {
eprintln!(" Section desc: {}", d);
}
}
Ok(ProcessResult {
action: "toggle_section".to_string(),
lines_changed,
section_id: Some(section_id.to_string()),
desc,
})
}
fn run_scan(cli: &Cli) -> Result<()> {
let walk_opts = walk::WalkOptions {
verbose: cli.verbose,
..walk::WalkOptions::default()
};
let files = walk::collect_files(&cli.paths, true, &walk_opts)?;
for path in &cli.paths {
if !path.exists() {
eprintln!("Warning: '{}' does not exist", path.display());
}
}
let mut all_sections: Vec<core::ScanSectionInfo> = Vec::new();
for file_path in &files {
match io::read_file_encoded(file_path, &cli.encoding) {
Ok(content) => {
let sections = core::scan_sections(file_path, &content);
all_sections.extend(sections);
}
Err(e) => {
if cli.verbose {
eprintln!("Warning: skipping {}: {}", file_path.display(), e);
}
}
}
}
if cli.check {
let mut per_file: BTreeMap<PathBuf, Vec<core::ScanSectionInfo>> = BTreeMap::new();
for s in &all_sections {
per_file
.entry(PathBuf::from(&s.file))
.or_default()
.push(s.clone());
}
let per_file_vec: Vec<_> = per_file.into_iter().collect();
let issues = core::validate_sections(&per_file_vec, cli.pair);
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&issues).expect("Failed to serialize JSON")
);
} else {
print_check_results(&issues);
}
if check_has_errors(&issues) {
return Err(anyhow::anyhow!("validation failed"));
}
return Ok(());
}
if cli.json {
let root = core::build_scan_json(&all_sections);
println!(
"{}",
serde_json::to_string_pretty(&root).expect("Failed to serialize JSON")
);
} else if !cli.sections.is_empty() {
print_scan_detailed(&all_sections, &cli.sections);
} else if cli.recursive {
print_scan_summary(&all_sections);
} else {
print_scan_results(&all_sections);
}
Ok(())
}
fn print_check_results(issues: &[core::CheckIssue]) {
if issues.is_empty() {
println!("OK no issues found");
return;
}
for i in issues {
let tag = match i.level {
core::CheckLevel::Ok => "OK ",
core::CheckLevel::Warn => "WARN",
core::CheckLevel::Err => "ERR ",
};
let file_part = i
.file
.as_deref()
.map(|f| format!(" ({f})"))
.unwrap_or_default();
println!("{tag} {:<18} {}{file_part}", i.group, i.message);
}
}
fn check_has_errors(issues: &[core::CheckIssue]) -> bool {
issues
.iter()
.any(|i| matches!(i.level, core::CheckLevel::Err))
}
fn print_scan_summary(sections: &[core::ScanSectionInfo]) {
if sections.is_empty() {
println!("No toggle sections found.");
return;
}
println!(
"{:<20} {:<7} {:<7} {:<10} STATE",
"SECTION", "TYPE", "FILES", "VARIANTS"
);
println!("{}", "\u{2500}".repeat(60));
for s in core::summarize_scan(sections) {
let type_label = section_type_label(&s.section_type);
let variants = if matches!(s.section_type, core::SectionType::Solo) {
"—".to_string()
} else {
s.variant_count.to_string()
};
println!(
"{:<20} {:<7} {:<7} {:<10} {}",
s.group, type_label, s.file_count, variants, s.state
);
}
}
fn print_scan_detailed(sections: &[core::ScanSectionInfo], ids: &[String]) {
use std::collections::BTreeMap;
for id in ids {
let (group, variant) = core::parse_id_parts(id);
let in_scope: Vec<&core::ScanSectionInfo> = sections
.iter()
.filter(|s| {
s.group == group
&& match &variant {
Some(v) => s.variant.as_deref() == Some(v.as_str()),
None => true,
}
})
.collect();
if in_scope.is_empty() {
println!("No sections found for '{id}'.");
continue;
}
if let Some(sum) = core::summarize_scan(sections)
.into_iter()
.find(|s| s.group == group)
{
println!(
"GROUP: {} ({}, {} variants)\n",
sum.group,
section_type_label(&sum.section_type),
sum.variant_count.max(1)
);
}
let mut by_id: BTreeMap<&String, Vec<&core::ScanSectionInfo>> = BTreeMap::new();
for s in in_scope {
by_id.entry(&s.id).or_default().push(s);
}
for (vid, items) in by_id {
let state = items[0].state.clone();
println!(" {vid} [{state}]");
for it in items {
let end = it.end_line.map_or("?".to_string(), |e| e.to_string());
println!(" {:<40} lines {}-{}", it.file, it.start_line, end);
}
println!();
}
}
}
fn section_type_label(t: &core::SectionType) -> &'static str {
match t {
core::SectionType::Solo => "solo",
core::SectionType::Pair => "pair",
core::SectionType::Group => "group",
}
}
fn print_scan_results(sections: &[core::ScanSectionInfo]) {
if sections.is_empty() {
println!("No toggle sections found.");
return;
}
println!(
"{:<20} {:<7} {:<12} {:<14} DESCRIPTION",
"SECTION", "TYPE", "STATE", "LINES"
);
println!("{}", "\u{2500}".repeat(80));
let summaries = core::summarize_scan(sections);
for summary in &summaries {
let mut items: Vec<&core::ScanSectionInfo> = sections
.iter()
.filter(|s| s.group == summary.group)
.collect();
items.sort_by_key(|s| (s.file.clone(), s.start_line));
let type_label = section_type_label(&summary.section_type);
for s in items {
let lines = match s.end_line {
Some(e) => format!("{}-{}", s.start_line, e),
None => format!("{}-?", s.start_line),
};
let desc = s.description.as_deref().unwrap_or("");
println!(
"{:<20} {:<7} {:<12} {:<14} {}",
s.id, type_label, s.state, lines, desc
);
}
}
}