use crate::config_schema::{ConfigSchemaKind, validate_yaml_value};
use anyhow::{Result, anyhow};
use schemars::{JsonSchema, schema_for};
use serde::{Deserialize, Serialize};
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[schemars(
title = "llman Tool Config",
description = "Tool configuration section for llman."
)]
pub struct Config {
#[schemars(description = "Configuration version for tool settings.")]
pub version: String,
#[schemars(description = "Tool-specific configuration.")]
pub tools: ToolsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[schemars(description = "Configuration for individual tools.")]
pub struct ToolsConfig {
#[serde(rename = "clean-useless-comments")]
#[schemars(description = "Settings for the clean-useless-comments tool.")]
pub clean_useless_comments: Option<CleanUselessCommentsConfig>,
#[serde(rename = "rm-useless-dirs")]
#[schemars(description = "Settings for the rm-useless-dirs tool.")]
pub rm_useless_dirs: Option<RmUselessDirsConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[schemars(description = "Settings for cleaning unnecessary comments.")]
pub struct CleanUselessCommentsConfig {
#[schemars(description = "File scope configuration.")]
pub scope: ScopeConfig,
#[serde(rename = "lang-rules")]
#[schemars(description = "Language-specific rules.")]
pub lang_rules: LanguageRules,
#[serde(rename = "global-rules")]
#[schemars(description = "Global rules applied across languages.")]
pub global_rules: Option<GlobalRules>,
#[schemars(description = "Safety controls for running the tool.")]
pub safety: Option<SafetyConfig>,
#[schemars(description = "Output reporting configuration.")]
pub output: Option<OutputConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[schemars(description = "File include/exclude patterns for tool scope.")]
pub struct ScopeConfig {
#[serde(default = "default_include")]
#[schemars(description = "Glob patterns to include.")]
pub include: Vec<String>,
#[serde(default = "default_exclude")]
#[schemars(description = "Glob patterns to exclude.")]
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[schemars(description = "Language-specific rule sets.")]
pub struct LanguageRules {
#[schemars(description = "Python rule overrides.")]
pub python: Option<LanguageSpecificRules>,
#[schemars(description = "JavaScript rule overrides.")]
pub javascript: Option<LanguageSpecificRules>,
#[schemars(description = "TypeScript rule overrides.")]
pub typescript: Option<LanguageSpecificRules>,
#[schemars(description = "Rust rule overrides.")]
pub rust: Option<LanguageSpecificRules>,
#[schemars(description = "Go rule overrides.")]
pub go: Option<LanguageSpecificRules>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
#[schemars(description = "Rules for a specific language.")]
pub struct LanguageSpecificRules {
#[serde(rename = "single-line-comments")]
pub single_line_comments: Option<bool>,
#[serde(rename = "multi-line-comments")]
pub multi_line_comments: Option<bool>,
#[serde(rename = "docstrings")]
pub docstrings: Option<bool>,
#[serde(rename = "jsdoc")]
pub jsdoc: Option<bool>,
#[serde(rename = "doc-comments")]
pub doc_comments: Option<bool>,
#[serde(rename = "godoc")]
pub godoc: Option<bool>,
#[serde(rename = "preserve-patterns")]
pub preserve_patterns: Option<Vec<String>>,
#[serde(rename = "min-comment-length")]
pub min_comment_length: Option<usize>,
#[serde(rename = "min-code-complexity")]
pub min_code_complexity: Option<u32>,
#[serde(rename = "remove-duplicate-comments")]
pub remove_duplicate_comments: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[schemars(description = "Global rules applied across all languages.")]
pub struct GlobalRules {
#[serde(rename = "preserve-empty-lines")]
pub preserve_empty_lines: Option<bool>,
#[serde(rename = "remove-consecutive-empty-lines")]
pub remove_consecutive_empty_lines: Option<bool>,
#[serde(rename = "remove-duplicate-comments")]
pub remove_duplicate_comments: Option<bool>,
#[serde(rename = "max-comment-density")]
pub max_comment_density: Option<f64>,
#[serde(rename = "min-comment-length")]
pub min_comment_length: Option<usize>,
#[serde(rename = "min-code-complexity")]
pub min_code_complexity: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[schemars(description = "Safety guardrails for running the tool.")]
pub struct SafetyConfig {
#[serde(rename = "dry-run-first")]
pub dry_run_first: Option<bool>,
#[serde(rename = "git-aware")]
pub git_aware: Option<bool>,
#[serde(rename = "require-git-commit")]
pub require_git_commit: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[schemars(description = "Output and reporting configuration.")]
pub struct OutputConfig {
#[serde(rename = "show-changed-files")]
pub show_changed_files: Option<bool>,
#[serde(rename = "show-removed-comments")]
pub show_removed_comments: Option<bool>,
#[serde(rename = "show-statistics")]
pub show_statistics: Option<bool>,
#[serde(rename = "generate-report")]
pub generate_report: Option<bool>,
#[serde(rename = "report-format")]
pub report_format: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
#[schemars(description = "List mode for directory names.")]
pub enum ListMode {
#[default]
Extend,
Override,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[schemars(description = "Directory list configuration.")]
pub struct DirListConfig {
#[serde(default)]
#[schemars(description = "List mode: extend or override.")]
pub mode: ListMode,
#[serde(default)]
#[schemars(description = "Directory names to apply for the selected mode.")]
pub names: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[schemars(description = "Settings for the rm-useless-dirs tool.")]
pub struct RmUselessDirsConfig {
#[schemars(description = "Protected directory configuration.")]
pub protected: Option<DirListConfig>,
#[schemars(description = "Useless directory configuration.")]
pub useless: Option<DirListConfig>,
}
fn default_include() -> Vec<String> {
vec![
"**/*.py".to_string(),
"**/*.js".to_string(),
"**/*.ts".to_string(),
"**/*.rs".to_string(),
"**/*.go".to_string(),
]
}
fn default_exclude() -> Vec<String> {
vec![
"**/node_modules/**".to_string(),
"**/target/**".to_string(),
"**/.git/**".to_string(),
"**/dist/**".to_string(),
"**/build/**".to_string(),
]
}
impl Default for ScopeConfig {
fn default() -> Self {
Self {
include: default_include(),
exclude: default_exclude(),
}
}
}
fn get_global_config_path() -> Result<PathBuf> {
let config_dir = crate::config::resolve_config_dir(None)?;
Ok(config_dir.join("config.yaml"))
}
fn is_project_config_path(path: &Path) -> bool {
path.file_name() == Some(OsStr::new("config.yaml"))
&& path.parent().and_then(|parent| parent.file_name()) == Some(OsStr::new(".llman"))
}
fn schema_kind_for_path(path: &Path) -> ConfigSchemaKind {
if is_project_config_path(path) {
return ConfigSchemaKind::Project;
}
if let Ok(global) = get_global_config_path()
&& path == global
{
return ConfigSchemaKind::Global;
}
ConfigSchemaKind::Global
}
fn reject_legacy_rm_empty_dirs(value: &serde_yaml::Value) -> Result<()> {
let tools = match value.get("tools") {
Some(value) => value,
None => return Ok(()),
};
let tools_map = match tools.as_mapping() {
Some(map) => map,
None => return Ok(()),
};
for key in tools_map.keys() {
if key.as_str() == Some("rm-empty-dirs") {
return Err(anyhow!(t!(
"tool.config.legacy_key_unsupported",
key = "tools.rm-empty-dirs",
replacement = "tools.rm-useless-dirs"
)));
}
}
Ok(())
}
impl Config {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
if !path.exists() {
return Err(anyhow!(t!("tool.config.not_found", path = path.display())));
}
let content = fs::read_to_string(path)?;
let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content)
.map_err(|e| anyhow!(t!("tool.config.parse_failed", error = e)))?;
reject_legacy_rm_empty_dirs(&yaml_value)?;
let schema_kind = schema_kind_for_path(path);
if let Err(error) = validate_yaml_value(schema_kind, &yaml_value) {
return Err(anyhow!(t!(
"tool.config.schema_invalid",
path = path.display(),
error = error
)));
}
let config: Config = serde_yaml::from_value(yaml_value)
.map_err(|e| anyhow!(t!("tool.config.parse_failed", error = e)))?;
Ok(config)
}
pub fn load_with_priority(explicit_path: Option<&Path>) -> Result<Self> {
if let Some(path) = explicit_path {
return Self::load(path);
}
let local_config = std::env::current_dir()?.join(".llman/config.yaml");
if local_config.exists() {
return Self::load(local_config);
}
let global_config = get_global_config_path()?;
if global_config.exists() {
return Self::load(global_config);
}
Err(anyhow!(t!(
"tool.config.not_found_with_priority",
local = ".llman/config.yaml"
)))
}
pub fn load_or_default<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
if path.exists() {
Self::load(path)
} else {
Ok(Self::default())
}
}
pub fn load_with_priority_or_default(explicit_path: Option<&Path>) -> Result<Self> {
if let Some(path) = explicit_path {
if path.exists() {
return Self::load(path);
}
return Ok(Self::default());
}
let local_config = std::env::current_dir()?.join(".llman/config.yaml");
if local_config.exists() {
return Self::load(local_config);
}
let global_config = get_global_config_path()?;
if global_config.exists() {
return Self::load(global_config);
}
Ok(Self::default())
}
pub fn get_clean_comments_config(&self) -> Option<&CleanUselessCommentsConfig> {
self.tools.clean_useless_comments.as_ref()
}
pub fn get_rm_useless_dirs_config(&self) -> Option<&RmUselessDirsConfig> {
self.tools.rm_useless_dirs.as_ref()
}
pub fn generate_schema() -> Result<String> {
let schema = schema_for!(Config);
serde_json::to_string_pretty(&schema)
.map_err(|e| anyhow!(t!("tool.config.schema_generate_failed", error = e)))
}
}
impl Default for Config {
fn default() -> Self {
Self {
version: "0.1".to_string(),
tools: ToolsConfig {
rm_useless_dirs: None,
clean_useless_comments: Some(CleanUselessCommentsConfig {
scope: ScopeConfig::default(),
lang_rules: LanguageRules {
python: Some(LanguageSpecificRules {
single_line_comments: Some(false),
multi_line_comments: Some(false),
docstrings: Some(false),
preserve_patterns: Some(vec![
r"^\s*#\s*(TODO|FIXME|NOTE|HACK):\s*.*".to_string(),
r"^\s*#\s*(type|param|return|raises):\s*.*".to_string(),
r"^\s*#\s*(Copyright|License):\s*.*".to_string(),
]),
min_comment_length: Some(10),
min_code_complexity: Some(3),
remove_duplicate_comments: Some(true),
..Default::default()
}),
javascript: Some(LanguageSpecificRules {
single_line_comments: Some(false),
multi_line_comments: Some(false),
jsdoc: Some(false),
preserve_patterns: Some(vec![
r"^\s*//\s*(TODO|FIXME|NOTE|HACK):\s*.*".to_string(),
r"^\s*/\*\*.*\*/".to_string(),
r"^\s*//\s*(type|param|return):\s*.*".to_string(),
]),
min_comment_length: Some(10),
min_code_complexity: Some(3),
remove_duplicate_comments: Some(true),
..Default::default()
}),
rust: Some(LanguageSpecificRules {
single_line_comments: Some(false),
multi_line_comments: Some(false),
doc_comments: Some(false),
preserve_patterns: Some(vec![
r"^\s*///\s*(TODO|FIXME|NOTE|HACK):\s*.*".to_string(),
r"^\s*//!\s*(TODO|FIXME|NOTE|HACK):\s*.*".to_string(),
r"^\s*///\s*(Examples|Safety|Panics):\s*.*".to_string(),
]),
min_comment_length: Some(8),
min_code_complexity: Some(2),
remove_duplicate_comments: Some(true),
..Default::default()
}),
go: Some(LanguageSpecificRules {
single_line_comments: Some(false),
multi_line_comments: Some(false),
godoc: Some(false),
preserve_patterns: Some(vec![
r"^\s*//\s*(TODO|FIXME|NOTE|HACK):\s*.*".to_string(),
r"^\s*//\s*(Package|Function|Return|Parameters):\s*.*".to_string(),
]),
min_comment_length: Some(10),
min_code_complexity: Some(3),
remove_duplicate_comments: Some(true),
..Default::default()
}),
typescript: None,
},
global_rules: Some(GlobalRules {
preserve_empty_lines: Some(true),
remove_consecutive_empty_lines: Some(true),
remove_duplicate_comments: Some(true),
max_comment_density: Some(0.3),
min_comment_length: Some(8),
min_code_complexity: Some(2),
}),
safety: Some(SafetyConfig {
dry_run_first: Some(true),
git_aware: Some(true),
require_git_commit: Some(true),
}),
output: Some(OutputConfig {
show_changed_files: Some(true),
show_removed_comments: Some(true),
show_statistics: Some(true),
generate_report: Some(true),
report_format: Some("markdown".to_string()),
}),
}),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.version, "0.1");
assert!(config.tools.clean_useless_comments.is_some());
}
#[test]
fn test_config_schema_generation() {
let schema = Config::generate_schema();
assert!(schema.is_ok());
}
#[test]
fn test_config_from_yaml() {
let yaml = r#"
version: "0.1"
tools:
clean-useless-comments:
scope:
include:
- "**/*.py"
exclude:
- "**/node_modules/**"
lang-rules:
python:
single-line-comments: true
min-comment-length: 10
"#;
let mut temp_file = NamedTempFile::new().unwrap();
write!(temp_file, "{}", yaml).unwrap();
let config = Config::load(temp_file.path());
assert!(config.is_ok());
}
#[test]
fn test_default_preserve_patterns_match_todo() {
let config = Config::default();
let clean_config = config.tools.clean_useless_comments.unwrap();
let patterns = clean_config
.lang_rules
.python
.unwrap()
.preserve_patterns
.unwrap();
let regex = regex::Regex::new(&patterns[0]).unwrap();
assert!(regex.is_match("# TODO: check this"));
}
}