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