use crate::config::{self, Config, LoadedConfig};
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use std::fs;
use std::io::{self, Write};
#[derive(Parser, Debug)]
#[command(name = "config")]
pub struct Args {
#[command(subcommand)]
pub command: ConfigCommand,
}
#[derive(Subcommand, Debug)]
pub enum ConfigCommand {
Show {
#[arg(long)]
raw: bool,
#[arg(long, short, default_value = "toml")]
format: String,
},
Path,
Edit {
#[arg(long, conflicts_with = "system")]
project: bool,
#[arg(long, conflicts_with = "project")]
system: bool,
},
Init {
#[arg(long)]
project: bool,
#[arg(long, short)]
force: bool,
},
Aliases,
}
pub fn run(args: &Args) -> Result<()> {
match &args.command {
ConfigCommand::Show { raw, format } => run_show(*raw, format),
ConfigCommand::Path => run_path(),
ConfigCommand::Edit { project, system } => run_edit(*project, *system),
ConfigCommand::Init { project, force } => run_init(*project, *force),
ConfigCommand::Aliases => run_aliases(),
}
}
fn run_show(raw: bool, format: &str) -> Result<()> {
let loaded = Config::load()?;
if raw {
println!("# Configuration sources (highest precedence first)\n");
for source in loaded.sources.iter().rev() {
match source {
config::ConfigSource::Project(path)
| config::ConfigSource::User(path)
| config::ConfigSource::System(path) => {
println!("# === {source} ===");
if let Ok(content) = fs::read_to_string(path) {
println!("{content}");
}
println!();
}
config::ConfigSource::Environment => {
println!("# === Environment Variables ===");
if let Ok(file) = std::env::var("RLEDGER_FILE") {
println!("RLEDGER_FILE={file}");
}
if let Ok(format) = std::env::var("RLEDGER_FORMAT") {
println!("RLEDGER_FORMAT={format}");
}
if std::env::var("NO_COLOR").is_ok() {
println!("NO_COLOR=1");
}
if let Ok(profile) = std::env::var("RLEDGER_PROFILE") {
println!("RLEDGER_PROFILE={profile}");
}
println!();
}
_ => {}
}
}
} else {
print_config(&loaded, format)?;
}
Ok(())
}
fn print_config(loaded: &LoadedConfig, format: &str) -> Result<()> {
let mut stdout = io::stdout().lock();
match format {
"toml" => {
writeln!(stdout, "# Merged configuration (highest priority wins)")?;
writeln!(stdout, "# Sources: {}", format_sources(&loaded.sources))?;
writeln!(stdout)?;
let toml_str = toml::to_string_pretty(&loaded.config)
.context("Failed to serialize config to TOML")?;
writeln!(stdout, "{toml_str}")?;
}
"json" => {
let json_str = serde_json::to_string_pretty(&loaded.config)
.context("Failed to serialize config to JSON")?;
writeln!(stdout, "{json_str}")?;
}
_ => {
bail!("Unknown format: {format}. Supported: toml, json");
}
}
Ok(())
}
fn format_sources(sources: &[config::ConfigSource]) -> String {
if sources.is_empty() {
"default".to_string()
} else {
sources
.iter()
.rev() .map(|s| match s {
config::ConfigSource::Cli => "cli".to_string(),
config::ConfigSource::Environment => "env".to_string(),
config::ConfigSource::Project(_) => "project".to_string(),
config::ConfigSource::User(_) => "user".to_string(),
config::ConfigSource::System(_) => "system".to_string(),
config::ConfigSource::Default => "default".to_string(),
})
.collect::<Vec<_>>()
.join(" > ")
}
}
fn run_path() -> Result<()> {
let paths = config::config_search_paths();
println!("Configuration file search paths:\n");
for (level, path, exists) in paths {
let status = if exists { "(found)" } else { "(not found)" };
println!(" {level:8} {status:12} {}", path.display());
}
println!();
println!("Environment variables:");
println!(" RLEDGER_FILE Default beancount file");
println!(" RLEDGER_FORMAT Output format (text, csv, json)");
println!(" RLEDGER_PROFILE Active profile name");
println!(" NO_COLOR Disable colored output");
Ok(())
}
fn run_edit(project: bool, system: bool) -> Result<()> {
let path = if system {
config::system_config_path().context("System config path not available on this platform")?
} else if project {
std::env::current_dir()?.join(".rledger.toml")
} else {
config::user_config_path().context("User config path not available")?
};
if !project
&& !system
&& let Some(parent) = path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
if !path.exists() {
fs::write(&path, Config::default_config_content())
.with_context(|| format!("Failed to create config file: {}", path.display()))?;
println!("Created new config file: {}", path.display());
}
let custom_editor = Config::load()
.ok()
.and_then(|l| l.config.default.editor)
.and_then(|e| {
let trimmed = e.trim();
if trimmed.is_empty() { None } else { Some(e) }
});
println!("Opening {}...", path.display());
if let Some(editor) = custom_editor {
let parts = shell_words::split(&editor)
.with_context(|| format!("Invalid editor command syntax: {editor}"))?;
let (cmd, args) = parts.split_first().context("Editor command is empty")?;
let status = std::process::Command::new(cmd)
.args(args)
.arg(&path)
.status()
.with_context(|| format!("Failed to run editor: {editor}"))?;
if !status.success() {
match status.code() {
Some(code) => bail!("Editor exited with error (exit code {code})"),
None => bail!("Editor terminated by signal"),
}
}
} else {
edit::edit_file(&path).with_context(|| {
"Failed to open editor. Set the EDITOR environment variable or configure \
'default.editor' in your config file."
})?;
}
Ok(())
}
fn run_init(project: bool, force: bool) -> Result<()> {
let path = if project {
std::env::current_dir()?.join(".rledger.toml")
} else {
config::user_config_path().context("User config path not available")?
};
if path.exists() && !force {
bail!(
"Config file already exists: {}\nUse --force to overwrite",
path.display()
);
}
if let Some(parent) = path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
fs::write(&path, Config::default_config_content())
.with_context(|| format!("Failed to write config file: {}", path.display()))?;
println!("Created config file: {}", path.display());
println!();
println!("Edit this file to set your default beancount file:");
println!(
" rledger config edit{}",
if project { " --project" } else { "" }
);
Ok(())
}
fn run_aliases() -> Result<()> {
let loaded = Config::load()?;
if loaded.config.aliases.is_empty() {
println!("No aliases configured.");
println!();
println!("Add aliases to your config file:");
println!(" [aliases]");
println!(" bal = \"report balances\"");
println!(" inc = \"report income\"");
return Ok(());
}
println!("Configured aliases:\n");
let mut aliases: Vec<_> = loaded.config.aliases.iter().collect();
aliases.sort_by_key(|(name, _)| *name);
for (name, expansion) in aliases {
println!(" {name} = \"{expansion}\"");
}
println!();
println!("Usage: rledger <alias> [additional args]");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_format_sources() {
let sources = vec![
config::ConfigSource::User("/home/user/.config/rledger/config.toml".into()),
config::ConfigSource::Project("/test/.rledger.toml".into()),
];
let formatted = format_sources(&sources);
assert_eq!(formatted, "project > user");
}
#[test]
fn test_format_sources_empty() {
let sources = vec![];
let formatted = format_sources(&sources);
assert_eq!(formatted, "default");
}
#[test]
fn test_init_creates_config() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("config.toml");
fs::write(&config_path, Config::default_config_content()).unwrap();
assert!(config_path.exists());
let content = fs::read_to_string(&config_path).unwrap();
assert!(content.contains("[default]"));
assert!(content.contains("# file ="));
}
#[test]
fn test_format_sources_all_types() {
let sources = vec![
config::ConfigSource::System("/etc/rledger/config.toml".into()),
config::ConfigSource::User("/home/user/.config/rledger/config.toml".into()),
config::ConfigSource::Project("/project/.rledger.toml".into()),
config::ConfigSource::Environment,
];
let formatted = format_sources(&sources);
assert_eq!(formatted, "env > project > user > system");
}
#[test]
fn test_format_sources_cli() {
let sources = vec![config::ConfigSource::Cli];
let formatted = format_sources(&sources);
assert_eq!(formatted, "cli");
}
#[test]
fn test_format_sources_default() {
let sources = vec![config::ConfigSource::Default];
let formatted = format_sources(&sources);
assert_eq!(formatted, "default");
}
#[test]
fn test_config_command_parsing() {
use clap::Parser;
let args = Args::try_parse_from(["config", "show"]).unwrap();
assert!(matches!(
args.command,
ConfigCommand::Show { raw: false, .. }
));
let args = Args::try_parse_from(["config", "show", "--raw"]).unwrap();
assert!(matches!(
args.command,
ConfigCommand::Show { raw: true, .. }
));
let args = Args::try_parse_from(["config", "show", "--format", "json"]).unwrap();
if let ConfigCommand::Show { format, .. } = args.command {
assert_eq!(format, "json");
}
let args = Args::try_parse_from(["config", "path"]).unwrap();
assert!(matches!(args.command, ConfigCommand::Path));
let args = Args::try_parse_from(["config", "edit"]).unwrap();
assert!(matches!(
args.command,
ConfigCommand::Edit {
project: false,
system: false
}
));
let args = Args::try_parse_from(["config", "edit", "--project"]).unwrap();
assert!(matches!(
args.command,
ConfigCommand::Edit {
project: true,
system: false
}
));
let args = Args::try_parse_from(["config", "edit", "--system"]).unwrap();
assert!(matches!(
args.command,
ConfigCommand::Edit {
project: false,
system: true
}
));
let args = Args::try_parse_from(["config", "init"]).unwrap();
assert!(matches!(
args.command,
ConfigCommand::Init {
project: false,
force: false
}
));
let args = Args::try_parse_from(["config", "init", "--project"]).unwrap();
assert!(matches!(
args.command,
ConfigCommand::Init {
project: true,
force: false
}
));
let args = Args::try_parse_from(["config", "init", "--force"]).unwrap();
assert!(matches!(
args.command,
ConfigCommand::Init {
project: false,
force: true
}
));
let args = Args::try_parse_from(["config", "aliases"]).unwrap();
assert!(matches!(args.command, ConfigCommand::Aliases));
}
#[test]
fn test_edit_conflicts_with() {
use clap::Parser;
let result = Args::try_parse_from(["config", "edit", "--project", "--system"]);
assert!(result.is_err());
}
#[test]
fn test_default_config_content_is_valid_toml() {
let content = Config::default_config_content();
let result: Result<Config, _> = toml::from_str(&content);
assert!(result.is_ok());
}
#[test]
fn test_config_show_format_options() {
use clap::Parser;
let args = Args::try_parse_from(["config", "show", "-f", "toml"]).unwrap();
if let ConfigCommand::Show { format, .. } = args.command {
assert_eq!(format, "toml");
}
let args = Args::try_parse_from(["config", "show", "-f", "json"]).unwrap();
if let ConfigCommand::Show { format, .. } = args.command {
assert_eq!(format, "json");
}
}
#[test]
fn test_editor_command_parsing() {
let parts = shell_words::split("vim").unwrap();
assert_eq!(parts, vec!["vim"]);
let parts = shell_words::split("code --wait").unwrap();
assert_eq!(parts, vec!["code", "--wait"]);
let parts =
shell_words::split(r#""C:\Program Files\Notepad++\notepad++.exe" -multiInst"#).unwrap();
assert_eq!(
parts,
vec![r"C:\Program Files\Notepad++\notepad++.exe", "-multiInst"]
);
let parts = shell_words::split("'/usr/bin/my editor' --wait").unwrap();
assert_eq!(parts, vec!["/usr/bin/my editor", "--wait"]);
}
#[test]
fn test_editor_empty_handling() {
let editor: Option<String> = Some(String::new());
let filtered = editor.and_then(|e| {
let trimmed = e.trim();
if trimmed.is_empty() { None } else { Some(e) }
});
assert!(filtered.is_none());
let editor: Option<String> = Some(String::from(" "));
let filtered = editor.and_then(|e| {
let trimmed = e.trim();
if trimmed.is_empty() { None } else { Some(e) }
});
assert!(filtered.is_none());
let editor: Option<String> = Some(String::from("vim"));
let filtered = editor.and_then(|e| {
let trimmed = e.trim();
if trimmed.is_empty() { None } else { Some(e) }
});
assert_eq!(filtered, Some(String::from("vim")));
}
}