1use std::collections::VecDeque;
2use std::path::{Path, PathBuf};
3
4use pfx_load::scene::{EditError, SceneError};
5
6pub const KEEP: usize = 500;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum Level {
10 Info,
11 Warn,
12 Error,
13}
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct Entry {
17 pub level: Level,
18 pub file: Option<PathBuf>,
19 pub line: Option<usize>,
20 pub text: String,
21}
22
23impl Entry {
24 pub fn place(&self, root: &Path) -> Option<String> {
25 let file = self.file.as_ref()?;
26 let name = crate::outline::relative(root, file);
27 Some(match self.line {
28 Some(line) => format!("{name}:{line}"),
29 None => name,
30 })
31 }
32}
33
34#[derive(Clone, Debug, Default, PartialEq, Eq)]
35pub struct Log {
36 pub entries: VecDeque<Entry>,
37}
38
39impl Log {
40 pub fn push(&mut self, entry: Entry) {
41 if self.entries.len() == KEEP {
42 self.entries.pop_front();
43 }
44 self.entries.push_back(entry);
45 }
46
47 pub fn info(&mut self, text: impl Into<String>) {
48 self.say(Level::Info, text);
49 }
50
51 pub fn warn(&mut self, text: impl Into<String>) {
52 self.say(Level::Warn, text);
53 }
54
55 pub fn error(&mut self, text: impl Into<String>) {
56 self.say(Level::Error, text);
57 }
58
59 fn say(&mut self, level: Level, text: impl Into<String>) {
60 self.push(Entry {
61 level,
62 file: None,
63 line: None,
64 text: text.into(),
65 });
66 }
67
68 fn see(&mut self, file: &Path, line: usize) {
69 self.push(Entry {
70 level: Level::Error,
71 file: Some(file.to_path_buf()),
72 line: Some(line),
73 text: "see here".to_string(),
74 });
75 }
76
77 pub fn scene_error(&mut self, error: &SceneError) {
78 self.push(Entry {
79 level: Level::Error,
80 file: Some(error.file.clone()),
81 line: error.line,
82 text: error.message.clone(),
83 });
84 for place in &error.related {
85 self.see(&place.file, place.line);
86 }
87 }
88
89 pub fn edit_error(&mut self, error: &EditError) {
90 self.push(Entry {
91 level: Level::Error,
92 file: Some(error.file.clone()),
93 line: error.line.map(|line| line as usize),
94 text: format!("{}: {}", error.key, error.message),
95 });
96 let root = pfx_scene::Project::root_of(&error.file);
97 for found in error.diagnostics.iter().filter(|found| found.is_error()) {
98 for place in &found.related {
99 self.see(&root.join(&place.file), place.line as usize);
100 }
101 }
102 }
103
104 pub fn last(&self) -> Option<&Entry> {
105 self.entries.back()
106 }
107
108 pub fn errors(&self) -> usize {
109 self.entries
110 .iter()
111 .filter(|entry| entry.level == Level::Error)
112 .count()
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn an_edit_refused_with_a_chain_logs_each_place_of_it() {
122 let mut log = Log::default();
123 let diagnostic = |file: &str, related: Vec<pfx_scene::Location>| pfx_scene::Diagnostic {
124 file: PathBuf::from(file),
125 line: 3,
126 column: 12,
127 end_line: 3,
128 end_column: 20,
129 severity: pfx_scene::Severity::Error,
130 code: pfx_scene::code::INCLUDE_CYCLE,
131 key: "include.0".to_string(),
132 message: "the scene includes itself".to_string(),
133 related,
134 };
135 let place = |file: &str| pfx_scene::Location {
136 file: PathBuf::from(file),
137 line: 3,
138 column: 12,
139 };
140 log.edit_error(&EditError {
141 file: PathBuf::from("/game/content/other.scene.toml"),
142 key: "include".to_string(),
143 line: Some(3),
144 code: pfx_scene::code::INCLUDE_CYCLE,
145 message: "the scene includes itself".to_string(),
146 diagnostics: vec![diagnostic(
147 "content/other.scene.toml",
148 vec![
149 place("content/main.scene.toml"),
150 place("content/other.scene.toml"),
151 ],
152 )],
153 });
154 let seen: Vec<(Option<PathBuf>, Option<usize>, &str)> = log
155 .entries
156 .iter()
157 .map(|entry| (entry.file.clone(), entry.line, entry.text.as_str()))
158 .collect();
159 assert_eq!(seen.len(), 3, "{seen:?}");
160 assert_eq!(seen[0].2, "include: the scene includes itself");
161 assert_eq!(seen[1].2, "see here");
162 assert_eq!(seen[1].1, Some(3));
163 assert!(
164 seen[1]
165 .0
166 .as_ref()
167 .unwrap()
168 .ends_with("content/main.scene.toml")
169 );
170 assert!(
171 seen[2]
172 .0
173 .as_ref()
174 .unwrap()
175 .ends_with("content/other.scene.toml")
176 );
177 }
178
179 #[test]
180 fn errors_keep_their_file_and_line_and_the_log_keeps_its_last_entries() {
181 let mut log = Log::default();
182 log.scene_error(&SceneError::new(
183 Path::new("/scenes/room.scene.toml"),
184 Some(14),
185 "unknown field 'colour'",
186 ));
187 let entry = log.last().unwrap();
188 assert_eq!(
189 entry.place(Path::new("/scenes/room.scene.toml")).unwrap(),
190 "room.scene.toml:14"
191 );
192 for index in 0..KEEP + 3 {
193 log.info(format!("line {index}"));
194 }
195 assert_eq!(log.entries.len(), KEEP);
196 assert_eq!(log.last().unwrap().text, format!("line {}", KEEP + 2));
197 assert_eq!(log.errors(), 0);
198 }
199}