1use std::fs;
4use std::path::Path;
5use thiserror::Error;
6
7use crate::types::FafData;
8
9#[derive(Error, Debug)]
11pub enum FafError {
12 #[error("Empty content")]
13 EmptyContent,
14
15 #[error("Invalid YAML: {0}")]
16 YamlError(#[from] serde_yaml::Error),
17
18 #[error("IO error: {0}")]
19 IoError(#[from] std::io::Error),
20
21 #[error("Missing required field: {0}")]
22 MissingField(String),
23}
24
25#[derive(Debug, Clone)]
27pub struct FafFile {
28 pub data: FafData,
30 pub path: Option<String>,
32}
33
34impl FafFile {
35 #[inline]
37 pub fn project_name(&self) -> &str {
38 &self.data.project.name
39 }
40
41 pub fn score(&self) -> Option<u8> {
43 self.data.ai_score.as_ref().and_then(|s| {
44 s.trim_end_matches('%')
45 .parse()
46 .ok()
47 })
48 }
49
50 #[inline]
52 pub fn version(&self) -> &str {
53 &self.data.faf_version
54 }
55
56 pub fn tech_stack(&self) -> Option<&str> {
58 self.data
59 .instant_context
60 .as_ref()
61 .and_then(|ic| ic.tech_stack.as_deref())
62 }
63
64 pub fn what_building(&self) -> Option<&str> {
66 self.data
67 .instant_context
68 .as_ref()
69 .and_then(|ic| ic.what_building.as_deref())
70 }
71
72 pub fn key_files(&self) -> &[String] {
74 self.data
75 .instant_context
76 .as_ref()
77 .map(|ic| ic.key_files.as_slice())
78 .unwrap_or(&[])
79 }
80
81 pub fn goal(&self) -> Option<&str> {
83 self.data.project.goal.as_deref()
84 }
85
86 pub fn is_high_quality(&self) -> bool {
88 self.score().map(|s| s >= 70).unwrap_or(false)
89 }
90}
91
92pub fn parse(content: &str) -> Result<FafFile, FafError> {
109 let content = content.trim();
110 if content.is_empty() {
111 return Err(FafError::EmptyContent);
112 }
113
114 let data: FafData = serde_yaml::from_str(content)?;
115
116 Ok(FafFile { data, path: None })
117}
118
119pub fn parse_file<P: AsRef<Path>>(path: P) -> Result<FafFile, FafError> {
130 let path_str = path.as_ref().to_string_lossy().to_string();
131 let content = fs::read_to_string(&path)?;
132
133 let mut faf = parse(&content)?;
134 faf.path = Some(path_str);
135
136 Ok(faf)
137}
138
139pub fn stringify(faf: &FafFile) -> Result<String, FafError> {
141 Ok(serde_yaml::to_string(&faf.data)?)
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn test_parse_minimal() {
150 let content = r#"
151faf_version: 2.5.0
152project:
153 name: test-project
154"#;
155 let faf = parse(content).unwrap();
156 assert_eq!(faf.project_name(), "test-project");
157 assert_eq!(faf.version(), "2.5.0");
158 }
159
160 #[test]
161 fn test_parse_with_score() {
162 let content = r#"
163faf_version: 2.5.0
164ai_score: "85%"
165project:
166 name: test
167"#;
168 let faf = parse(content).unwrap();
169 assert_eq!(faf.score(), Some(85));
170 }
171
172 #[test]
173 fn test_parse_full() {
174 let content = r#"
175faf_version: 2.5.0
176ai_score: "90%"
177project:
178 name: full-test
179 goal: Test everything
180instant_context:
181 what_building: Test app
182 tech_stack: Rust, Python
183 key_files:
184 - src/main.rs
185 - src/lib.rs
186stack:
187 backend: Rust
188 database: PostgreSQL
189"#;
190 let faf = parse(content).unwrap();
191 assert_eq!(faf.project_name(), "full-test");
192 assert_eq!(faf.tech_stack(), Some("Rust, Python"));
193 assert_eq!(faf.key_files().len(), 2);
194 assert!(faf.is_high_quality());
195 }
196
197 #[test]
198 fn test_empty_content() {
199 let result = parse("");
200 assert!(matches!(result, Err(FafError::EmptyContent)));
201 }
202
203 #[test]
204 fn test_invalid_yaml() {
205 let result = parse("invalid: [unclosed");
206 assert!(matches!(result, Err(FafError::YamlError(_))));
207 }
208}