1use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6use thiserror::Error;
7
8use crate::types::FafData;
9
10#[derive(Error, Debug)]
12pub enum FafError {
13 #[error("Empty content")]
15 EmptyContent,
16
17 #[error("Invalid YAML: {0}")]
19 YamlError(#[from] serde_yaml_ng::Error),
20
21 #[error("IO error: {0}")]
23 IoError(#[from] std::io::Error),
24
25 #[error("Missing required field: {0}")]
27 MissingField(String),
28}
29
30#[derive(Debug, Clone)]
32pub struct FafFile {
33 pub data: FafData,
35 pub path: Option<String>,
37}
38
39impl FafFile {
40 #[inline]
42 pub fn project_name(&self) -> &str {
43 &self.data.project.name
44 }
45
46 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 #[inline]
56 pub fn version(&self) -> &str {
57 &self.data.faf_version
58 }
59
60 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 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 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 pub fn goal(&self) -> Option<&str> {
87 self.data.project.goal.as_deref()
88 }
89
90 pub fn is_high_quality(&self) -> bool {
92 self.score().map(|s| s >= 70).unwrap_or(false)
93 }
94
95 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
110pub 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
137pub 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
157pub 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}