use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Config {
pub base_branch: Option<String>,
pub ignore_patterns: Vec<String>,
pub check_prs: bool,
pub github_token: Option<String>,
pub format: Option<String>,
pub stats: bool,
pub diff: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
base_branch: None,
ignore_patterns: Vec::new(),
check_prs: true,
github_token: None,
format: None,
stats: false,
diff: false,
}
}
}
impl Config {
pub fn load() -> Self {
if let Some(path) = find_config_file() {
match fs::read_to_string(&path) {
Ok(content) => parse_toml(&content),
Err(_) => Self::default(),
}
} else {
Self::default()
}
}
pub fn is_ignored(&self, file_path: &str) -> bool {
for pattern in &self.ignore_patterns {
if matches_glob(pattern, file_path) {
return true;
}
}
false
}
pub fn filter_conflicts(&self, files: &[String]) -> Vec<String> {
if self.ignore_patterns.is_empty() {
return files.to_vec();
}
files
.iter()
.filter(|f| !self.is_ignored(f))
.cloned()
.collect()
}
}
fn find_config_file() -> Option<PathBuf> {
if let Ok(output) = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
{
if output.status.success() {
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
let config_path = Path::new(&root).join(".clearedforpush.toml");
if config_path.exists() {
return Some(config_path);
}
}
}
let local = PathBuf::from(".clearedforpush.toml");
if local.exists() {
return Some(local);
}
None
}
fn parse_toml(content: &str) -> Config {
let mut config = Config::default();
let mut current_section = String::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
current_section = trimmed[1..trimmed.len() - 1].trim().to_string();
continue;
}
if let Some((key, value)) = parse_key_value(trimmed) {
let full_key = if current_section.is_empty() {
key.to_string()
} else {
format!("{}.{}", current_section, key)
};
match full_key.as_str() {
"base" | "base_branch" => {
config.base_branch = Some(value.to_string());
}
"check_prs" | "prs" => {
config.check_prs = parse_bool(&value);
}
"format" => {
config.format = Some(value.to_string());
}
"stats" => {
config.stats = parse_bool(&value);
}
"diff" => {
config.diff = parse_bool(&value);
}
"github.token" => {
config.github_token = Some(value.to_string());
}
"ignore" | "ignore_patterns" => {
if !value.is_empty() {
config.ignore_patterns.push(value.to_string());
}
}
_ => {
if current_section == "ignore" {
config.ignore_patterns.push(key.to_string());
}
}
}
} else if current_section == "ignore" {
let pattern = trimmed.trim_start_matches('-').trim().trim_matches('"');
if !pattern.is_empty() {
config.ignore_patterns.push(pattern.to_string());
}
}
}
for line in content.lines() {
let trimmed = line.trim();
if let Some((key, _)) = parse_key_value(trimmed) {
if key == "ignore" || key == "ignore_patterns" {
if let Some(arr) = parse_string_array(trimmed) {
config.ignore_patterns = arr;
}
}
}
}
config
}
fn parse_key_value(line: &str) -> Option<(String, String)> {
let eq_pos = line.find('=')?;
let key = line[..eq_pos].trim().to_string();
let raw_value = line[eq_pos + 1..].trim();
let value = if (raw_value.starts_with('"') && raw_value.ends_with('"'))
|| (raw_value.starts_with('\'') && raw_value.ends_with('\''))
{
raw_value[1..raw_value.len() - 1].to_string()
} else {
raw_value.to_string()
};
Some((key, value))
}
fn parse_bool(value: &str) -> bool {
matches!(value.to_lowercase().as_str(), "true" | "yes" | "1")
}
fn parse_string_array(line: &str) -> Option<Vec<String>> {
let eq_pos = line.find('=')?;
let value_part = line[eq_pos + 1..].trim();
if !value_part.starts_with('[') || !value_part.ends_with(']') {
return None;
}
let inner = &value_part[1..value_part.len() - 1];
let items: Vec<String> = inner
.split(',')
.map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
.filter(|s| !s.is_empty())
.collect();
Some(items)
}
fn matches_glob(pattern: &str, path: &str) -> bool {
if pattern.contains("**") {
let parts: Vec<&str> = pattern.splitn(2, "**").collect();
if parts.len() == 2 {
let prefix = parts[0].trim_end_matches('/');
let suffix = parts[1].trim_start_matches('/');
let prefix_ok = prefix.is_empty() || path.starts_with(prefix);
if suffix.is_empty() {
return prefix_ok;
}
if prefix_ok {
if suffix.contains('*') {
let filename = path.rsplit('/').next().unwrap_or(path);
return matches_glob(suffix, filename);
} else {
return path.ends_with(suffix);
}
}
return false;
}
}
if pattern.contains('*') {
let parts: Vec<&str> = pattern.splitn(2, '*').collect();
if parts.len() == 2 {
let prefix = parts[0];
let suffix = parts[1];
return path.starts_with(prefix) && path.ends_with(suffix);
}
}
path == pattern || path.starts_with(&format!("{}/", pattern))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_basic_config() {
let toml = r#"
base = "develop"
check_prs = false
format = "compact"
stats = true
diff = true
ignore = ["*.lock", "docs/**"]
"#;
let config = parse_toml(toml);
assert_eq!(config.base_branch.as_deref(), Some("develop"));
assert!(!config.check_prs);
assert_eq!(config.format.as_deref(), Some("compact"));
assert!(config.stats);
assert!(config.diff);
assert_eq!(config.ignore_patterns, vec!["*.lock", "docs/**"]);
}
#[test]
fn test_parse_with_sections() {
let toml = r#"
base = "main"
[github]
token = "ghp_xxxx"
"#;
let config = parse_toml(toml);
assert_eq!(config.base_branch.as_deref(), Some("main"));
assert_eq!(config.github_token.as_deref(), Some("ghp_xxxx"));
}
#[test]
fn test_glob_matching() {
assert!(matches_glob("*.lock", "Cargo.lock"));
assert!(matches_glob("*.lock", "package-lock.json") == false);
assert!(matches_glob("docs/**", "docs/dev/guide.md"));
assert!(matches_glob("docs/**", "src/main.rs") == false);
assert!(matches_glob("**/*.generated.rs", "src/schema.generated.rs"));
}
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.base_branch.is_none());
assert!(config.check_prs);
assert!(config.ignore_patterns.is_empty());
assert!(!config.stats);
assert!(!config.diff);
}
#[test]
fn test_filter_conflicts() {
let config = Config {
ignore_patterns: vec!["*.lock".to_string(), "docs/**".to_string()],
..Config::default()
};
let files = vec![
"src/main.rs".to_string(),
"Cargo.lock".to_string(),
"docs/README.md".to_string(),
];
let filtered = config.filter_conflicts(&files);
assert_eq!(filtered, vec!["src/main.rs"]);
}
#[test]
fn test_parse_comments_and_empty() {
let toml = r#"
# This is a comment
base = "main"
# Another comment
stats = true
"#;
let config = parse_toml(toml);
assert_eq!(config.base_branch.as_deref(), Some("main"));
assert!(config.stats);
}
}