Skip to main content

panproto_project/
config.rs

1//! Project manifest (`panproto.toml`) loading, generation, and serialization.
2
3use std::path::{Path, PathBuf};
4
5use globset::{Glob, GlobSet, GlobSetBuilder};
6use serde::{Deserialize, Serialize};
7
8use crate::detect;
9use crate::error::ProjectError;
10
11/// Root manifest structure, deserialized from `panproto.toml`.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ProjectConfig {
14    /// Workspace-level settings.
15    pub workspace: WorkspaceConfig,
16    /// Package declarations.
17    #[serde(default)]
18    pub package: Vec<PackageConfig>,
19}
20
21/// Workspace-level settings.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct WorkspaceConfig {
24    /// Human-readable workspace name.
25    pub name: String,
26    /// Glob patterns for files/directories to exclude from parsing.
27    #[serde(default)]
28    pub exclude: Vec<String>,
29}
30
31/// A package within the workspace.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PackageConfig {
34    /// Package name (e.g., "panproto-gat").
35    pub name: String,
36    /// Path to the package root, relative to the workspace root.
37    pub path: PathBuf,
38    /// Protocol override for all files in this package.
39    /// If absent, language detection proceeds as normal.
40    #[serde(default)]
41    pub protocol: Option<String>,
42}
43
44/// Load a `ProjectConfig` from a `panproto.toml` file in `dir`.
45///
46/// Returns `Ok(None)` if the file does not exist.
47///
48/// # Errors
49///
50/// Returns `ProjectError::InvalidManifest` if the file exists but cannot be parsed.
51pub fn load_config(dir: &Path) -> Result<Option<ProjectConfig>, ProjectError> {
52    let manifest_path = dir.join("panproto.toml");
53    if !manifest_path.exists() {
54        return Ok(None);
55    }
56    let content = std::fs::read_to_string(&manifest_path)?;
57    let config: ProjectConfig =
58        toml::from_str(&content).map_err(|e| ProjectError::InvalidManifest {
59            path: manifest_path.display().to_string(),
60            reason: e.to_string(),
61        })?;
62    Ok(Some(config))
63}
64
65/// Compile exclude patterns from the manifest into a `GlobSet` for efficient matching.
66///
67/// Patterns are resolved relative to `base` so that `"target"` matches
68/// `<base>/target` and `"grammars/*/src/parser.c"` matches the expected paths.
69///
70/// # Errors
71///
72/// Returns `ProjectError::InvalidPattern` if a glob pattern is malformed.
73pub fn compile_excludes(base: &Path, patterns: &[String]) -> Result<GlobSet, ProjectError> {
74    let mut builder = GlobSetBuilder::new();
75    for pattern in patterns {
76        let full_pattern = base.join(pattern).display().to_string();
77        let glob = Glob::new(&full_pattern).map_err(|e| ProjectError::InvalidPattern {
78            pattern: pattern.clone(),
79            reason: e.to_string(),
80        })?;
81        builder.add(glob);
82    }
83    builder.build().map_err(|e| ProjectError::InvalidPattern {
84        pattern: "<composite>".to_owned(),
85        reason: e.to_string(),
86    })
87}
88
89/// Generate a `ProjectConfig` by scanning `dir` for known package markers.
90///
91/// # Errors
92///
93/// Returns `ProjectError` if directory scanning fails.
94pub fn generate_config(dir: &Path, name: &str) -> Result<ProjectConfig, ProjectError> {
95    let packages = detect::scan_packages(dir)?;
96    let package_configs: Vec<PackageConfig> = packages
97        .into_iter()
98        .map(|pkg| {
99            let relative_path = pkg
100                .path
101                .strip_prefix(dir)
102                .unwrap_or(&pkg.path)
103                .to_path_buf();
104            PackageConfig {
105                name: pkg.name,
106                path: relative_path,
107                protocol: Some(pkg.protocol),
108            }
109        })
110        .collect();
111
112    Ok(ProjectConfig {
113        workspace: WorkspaceConfig {
114            name: name.to_owned(),
115            exclude: vec![
116                "target".to_owned(),
117                "node_modules".to_owned(),
118                "__pycache__".to_owned(),
119                "build".to_owned(),
120                "dist".to_owned(),
121                ".git".to_owned(),
122            ],
123        },
124        package: package_configs,
125    })
126}
127
128/// Serialize a `ProjectConfig` to a TOML string.
129///
130/// # Errors
131///
132/// Returns `ProjectError::InvalidManifest` if serialization fails.
133pub fn serialize_config(config: &ProjectConfig) -> Result<String, ProjectError> {
134    toml::to_string_pretty(config).map_err(|e| ProjectError::InvalidManifest {
135        path: "panproto.toml".to_owned(),
136        reason: e.to_string(),
137    })
138}
139
140#[cfg(test)]
141#[allow(clippy::unwrap_used)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn round_trip_config() {
147        let config = ProjectConfig {
148            workspace: WorkspaceConfig {
149                name: "test-project".to_owned(),
150                exclude: vec!["target".to_owned(), "node_modules".to_owned()],
151            },
152            package: vec![
153                PackageConfig {
154                    name: "core".to_owned(),
155                    path: PathBuf::from("crates/core"),
156                    protocol: Some("rust".to_owned()),
157                },
158                PackageConfig {
159                    name: "sdk".to_owned(),
160                    path: PathBuf::from("sdk/typescript"),
161                    protocol: Some("typescript".to_owned()),
162                },
163            ],
164        };
165
166        let toml_str = serialize_config(&config).unwrap();
167        let parsed: ProjectConfig = toml::from_str(&toml_str).unwrap();
168        assert_eq!(parsed.workspace.name, "test-project");
169        assert_eq!(parsed.package.len(), 2);
170        assert_eq!(parsed.package[0].name, "core");
171        assert_eq!(parsed.package[1].protocol.as_deref(), Some("typescript"));
172    }
173
174    #[test]
175    fn compile_excludes_builds_globset() {
176        let base = Path::new("/tmp/project");
177        let patterns = vec!["target".to_owned(), "**/*.log".to_owned()];
178        let globset = compile_excludes(base, &patterns).unwrap();
179        assert!(globset.is_match("/tmp/project/target"));
180        assert!(globset.is_match("/tmp/project/logs/debug.log"));
181        assert!(!globset.is_match("/tmp/project/src/main.rs"));
182    }
183
184    #[test]
185    fn load_config_missing_file() {
186        let result = load_config(Path::new("/nonexistent/path")).unwrap();
187        assert!(result.is_none());
188    }
189}