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 = "[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}