✨ 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:
committed by
Jeremiah Russell
parent
df9d2b6c8a
commit
2083c5c5fe
@@ -146,6 +146,23 @@ pub struct InitCli {
|
|||||||
)]
|
)]
|
||||||
pub credential_file: Option<PathBuf>,
|
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.
|
/// Overwrite existing files without prompting.
|
||||||
///
|
///
|
||||||
/// When enabled, existing configuration files will be backed up with
|
/// When enabled, existing configuration files will be backed up with
|
||||||
@@ -292,6 +309,31 @@ execute = false
|
|||||||
token_cache_env = "CULL_GMAIL_TOKEN_CACHE"
|
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
|
const RULES_FILE_CONTENT: &'static str = r#"# Example rules for cull-gmail
|
||||||
# Each rule targets a Gmail label and specifies an action.
|
# 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)?;
|
self.plan_credential_file_operation(&mut operations, config_path, cred_file)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Write config file
|
// 3. Determine rules directory
|
||||||
self.plan_config_file_operation(&mut operations, config_path)?;
|
let rules_dir = self.get_rules_directory(config_path);
|
||||||
|
|
||||||
// 4. Write rules file
|
// 4. Write config file (with correct rules path)
|
||||||
self.plan_rules_file_operation(&mut operations, config_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);
|
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() {
|
if credential_file.is_some() {
|
||||||
self.plan_oauth_operation(&mut operations);
|
self.plan_oauth_operation(&mut operations);
|
||||||
}
|
}
|
||||||
@@ -490,6 +535,19 @@ impl InitCli {
|
|||||||
Ok(operations)
|
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.
|
/// Plan config directory creation.
|
||||||
fn plan_config_directory(&self, operations: &mut Vec<Operation>, config_path: &Path) {
|
fn plan_config_directory(&self, operations: &mut Vec<Operation>, config_path: &Path) {
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
@@ -526,13 +584,26 @@ impl InitCli {
|
|||||||
&self,
|
&self,
|
||||||
operations: &mut Vec<Operation>,
|
operations: &mut Vec<Operation>,
|
||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
|
rules_dir: &Path,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let config_file_path = config_path.join(InitDefaults::config_filename());
|
let config_file_path = config_path.join(InitDefaults::config_filename());
|
||||||
self.check_file_conflicts(&config_file_path, "Configuration file")?;
|
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 {
|
operations.push(Operation::WriteFile {
|
||||||
path: config_file_path.clone(),
|
path: config_file_path.clone(),
|
||||||
contents: InitDefaults::CONFIG_FILE_CONTENT.to_string(),
|
contents: config_contents,
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
mode: Some(0o644),
|
mode: Some(0o644),
|
||||||
backup_if_exists: self.should_backup(&config_file_path),
|
backup_if_exists: self.should_backup(&config_file_path),
|
||||||
@@ -544,9 +615,18 @@ impl InitCli {
|
|||||||
fn plan_rules_file_operation(
|
fn plan_rules_file_operation(
|
||||||
&self,
|
&self,
|
||||||
operations: &mut Vec<Operation>,
|
operations: &mut Vec<Operation>,
|
||||||
config_path: &Path,
|
rules_dir: &Path,
|
||||||
) -> Result<()> {
|
) -> 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")?;
|
self.check_file_conflicts(&rules_file_path, "Rules file")?;
|
||||||
|
|
||||||
operations.push(Operation::WriteFile {
|
operations.push(Operation::WriteFile {
|
||||||
|
|||||||
81
src/rules.rs
81
src/rules.rs
@@ -45,7 +45,7 @@ use std::{
|
|||||||
collections::BTreeMap,
|
collections::BTreeMap,
|
||||||
env,
|
env,
|
||||||
fs::{self, read_to_string},
|
fs::{self, read_to_string},
|
||||||
path::PathBuf,
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -492,12 +492,45 @@ impl Rules {
|
|||||||
/// * IO errors when writing to the file system
|
/// * IO errors when writing to the file system
|
||||||
/// * File system permission errors
|
/// * File system permission errors
|
||||||
pub fn save(&self) -> Result<()> {
|
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()
|
let home_dir = env::home_dir()
|
||||||
.ok_or_else(|| Error::HomeExpansionFailed("~/.cull-gmail/rules.toml".to_string()))?;
|
.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
|
// Ensure directory exists
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = save_path.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,8 +538,8 @@ impl Rules {
|
|||||||
log::trace!("toml conversion result: {res:#?}");
|
log::trace!("toml conversion result: {res:#?}");
|
||||||
|
|
||||||
if let Ok(output) = res {
|
if let Ok(output) = res {
|
||||||
fs::write(&path, output)?;
|
fs::write(&save_path, output)?;
|
||||||
log::trace!("Config saved to {}", path.display());
|
log::trace!("Config saved to {}", save_path.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -537,12 +570,44 @@ impl Rules {
|
|||||||
/// * TOML parsing errors if the file is malformed
|
/// * TOML parsing errors if the file is malformed
|
||||||
/// * File not found errors if the configuration doesn't exist
|
/// * File not found errors if the configuration doesn't exist
|
||||||
pub fn load() -> Result<Rules> {
|
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()
|
let home_dir = env::home_dir()
|
||||||
.ok_or_else(|| Error::HomeExpansionFailed("~/.cull-gmail/rules.toml".to_string()))?;
|
.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")
|
||||||
log::trace!("Loading config from {}", path.display());
|
};
|
||||||
|
|
||||||
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)?;
|
let config = toml::from_str::<Rules>(&input)?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user