Skip to main content

cgraph/config/
mod.rs

1#![doc = include_str!("README.md")]
2
3use std::{
4    fs::{self, OpenOptions},
5    io::{ErrorKind, Write},
6    path::{Path, PathBuf},
7};
8
9use anyhow::{Context, Result, bail};
10use serde::Deserialize;
11
12pub const PROJECT_CONFIG_FILE: &str = ".cgraph.toml";
13const PROJECT_CONFIG_TEMPLATE: &str =
14    "# Full symbol names; * matches any number of characters.\n[filters]\nsymbols = []\n";
15
16#[derive(Clone, Debug, Default, Eq, PartialEq)]
17pub struct SymbolFilter {
18    patterns: Vec<String>,
19}
20
21impl SymbolFilter {
22    pub fn from_patterns<I, S>(patterns: I) -> Result<Self>
23    where
24        I: IntoIterator<Item = S>,
25        S: Into<String>,
26    {
27        let patterns = patterns.into_iter();
28        let mut normalized = Vec::with_capacity(patterns.size_hint().0);
29        for pattern in patterns {
30            let pattern = pattern.into();
31            let pattern = pattern.trim();
32            if pattern.is_empty() {
33                bail!("symbol filter contains an empty pattern");
34            }
35            if !normalized.iter().any(|existing| existing == pattern) {
36                normalized.push(pattern.to_owned());
37            }
38        }
39        Ok(Self {
40            patterns: normalized,
41        })
42    }
43
44    pub fn is_ignored(&self, symbol_name: &str) -> bool {
45        self.patterns
46            .iter()
47            .any(|pattern| wildcard_matches(pattern, symbol_name))
48    }
49}
50
51fn wildcard_matches(pattern: &str, candidate: &str) -> bool {
52    let pattern = pattern.chars().collect::<Vec<_>>();
53    let candidate = candidate.chars().collect::<Vec<_>>();
54    let mut previous = vec![false; candidate.len() + 1];
55    previous[0] = true;
56    for pattern_character in pattern {
57        let mut current = vec![false; candidate.len() + 1];
58        if pattern_character == '*' {
59            current[0] = previous[0];
60            for index in 1..=candidate.len() {
61                current[index] = previous[index] || current[index - 1];
62            }
63        } else {
64            for index in 1..=candidate.len() {
65                current[index] = previous[index - 1] && candidate[index - 1] == pattern_character;
66            }
67        }
68        previous = current;
69    }
70    previous[candidate.len()]
71}
72
73#[derive(Clone, Debug, Default, Eq, PartialEq)]
74pub struct ProjectConfig {
75    pub symbol_filter: SymbolFilter,
76}
77
78impl ProjectConfig {
79    pub fn path(workspace_root: &Path) -> PathBuf {
80        workspace_root.join(PROJECT_CONFIG_FILE)
81    }
82
83    pub fn create_if_missing(workspace_root: &Path) -> Result<PathBuf> {
84        let path = Self::path(workspace_root);
85        match OpenOptions::new().write(true).create_new(true).open(&path) {
86            Ok(mut file) => file
87                .write_all(PROJECT_CONFIG_TEMPLATE.as_bytes())
88                .with_context(|| {
89                    format!("failed to initialize project config {}", path.display())
90                })?,
91            Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
92            Err(error) => {
93                return Err(error).with_context(|| {
94                    format!("failed to create project config {}", path.display())
95                });
96            }
97        }
98        Ok(path)
99    }
100
101    pub fn load(workspace_root: &Path) -> Result<Self> {
102        let path = Self::path(workspace_root);
103        let contents = match fs::read_to_string(&path) {
104            Ok(contents) => contents,
105            Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Self::default()),
106            Err(error) => {
107                return Err(error)
108                    .with_context(|| format!("failed to read project config {}", path.display()));
109            }
110        };
111        let raw = toml::from_str::<RawProjectConfig>(&contents)
112            .with_context(|| format!("failed to parse project config {}", path.display()))?;
113        Ok(Self {
114            symbol_filter: SymbolFilter::from_patterns(raw.filters.symbols)
115                .with_context(|| format!("{} contains invalid filters.symbols", path.display()))?,
116        })
117    }
118}
119
120#[derive(Debug, Default, Deserialize)]
121#[serde(default, deny_unknown_fields)]
122struct RawProjectConfig {
123    filters: RawFilters,
124}
125
126#[derive(Debug, Default, Deserialize)]
127#[serde(default, deny_unknown_fields)]
128struct RawFilters {
129    symbols: Vec<String>,
130}
131
132#[cfg(test)]
133mod tests {
134    use std::{
135        fs,
136        path::PathBuf,
137        time::{SystemTime, UNIX_EPOCH},
138    };
139
140    use super::{ProjectConfig, SymbolFilter};
141
142    #[test]
143    fn loads_and_normalizes_project_local_symbol_filters() {
144        let workspace = temporary_workspace("load");
145        assert_eq!(ProjectConfig::load(&workspace).unwrap(), Default::default());
146        let path = ProjectConfig::create_if_missing(&workspace).unwrap();
147        assert_eq!(path, workspace.join(".cgraph.toml"));
148        assert_eq!(ProjectConfig::load(&workspace).unwrap(), Default::default());
149        fs::write(
150            &path,
151            "[filters]\nsymbols = [\"*::into\", \" Option::is_some \", \"*::into\", \"*::Some\"]\n",
152        )
153        .unwrap();
154        ProjectConfig::create_if_missing(&workspace).unwrap();
155        assert!(fs::read_to_string(&path).unwrap().contains("*::into"));
156
157        let config = ProjectConfig::load(&workspace).unwrap();
158
159        assert!(config.symbol_filter.is_ignored("Vec::into"));
160        assert!(config.symbol_filter.is_ignored("Option::is_some"));
161        assert!(config.symbol_filter.is_ignored("Option::Some"));
162        assert!(!config.symbol_filter.is_ignored("is_some"));
163        assert!(!config.symbol_filter.is_ignored("Option::some"));
164        assert!(
165            SymbolFilter::from_patterns(["*选*::方*"])
166                .unwrap()
167                .is_ignored("可选项::方法")
168        );
169        fs::remove_dir_all(workspace).unwrap();
170    }
171
172    #[test]
173    fn rejects_invalid_or_empty_filter_entries() {
174        let workspace = temporary_workspace("invalid");
175        fs::write(
176            workspace.join(".cgraph.toml"),
177            "[filters]\nsymbols = [\"  \"]\n",
178        )
179        .unwrap();
180
181        let error = ProjectConfig::load(&workspace).unwrap_err();
182
183        assert!(format!("{error:#}").contains("empty pattern"));
184        fs::write(
185            workspace.join(".cgraph.toml"),
186            "[filters]\nsymbols = []\nunknown = true\n",
187        )
188        .unwrap();
189        let error = ProjectConfig::load(&workspace).unwrap_err();
190        assert!(format!("{error:#}").contains("unknown field"));
191        fs::remove_dir_all(workspace).unwrap();
192    }
193
194    fn temporary_workspace(name: &str) -> PathBuf {
195        let unique = SystemTime::now()
196            .duration_since(UNIX_EPOCH)
197            .unwrap()
198            .as_nanos();
199        let workspace = std::env::temp_dir().join(format!("cgraph-config-{name}-{unique}"));
200        fs::create_dir(&workspace).unwrap();
201        workspace
202    }
203}