Skip to main content

faf_kernel/
parser.rs

1//! Core FAF parser - optimized for inference workloads
2
3use std::fs;
4use std::path::Path;
5use thiserror::Error;
6
7use crate::types::FafData;
8
9/// FAF parsing errors
10#[derive(Error, Debug)]
11pub enum FafError {
12    /// The input was empty or whitespace-only.
13    #[error("Empty content")]
14    EmptyContent,
15
16    /// The content was not valid YAML.
17    #[error("Invalid YAML: {0}")]
18    YamlError(#[from] serde_yaml_ng::Error),
19
20    /// An I/O error occurred while reading a file.
21    #[error("IO error: {0}")]
22    IoError(#[from] std::io::Error),
23
24    /// A required field was absent (the field name is included).
25    #[error("Missing required field: {0}")]
26    MissingField(String),
27}
28
29/// Parsed FAF file with convenient accessors
30#[derive(Debug, Clone)]
31pub struct FafFile {
32    /// Parsed and typed data
33    pub data: FafData,
34    /// Original file path (if loaded from file)
35    pub path: Option<String>,
36}
37
38impl FafFile {
39    /// Get project name
40    #[inline]
41    pub fn project_name(&self) -> &str {
42        &self.data.project.name
43    }
44
45    /// Get AI score as integer (0-100)
46    pub fn score(&self) -> Option<u8> {
47        self.data
48            .ai_score
49            .as_ref()
50            .and_then(|s| s.trim_end_matches('%').parse().ok())
51    }
52
53    /// Get FAF version
54    #[inline]
55    pub fn version(&self) -> &str {
56        &self.data.faf_version
57    }
58
59    /// Get tech stack string
60    pub fn tech_stack(&self) -> Option<&str> {
61        self.data
62            .instant_context
63            .as_ref()
64            .and_then(|ic| ic.tech_stack.as_deref())
65    }
66
67    /// Get what building
68    pub fn what_building(&self) -> Option<&str> {
69        self.data
70            .instant_context
71            .as_ref()
72            .and_then(|ic| ic.what_building.as_deref())
73    }
74
75    /// Get key files
76    pub fn key_files(&self) -> &[String] {
77        self.data
78            .instant_context
79            .as_ref()
80            .map(|ic| ic.key_files.as_slice())
81            .unwrap_or(&[])
82    }
83
84    /// Get project goal
85    pub fn goal(&self) -> Option<&str> {
86        self.data.project.goal.as_deref()
87    }
88
89    /// Check if score indicates high quality (>= 70%)
90    pub fn is_high_quality(&self) -> bool {
91        self.score().map(|s| s >= 70).unwrap_or(false)
92    }
93}
94
95/// Parse FAF content from string
96///
97/// # Example
98///
99/// ```rust
100/// use faf_kernel::parse;
101///
102/// let content = r#"
103/// faf_version: 2.5.0
104/// project:
105///   name: test
106/// "#;
107///
108/// let faf = parse(content).unwrap();
109/// assert_eq!(faf.project_name(), "test");
110/// ```
111pub fn parse(content: &str) -> Result<FafFile, FafError> {
112    let content = content.trim();
113    if content.is_empty() {
114        return Err(FafError::EmptyContent);
115    }
116
117    let data: FafData = serde_yaml_ng::from_str(content)?;
118
119    Ok(FafFile { data, path: None })
120}
121
122/// Parse FAF from file path
123///
124/// # Example
125///
126/// ```rust,no_run
127/// use faf_kernel::parse_file;
128///
129/// let faf = parse_file("project.faf").unwrap();
130/// println!("Project: {}", faf.project_name());
131/// ```
132pub fn parse_file<P: AsRef<Path>>(path: P) -> Result<FafFile, FafError> {
133    let path_str = path.as_ref().to_string_lossy().to_string();
134    let content = fs::read_to_string(&path)?;
135
136    let mut faf = parse(&content)?;
137    faf.path = Some(path_str);
138
139    Ok(faf)
140}
141
142/// Serialize FAF back to YAML string
143pub fn stringify(faf: &FafFile) -> Result<String, FafError> {
144    Ok(serde_yaml_ng::to_string(&faf.data)?)
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_parse_minimal() {
153        let content = r#"
154faf_version: 2.5.0
155project:
156  name: test-project
157"#;
158        let faf = parse(content).unwrap();
159        assert_eq!(faf.project_name(), "test-project");
160        assert_eq!(faf.version(), "2.5.0");
161    }
162
163    #[test]
164    fn test_parse_with_score() {
165        let content = r#"
166faf_version: 2.5.0
167ai_score: "85%"
168project:
169  name: test
170"#;
171        let faf = parse(content).unwrap();
172        assert_eq!(faf.score(), Some(85));
173    }
174
175    #[test]
176    fn test_parse_full() {
177        let content = r#"
178faf_version: 2.5.0
179ai_score: "90%"
180project:
181  name: full-test
182  goal: Test everything
183instant_context:
184  what_building: Test app
185  tech_stack: Rust, Python
186  key_files:
187    - src/main.rs
188    - src/lib.rs
189stack:
190  backend: Rust
191  database: PostgreSQL
192"#;
193        let faf = parse(content).unwrap();
194        assert_eq!(faf.project_name(), "full-test");
195        assert_eq!(faf.tech_stack(), Some("Rust, Python"));
196        assert_eq!(faf.key_files().len(), 2);
197        assert!(faf.is_high_quality());
198    }
199
200    #[test]
201    fn test_empty_content() {
202        let result = parse("");
203        assert!(matches!(result, Err(FafError::EmptyContent)));
204    }
205
206    #[test]
207    fn test_invalid_yaml() {
208        let result = parse("invalid: [unclosed");
209        assert!(matches!(result, Err(FafError::YamlError(_))));
210    }
211}