#![warn(missing_docs)]
mod ast_formatter;
mod formatter;
mod utils;
use reinhardt_admin_cli::migrate_v2;
use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand, ValueEnum};
use colored::Colorize;
use reinhardt_commands::{
BaseCommand, CommandContext, CommandResult, PluginDisableCommand, PluginEnableCommand,
PluginInfoCommand, PluginInstallCommand, PluginListCommand, PluginRemoveCommand,
PluginSearchCommand, PluginUpdateCommand, StartAppCommand, StartProjectCommand,
};
use std::process;
use zeroize::Zeroize;
#[derive(Clone, Debug, ValueEnum)]
enum TemplateType {
Rest,
Pages,
}
enum ResolvedProjectType {
Pages,
Rest,
}
fn resolve_project_type(
template: Option<TemplateType>,
with_pages: bool,
with_rest: bool,
) -> ResolvedProjectType {
match (template, with_pages, with_rest) {
(Some(TemplateType::Pages), _, _) | (_, true, _) => ResolvedProjectType::Pages,
(Some(TemplateType::Rest), _, _) | (_, _, true) => ResolvedProjectType::Rest,
_ => unreachable!(),
}
}
#[derive(Parser)]
#[command(name = "reinhardt-admin")]
#[command(about = "Reinhardt project administration utility", long_about = None)]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(short, long, action = clap::ArgAction::Count)]
verbosity: u8,
}
#[derive(Subcommand)]
enum Commands {
#[command(group(
clap::ArgGroup::new("project_type")
.required(true)
.args(["template", "with_pages", "with_rest"])
))]
Startproject {
#[arg(value_name = "PROJECT_NAME")]
name: String,
#[arg(value_name = "DIRECTORY")]
directory: Option<String>,
#[arg(long, value_name = "TYPE", value_enum, group = "project_type")]
template: Option<TemplateType>,
#[arg(long, group = "project_type")]
with_pages: bool,
#[arg(long, group = "project_type")]
with_rest: bool,
#[arg(long, value_name = "DIR")]
template_dir: Option<String>,
},
#[command(group(
clap::ArgGroup::new("app_type")
.required(true)
.args(["template", "with_pages", "with_rest"])
))]
Startapp {
#[arg(value_name = "APP_NAME")]
name: String,
#[arg(value_name = "DIRECTORY")]
directory: Option<String>,
#[arg(long, value_name = "TYPE", value_enum, group = "app_type")]
template: Option<TemplateType>,
#[arg(long, group = "app_type")]
with_pages: bool,
#[arg(long, group = "app_type")]
with_rest: bool,
#[arg(long, value_name = "DIR")]
template_dir: Option<String>,
},
Plugin {
#[command(subcommand)]
subcommand: PluginCommands,
},
Fmt {
#[arg(value_name = "PATH")]
path: PathBuf,
#[arg(long)]
check: bool,
#[arg(long, default_value = "true", action = clap::ArgAction::Set)]
with_rustfmt: bool,
#[arg(long, value_name = "PATH")]
config_path: Option<PathBuf>,
#[arg(long, value_name = "EDITION")]
edition: Option<String>,
#[arg(long, value_name = "EDITION")]
style_edition: Option<String>,
#[arg(long, value_name = "OPTIONS")]
config: Option<String>,
#[arg(long, value_name = "WHEN", default_value = "auto")]
color: String,
#[arg(long)]
backup: bool,
},
FmtAll {
#[arg(long)]
check: bool,
#[arg(long, value_name = "PATH")]
config_path: Option<PathBuf>,
#[arg(long, value_name = "EDITION")]
edition: Option<String>,
#[arg(long, value_name = "EDITION")]
style_edition: Option<String>,
#[arg(long, value_name = "OPTIONS")]
config: Option<String>,
#[arg(long, value_name = "WHEN", default_value = "auto")]
color: String,
#[arg(long)]
backup: bool,
},
MigrateManoucheV2(migrate_v2::MigrateV2Args),
}
#[derive(Subcommand)]
enum PluginCommands {
List {
#[arg(short, long)]
verbose: bool,
#[arg(long)]
enabled: bool,
#[arg(long)]
disabled: bool,
#[arg(long)]
project_root: Option<String>,
},
Info {
#[arg(value_name = "NAME")]
name: String,
#[arg(long)]
remote: bool,
#[arg(long)]
project_root: Option<String>,
},
Install {
#[arg(value_name = "NAME")]
name: String,
#[arg(long)]
version: Option<String>,
#[arg(short, long)]
yes: bool,
#[arg(long)]
project_root: Option<String>,
},
Remove {
#[arg(value_name = "NAME")]
name: String,
#[arg(long)]
purge: bool,
#[arg(short, long)]
yes: bool,
#[arg(long)]
project_root: Option<String>,
},
Enable {
#[arg(value_name = "NAME")]
name: String,
#[arg(long)]
project_root: Option<String>,
},
Disable {
#[arg(value_name = "NAME")]
name: String,
#[arg(long)]
project_root: Option<String>,
},
Search {
#[arg(value_name = "QUERY")]
query: String,
#[arg(long, default_value = "10")]
limit: u64,
},
Update {
#[arg(value_name = "NAME")]
name: Option<String>,
#[arg(long)]
all: bool,
#[arg(short, long)]
yes: bool,
#[arg(long)]
project_root: Option<String>,
},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let result = match cli.command {
Commands::Startproject {
name,
directory,
template,
with_pages,
with_rest,
template_dir,
} => {
run_startproject(
name,
directory,
template,
with_pages,
with_rest,
template_dir,
cli.verbosity,
)
.await
}
Commands::Startapp {
name,
directory,
template,
with_pages,
with_rest,
template_dir,
} => {
run_startapp(
name,
directory,
template,
with_pages,
with_rest,
template_dir,
cli.verbosity,
)
.await
}
Commands::Plugin { subcommand } => run_plugin(subcommand, cli.verbosity).await,
Commands::Fmt {
path,
check,
with_rustfmt,
config_path,
edition,
style_edition,
config,
color,
backup,
} => run_fmt(
path,
check,
with_rustfmt,
config_path,
edition,
style_edition,
config,
color,
backup,
cli.verbosity,
),
Commands::FmtAll {
check,
config_path,
edition,
style_edition,
config,
color,
backup,
} => run_fmt_all(
check,
config_path,
edition,
style_edition,
config,
color,
backup,
cli.verbosity,
),
Commands::MigrateManoucheV2(args) => {
if let Err(e) = migrate_v2::run(args) {
eprintln!("{}", format!("error: {e}").red());
process::exit(1);
}
Ok(())
}
};
if let Err(e) = result {
eprintln!("Error: {}", e);
process::exit(1);
}
}
async fn run_startproject(
name: String,
directory: Option<String>,
template: Option<TemplateType>,
with_pages: bool,
with_rest: bool,
template_dir: Option<String>,
verbosity: u8,
) -> CommandResult<()> {
let mut ctx = CommandContext::default();
ctx.set_verbosity(verbosity);
ctx.add_arg(name);
if let Some(dir) = directory {
ctx.add_arg(dir);
}
match resolve_project_type(template, with_pages, with_rest) {
ResolvedProjectType::Pages => ctx.set_option("with-pages".to_string(), "true".to_string()),
ResolvedProjectType::Rest => ctx.set_option("restful".to_string(), "true".to_string()),
}
if let Some(td) = template_dir {
ctx.set_option("template-dir".to_string(), td);
}
let cmd = StartProjectCommand;
cmd.execute(&ctx).await
}
async fn run_startapp(
name: String,
directory: Option<String>,
template: Option<TemplateType>,
with_pages: bool,
with_rest: bool,
template_dir: Option<String>,
verbosity: u8,
) -> CommandResult<()> {
let mut ctx = CommandContext::default();
ctx.set_verbosity(verbosity);
ctx.add_arg(name);
if let Some(dir) = directory {
ctx.add_arg(dir);
}
match resolve_project_type(template, with_pages, with_rest) {
ResolvedProjectType::Pages => ctx.set_option("with-pages".to_string(), "true".to_string()),
ResolvedProjectType::Rest => ctx.set_option("restful".to_string(), "true".to_string()),
}
if let Some(td) = template_dir {
ctx.set_option("template-dir".to_string(), td);
}
let cmd = StartAppCommand;
cmd.execute(&ctx).await
}
async fn run_plugin(subcommand: PluginCommands, verbosity: u8) -> CommandResult<()> {
match subcommand {
PluginCommands::List {
verbose,
enabled,
disabled,
project_root,
} => {
let mut ctx = CommandContext::default();
ctx.set_verbosity(verbosity);
if verbose {
ctx.set_option("verbose".to_string(), "true".to_string());
}
if enabled {
ctx.set_option("enabled".to_string(), "true".to_string());
}
if disabled {
ctx.set_option("disabled".to_string(), "true".to_string());
}
if let Some(root) = project_root {
ctx.set_option("project-root".to_string(), root);
}
PluginListCommand.execute(&ctx).await
}
PluginCommands::Info {
name,
remote,
project_root,
} => {
let mut ctx = CommandContext::default();
ctx.set_verbosity(verbosity);
ctx.add_arg(name);
if remote {
ctx.set_option("remote".to_string(), "true".to_string());
}
if let Some(root) = project_root {
ctx.set_option("project-root".to_string(), root);
}
PluginInfoCommand.execute(&ctx).await
}
PluginCommands::Install {
name,
version,
yes,
project_root,
} => {
let mut ctx = CommandContext::default();
ctx.set_verbosity(verbosity);
ctx.add_arg(name);
if let Some(v) = version {
ctx.set_option("version".to_string(), v);
}
if yes {
ctx.set_option("yes".to_string(), "true".to_string());
}
if let Some(root) = project_root {
ctx.set_option("project-root".to_string(), root);
}
PluginInstallCommand.execute(&ctx).await
}
PluginCommands::Remove {
name,
purge,
yes,
project_root,
} => {
let mut ctx = CommandContext::default();
ctx.set_verbosity(verbosity);
ctx.add_arg(name);
if purge {
ctx.set_option("purge".to_string(), "true".to_string());
}
if yes {
ctx.set_option("yes".to_string(), "true".to_string());
}
if let Some(root) = project_root {
ctx.set_option("project-root".to_string(), root);
}
PluginRemoveCommand.execute(&ctx).await
}
PluginCommands::Enable { name, project_root } => {
let mut ctx = CommandContext::default();
ctx.set_verbosity(verbosity);
ctx.add_arg(name);
if let Some(root) = project_root {
ctx.set_option("project-root".to_string(), root);
}
PluginEnableCommand.execute(&ctx).await
}
PluginCommands::Disable { name, project_root } => {
let mut ctx = CommandContext::default();
ctx.set_verbosity(verbosity);
ctx.add_arg(name);
if let Some(root) = project_root {
ctx.set_option("project-root".to_string(), root);
}
PluginDisableCommand.execute(&ctx).await
}
PluginCommands::Search { query, limit } => {
let mut ctx = CommandContext::default();
ctx.set_verbosity(verbosity);
ctx.add_arg(query);
ctx.set_option("limit".to_string(), limit.to_string());
PluginSearchCommand.execute(&ctx).await
}
PluginCommands::Update {
name,
all,
yes,
project_root,
} => {
let mut ctx = CommandContext::default();
ctx.set_verbosity(verbosity);
if let Some(n) = name {
ctx.add_arg(n);
}
if all {
ctx.set_option("all".to_string(), "true".to_string());
}
if yes {
ctx.set_option("yes".to_string(), "true".to_string());
}
if let Some(root) = project_root {
ctx.set_option("project-root".to_string(), root);
}
PluginUpdateCommand.execute(&ctx).await
}
}
}
#[allow(clippy::too_many_arguments)] fn run_fmt(
path: PathBuf,
check: bool,
with_rustfmt: bool,
config_path: Option<PathBuf>,
edition: Option<String>,
style_edition: Option<String>,
config: Option<String>,
color: String,
backup: bool,
verbosity: u8,
) -> CommandResult<()> {
use ast_formatter::{AstPageFormatter, RustfmtOptions};
use formatter::collect_rust_files;
if let Some(ref cp) = config_path {
validate_config_path(cp).map_err(|e| {
reinhardt_commands::CommandError::ExecutionError(format!("Invalid config path: {}", e))
})?;
check_file_size(cp, MAX_CONFIG_FILE_SIZE)
.map_err(reinhardt_commands::CommandError::ExecutionError)?;
}
let files = collect_rust_files(&path).map_err(|e| {
reinhardt_commands::CommandError::ExecutionError(format!(
"Failed to collect files in {}: {}",
display_path(&path),
sanitize_error(&e)
))
})?;
if files.is_empty() {
if verbosity > 0 {
println!("No Rust files found in {}", display_path(&path));
}
return Ok(());
}
let resolved_config_path = config_path.or_else(|| find_rustfmt_config(&path));
let options = RustfmtOptions {
config_path: resolved_config_path.clone(),
edition,
style_edition,
config,
color: Some(color),
};
if verbosity > 0
&& let Some(ref p) = resolved_config_path
{
println!("Using rustfmt config: {}", display_path(p));
}
let formatter = if let Some(ref config) = resolved_config_path {
AstPageFormatter::with_config(config.clone())
} else {
AstPageFormatter::new()
};
let mut formatted_count = 0;
let mut unchanged_count = 0;
let mut ignored_count = 0;
let mut error_count = 0;
let total_files = files.len();
for (index, file_path) in files.iter().enumerate() {
let progress = format!("[{}/{}]", index + 1, total_files);
if let Err(e) = check_file_size(file_path, MAX_SOURCE_FILE_SIZE) {
eprintln!(
"{} {} {}: {}",
progress.bright_blue(),
"Skipped:".yellow(),
display_path(file_path),
e
);
error_count += 1;
continue;
}
let original_content = std::fs::read_to_string(file_path).map_err(|e| {
reinhardt_commands::CommandError::ExecutionError(format!(
"Failed to read {}: {}",
mask_path(file_path),
sanitize_error(&e.to_string())
))
})?;
if formatter.has_ignore_all_marker(&original_content) {
ignored_count += 1;
if verbosity > 0 {
println!(
"{} {} {} (reinhardt-fmt: ignore-all)",
progress.bright_blue(),
"Ignored:".yellow(),
display_path(file_path)
);
}
continue;
}
let final_result = if with_rustfmt {
let protect_result = formatter.protect_page_macros(&original_content);
let rustfmt_output = match run_rustfmt(&protect_result.protected_content, &options) {
Ok(output) => output,
Err(e) => {
eprintln!(
"{} {} {}: rustfmt failed: {}",
progress.bright_blue(),
"Error".red(),
display_path(file_path),
sanitize_error(&e)
);
error_count += 1;
continue;
}
};
let restored =
AstPageFormatter::restore_page_macros(&rustfmt_output, &protect_result.backups);
match formatter.format(&restored) {
Ok(result) => result.content,
Err(e) => {
eprintln!(
"{} {} {}: page! format failed: {}",
progress.bright_blue(),
"Error".red(),
display_path(file_path),
sanitize_error(&e.to_string())
);
error_count += 1;
continue;
}
}
} else {
match formatter.format(&original_content) {
Ok(result) => {
if let Some(reason) = &result.skipped {
ignored_count += 1;
println!(
"{} {} {} ({})",
progress.bright_blue(),
"Ignored:".yellow(),
display_path(file_path),
reason
);
continue;
}
result.content
}
Err(e) => {
eprintln!(
"{} {} {}: {}",
progress.bright_blue(),
"Error".red(),
display_path(file_path),
sanitize_error(&e.to_string())
);
error_count += 1;
continue;
}
}
};
if final_result != original_content {
if check {
println!("{} Would format: {}", progress, display_path(file_path));
formatted_count += 1;
} else {
let mut _backup_guard = None;
if backup {
let backup_path = create_temp_backup_path(file_path);
create_secure_backup(file_path, &backup_path).map_err(|e| {
reinhardt_commands::CommandError::ExecutionError(format!(
"Failed to backup {}: {}",
mask_path(file_path),
e
))
})?;
_backup_guard = Some(utils::BackupGuard::new(backup_path));
}
utils::atomic_write(file_path, &final_result).map_err(|e| {
reinhardt_commands::CommandError::ExecutionError(format!(
"Failed to write {}: {}",
mask_path(file_path),
sanitize_error(&e.to_string())
))
})?;
if let Some(ref mut guard) = _backup_guard {
guard.commit();
}
println!(
"{} {} {}",
progress.bright_blue(),
"Formatted:".green(),
display_path(file_path)
);
formatted_count += 1;
}
} else {
unchanged_count += 1;
if verbosity > 0 {
println!(
"{} {} {}",
progress.bright_blue(),
"Unchanged:".dimmed(),
display_path(file_path)
);
}
}
}
println!();
if check {
println!(
"{}: {} would be formatted, {} unchanged, {} ignored, {} errors",
"Summary".bright_cyan(),
formatted_count.to_string().yellow(),
unchanged_count,
ignored_count,
if error_count > 0 {
error_count.to_string().red()
} else {
error_count.to_string().green()
}
);
} else {
println!(
"{}: {} formatted, {} unchanged, {} ignored, {} errors",
"Summary".bright_cyan(),
if formatted_count > 0 {
formatted_count.to_string().green()
} else {
formatted_count.to_string().dimmed()
},
unchanged_count,
ignored_count,
if error_count > 0 {
error_count.to_string().red()
} else {
error_count.to_string().dimmed()
}
);
}
if check && formatted_count > 0 {
return Err(reinhardt_commands::CommandError::ExecutionError(
"Some files are not properly formatted".to_string(),
));
}
if error_count > 0 {
return Err(reinhardt_commands::CommandError::ExecutionError(format!(
"{} files had formatting errors",
error_count
)));
}
Ok(())
}
#[allow(clippy::too_many_arguments)] fn run_fmt_all(
check: bool,
config_path: Option<PathBuf>,
edition: Option<String>,
style_edition: Option<String>,
config: Option<String>,
color: String,
backup: bool,
verbosity: u8,
) -> CommandResult<()> {
use ast_formatter::AstPageFormatter;
use formatter::collect_rust_files;
use std::collections::HashMap;
use std::process::{Command, Stdio};
if let Some(ref cp) = config_path {
validate_config_path(cp).map_err(|e| {
reinhardt_commands::CommandError::ExecutionError(format!("Invalid config path: {}", e))
})?;
check_file_size(cp, MAX_CONFIG_FILE_SIZE)
.map_err(reinhardt_commands::CommandError::ExecutionError)?;
}
let project_root = find_project_root().ok_or_else(|| {
reinhardt_commands::CommandError::ExecutionError(
"Could not find project root (no Cargo.toml found)".to_string(),
)
})?;
if verbosity > 0 {
println!("Project root: {}", display_path(&project_root));
}
let files = collect_rust_files(&project_root).map_err(|e| {
reinhardt_commands::CommandError::ExecutionError(format!(
"Failed to collect files: {}",
sanitize_error(&e)
))
})?;
if files.is_empty() {
if verbosity > 0 {
println!("No Rust files found in {}", display_path(&project_root));
}
return Ok(());
}
let formatter = AstPageFormatter::new();
let lock_path = project_root.join(".reinhardt-fmt.lock");
let _lock_file = acquire_format_lock(&lock_path).map_err(|e| {
reinhardt_commands::CommandError::ExecutionError(format!(
"Failed to acquire format lock: {}. Another format operation may be in progress.",
e
))
})?;
let mut original_contents: HashMap<PathBuf, String> = HashMap::new();
let mut protected_files: Vec<(PathBuf, Vec<ast_formatter::PageMacroBackup>)> = Vec::new();
let total_files = files.len();
let mut page_macro_count = 0;
if verbosity > 0 {
println!(
"{} Phase 1: Protecting page! macros...",
"[Step 1/3]".bright_blue()
);
}
for file_path in &files {
if let Err(e) = check_file_size(file_path, MAX_SOURCE_FILE_SIZE) {
eprintln!("{} Skipping oversized file: {}", "Warning:".yellow(), e);
continue;
}
let original_content = std::fs::read_to_string(file_path).map_err(|e| {
reinhardt_commands::CommandError::ExecutionError(format!(
"Failed to read {}: {}",
mask_path(file_path),
sanitize_error(&e.to_string())
))
})?;
original_contents.insert(file_path.clone(), original_content.clone());
if formatter.has_ignore_all_marker(&original_content) {
continue;
}
if !original_content.contains("page!(")
&& !original_content.contains("form!(")
&& !original_content.contains("form!{")
{
continue;
}
let protect_result = formatter.protect_page_macros(&original_content);
if !protect_result.backups.is_empty() {
page_macro_count += protect_result.backups.len();
utils::atomic_write(file_path, &protect_result.protected_content).map_err(|e| {
reinhardt_commands::CommandError::ExecutionError(format!(
"Failed to write protected content to {}: {}",
mask_path(file_path),
sanitize_error(&e.to_string())
))
})?;
protected_files.push((file_path.clone(), protect_result.backups));
}
}
if verbosity > 0 {
println!(
" Protected {} page! macros in {} files",
page_macro_count,
protected_files.len()
);
}
if verbosity > 0 {
println!(
"{} Phase 2: Running cargo fmt --all...",
"[Step 2/3]".bright_blue()
);
}
let mut cmd = Command::new("cargo");
cmd.arg("fmt").arg("--all");
cmd.current_dir(&project_root);
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
let has_rustfmt_options = config_path.is_some()
|| edition.is_some()
|| style_edition.is_some()
|| config.is_some()
|| color != "auto";
if has_rustfmt_options {
cmd.arg("--");
if let Some(ref path) = config_path {
cmd.arg("--config-path").arg(path);
}
if let Some(ref ed) = edition {
cmd.arg("--edition").arg(ed);
}
if let Some(ref se) = style_edition {
cmd.arg("--style-edition").arg(se);
}
if let Some(ref cfg) = config {
cmd.arg("--config").arg(cfg);
}
if color != "auto" {
cmd.arg("--color").arg(&color);
}
}
if verbosity > 1 {
println!(" Running: cargo fmt --all");
}
let cargo_fmt_result = cmd.output();
let modified_on_disk: Vec<PathBuf> = protected_files.iter().map(|(p, _)| p.clone()).collect();
let output = match cargo_fmt_result {
Ok(output) => output,
Err(e) => {
eprintln!(
"{} cargo fmt failed: {}",
"Error:".red(),
sanitize_error(&e.to_string())
);
let rollback_errors = utils::rollback_files(&modified_on_disk, &original_contents);
utils::report_rollback_errors(&rollback_errors);
return Err(reinhardt_commands::CommandError::ExecutionError(
"cargo fmt failed to execute".to_string(),
));
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let sanitized_stderr = sanitize_error(&stderr);
eprintln!(
"{} cargo fmt exited with error: {}",
"Error:".red(),
sanitized_stderr
);
let rollback_errors = utils::rollback_files(&modified_on_disk, &original_contents);
utils::report_rollback_errors(&rollback_errors);
return Err(reinhardt_commands::CommandError::ExecutionError(format!(
"cargo fmt exited with error: {}",
sanitized_stderr
)));
}
if verbosity > 0 {
println!(
"{} Phase 3: Restoring and formatting page! macros...",
"[Step 3/3]".bright_blue()
);
}
let mut error_count = 0;
for (file_path, backups) in &protected_files {
let formatted_content = match std::fs::read_to_string(file_path) {
Ok(content) => content,
Err(e) => {
eprintln!(
"{} Failed to read {}: {}",
"Error:".red(),
display_path(file_path),
sanitize_error(&e.to_string())
);
error_count += 1;
continue;
}
};
let restored = AstPageFormatter::restore_page_macros(&formatted_content, backups);
let final_result = match formatter.format(&restored) {
Ok(result) => result.content,
Err(e) => {
eprintln!(
"{} page! format failed for {}: {}",
"Error:".red(),
display_path(file_path),
sanitize_error(&e.to_string())
);
error_count += 1;
let _ = utils::atomic_write(file_path, &restored);
continue;
}
};
if let Err(e) = utils::atomic_write(file_path, &final_result) {
eprintln!(
"{} Failed to write {}: {}",
"Error:".red(),
display_path(file_path),
sanitize_error(&e.to_string())
);
error_count += 1;
}
}
let mut formatted_count = 0;
let mut unchanged_count = 0;
for (index, file_path) in files.iter().enumerate() {
let progress = format!("[{}/{}]", index + 1, total_files);
let original_content = match original_contents.get(file_path) {
Some(content) => content,
None => continue,
};
let current_content = match std::fs::read_to_string(file_path) {
Ok(content) => content,
Err(_) => continue,
};
if ¤t_content != original_content {
if check {
println!("{} Would format: {}", progress, display_path(file_path));
if let Err(e) = std::fs::write(file_path, original_content) {
eprintln!(
"Warning: failed to restore {} in check mode: {}",
file_path.display(),
e
);
}
} else if verbosity > 0 {
println!(
"{} {} {}",
progress.bright_blue(),
"Formatted:".green(),
display_path(file_path)
);
}
formatted_count += 1;
if backup && !check {
let backup_path = create_temp_backup_path(file_path);
match create_secure_backup(file_path, &backup_path) {
Ok(()) => {
let mut guard = utils::BackupGuard::new(backup_path);
guard.commit();
}
Err(e) => {
eprintln!(
"Warning: failed to create backup for {}: {}",
mask_path(file_path),
e
);
}
}
}
} else {
unchanged_count += 1;
if verbosity > 0 {
println!(
"{} {} {}",
progress.bright_blue(),
"Unchanged:".dimmed(),
display_path(file_path)
);
}
}
}
if check {
let all_paths: Vec<PathBuf> = original_contents.keys().cloned().collect();
let rollback_errors = utils::rollback_files(&all_paths, &original_contents);
utils::report_rollback_errors(&rollback_errors);
}
secure_clear_hashmap(&mut original_contents);
println!();
if check {
println!(
"{}: {} would be formatted, {} unchanged, {} errors",
"Summary".bright_cyan(),
formatted_count.to_string().yellow(),
unchanged_count,
if error_count > 0 {
error_count.to_string().red()
} else {
error_count.to_string().green()
}
);
} else {
println!(
"{}: {} formatted, {} unchanged, {} errors",
"Summary".bright_cyan(),
if formatted_count > 0 {
formatted_count.to_string().green()
} else {
formatted_count.to_string().dimmed()
},
unchanged_count,
if error_count > 0 {
error_count.to_string().red()
} else {
error_count.to_string().dimmed()
}
);
}
if check && formatted_count > 0 {
return Err(reinhardt_commands::CommandError::ExecutionError(
"Some files are not properly formatted".to_string(),
));
}
if error_count > 0 {
return Err(reinhardt_commands::CommandError::ExecutionError(format!(
"{} files had formatting errors",
error_count
)));
}
Ok(())
}
const MAX_PROJECT_ROOT_DEPTH: usize = 10;
fn find_project_root() -> Option<PathBuf> {
let current_dir = std::env::current_dir().ok()?;
let mut current = current_dir.as_path();
for _ in 0..MAX_PROJECT_ROOT_DEPTH {
if std::fs::metadata(current.join("Cargo.toml")).is_ok() {
return Some(current.to_path_buf());
}
current = current.parent()?;
}
None
}
fn find_rustfmt_config(start_path: &Path) -> Option<PathBuf> {
let mut current = if start_path.is_file() {
start_path.parent()
} else {
Some(start_path)
}?;
loop {
let config = current.join("rustfmt.toml");
if std::fs::metadata(&config).is_ok() {
return Some(config);
}
let hidden_config = current.join(".rustfmt.toml");
if std::fs::metadata(&hidden_config).is_ok() {
return Some(hidden_config);
}
if std::fs::metadata(current.join("Cargo.toml")).is_ok() {
break;
}
current = current.parent()?;
}
None
}
fn run_rustfmt(content: &str, options: &ast_formatter::RustfmtOptions) -> Result<String, String> {
use std::io::Write;
use std::process::{Command, Stdio};
let mut cmd = Command::new("rustfmt");
options.apply_to_command(&mut cmd);
if options.config_path.is_none() && options.edition.is_none() {
cmd.arg("--edition=2024");
}
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn rustfmt: {}", e))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(content.as_bytes())
.map_err(|e| format!("Failed to write to rustfmt stdin: {}", e))?;
}
let output = child
.wait_with_output()
.map_err(|e| format!("Failed to wait for rustfmt: {}", e))?;
if output.status.success() {
String::from_utf8(output.stdout).map_err(|e| format!("Invalid UTF-8 from rustfmt: {}", e))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("rustfmt failed: {}", stderr))
}
}
#[cfg(unix)]
fn create_secure_backup(source: &Path, backup_path: &Path) -> Result<(), std::io::Error> {
use std::fs::OpenOptions;
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
let mut content = Vec::new();
let mut file = std::fs::File::open(source)?;
file.read_to_end(&mut content)?;
let mut backup_file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600) .open(backup_path)?;
std::io::copy(&mut content.as_slice(), &mut backup_file)?;
content.zeroize();
Ok(())
}
#[cfg(not(unix))]
fn create_secure_backup(source: &Path, backup_path: &Path) -> Result<(), std::io::Error> {
use std::io::Read;
let mut content = Vec::new();
let mut file = std::fs::File::open(source)?;
file.read_to_end(&mut content)?;
std::fs::write(backup_path, &content)?;
content.zeroize();
Ok(())
}
fn create_temp_backup_path(source: &Path) -> PathBuf {
let file_name = source
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("unknown"));
let backup_name = format!("reinhardt-fmt-{}.bak", file_name.to_string_lossy());
std::env::temp_dir().join(backup_name)
}
fn mask_path(path: &Path) -> String {
path.file_name()
.map(|name| format!("<...>/{}", name.to_string_lossy()))
.unwrap_or_else(|| "<file>".to_string())
}
fn display_path(path: &Path) -> String {
if let Ok(cwd) = std::env::current_dir()
&& let Ok(relative) = path.strip_prefix(&cwd)
{
return relative.display().to_string();
}
mask_path(path)
}
fn sanitize_error(error: &str) -> String {
use std::sync::LazyLock;
static PATH_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(/[a-zA-Z0-9._-]+){3,}").unwrap());
static DB_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)(postgres|mysql|sqlite|mongodb|redis)://[^\s]+").unwrap()
});
static TOKEN_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(
r"(?i)(api[_\-]?key|token|secret|password|auth)[=:]\s*['\x22]?[a-zA-Z0-9+/=_\-]{8,}",
)
.unwrap()
});
let sanitized = PATH_RE.replace_all(error, |caps: ®ex::Captures| {
let matched_path = Path::new(caps.get(0).unwrap().as_str());
mask_path(matched_path)
});
let sanitized = DB_RE.replace_all(&sanitized, "[REDACTED_DATABASE_URL]");
let sanitized = TOKEN_RE.replace_all(&sanitized, "[REDACTED_CREDENTIAL]");
sanitized.to_string()
}
fn secure_clear_hashmap(map: &mut std::collections::HashMap<PathBuf, String>) {
for (_, value) in map.iter_mut() {
value.zeroize();
}
map.clear();
}
fn validate_config_path(path: &Path) -> Result<(), String> {
let path_str = path.to_string_lossy();
if path_str.contains("..") {
return Err(format!(
"Config path contains path traversal sequence: {}",
mask_path(path)
));
}
#[cfg(unix)]
if path_str.starts_with("/dev/")
|| path_str.starts_with("/proc/")
|| path_str.starts_with("/sys/")
{
return Err(format!(
"Config path refers to a special device: {}",
mask_path(path)
));
}
let symlink_meta = std::fs::symlink_metadata(path)
.map_err(|e| format!("Config path is not accessible: {} ({})", mask_path(path), e))?;
if symlink_meta.file_type().is_symlink() {
return Err(format!(
"Config path is a symlink, which is not allowed: {}",
mask_path(path)
));
}
if !symlink_meta.is_file() {
return Err(format!(
"Config path is not a regular file: {}",
mask_path(path)
));
}
Ok(())
}
const MAX_CONFIG_FILE_SIZE: u64 = 10 * 1024 * 1024;
const MAX_SOURCE_FILE_SIZE: u64 = 5 * 1024 * 1024;
fn check_file_size(path: &Path, max_size: u64) -> Result<(), String> {
match std::fs::metadata(path) {
Ok(metadata) => {
if metadata.len() > max_size {
Err(format!(
"File {} exceeds maximum allowed size ({} bytes, limit {} bytes)",
mask_path(path),
metadata.len(),
max_size
))
} else {
Ok(())
}
}
Err(e) => Err(format!(
"Failed to check file size for {}: {}",
mask_path(path),
e
)),
}
}
fn acquire_format_lock(lock_path: &Path) -> Result<FormatLockGuard, std::io::Error> {
use std::fs::OpenOptions;
let file = OpenOptions::new()
.write(true)
.create_new(true)
.open(lock_path)?;
use std::io::Write;
let mut file = file;
let _ = writeln!(file, "{}", std::process::id());
Ok(FormatLockGuard {
path: lock_path.to_path_buf(),
})
}
struct FormatLockGuard {
path: PathBuf,
}
impl Drop for FormatLockGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
#[cfg(test)]
mod resolve_project_type_tests {
use super::*;
#[test]
fn with_pages_bool_resolves_to_pages() {
assert!(matches!(
resolve_project_type(None, true, false),
ResolvedProjectType::Pages
));
}
#[test]
fn with_rest_bool_resolves_to_rest() {
assert!(matches!(
resolve_project_type(None, false, true),
ResolvedProjectType::Rest
));
}
#[test]
fn template_pages_resolves_to_pages() {
assert!(matches!(
resolve_project_type(Some(TemplateType::Pages), false, false),
ResolvedProjectType::Pages
));
}
#[test]
fn template_rest_resolves_to_rest() {
assert!(matches!(
resolve_project_type(Some(TemplateType::Rest), false, false),
ResolvedProjectType::Rest
));
}
#[test]
fn template_pages_with_bool_false_resolves_to_pages() {
assert!(matches!(
resolve_project_type(Some(TemplateType::Pages), false, false),
ResolvedProjectType::Pages
));
}
#[test]
fn template_rest_with_bool_false_resolves_to_rest() {
assert!(matches!(
resolve_project_type(Some(TemplateType::Rest), false, false),
ResolvedProjectType::Rest
));
}
}
#[cfg(test)]
mod arg_group_tests {
use super::*;
use clap::error::ErrorKind;
fn try_parse(args: &[&str]) -> Result<Cli, clap::Error> {
Cli::try_parse_from(args)
}
#[test]
fn startproject_with_pages_flag_accepted() {
assert!(
try_parse(&["reinhardt-admin", "startproject", "myproj", "--with-pages"]).is_ok(),
"--with-pages should be accepted"
);
}
#[test]
fn startproject_with_rest_flag_accepted() {
assert!(
try_parse(&["reinhardt-admin", "startproject", "myproj", "--with-rest"]).is_ok(),
"--with-rest should be accepted"
);
}
#[test]
fn startproject_template_pages_accepted() {
assert!(
try_parse(&[
"reinhardt-admin",
"startproject",
"myproj",
"--template",
"pages"
])
.is_ok(),
"--template pages should be accepted"
);
}
#[test]
fn startproject_template_rest_accepted() {
assert!(
try_parse(&[
"reinhardt-admin",
"startproject",
"myproj",
"--template",
"rest"
])
.is_ok(),
"--template rest should be accepted"
);
}
#[test]
fn startproject_missing_type_is_error() {
let result = try_parse(&["reinhardt-admin", "startproject", "myproj"]);
assert!(result.is_err(), "expected Err when type flag omitted");
assert_eq!(
result.err().unwrap().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn startproject_duplicate_flags_are_error() {
assert!(
try_parse(&[
"reinhardt-admin",
"startproject",
"myproj",
"--with-pages",
"--with-rest",
])
.is_err(),
"duplicate type flags should be rejected"
);
}
#[test]
fn startproject_template_and_alias_together_are_error() {
assert!(
try_parse(&[
"reinhardt-admin",
"startproject",
"myproj",
"--template",
"pages",
"--with-pages",
])
.is_err(),
"--template + --with-pages should be rejected"
);
}
#[test]
fn startapp_with_pages_flag_accepted() {
assert!(
try_parse(&["reinhardt-admin", "startapp", "myapp", "--with-pages"]).is_ok(),
"--with-pages should be accepted for startapp"
);
}
#[test]
fn startapp_missing_type_is_error() {
let result = try_parse(&["reinhardt-admin", "startapp", "myapp"]);
assert!(result.is_err(), "expected Err when type flag omitted");
assert_eq!(
result.err().unwrap().kind(),
ErrorKind::MissingRequiredArgument
);
}
}