use anyhow::{Context, Result, bail};
use std::collections::HashSet;
use std::fs;
use crate::registries::{all_pnames, find_plugin, parse_name};
const CONFIG_FILE: &str = "rsconstruct.toml";
fn load_doc() -> Result<toml_edit::DocumentMut> {
let content =
fs::read_to_string(CONFIG_FILE).with_context(|| format!("Failed to read {CONFIG_FILE}"))?;
content
.parse()
.with_context(|| format!("Failed to parse {CONFIG_FILE}"))
}
fn save_doc(doc: &toml_edit::DocumentMut) -> Result<()> {
fs::write(CONFIG_FILE, doc.to_string())
.with_context(|| format!("Failed to write {CONFIG_FILE}"))
}
fn processor_table(doc: &mut toml_edit::DocumentMut) -> Result<&mut toml_edit::Table> {
doc.entry("processor")
.or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
.as_table_mut()
.context("[processor] must be a table")
}
fn validate_name(name: &str) -> Result<()> {
match parse_name(name) {
Some(parsed) if parsed.instance.is_none() && find_plugin(name).is_some() => Ok(()),
_ => bail!(
"Unknown processor '{name}'. Processors are named processor.<type>.<name>; run \
'rsconstruct processor list' to see them."
),
}
}
fn key_path(section: &str, name: &str) -> Vec<String> {
let prefix = format!("{section}.");
let rest = name.strip_prefix(&prefix).unwrap_or(name);
rest.split('.').map(str::to_string).collect()
}
fn ensure_table<'a>(
table: &'a mut toml_edit::Table,
path: &[String],
) -> Result<&'a mut toml_edit::Table> {
let mut current = table;
for key in path {
current = current
.entry(key)
.or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
.as_table_mut()
.with_context(|| format!("'{key}' must be a table"))?;
}
Ok(current)
}
fn get_table<'a>(
table: &'a mut toml_edit::Table,
path: &[String],
) -> Option<&'a mut toml_edit::Table> {
let mut current = table;
for key in path {
current = current.get_mut(key)?.as_table_mut()?;
}
Some(current)
}
fn has_section(table: &mut toml_edit::Table, section: &str, name: &str) -> bool {
get_table(table, &key_path(section, name)).is_some()
}
fn add_section(table: &mut toml_edit::Table, section: &str, name: &str) -> Result<bool> {
if has_section(table, section, name) {
return Ok(false);
}
let path = key_path(section, name);
let (parents, last) = path.split_at(path.len() - 1);
let parent = ensure_table(table, parents)?;
parent.insert(&last[0], toml_edit::Item::Table(toml_edit::Table::new()));
Ok(true)
}
fn remove_section(table: &mut toml_edit::Table, section: &str, name: &str) -> bool {
let path = key_path(section, name);
fn remove_at(table: &mut toml_edit::Table, path: &[String]) -> bool {
let Some((first, rest)) = path.split_first() else {
return false;
};
if rest.is_empty() {
return table.remove(first).is_some();
}
let Some(child) = table.get_mut(first).and_then(|t| t.as_table_mut()) else {
return false;
};
let removed = remove_at(child, rest);
if removed && child.is_empty() {
table.remove(first);
}
removed
}
remove_at(table, &path)
}
pub fn disable_all() -> Result<()> {
let mut doc = load_doc()?;
let table = processor_table(&mut doc)?;
let count = remove_all_sections(table);
save_doc(&doc)?;
println!("Removed {count} processor sections from {CONFIG_FILE}.");
Ok(())
}
fn remove_all_sections(table: &mut toml_edit::Table) -> usize {
fn count_sections(name_table: &toml_edit::Table) -> usize {
let instances = name_table
.iter()
.filter(|(_, item)| item.is_table())
.count();
if instances > 0 && instances == name_table.len() {
instances
} else {
1
}
}
let count: usize = table
.iter()
.filter_map(|(_, type_item)| type_item.as_table())
.flat_map(|type_table| type_table.iter())
.filter_map(|(_, name_item)| name_item.as_table())
.map(count_sections)
.sum();
let keys: Vec<String> = table.iter().map(|(k, _)| k.to_string()).collect();
for key in &keys {
table.remove(key);
}
count
}
pub fn enable_all() -> Result<()> {
let mut doc = load_doc()?;
let table = processor_table(&mut doc)?;
let mut count = 0;
for name in all_pnames() {
if add_section(table, "processor", &name)? {
count += 1;
}
}
save_doc(&doc)?;
println!("Added {count} processor sections to {CONFIG_FILE}.");
Ok(())
}
pub fn disable(name: &str) -> Result<()> {
validate_name(name)?;
let mut doc = load_doc()?;
let table = processor_table(&mut doc)?;
if remove_section(table, "processor", name) {
save_doc(&doc)?;
println!("Removed processor '{name}'.");
} else {
println!("Processor '{name}' is not declared.");
}
Ok(())
}
pub fn enable(name: &str) -> Result<()> {
validate_name(name)?;
let mut doc = load_doc()?;
let table = processor_table(&mut doc)?;
if add_section(table, "processor", name)? {
save_doc(&doc)?;
println!("Added processor '{name}'.");
} else {
println!("Processor '{name}' is already declared.");
}
Ok(())
}
pub fn enable_detected(detected: &HashSet<String>) -> Result<()> {
let mut doc = load_doc()?;
let table = processor_table(&mut doc)?;
let mut count = 0;
for name in detected {
if add_section(table, "processor", name)? {
count += 1;
}
}
save_doc(&doc)?;
println!("Added {count} detected processor sections to {CONFIG_FILE}.");
Ok(())
}
pub fn reset() -> Result<()> {
disable_all()
}
pub fn only(names: &[String]) -> Result<()> {
for name in names {
validate_name(name)?;
}
let mut doc = load_doc()?;
let table = processor_table(&mut doc)?;
remove_all_sections(table);
for name in names {
add_section(table, "processor", name)?;
}
save_doc(&doc)?;
println!("Active processors: {}", names.join(", "));
Ok(())
}
pub fn minimal(detected: &HashSet<String>) -> Result<()> {
let mut doc = load_doc()?;
let table = processor_table(&mut doc)?;
remove_all_sections(table);
for name in detected {
add_section(table, "processor", name)?;
}
save_doc(&doc)?;
if detected.is_empty() {
println!("No processors detected.");
} else {
let mut names: Vec<&String> = detected.iter().collect();
names.sort();
println!(
"Minimal config: {}",
names
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
Ok(())
}
pub fn enable_if_available(available: &HashSet<String>) -> Result<()> {
enable_detected(available)
}
pub fn auto(available: &HashSet<String>) -> Result<()> {
let mut doc = load_doc()?;
let table = processor_table(&mut doc)?;
let mut added = Vec::new();
for name in available {
if add_section(table, "processor", name)? {
added.push(name.as_str());
}
}
if added.is_empty() {
println!("No new processors to add (all detected processors are already declared).");
} else {
save_doc(&doc)?;
let mut added = added;
added.sort_unstable();
println!("Added {} processor(s): {}", added.len(), added.join(", "));
}
Ok(())
}
fn analyzer_table(doc: &mut toml_edit::DocumentMut) -> Result<&mut toml_edit::Table> {
doc.entry("analyzer")
.or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
.as_table_mut()
.context("[analyzer] must be a table")
}
fn set_enabled_iname(
section_table: &mut toml_edit::Table,
iname: &str,
value: bool,
section: &str,
) -> Result<()> {
match get_table(section_table, &key_path(section, iname)) {
Some(t) => {
t.insert("enabled", toml_edit::value(value));
Ok(())
}
None => bail!("{section} '{iname}' is not declared in rsconstruct.toml."),
}
}
pub fn delete_processor(iname: &str) -> Result<()> {
let mut doc = load_doc()?;
let table = processor_table(&mut doc)?;
if remove_section(table, "processor", iname) {
save_doc(&doc)?;
println!("Deleted processor '{iname}'.");
} else {
println!("Processor '{iname}' is not declared.");
}
Ok(())
}
pub fn disable_processor(iname: &str) -> Result<()> {
let mut doc = load_doc()?;
let table = processor_table(&mut doc)?;
set_enabled_iname(table, iname, false, "processor")?;
save_doc(&doc)?;
println!("Disabled processor '{iname}'.");
Ok(())
}
pub fn enable_processor(iname: &str) -> Result<()> {
let mut doc = load_doc()?;
let table = processor_table(&mut doc)?;
set_enabled_iname(table, iname, true, "processor")?;
save_doc(&doc)?;
println!("Enabled processor '{iname}'.");
Ok(())
}
pub fn delete_analyzer(iname: &str) -> Result<()> {
let mut doc = load_doc()?;
let table = analyzer_table(&mut doc)?;
if remove_section(table, "analyzer", iname) {
save_doc(&doc)?;
println!("Deleted analyzer '{iname}'.");
} else {
println!("Analyzer '{iname}' is not declared.");
}
Ok(())
}
pub fn disable_analyzer(iname: &str) -> Result<()> {
let mut doc = load_doc()?;
let table = analyzer_table(&mut doc)?;
set_enabled_iname(table, iname, false, "analyzer")?;
save_doc(&doc)?;
println!("Disabled analyzer '{iname}'.");
Ok(())
}
pub fn enable_analyzer(iname: &str) -> Result<()> {
let mut doc = load_doc()?;
let table = analyzer_table(&mut doc)?;
set_enabled_iname(table, iname, true, "analyzer")?;
save_doc(&doc)?;
println!("Enabled analyzer '{iname}'.");
Ok(())
}
pub fn remove_no_file_processors(empty_processors: &[String]) -> Result<()> {
if empty_processors.is_empty() {
println!("All processors match at least one file.");
return Ok(());
}
let mut doc = load_doc()?;
let table = processor_table(&mut doc)?;
let mut removed = Vec::new();
for name in empty_processors {
if remove_section(table, "processor", name) {
removed.push(name.as_str());
}
}
if removed.is_empty() {
println!("No processors to remove.");
} else {
save_doc(&doc)?;
println!(
"Removed {} processor(s) with no files: {}",
removed.len(),
removed.join(", ")
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(src: &str) -> toml_edit::DocumentMut {
src.parse().unwrap()
}
#[test]
fn sections_nest_by_name_segment() {
let mut d = doc("");
let table = processor_table(&mut d).unwrap();
assert!(add_section(table, "processor", "processor.checker.ruff").unwrap());
assert!(!add_section(table, "processor", "processor.checker.ruff").unwrap());
assert!(add_section(table, "processor", "processor.checker.pylint.core").unwrap());
let text = d.to_string();
assert!(text.contains("[processor.checker.ruff]"), "{text}");
assert!(text.contains("[processor.checker.pylint.core]"), "{text}");
let table = processor_table(&mut d).unwrap();
assert!(remove_section(table, "processor", "processor.checker.ruff"));
assert!(remove_section(
table,
"processor",
"processor.checker.pylint.core"
));
assert!(!remove_section(
table,
"processor",
"processor.checker.pylint.core"
));
assert!(
table.get("checker").is_none(),
"empty type table must go: {d}"
);
}
#[test]
fn enabled_flag_lands_on_the_named_section() {
let mut d = doc("[processor.checker.pylint.core]\nargs = []\n[analyzer.tera]\n");
let table = processor_table(&mut d).unwrap();
set_enabled_iname(table, "processor.checker.pylint.core", false, "processor").unwrap();
assert!(set_enabled_iname(table, "processor.checker.ruff", false, "processor").is_err());
let table = analyzer_table(&mut d).unwrap();
set_enabled_iname(table, "tera", false, "analyzer").unwrap();
let text = d.to_string();
assert!(
text.contains("[processor.checker.pylint.core]\nargs = []\nenabled = false"),
"{text}"
);
assert!(text.contains("[analyzer.tera]\nenabled = false"), "{text}");
}
#[test]
fn remove_all_counts_processors_and_instances() {
let mut d = doc(
"[processor.checker.ruff]\n[processor.checker.pylint.core]\n[processor.checker.pylint.tests]\n[processor.generator.tera]\n",
);
let table = processor_table(&mut d).unwrap();
assert_eq!(remove_all_sections(table), 4);
assert!(table.is_empty());
}
}