feat: add configurable rules directory support to Rules and InitCli

- Add Rules::load_from() and Rules::save_to() methods for custom paths
- Add --rules-dir option to init command
- Support h:, c:, r: path prefixes for rules directory
- Generate config file with correct rules path when using separate directory
- Create rules directory automatically if it doesn't exist
- Maintain backward compatibility with default ~/.cull-gmail/rules.toml
This commit is contained in:
Jeremiah Russell
2025-10-21 17:45:15 +01:00
committed by Jeremiah Russell
parent df9d2b6c8a
commit 2083c5c5fe
2 changed files with 166 additions and 21 deletions

View File

@@ -146,6 +146,23 @@ pub struct InitCli {
)]
pub credential_file: Option<PathBuf>,
/// Rules file directory path.
///
/// Optionally specify a separate directory for the rules.toml file.
/// If not provided, rules.toml will be created in the configuration directory.
/// Supports the same path prefixes as --config-dir (h:, c:, r:).
///
/// This is useful for:
/// - Version controlling rules separately from credentials
/// - Sharing rules across multiple configurations
/// - Organizing files by security sensitivity
#[arg(
long = "rules-dir",
value_name = "DIR",
help = "Optional separate directory for rules.toml file"
)]
pub rules_dir: Option<String>,
/// Overwrite existing files without prompting.
///
/// When enabled, existing configuration files will be backed up with
@@ -292,6 +309,31 @@ execute = false
token_cache_env = "CULL_GMAIL_TOKEN_CACHE"
"#;
/// Generate config file content with custom rules path.
fn config_content_with_rules_path(rules_path: &str) -> String {
format!(
r#"# cull-gmail configuration
# This file configures the cull-gmail application.
# OAuth2 credential file (relative to config_root)
credential_file = "credential.json"
# Configuration root directory
config_root = "h:.cull-gmail"
# Rules configuration file (supports h:, c:, r: prefixes)
rules = "{rules_path}"
# Default execution mode (false = dry-run, true = execute)
# Set to false for safety - you can override with --execute flag
execute = false
# Environment variable name for token cache (for ephemeral environments)
token_cache_env = "CULL_GMAIL_TOKEN_CACHE"
"#
)
}
const RULES_FILE_CONTENT: &'static str = r#"# Example rules for cull-gmail
# Each rule targets a Gmail label and specifies an action.
#
@@ -473,16 +515,19 @@ impl InitCli {
self.plan_credential_file_operation(&mut operations, config_path, cred_file)?;
}
// 3. Write config file
self.plan_config_file_operation(&mut operations, config_path)?;
// 3. Determine rules directory
let rules_dir = self.get_rules_directory(config_path);
// 4. Write rules file
self.plan_rules_file_operation(&mut operations, config_path)?;
// 4. Write config file (with correct rules path)
self.plan_config_file_operation(&mut operations, config_path, &rules_dir)?;
// 5. Ensure token directory exists
// 5. Write rules file (possibly in separate directory)
self.plan_rules_file_operation(&mut operations, &rules_dir)?;
// 6. Ensure token directory exists
self.plan_token_directory(&mut operations, config_path);
// 6. Run OAuth2 if we have credentials
// 7. Run OAuth2 if we have credentials
if credential_file.is_some() {
self.plan_oauth_operation(&mut operations);
}
@@ -490,6 +535,19 @@ impl InitCli {
Ok(operations)
}
/// Get the directory where rules.toml should be placed.
///
/// Returns the rules directory path, which is either:
/// - The custom directory specified with --rules-dir
/// - The config directory (default)
fn get_rules_directory(&self, config_path: &Path) -> PathBuf {
if let Some(ref rules_dir_str) = self.rules_dir {
parse_config_root(rules_dir_str)
} else {
config_path.to_path_buf()
}
}
/// Plan config directory creation.
fn plan_config_directory(&self, operations: &mut Vec<Operation>, config_path: &Path) {
if !config_path.exists() {
@@ -526,13 +584,26 @@ impl InitCli {
&self,
operations: &mut Vec<Operation>,
config_path: &Path,
rules_dir: &Path,
) -> Result<()> {
let config_file_path = config_path.join(InitDefaults::config_filename());
self.check_file_conflicts(&config_file_path, "Configuration file")?;
// Generate rules path for config file
let rules_path = rules_dir.join(InitDefaults::rules_filename());
let rules_path_str = rules_path.to_string_lossy().to_string();
let config_contents = if rules_dir == config_path {
// Rules in same directory - use relative path
InitDefaults::CONFIG_FILE_CONTENT.to_string()
} else {
// Rules in different directory - use full path
InitDefaults::config_content_with_rules_path(&rules_path_str)
};
operations.push(Operation::WriteFile {
path: config_file_path.clone(),
contents: InitDefaults::CONFIG_FILE_CONTENT.to_string(),
contents: config_contents,
#[cfg(unix)]
mode: Some(0o644),
backup_if_exists: self.should_backup(&config_file_path),
@@ -544,9 +615,18 @@ impl InitCli {
fn plan_rules_file_operation(
&self,
operations: &mut Vec<Operation>,
config_path: &Path,
rules_dir: &Path,
) -> Result<()> {
let rules_file_path = config_path.join(InitDefaults::rules_filename());
// Create rules directory if it doesn't exist and is different from config dir
if !rules_dir.exists() {
operations.push(Operation::CreateDir {
path: rules_dir.to_path_buf(),
#[cfg(unix)]
mode: Some(0o755),
});
}
let rules_file_path = rules_dir.join(InitDefaults::rules_filename());
self.check_file_conflicts(&rules_file_path, "Rules file")?;
operations.push(Operation::WriteFile {

View File

@@ -45,7 +45,7 @@ use std::{
collections::BTreeMap,
env,
fs::{self, read_to_string},
path::PathBuf,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
@@ -492,12 +492,45 @@ impl Rules {
/// * IO errors when writing to the file system
/// * File system permission errors
pub fn save(&self) -> Result<()> {
self.save_to(None)
}
/// Saves the current rule configuration to a specified path.
///
/// If no path is provided, defaults to `~/.cull-gmail/rules.toml`.
/// The directory is created if it doesn't exist.
///
/// # Arguments
///
/// * `path` - Optional path where the rules should be saved
///
/// # Examples
///
/// ```ignore
/// use cull_gmail::Rules;
/// use std::path::Path;
///
/// let rules = Rules::new();
/// rules.save_to(Some(Path::new("/custom/path/rules.toml")))
/// .expect("Failed to save");
/// ```
///
/// # Errors
///
/// * TOML serialization errors
/// * IO errors when writing to the file system
/// * File system permission errors
pub fn save_to(&self, path: Option<&Path>) -> Result<()> {
let save_path = if let Some(p) = path {
p.to_path_buf()
} else {
let home_dir = env::home_dir()
.ok_or_else(|| Error::HomeExpansionFailed("~/.cull-gmail/rules.toml".to_string()))?;
let path = PathBuf::new().join(home_dir).join(".cull-gmail/rules.toml");
home_dir.join(".cull-gmail/rules.toml")
};
// Ensure directory exists
if let Some(parent) = path.parent() {
if let Some(parent) = save_path.parent() {
fs::create_dir_all(parent)?;
}
@@ -505,8 +538,8 @@ impl Rules {
log::trace!("toml conversion result: {res:#?}");
if let Ok(output) = res {
fs::write(&path, output)?;
log::trace!("Config saved to {}", path.display());
fs::write(&save_path, output)?;
log::trace!("Config saved to {}", save_path.display());
}
Ok(())
@@ -537,12 +570,44 @@ impl Rules {
/// * TOML parsing errors if the file is malformed
/// * File not found errors if the configuration doesn't exist
pub fn load() -> Result<Rules> {
Self::load_from(None)
}
/// Loads rule configuration from a specified path.
///
/// If no path is provided, defaults to `~/.cull-gmail/rules.toml`.
///
/// # Arguments
///
/// * `path` - Optional path to load rules from
///
/// # Examples
///
/// ```ignore
/// use cull_gmail::Rules;
/// use std::path::Path;
///
/// let rules = Rules::load_from(Some(Path::new("/custom/path/rules.toml")))
/// .expect("Failed to load rules");
/// ```
///
/// # Errors
///
/// * IO errors when reading from the file system
/// * TOML parsing errors if the file is malformed
/// * File not found errors if the configuration doesn't exist
pub fn load_from(path: Option<&Path>) -> Result<Rules> {
let load_path = if let Some(p) = path {
p.to_path_buf()
} else {
let home_dir = env::home_dir()
.ok_or_else(|| Error::HomeExpansionFailed("~/.cull-gmail/rules.toml".to_string()))?;
let path = PathBuf::new().join(home_dir).join(".cull-gmail/rules.toml");
log::trace!("Loading config from {}", path.display());
home_dir.join(".cull-gmail/rules.toml")
};
let input = read_to_string(path)?;
log::trace!("Loading config from {}", load_path.display());
let input = read_to_string(load_path)?;
let config = toml::from_str::<Rules>(&input)?;
Ok(config)
}