From 2083c5c5fe4364322a5c53c02c86e9323174849a Mon Sep 17 00:00:00 2001 From: Jeremiah Russell Date: Tue, 21 Oct 2025 17:45:15 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20configurable=20rules?= =?UTF-8?q?=20directory=20support=20to=20Rules=20and=20InitCli?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/cli/init_cli.rs | 98 ++++++++++++++++++++++++++++++++++++++++----- src/rules.rs | 89 ++++++++++++++++++++++++++++++++++------ 2 files changed, 166 insertions(+), 21 deletions(-) diff --git a/src/cli/init_cli.rs b/src/cli/init_cli.rs index b66d20a..bedb35b 100644 --- a/src/cli/init_cli.rs +++ b/src/cli/init_cli.rs @@ -146,6 +146,23 @@ pub struct InitCli { )] pub credential_file: Option, + /// 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, + /// 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, config_path: &Path) { if !config_path.exists() { @@ -526,13 +584,26 @@ impl InitCli { &self, operations: &mut Vec, 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, - 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 { diff --git a/src/rules.rs b/src/rules.rs index abdd7b0..0c99a4b 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -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<()> { - 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"); + 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()))?; + 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 { - 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()); + Self::load_from(None) + } - let input = read_to_string(path)?; + /// 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 { + 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()))?; + home_dir.join(".cull-gmail/rules.toml") + }; + + log::trace!("Loading config from {}", load_path.display()); + + let input = read_to_string(load_path)?; let config = toml::from_str::(&input)?; Ok(config) }