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")]
14 EmptyContent,
15
16 #[error("Invalid YAML: {0}")]
18 YamlError(#[from] serde_yaml_ng::Error),
19
20 #[error("IO error: {0}")]
22 IoError(#[from] std::io::Error),
23
24 #[error("Missing required field: {0}")]
26 MissingField(String),
27}
28
29#[derive(Debug, Clone)]
31pub struct FafFile {
32 pub data: FafData,
34 pub path: Option<String>,
36}
37
38impl FafFile {
39 #[inline]
41 pub fn project_name(&self) -> &str {
42 &self.data.project.name
43 }
44
45 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 #[inline]
55 pub fn version(&self) -> &str {
56 &self.data.faf_version
57 }
58
59 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 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 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 pub fn goal(&self) -> Option<&str> {
86 self.data.project.goal.as_deref()
87 }
88
89 pub fn is_high_quality(&self) -> bool {
91 self.score().map(|s| s >= 70).unwrap_or(false)
92 }
93}
94
95pub 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
122pub 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
142pub 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}