Skip to main content

faf_kernel/
parser.rs

1//! Core FAF parser - optimized for inference workloads
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6use thiserror::Error;
7
8use crate::types::FafData;
9
10/// FAF parsing errors
11#[derive(Error, Debug)]
12pub enum FafError {
13    /// The input was empty or whitespace-only.
14    #[error("Empty content")]
15    EmptyContent,
16
17    /// The content was not valid YAML.
18    #[error("Invalid YAML: {0}")]
19    YamlError(#[from] serde_yaml_ng::Error),
20
21    /// An I/O error occurred while reading a file.
22    #[error("IO error: {0}")]
23    IoError(#[from] std::io::Error),
24
25    /// A required field was absent (the field name is included).
26    #[error("Missing required field: {0}")]
27    MissingField(String),
28}
29
30/// Parsed FAF file with convenient accessors
31#[derive(Debug, Clone)]
32pub struct FafFile {
33    /// Parsed and typed data
34    pub data: FafData,
35    /// Original file path (if loaded from file)
36    pub path: Option<String>,
37}
38
39impl FafFile {
40    /// Get project name
41    #[inline]
42    pub fn project_name(&self) -> &str {
43        &self.data.project.name
44    }
45
46    /// Get AI score as integer (0-100)
47    pub fn score(&self) -> Option<u8> {
48        self.data
49            .ai_score
50            .as_ref()
51            .and_then(|s| s.trim_end_matches('%').parse().ok())
52    }
53
54    /// Get FAF version
55    #[inline]
56    pub fn version(&self) -> &str {
57        &self.data.faf_version
58    }
59
60    /// Get tech stack string
61    pub fn tech_stack(&self) -> Option<&str> {
62        self.data
63            .instant_context
64            .as_ref()
65            .and_then(|ic| ic.tech_stack.as_deref())
66    }
67
68    /// Get what building
69    pub fn what_building(&self) -> Option<&str> {
70        self.data
71            .instant_context
72            .as_ref()
73            .and_then(|ic| ic.what_building.as_deref())
74    }
75
76    /// Get key files
77    pub fn key_files(&self) -> &[String] {
78        self.data
79            .instant_context
80            .as_ref()
81            .map(|ic| ic.key_files.as_slice())
82            .unwrap_or(&[])
83    }
84
85    /// Get project goal
86    pub fn goal(&self) -> Option<&str> {
87        self.data.project.goal.as_deref()
88    }
89
90    /// Check if score indicates high quality (>= 70%)
91    pub fn is_high_quality(&self) -> bool {
92        self.score().map(|s| s >= 70).unwrap_or(false)
93    }
94
95    /// Get commands (build/test/lint/dev), checking top-level first and
96    /// falling back to `instant_context.commands` for older `.faf` files
97    /// that nest them there.
98    pub fn commands(&self) -> HashMap<String, String> {
99        if !self.data.commands.is_empty() {
100            return self.data.commands.clone();
101        }
102        self.data
103            .instant_context
104            .as_ref()
105            .map(|ic| ic.commands.clone())
106            .unwrap_or_default()
107    }
108}
109
110/// Parse FAF content from string
111///
112/// # Example
113///
114/// ```rust
115/// use faf_kernel::parse;
116///
117/// let content = r#"
118/// faf_version: 2.5.0
119/// project:
120///   name: test
121/// "#;
122///
123/// let faf = parse(content).unwrap();
124/// assert_eq!(faf.project_name(), "test");
125/// ```
126pub fn parse(content: &str) -> Result<FafFile, FafError> {
127    let content = content.trim();
128    if content.is_empty() {
129        return Err(FafError::EmptyContent);
130    }
131
132    let data: FafData = serde_yaml_ng::from_str(content)?;
133
134    Ok(FafFile { data, path: None })
135}
136
137/// Parse FAF from file path
138///
139/// # Example
140///
141/// ```rust,no_run
142/// use faf_kernel::parse_file;
143///
144/// let faf = parse_file("project.faf").unwrap();
145/// println!("Project: {}", faf.project_name());
146/// ```
147pub fn parse_file<P: AsRef<Path>>(path: P) -> Result<FafFile, FafError> {
148    let path_str = path.as_ref().to_string_lossy().to_string();
149    let content = fs::read_to_string(&path)?;
150
151    let mut faf = parse(&content)?;
152    faf.path = Some(path_str);
153
154    Ok(faf)
155}
156
157/// Serialize FAF back to YAML string
158pub fn stringify(faf: &FafFile) -> Result<String, FafError> {
159    Ok(serde_yaml_ng::to_string(&faf.data)?)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn test_parse_minimal() {
168        let content = r#"
169faf_version: 2.5.0
170project:
171  name: test-project
172"#;
173        let faf = parse(content).unwrap();
174        assert_eq!(faf.project_name(), "test-project");
175        assert_eq!(faf.version(), "2.5.0");
176    }
177
178    #[test]
179    fn test_parse_with_score() {
180        let content = r#"
181faf_version: 2.5.0
182ai_score: "85%"
183project:
184  name: test
185"#;
186        let faf = parse(content).unwrap();
187        assert_eq!(faf.score(), Some(85));
188    }
189
190    #[test]
191    fn test_parse_full() {
192        let content = r#"
193faf_version: 2.5.0
194ai_score: "90%"
195project:
196  name: full-test
197  goal: Test everything
198instant_context:
199  what_building: Test app
200  tech_stack: Rust, Python
201  key_files:
202    - src/main.rs
203    - src/lib.rs
204stack:
205  backend: Rust
206  database: PostgreSQL
207"#;
208        let faf = parse(content).unwrap();
209        assert_eq!(faf.project_name(), "full-test");
210        assert_eq!(faf.tech_stack(), Some("Rust, Python"));
211        assert_eq!(faf.key_files().len(), 2);
212        assert!(faf.is_high_quality());
213    }
214
215    #[test]
216    fn test_parse_top_level_commands_security_ai_instructions_conventions() {
217        let content = r#"
218faf_version: 2.5.0
219project:
220  name: full-test
221commands:
222  build: cargo build
223  test: cargo test
224security:
225  secrets: .env
226  example: .env.example
227  never:
228    - .env
229ai_instructions:
230  warnings:
231    - "Use bun, not npm"
232conventions:
233  - "Conventional Commits"
234"#;
235        let faf = parse(content).unwrap();
236        assert_eq!(
237            faf.commands().get("build").map(String::as_str),
238            Some("cargo build")
239        );
240        let security = faf.data.security.as_ref().unwrap();
241        assert_eq!(security.secrets.as_deref(), Some(".env"));
242        assert_eq!(security.never, vec![".env".to_string()]);
243        let ai = faf.data.ai_instructions.as_ref().unwrap();
244        assert_eq!(ai.warnings, vec!["Use bun, not npm".to_string()]);
245        assert_eq!(
246            faf.data.conventions,
247            vec!["Conventional Commits".to_string()]
248        );
249    }
250
251    #[test]
252    fn test_commands_falls_back_to_instant_context() {
253        let content = r#"
254faf_version: 2.5.0
255project:
256  name: legacy-shape
257instant_context:
258  commands:
259    build: cargo build --release
260"#;
261        let faf = parse(content).unwrap();
262        assert_eq!(
263            faf.commands().get("build").map(String::as_str),
264            Some("cargo build --release")
265        );
266    }
267
268    #[test]
269    fn test_missing_new_fields_default_empty() {
270        let content = r#"
271faf_version: 2.5.0
272project:
273  name: minimal
274"#;
275        let faf = parse(content).unwrap();
276        assert!(faf.commands().is_empty());
277        assert!(faf.data.security.is_none());
278        assert!(faf.data.ai_instructions.is_none());
279        assert!(faf.data.conventions.is_empty());
280    }
281
282    #[test]
283    fn test_empty_content() {
284        let result = parse("");
285        assert!(matches!(result, Err(FafError::EmptyContent)));
286    }
287
288    #[test]
289    fn test_invalid_yaml() {
290        let result = parse("invalid: [unclosed");
291        assert!(matches!(result, Err(FafError::YamlError(_))));
292    }
293}