Skip to main content

ztr_lib/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashSet;
4use std::path::Path;
5
6/// 表示 ZTR 压缩工具的配置。
7/// 包含压缩格式、输出文件名、忽略规则和忽略文件路径。
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Config {
10    /// 压缩格式: "zip", "tar.gz", "7z"
11    pub format: String,
12    /// 输出文件名 (可选)
13    pub output_name: Option<String>,
14    /// 忽略规则列表
15    pub ignore: Option<Vec<String>>,
16    /// 忽略文件路径
17    pub ignore_file: Option<String>,
18    /// 已经解析的忽略文件内容 (在加载配置时读取并存储)
19    #[serde(skip)]
20    pub resolved_ignore_file_content: Option<String>,
21}
22
23impl Default for Config {
24    fn default() -> Self {
25        Self {
26            format: "tar.gz".to_string(),
27            output_name: None,
28            ignore: Some(vec![
29                "target/".to_string(),
30                "*.tmp".to_string(),
31                "*.log".to_string(),
32                ".DS_Store".to_string(),
33                "Thumbs.db".to_string(),
34                "*.swp".to_string(),
35                "*.swo".to_string(),
36                "*~".to_string(),
37                ".git/".to_string(),
38                ".svn/".to_string(),
39                ".hg/".to_string(),
40                "node_modules/".to_string(),
41                "__pycache__/".to_string(),
42                ".pytest_cache/".to_string(),
43                ".venv/".to_string(),
44                "venv/".to_string(),
45                "env/".to_string(),
46                "*.pyc".to_string(),
47                "*.pyo".to_string(),
48                "*.pyd".to_string(),
49                ".idea/".to_string(),
50                ".vscode/".to_string(),
51                "*.iml".to_string(),
52            ]),
53            ignore_file: None,
54            resolved_ignore_file_content: None, // 默认初始化为 None
55        }
56    }
57}
58
59impl Config {
60    /// 从指定路径加载配置文件并解析为 Config 结构体。
61    ///
62    /// 如果配置中指定了 `ignore_file`,则会尝试读取其内容并存储在 `resolved_ignore_file_content` 字段中。
63    ///
64    /// # 参数
65    /// - `path`: 配置文件的路径。
66    ///
67    /// # 返回
68    /// `Result<Self>`: 成功时返回解析后的 Config 结构体,失败时返回错误信息。
69    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
70        let content = std::fs::read_to_string(&path)
71            .with_context(|| format!("无法读取配置文件: {}", path.as_ref().display()))?;
72
73        let mut config: Config = toml::from_str(&content).with_context(|| "解析配置文件失败")?;
74
75        // 验证压缩格式
76        if !["zip", "tar.gz", "7z"].contains(&config.format.as_str()) {
77            anyhow::bail!(
78                "不支持的压缩格式: {},支持的格式: zip, tar.gz, 7z",
79                config.format
80            );
81        }
82
83        // 如果指定了忽略文件路径,则读取其内容
84        if let Some(ignore_file_path) = &config.ignore_file {
85            if let Ok(file_content) = std::fs::read_to_string(ignore_file_path) {
86                config.resolved_ignore_file_content = Some(file_content);
87            }
88        }
89
90        Ok(config)
91    }
92
93    /// 创建一个默认的 `ztr.toml` 配置文件。
94    ///
95    /// # 参数
96    /// - `output_path`: 配置文件的输出路径。如果为 `None`,则默认为当前目录下的 `ztr.toml`。
97    ///
98    /// # 返回
99    /// `Result<()>`: 成功时返回 `Ok(())`,失败时返回错误信息。
100    pub fn create_default_config_file(output_path: Option<&Path>) -> Result<()> {
101        let config = Config::default();
102        let toml_content = toml::to_string_pretty(&config).context("无法序列化默认配置")?;
103
104        let path = output_path.unwrap_or(&Path::new("ztr.toml"));
105        std::fs::write(path, toml_content)
106            .with_context(|| format!("无法写入配置文件: {}", path.display()))?;
107
108        Ok(())
109    }
110
111    /// 获取压缩包的输出名称。
112    /// 如果配置中指定了输出名称,则使用该名称;否则,使用当前目录名作为输出名称。
113    ///
114    /// # 返回
115    /// `String`: 压缩包的输出名称。
116    pub fn get_output_name(&self) -> String {
117        if let Some(name) = &self.output_name {
118            name.clone()
119        } else {
120            // 使用当前目录名
121            match std::env::current_dir() {
122                Ok(path) => path
123                    .file_name()
124                    .and_then(|n| n.to_str())
125                    .unwrap_or("archive")
126                    .to_string(),
127                Err(_) => "archive".to_string(),
128            }
129        }
130    }
131
132    /// 获取忽略规则列表,优先使用 `ignore` 字段,其次是 `resolved_ignore_file_content`。
133    pub fn get_ignore_rules(&self) -> Vec<String> {
134        let mut all_rules: HashSet<String> = HashSet::new();
135
136        if let Some(ignore_list) = &self.ignore {
137            for rule in ignore_list {
138                all_rules.insert(rule.clone());
139            }
140        }
141
142        if let Some(content) = &self.resolved_ignore_file_content {
143            for line in content.lines() {
144                let trimmed_line = line.trim();
145                if !trimmed_line.is_empty() && !trimmed_line.starts_with('#') {
146                    all_rules.insert(trimmed_line.to_string());
147                }
148            }
149        }
150
151        all_rules.into_iter().collect()
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use std::io::Write;
159    use tempfile::NamedTempFile;
160
161    #[test]
162    fn test_config_load() -> Result<()> {
163        let toml_content = r#"
164            format = "zip"
165            output_name = "test_archive"
166            ignore = [".test_ignore"]
167            ignore_file = "./test_ignore_file.txt"
168        "#;
169        let mut file = NamedTempFile::new()?;
170        write!(file, "{}", toml_content)?;
171        let config = Config::load(file.path())?;
172
173        assert_eq!(config.format, "zip");
174        assert_eq!(config.output_name, Some("test_archive".to_string()));
175        assert_eq!(config.ignore, Some(vec![".test_ignore".to_string()]));
176        assert_eq!(
177            config.ignore_file,
178            Some("./test_ignore_file.txt".to_string())
179        );
180        assert_eq!(config.resolved_ignore_file_content, None); // ignore_file.txt 不存在,所以内容应为 None
181        Ok(())
182    }
183
184    #[test]
185    fn test_config_load_with_ignore_file_content() -> Result<()> {
186        let mut ignore_file = NamedTempFile::new()?;
187        writeln!(ignore_file, "file_from_ignore.txt")?;
188        let ignore_file_path = ignore_file.path().to_string_lossy().to_string();
189
190        let toml_content = format!(
191            r#"
192            format = "zip"
193            ignore_file = "{}"
194        "#,
195            ignore_file_path
196        );
197        let mut config_file = NamedTempFile::new()?;
198        write!(config_file, "{}", toml_content)?;
199        let config = Config::load(config_file.path())?;
200
201        assert_eq!(config.format, "zip");
202        assert_eq!(config.ignore_file, Some(ignore_file_path.clone()));
203        assert_eq!(
204            config.resolved_ignore_file_content,
205            Some("file_from_ignore.txt\n".to_string())
206        );
207
208        let rules = config.get_ignore_rules();
209        assert!(rules.contains(&"file_from_ignore.txt".to_string()));
210        assert_eq!(rules.len(), 1);
211        Ok(())
212    }
213
214    #[test]
215    fn test_config_load_invalid_format() -> Result<()> {
216        let toml_content = r#"
217            format = "rar"
218        "#;
219        let mut file = NamedTempFile::new()?;
220        write!(file, "{}", toml_content)?;
221        let err = Config::load(file.path()).unwrap_err();
222        assert!(err.to_string().contains("不支持的压缩格式"));
223        Ok(())
224    }
225
226    #[test]
227    fn test_get_output_name_from_config() {
228        let config = Config {
229            format: "zip".to_string(),
230            output_name: Some("my_custom_name".to_string()),
231            ignore: None,
232            ignore_file: None,
233            resolved_ignore_file_content: None,
234        };
235        assert_eq!(config.get_output_name(), "my_custom_name");
236    }
237
238    #[test]
239    fn test_get_output_name_default() {
240        let config = Config::default();
241        // 假设当前目录名不是 "archive",这里需要一个更健壮的测试,可能需要模拟当前目录
242        // 为了测试目的,我们只检查它不是 None 并且不是空字符串
243        let output_name = config.get_output_name();
244        assert!(!output_name.is_empty());
245        assert_ne!(output_name, "archive"); // 除非当前目录是根目录,否则不会是 "archive"
246    }
247
248    #[test]
249    fn test_get_ignore_rules_from_config() {
250        let config = Config {
251            format: "zip".to_string(),
252            output_name: None,
253            ignore: Some(vec!["rule1".to_string(), "rule2".to_string()]),
254            ignore_file: None,
255            resolved_ignore_file_content: None,
256        };
257        let rules = config.get_ignore_rules();
258        assert!(rules.contains(&"rule1".to_string()));
259        assert!(rules.contains(&"rule2".to_string()));
260        assert_eq!(rules.len(), 2);
261    }
262
263    #[test]
264    fn test_get_ignore_rules_from_resolved_file_content() {
265        let mut config_with_file_content = Config::default();
266        config_with_file_content.resolved_ignore_file_content =
267            Some("# 注释\nrule_from_file1\n\nrule_from_file2".to_string());
268        let rules = config_with_file_content.get_ignore_rules();
269        assert!(rules.contains(&"rule_from_file1".to_string()));
270        assert!(rules.contains(&"rule_from_file2".to_string()));
271        assert_eq!(rules.len(), 2);
272    }
273
274    #[test]
275    fn test_get_ignore_rules_priority() {
276        let mut config = Config {
277            format: "zip".to_string(),
278            output_name: None,
279            ignore: Some(vec![
280                "rule_from_config".to_string(),
281                "common_rule".to_string(),
282            ]),
283            ignore_file: None,
284            resolved_ignore_file_content: None,
285        };
286        config.resolved_ignore_file_content = Some("rule_from_file\ncommon_rule".to_string());
287        let rules = config.get_ignore_rules();
288        assert!(rules.contains(&"rule_from_config".to_string()));
289        assert!(rules.contains(&"rule_from_file".to_string()));
290        assert!(rules.contains(&"common_rule".to_string()));
291        assert_eq!(rules.len(), 3); // "common_rule" 不会重复
292    }
293}