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_ng::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
44 .ai_score
45 .as_ref()
46 .and_then(|s| s.trim_end_matches('%').parse().ok())
47 }
48
49 #[inline]
51 pub fn version(&self) -> &str {
52 &self.data.faf_version
53 }
54
55 pub fn tech_stack(&self) -> Option<&str> {
57 self.data
58 .instant_context
59 .as_ref()
60 .and_then(|ic| ic.tech_stack.as_deref())
61 }
62
63 pub fn what_building(&self) -> Option<&str> {
65 self.data
66 .instant_context
67 .as_ref()
68 .and_then(|ic| ic.what_building.as_deref())
69 }
70
71 pub fn key_files(&self) -> &[String] {
73 self.data
74 .instant_context
75 .as_ref()
76 .map(|ic| ic.key_files.as_slice())
77 .unwrap_or(&[])
78 }
79
80 pub fn goal(&self) -> Option<&str> {
82 self.data.project.goal.as_deref()
83 }
84
85 pub fn is_high_quality(&self) -> bool {
87 self.score().map(|s| s >= 70).unwrap_or(false)
88 }
89}
90
91pub fn parse(content: &str) -> Result<FafFile, FafError> {
108 let content = content.trim();
109 if content.is_empty() {
110 return Err(FafError::EmptyContent);
111 }
112
113 let data: FafData = serde_yaml_ng::from_str(content)?;
114
115 Ok(FafFile { data, path: None })
116}
117
118pub fn parse_file<P: AsRef<Path>>(path: P) -> Result<FafFile, FafError> {
129 let path_str = path.as_ref().to_string_lossy().to_string();
130 let content = fs::read_to_string(&path)?;
131
132 let mut faf = parse(&content)?;
133 faf.path = Some(path_str);
134
135 Ok(faf)
136}
137
138pub fn stringify(faf: &FafFile) -> Result<String, FafError> {
140 Ok(serde_yaml_ng::to_string(&faf.data)?)
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn test_parse_minimal() {
149 let content = r#"
150faf_version: 2.5.0
151project:
152 name: test-project
153"#;
154 let faf = parse(content).unwrap();
155 assert_eq!(faf.project_name(), "test-project");
156 assert_eq!(faf.version(), "2.5.0");
157 }
158
159 #[test]
160 fn test_parse_with_score() {
161 let content = r#"
162faf_version: 2.5.0
163ai_score: "85%"
164project:
165 name: test
166"#;
167 let faf = parse(content).unwrap();
168 assert_eq!(faf.score(), Some(85));
169 }
170
171 #[test]
172 fn test_parse_full() {
173 let content = r#"
174faf_version: 2.5.0
175ai_score: "90%"
176project:
177 name: full-test
178 goal: Test everything
179instant_context:
180 what_building: Test app
181 tech_stack: Rust, Python
182 key_files:
183 - src/main.rs
184 - src/lib.rs
185stack:
186 backend: Rust
187 database: PostgreSQL
188"#;
189 let faf = parse(content).unwrap();
190 assert_eq!(faf.project_name(), "full-test");
191 assert_eq!(faf.tech_stack(), Some("Rust, Python"));
192 assert_eq!(faf.key_files().len(), 2);
193 assert!(faf.is_high_quality());
194 }
195
196 #[test]
197 fn test_empty_content() {
198 let result = parse("");
199 assert!(matches!(result, Err(FafError::EmptyContent)));
200 }
201
202 #[test]
203 fn test_invalid_yaml() {
204 let result = parse("invalid: [unclosed");
205 assert!(matches!(result, Err(FafError::YamlError(_))));
206 }
207}