Skip to main content

tessera_codegraph/
config.rs

1use std::fs;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7pub struct TesseraConfig {
8    /// Only index paths with one of these relative path prefixes. Empty means all.
9    pub include_paths: Vec<String>,
10    /// Skip paths with one of these relative path prefixes.
11    pub exclude_paths: Vec<String>,
12    /// Extra directory or file names to skip during traversal.
13    pub ignore_names: Vec<String>,
14}
15
16impl TesseraConfig {
17    pub fn load(root: &Path) -> Self {
18        let path = root.join(".tessera/config.toml");
19        let Ok(content) = fs::read_to_string(path) else {
20            return Self::default();
21        };
22        Self::parse_lossy(&content)
23    }
24
25    pub fn parse_lossy(content: &str) -> Self {
26        let mut config = Self::default();
27        let mut section = String::new();
28
29        for raw_line in content.lines() {
30            let line = raw_line.split('#').next().unwrap_or_default().trim();
31            if line.is_empty() {
32                continue;
33            }
34            if line.starts_with('[') && line.ends_with(']') {
35                section = line.trim_matches(['[', ']']).trim().to_string();
36                continue;
37            }
38            let Some((key, value)) = line.split_once('=') else {
39                continue;
40            };
41            let key = key.trim();
42            let values = parse_string_array(value.trim());
43            match (section.as_str(), key) {
44                ("include", "paths") => config.include_paths = normalize_prefixes(values),
45                ("exclude", "paths") => config.exclude_paths = normalize_prefixes(values),
46                ("ignore", "extra") => config.ignore_names = values,
47                _ => {}
48            }
49        }
50
51        config
52    }
53
54    pub fn includes_path(&self, rel_path: &str) -> bool {
55        self.include_paths.is_empty()
56            || self
57                .include_paths
58                .iter()
59                .any(|prefix| rel_path.starts_with(prefix))
60    }
61
62    pub fn excludes_path(&self, rel_path: &str) -> bool {
63        self.exclude_paths
64            .iter()
65            .any(|prefix| rel_path.starts_with(prefix))
66    }
67
68    pub fn ignores_name(&self, name: &str) -> bool {
69        self.ignore_names.iter().any(|ignored| ignored == name)
70    }
71}
72
73fn parse_string_array(value: &str) -> Vec<String> {
74    let value = value.trim();
75    let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else {
76        return Vec::new();
77    };
78    inner
79        .split(',')
80        .filter_map(|part| {
81            let trimmed = part.trim().trim_matches('"').trim_matches('\'').trim();
82            if trimmed.is_empty() {
83                None
84            } else {
85                Some(trimmed.replace('\\', "/"))
86            }
87        })
88        .collect()
89}
90
91fn normalize_prefixes(values: Vec<String>) -> Vec<String> {
92    values
93        .into_iter()
94        .map(|value| value.trim_start_matches("./").replace('\\', "/"))
95        .collect()
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn parses_include_exclude_and_extra_ignores() {
104        let config = TesseraConfig::parse_lossy(
105            r#"
106[include]
107paths = ["src/", "lib"]
108
109[exclude]
110paths = ["src/generated/"]
111
112[ignore]
113extra = ["fixtures", "tmp"]
114"#,
115        );
116
117        assert!(config.includes_path("src/app.ts"));
118        assert!(!config.includes_path("tests/app.ts"));
119        assert!(config.excludes_path("src/generated/app.ts"));
120        assert!(config.ignores_name("fixtures"));
121        assert!(config.ignores_name("tmp"));
122    }
123}